From c2f36964eac84a3545b73b981e5f8a3d50dc249b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 00:34:29 +0000 Subject: [PATCH 1/5] fix: accept static and plain-hook plugins --- examples/hooks-and-scripts/README.md | 4 + .../src/hooks/session-start.ts | 12 +- .../hooks-and-scripts/src/release-context.ts | 9 + examples/skills-starter/README.md | 17 +- .../skills-starter/agent-bundle.config.ts | 2 +- .../src/commands/review-release.md | 3 + .../src/rules/release-safety.mdc | 7 + packages/agent-bundle/src/adapters/codex.ts | 12 +- .../src/adapters/composite-layout.ts | 30 +--- packages/agent-bundle/src/adapters/cursor.ts | 13 +- packages/agent-bundle/src/contracts/skills.ts | 3 + .../src/dev/routes/application-tree.ts | 19 ++ .../src/dev/skill-document-service.ts | 78 +++++++- packages/agent-bundle/src/test/workbench.ts | 4 + .../agent-bundle/tests/cursor-adapter.test.ts | 18 ++ .../tests/examples-contract.test.ts | 27 ++- .../agent-bundle/tests/host-adapters.test.ts | 13 ++ .../tests/packed-small-plugin.test.ts | 169 ++++++++++++++++++ .../tests/support/watched-files.ts | 33 +++- .../tests/workbench-surface.test.ts | 21 ++- .../src/application/application-tree-model.ts | 1 + .../src/application/route-workspace.tsx | 18 ++ packages/workbench/src/skill-client.ts | 71 +++++++- .../tests/application-tree-model.test.ts | 1 + .../workbench/tests/examples-real.e2e.test.ts | 31 ++++ packages/workbench/tests/skill-client.test.ts | 33 +++- .../tests/support/workbench-acceptance.ts | 18 +- .../tests/workbench-capabilities.test.ts | 6 +- rstest.integration-tests.ts | 1 + .../docs/en/examples/hooks-and-scripts.mdx | 5 +- website/docs/en/examples/skills-starter.mdx | 31 ++-- .../docs/zh/examples/hooks-and-scripts.mdx | 5 +- website/docs/zh/examples/skills-starter.mdx | 26 +-- 33 files changed, 646 insertions(+), 95 deletions(-) create mode 100644 examples/hooks-and-scripts/src/release-context.ts create mode 100644 examples/skills-starter/src/commands/review-release.md create mode 100644 examples/skills-starter/src/rules/release-safety.mdc create mode 100644 packages/agent-bundle/tests/packed-small-plugin.test.ts diff --git a/examples/hooks-and-scripts/README.md b/examples/hooks-and-scripts/README.md index 7e69d9f72..7149fe7a3 100644 --- a/examples/hooks-and-scripts/README.md +++ b/examples/hooks-and-scripts/README.md @@ -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 diff --git a/examples/hooks-and-scripts/src/hooks/session-start.ts b/examples/hooks-and-scripts/src/hooks/session-start.ts index 061c9c330..4e4959a37 100644 --- a/examples/hooks-and-scripts/src/hooks/session-start.ts +++ b/examples/hooks-and-scripts/src/hooks/session-start.ts @@ -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'>; diff --git a/examples/hooks-and-scripts/src/release-context.ts b/examples/hooks-and-scripts/src/release-context.ts new file mode 100644 index 000000000..e1c1d53ec --- /dev/null +++ b/examples/hooks-and-scripts/src/release-context.ts @@ -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(' '); diff --git a/examples/skills-starter/README.md b/examples/skills-starter/README.md index c03c30895..83a182a28 100644 --- a/examples/skills-starter/README.md +++ b/examples/skills-starter/README.md @@ -12,9 +12,9 @@ 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 +- `agent-bundle.config.ts` declares the plugin and its portable, Codex, Claude, + and 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. @@ -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 @@ -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. diff --git a/examples/skills-starter/agent-bundle.config.ts b/examples/skills-starter/agent-bundle.config.ts index da0b4bafd..6a64f2d56 100644 --- a/examples/skills-starter/agent-bundle.config.ts +++ b/examples/skills-starter/agent-bundle.config.ts @@ -6,5 +6,5 @@ export default defineConfig({ name: 'skills-starter', version: '1.0.0', }, - targets: ['portable', 'codex', 'claude'], + targets: ['portable', 'codex', 'claude', 'cursor'], }); diff --git a/examples/skills-starter/src/commands/review-release.md b/examples/skills-starter/src/commands/review-release.md new file mode 100644 index 000000000..26890d7d0 --- /dev/null +++ b/examples/skills-starter/src/commands/review-release.md @@ -0,0 +1,3 @@ +Review the current release evidence with the `release-review` Skill. + +Report missing checks and do not approve a release with unresolved blockers. diff --git a/examples/skills-starter/src/rules/release-safety.mdc b/examples/skills-starter/src/rules/release-safety.mdc new file mode 100644 index 000000000..d40b110d7 --- /dev/null +++ b/examples/skills-starter/src/rules/release-safety.mdc @@ -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. diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 09a804b5d..1c7aab927 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -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 { @@ -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> = Object.create(null) as Record>; for (const server of model.mcpServers) { @@ -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)); @@ -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); diff --git a/packages/agent-bundle/src/adapters/composite-layout.ts b/packages/agent-bundle/src/adapters/composite-layout.ts index 8ed5cd08b..ad230ccc1 100644 --- a/packages/agent-bundle/src/adapters/composite-layout.ts +++ b/packages/agent-bundle/src/adapters/composite-layout.ts @@ -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/..mjs`; @@ -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> = 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): boolean => { - const selection = new Set(selected); - return (folderDiscoveryClaimants[defaultPath] ?? []).some((host) => selection.has(host)); -}; diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 088d882e3..cbfdb7e94 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -34,6 +34,7 @@ import { emptyHookDocument, encodeCursorPlaygroundInput, encodeCursorPlaygroundOutput, + nativeHooksFor, planHooks, readCursorNativeHookCommands, type TargetHookContract, @@ -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'; @@ -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))); @@ -596,7 +603,7 @@ 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)); @@ -604,7 +611,7 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan 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)); diff --git a/packages/agent-bundle/src/contracts/skills.ts b/packages/agent-bundle/src/contracts/skills.ts index 13a51b60c..707255f7f 100644 --- a/packages/agent-bundle/src/contracts/skills.ts +++ b/packages/agent-bundle/src/contracts/skills.ts @@ -4,7 +4,10 @@ */ export type { ServedSkillDocument, + ServedStaticDocument, SkillDocumentBase, SkillDocumentResource, SkillDocumentTree, + StaticDocumentKind, + StaticDocumentProjection, } from '../dev/skill-document-service.ts'; diff --git a/packages/agent-bundle/src/dev/routes/application-tree.ts b/packages/agent-bundle/src/dev/routes/application-tree.ts index afe91621c..f914b9be4 100644 --- a/packages/agent-bundle/src/dev/routes/application-tree.ts +++ b/packages/agent-bundle/src/dev/routes/application-tree.ts @@ -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, @@ -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; @@ -106,6 +108,7 @@ export interface ApplicationTreeManifestSources { readonly manifest?: RouteManifest; readonly message?: string; readonly skills?: readonly ApplicationTreeSkill[]; + readonly staticDocuments?: readonly ServedStaticDocument[]; readonly state: ApplicationTreeState; } @@ -299,6 +302,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)) @@ -326,6 +344,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 ?? [])]), diff --git a/packages/agent-bundle/src/dev/skill-document-service.ts b/packages/agent-bundle/src/dev/skill-document-service.ts index 4527b0e28..92c2e1c98 100644 --- a/packages/agent-bundle/src/dev/skill-document-service.ts +++ b/packages/agent-bundle/src/dev/skill-document-service.ts @@ -1,12 +1,21 @@ import { lstat, readdir, realpath } from 'node:fs/promises'; import { extname, join, resolve } from 'node:path'; +import type { TargetRegistry } from '../adapters/registry.ts'; +import type { TargetArtifactPlan } from '../adapters/types.ts'; import { projectMeta } from '../build/meta.ts'; import { parseSkill, type SkillDocument, type SkillResource } from '../config/skill.ts'; +import type { CapabilityState } from '../core/capabilities.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { CodedError, isErrno } from '../core/errors.ts'; -import type { NormalizedPlugin, NormalizedSkill, SourceProvenance } from '../core/types.ts'; +import type { + NormalizedCommand, + NormalizedPlugin, + NormalizedRule, + NormalizedSkill, + SourceProvenance, +} from '../core/types.ts'; import { EpochStore } from './epoch-store.ts'; import { ProjectService } from './project-service.ts'; import { isInsideOrEqual } from '../core/paths.ts'; @@ -75,6 +84,27 @@ export interface ServedSkillResource { export interface SkillDocumentTree { readonly diagnostics: readonly Diagnostic[]; readonly skills: readonly ServedSkillDocument[]; + readonly staticDocuments: readonly ServedStaticDocument[]; +} + +export type StaticDocumentKind = 'command' | 'rule'; + +export interface StaticDocumentProjection { + readonly capability: CapabilityState; + readonly markdown?: string; + readonly path?: string; + readonly target: string; +} + +export interface ServedStaticDocument { + readonly body: string; + readonly frontmatter: Readonly>; + readonly id: string; + readonly kind: StaticDocumentKind; + readonly markdown: string; + readonly name: string; + readonly projections: readonly StaticDocumentProjection[]; + readonly provenance: SourceProvenance; } export interface SkillDocumentServiceOptions { @@ -120,6 +150,49 @@ const sourceResource = (resource: SkillResource): SkillDocumentResource => Objec const documentResources = (resources: readonly SkillResource[]): readonly SkillDocumentResource[] => Object.freeze(resources.map(sourceResource)); +const staticDocument = ( + document: NormalizedCommand | NormalizedRule, + kind: StaticDocumentKind, + model: NormalizedPlugin, + plans: ReadonlyMap, + registry: TargetRegistry, +): ServedStaticDocument => { + const capabilityName = kind === 'command' ? 'commands' : 'rules'; + const expectedPath = `${capabilityName}/${document.name}.${kind === 'command' ? 'md' : 'mdc'}`; + const projections = model.targets.map(({ name: target }): StaticDocumentProjection => { + const adapter = registry.get(target); + const capability = kind === 'command' ? adapter.capabilities.commands : adapter.capabilities.rules; + const entry = plans.get(target)!.entries.find((candidate) => + candidate.relativePath === expectedPath && candidate.sourceInputs.includes(document.source)); + return Object.freeze({ + capability: Object.freeze({ ...capability }), + ...(entry?.kind === 'write' ? { markdown: entry.content, path: entry.relativePath } : {}), + target, + }); + }); + return Object.freeze({ + body: document.body, + frontmatter: Object.freeze(structuredClone(document.frontmatter)), + id: document.id, + kind, + markdown: document.markdown, + name: document.name, + projections: Object.freeze(projections), + provenance: Object.freeze({ ...document.provenance }), + }); +}; + +export const staticDocumentsFor = ( + model: NormalizedPlugin, + registry: TargetRegistry, +): readonly ServedStaticDocument[] => { + const plans = new Map(model.targets.map(({ name }) => [name, registry.get(name).plan(model)])); + return Object.freeze([ + ...(model.commands ?? []).map((command) => staticDocument(command, 'command', model, plans, registry)), + ...(model.rules ?? []).map((rule) => staticDocument(rule, 'rule', model, plans, registry)), + ]); +}; + const contentTypeFor = (path: string): string => contentTypes[extname(path).toLowerCase()] ?? 'application/octet-stream'; @@ -256,6 +329,7 @@ export class SkillDocumentService { const model = prepared.model; return Object.freeze({ diagnostics: freezeDiagnostics(prepared.diagnostics), + staticDocuments: model === undefined ? [] : staticDocumentsFor(model, prepared.registry), skills: Object.freeze(model === undefined ? [] : await Promise.all(model.skills.map((skill) => this.#sourceDocument(skill, model)))), @@ -289,7 +363,7 @@ export class SkillDocumentService { .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && safeSegment(entry.name)) .sort((left, right) => left.name.localeCompare(right.name)) .map(async (entry) => this.#generatedDocument(epochId, target, `skill:${entry.name}`, targetRoot))); - return deepFreeze({ diagnostics: [], skills: documents }); + return deepFreeze({ diagnostics: [], skills: documents, staticDocuments: [] }); }); } diff --git a/packages/agent-bundle/src/test/workbench.ts b/packages/agent-bundle/src/test/workbench.ts index f5e53991f..6268b9208 100644 --- a/packages/agent-bundle/src/test/workbench.ts +++ b/packages/agent-bundle/src/test/workbench.ts @@ -31,6 +31,7 @@ import { } from '../dev/routes/application-tree.ts'; import { applicationNodePath } from '../dev/routes/application-node.ts'; import { routeManifestFor } from '../dev/routes/route-manifest.ts'; +import { staticDocumentsFor, type ServedStaticDocument } from '../dev/skill-document-service.ts'; import type { RouteManifest, RouteManifestCliCommand, @@ -251,6 +252,7 @@ export interface WorkbenchSurfaceFromGraphInput { readonly sourceRevision: string; readonly notices?: NormalizedNotices; readonly skills?: readonly { readonly id: string; readonly label: string; readonly source?: string }[]; + readonly staticDocuments?: readonly ServedStaticDocument[]; readonly state?: NormalizedStateDefinition; readonly targets: readonly string[]; } @@ -267,6 +269,7 @@ export const workbenchSurfaceFromRouteGraph = (input: WorkbenchSurfaceFromGraphI inspection: input.inspection, manifest, skills: input.skills, + staticDocuments: input.staticDocuments, state: 'fresh', }); const advanced: AdvancedSection[] = [ @@ -431,6 +434,7 @@ export const inspectWorkbenchSurface = async ( label: skill.name, source: skill.provenance.sourcePath, })), + staticDocuments: staticDocumentsFor(model, prepared.registry), ...(model.state === undefined ? {} : { state: model.state }), targets, }); diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index b81341141..8b18fa424 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -452,6 +452,24 @@ it('rejects portable Agent Plugin tokens instead of emitting a hybrid Cursor art expect(manifest).not.toHaveProperty('variables'); }); +it('does not manufacture Cursor MCP or hook documents from a static portable selection', () => { + const base = plugin(); + const model: NormalizedPlugin = { + ...base, + mcpServers: [], + targets: [ + ...base.targets, + { id: 'target:portable', name: 'portable', provenance: { kind: 'config', sourcePath: configPath } }, + ], + }; + const documents = writeContents(model); + expect(documents).not.toHaveProperty('.cursor-plugin/hooks.json'); + expect(documents).not.toHaveProperty('.cursor-plugin/mcp.json'); + const manifest = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; + expect(manifest).not.toHaveProperty('hooks'); + expect(manifest).not.toHaveProperty('mcpServers'); +}); + it('rejects the plugin-data token and omits the failed server from the document', () => { const model = plugin(); const plan = cursorAdapter.plan({ diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index 44d18d579..73d8b0b96 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -25,7 +25,7 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { model: { metadata: { name: 'skills-starter' }, scripts: [], - targets: [{ name: 'claude' }, { name: 'codex' }, { name: 'portable' }], + targets: [{ name: 'claude' }, { name: 'codex' }, { name: 'cursor' }, { name: 'portable' }], }, state: 'ready', }); @@ -35,8 +35,29 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { expect(inspection.projectContext.packageName).toBe('@agent-bundle-example/skills-starter'); expect(inspection.projectContext.packageVersion).toBeUndefined(); expect(projectVersionLabel(inspection.projectContext)).toContain('development fallback'); - await build({ output, root }); - await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); + const built = await build({ output, root }); + const validation = await validate({ artifact: output, root }); + expect(validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + expect(validation.diagnostics).toContainEqual(expect.objectContaining({ + code: 'AB6020', + target: 'claude', + })); + expect(built.build.manifest.executables).toEqual({ + bins: [], + hooks: [], + mcpServers: [], + scripts: [], + }); + expect(built.build.manifest).not.toHaveProperty('web'); + expect(built.model).toMatchObject({ + mcpApps: [], + mcpServers: [], + }); + expect(built.model).not.toHaveProperty('state'); + await expect(readFile(join(output, 'commands', 'review-release.md'), 'utf8')) + .resolves.toContain('Review the current release evidence'); + await expect(readFile(join(output, 'rules', 'release-safety.mdc'), 'utf8')) + .resolves.toContain('Keep release reviews evidence-based'); await expect(readFile(join(output, 'skills', 'release-review', 'SKILL.md'), 'utf8')) .resolves.toContain('# Release review'); await expect(readFile(join(output, 'skills', 'release-review', 'SKILL.md'), 'utf8')) diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 409a7107f..f5fbf75b6 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -1207,6 +1207,19 @@ it('admits documented Codex component path and inline manifest forms', async () } }); +it('does not manufacture Codex MCP or hook documents from a static Claude selection', () => { + const model: NormalizedPlugin = { ...plugin, hooks: [], mcpServers: [] }; + const documents = writeContents(model, 'codex'); + expect(documents).not.toHaveProperty(codexArtifactPaths.hooksManifest); + expect(documents).not.toHaveProperty(codexArtifactPaths.mcp); + const manifest = JSON.parse(documents[codexArtifactPaths.plugin]!) as Record; + expect(manifest).toMatchObject({ + interface: { capabilities: ['skills'] }, + }); + expect(manifest).not.toHaveProperty('hooks'); + expect(manifest).not.toHaveProperty('mcpServers'); +}); + it('plans byte-stable native Codex and Claude plugin trees from the same frozen model', async () => { const registry = createDefaultRegistry(); expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); diff --git a/packages/agent-bundle/tests/packed-small-plugin.test.ts b/packages/agent-bundle/tests/packed-small-plugin.test.ts new file mode 100644 index 000000000..530283290 --- /dev/null +++ b/packages/agent-bundle/tests/packed-small-plugin.test.ts @@ -0,0 +1,169 @@ +import { execFile as executeFile } from 'node:child_process'; +import { access, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { expect, it } from '@rstest/core'; + +import { + cachedNpmInstallArguments, + installedEnvironment, + sharedPackedTarball, +} from './support/shared-pack.ts'; + +const execFile = promisify(executeFile); +const examples = resolve(process.cwd(), 'examples'); + +interface ArtifactManifest { + readonly executables: { + readonly bins: readonly unknown[]; + readonly hooks: readonly { readonly host: string; readonly id: string; readonly path: string }[]; + readonly mcpServers: readonly unknown[]; + readonly scripts: readonly unknown[]; + }; + readonly files: readonly { readonly path: string }[]; + readonly web?: unknown; +} + +const artifactFiles = async (root: string): Promise => + (await readdir(root, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => relative(root, join(entry.parentPath, entry.name))) + .sort(); + +const run = ( + cli: string, + root: string, + args: readonly string[], +): Promise<{ readonly stderr: string; readonly stdout: string }> => + execFile(cli, [...args], { cwd: root, env: installedEnvironment() }); + +const assertSmallRuntime = async ( + artifact: string, + expectedHooks: number, +): Promise => { + const manifest = JSON.parse(await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8')) as ArtifactManifest; + const files = await artifactFiles(artifact); + expect(files).toHaveLength(manifest.files.length + 1); + expect(manifest.executables.bins).toEqual([]); + expect(manifest.executables.mcpServers).toEqual([]); + expect(manifest.executables.scripts).toEqual([]); + expect(manifest.executables.hooks).toHaveLength(expectedHooks); + expect(manifest).not.toHaveProperty('web'); + expect(files).not.toEqual(expect.arrayContaining([ + expect.stringMatching(/(?:^|\/)mcp(?:-apps)?\//u), + expect.stringMatching(/-flight\.mjs$/u), + expect.stringMatching(/(?:^|\/)(?:app-renderer|flight|notices?|sqlite|state)(?:\/|\.|-|$)/u), + ])); + expect(files.filter((path) => /(?:^|\/)mcp(?:-apps)?(?:\.json|\/)/u.test(path))).toEqual([]); + if (expectedHooks === 0) { + expect(files.filter((path) => /hooks\.json$/u.test(path))).toEqual([]); + } + return manifest; +}; + +it('keeps packed static and plain-hook plugins free of undeclared runtimes', async () => { + const { tarball } = await sharedPackedTarball('agent-bundle'); + const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-small-plugin-')); + const staticRoot = join(consumer, 'static'); + const hookRoot = join(consumer, 'plain-hook'); + const processStarts: string[] = []; + + try { + await Promise.all([ + cp(join(examples, 'skills-starter'), staticRoot, { + filter: (source) => !['.agent-bundle', 'node_modules'].includes(basename(source)), + recursive: true, + }), + cp(join(examples, 'skills-starter'), hookRoot, { + filter: (source) => !['.agent-bundle', 'node_modules'].includes(basename(source)), + recursive: true, + }), + ]); + await Promise.all([staticRoot, hookRoot].map((root) => + writeFile(join(root, 'package.json'), JSON.stringify({ private: true, type: 'module' })))); + await mkdir(join(hookRoot, 'src', 'hooks'), { recursive: true }); + await Promise.all([ + cp( + join(examples, 'hooks-and-scripts', 'src', 'hooks', 'session-start.ts'), + join(hookRoot, 'src', 'hooks', 'session-start.ts'), + ), + cp( + join(examples, 'hooks-and-scripts', 'src', 'release-context.ts'), + join(hookRoot, 'src', 'release-context.ts'), + ), + ]); + const hookConfig = (await readFile(join(hookRoot, 'agent-bundle.config.ts'), 'utf8')) + .replace( + ' plugin:', + " hooks: { sessionStart: { handler: './src/hooks/session-start.ts', targets: ['claude', 'codex'] } },\n plugin:", + ) + .replace("name: 'skills-starter'", "name: 'skills-starter-hook'"); + await writeFile(join(hookRoot, 'agent-bundle.config.ts'), hookConfig); + await Promise.all([staticRoot, hookRoot].map((root) => + execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { + cwd: root, + env: installedEnvironment(), + }))); + + for (const [root, expectedHooks] of [[staticRoot, 0], [hookRoot, 2]] as const) { + const cli = join(root, 'node_modules', '.bin', 'agent-bundle'); + const artifact = join(root, 'artifact'); + const relocated = join(root, 'relocated'); + const { stdout: inspection } = await run(cli, root, ['inspect', '--json', '--root', root]); + const model = (JSON.parse(inspection) as { readonly model: { + readonly mcpApps: readonly unknown[]; + readonly mcpServers: readonly unknown[]; + readonly state?: unknown; + } }).model; + expect(model.mcpApps).toEqual([]); + expect(model.mcpServers).toEqual([]); + expect(model).not.toHaveProperty('state'); + await run(cli, root, ['build', '--root', root, '--output', artifact]); + await run(cli, root, ['validate', '--root', root, '--artifact', artifact]); + const manifest = await assertSmallRuntime(artifact, expectedHooks); + await rename(artifact, relocated); + await run(cli, root, ['validate', '--root', root, '--artifact', relocated]); + + if (expectedHooks === 0) { + expect(processStarts).toEqual([]); + const authored = (await readdir(join(root, 'src'), { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + expect(authored.some((name) => /\.[jt]sx$/u.test(name))).toBe(false); + const rebuilt = join(root, 'rebuilt'); + const commandSource = join(root, 'src', 'commands', 'review-release.md'); + await writeFile(commandSource, `${await readFile(commandSource, 'utf8')}\nReport the selected host projection.\n`); + await run(cli, root, ['build', '--root', root, '--output', rebuilt]); + await expect(readFile(join(rebuilt, 'commands', 'review-release.md'), 'utf8')) + .resolves.toContain('Report the selected host projection.'); + await rm(join(root, 'src', 'rules', 'release-safety.mdc')); + await run(cli, root, ['build', '--root', root, '--output', rebuilt]); + await expect(access(join(rebuilt, 'rules', 'release-safety.mdc'))).rejects.toThrow(); + continue; + } + + const hook = manifest.executables.hooks.find((entry) => entry.host === 'codex'); + if (hook === undefined) throw new Error('Packed plain-hook artifact has no Codex hook executable.'); + processStarts.push(hook.path); + const { stdout } = await run(cli, root, [ + 'hooks', 'simulate', '--json', '--root', root, '--artifact', relocated, + '--target', hook.host, '--hook', hook.id, + '--input', JSON.stringify({ + cwd: root, + sessionId: 'packed-small', + source: 'acceptance', + transcriptPath: join(root, 'transcript.jsonl'), + }), + ]); + expect(JSON.parse(stdout)).toMatchObject({ + additionalContext: expect.stringContaining('packed-small'), + outcome: 'continue', + }); + } + expect(processStarts).toHaveLength(1); + } finally { + await rm(consumer, { force: true, recursive: true }); + } +}, 180_000); diff --git a/packages/agent-bundle/tests/support/watched-files.ts b/packages/agent-bundle/tests/support/watched-files.ts index 04b50c0b3..0d394d906 100644 --- a/packages/agent-bundle/tests/support/watched-files.ts +++ b/packages/agent-bundle/tests/support/watched-files.ts @@ -1,4 +1,4 @@ -import { rename, writeFile } from 'node:fs/promises'; +import { rename, rm, writeFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; @@ -50,15 +50,14 @@ const pollIntervalMs = 25; * load — the race behind the retired `{ retry: 2 }` guards in * `examples-real.e2e.test.ts`. */ -export const replaceWatchedSourceAndAwaitRebuild = async ( +const changeWatchedSourceAndAwaitRebuild = async ( session: WatchedBuildSession, - projectRoot: string, path: string, - content: string, + change: () => Promise, options: AwaitWatcherRebuildOptions, ): Promise => { const known = attemptIds(session.status()); - await replaceWatchedSource(projectRoot, path, content); + await change(); const deadline = Date.now() + options.timeoutMs; for (;;) { const status = session.status(); @@ -78,3 +77,27 @@ export const replaceWatchedSourceAndAwaitRebuild = async ( await sleep(pollIntervalMs); } }; + +export const replaceWatchedSourceAndAwaitRebuild = ( + session: WatchedBuildSession, + projectRoot: string, + path: string, + content: string, + options: AwaitWatcherRebuildOptions, +): Promise => changeWatchedSourceAndAwaitRebuild( + session, + path, + () => replaceWatchedSource(projectRoot, path, content), + options, +); + +export const removeWatchedSourceAndAwaitRebuild = ( + session: WatchedBuildSession, + path: string, + options: AwaitWatcherRebuildOptions, +): Promise => changeWatchedSourceAndAwaitRebuild( + session, + path, + () => rm(path), + options, +); diff --git a/packages/agent-bundle/tests/workbench-surface.test.ts b/packages/agent-bundle/tests/workbench-surface.test.ts index 059674cad..7a3c5b5a7 100644 --- a/packages/agent-bundle/tests/workbench-surface.test.ts +++ b/packages/agent-bundle/tests/workbench-surface.test.ts @@ -220,17 +220,32 @@ describe('the Workbench surface of the configured-only examples', () => { expect(surface.counts.scripts).toBeGreaterThan(0); }); - it('shows only Skill leaves for the Skills Starter', async () => { + it('shows the Skills Starter static content without executable groups', async () => { const surface = await inspectWorkbenchSurface({ root: exampleRoot('skills-starter') }); - expect(surface.application.groups.map((group) => group.kind)).toEqual(['skills']); + expect(surface.application.groups.map((group) => group.kind)).toEqual(['skills', 'rules']); expect(applicationGroup(surface, 'skills')).toMatchObject({ leaves: expect.arrayContaining([ expect.objectContaining({ label: 'dependency-upgrade' }), expect.objectContaining({ label: 'incident-triage' }), expect.objectContaining({ label: 'release-review' }), ]) }); + expect(applicationGroup(surface, 'rules')).toMatchObject({ + label: 'Rules / Commands', + leaves: [ + expect.objectContaining({ + execution: 'document', + label: 'release-safety', + ref: { id: 'rule:release-safety', kind: 'rule' }, + }), + expect.objectContaining({ + execution: 'document', + label: 'review-release', + ref: { id: 'command:review-release', kind: 'command' }, + }), + ], + }); expect(surface.advanced).toEqual(['evals', 'artifact', 'hosts', 'logs']); - expect(surface.counts).toMatchObject({ hooks: 0, mcpServers: 0, scripts: 0, skills: 3, targets: 3 }); + expect(surface.counts).toMatchObject({ hooks: 0, mcpServers: 0, scripts: 0, skills: 3, targets: 4 }); }); }); diff --git a/packages/workbench/src/application/application-tree-model.ts b/packages/workbench/src/application/application-tree-model.ts index d01d4ceb3..a87c62290 100644 --- a/packages/workbench/src/application/application-tree-model.ts +++ b/packages/workbench/src/application/application-tree-model.ts @@ -67,6 +67,7 @@ export const applicationTreeFor = (sources: ApplicationTreeSources): Application label: skill.name, ...(skill.provenance === undefined ? {} : { source: skill.provenance.sourcePath }), })), + staticDocuments: sources.skillTree.staticDocuments, }), state: applicationState(sources.state), }); diff --git a/packages/workbench/src/application/route-workspace.tsx b/packages/workbench/src/application/route-workspace.tsx index a06b91cca..6a7775fe6 100644 --- a/packages/workbench/src/application/route-workspace.tsx +++ b/packages/workbench/src/application/route-workspace.tsx @@ -34,6 +34,24 @@ export const DocumentWorkspace = ({ leaf }: { readonly leaf: ApplicationLeaf }): {leaf.event === undefined ? undefined :
Event
{leaf.event}
} {configRows(leaf).map((entry) =>
{entry.label}
{entry.value}
)} + {leaf.document === undefined ? undefined : <> +

Authored content

+
{leaf.document.markdown}
+

Host projections

+ {leaf.document.projections.map((projection) =>
+

{projection.target}

+
+
Capability
{projection.capability.state}
+ {'evidence' in projection.capability && projection.capability.evidence !== undefined + ?
Host contract
{projection.capability.evidence.observedVersion}
+ : undefined} + {projection.path === undefined ? undefined :
Generated path
{projection.path}
} +
+ {projection.markdown === undefined + ?

{'reason' in projection.capability ? projection.capability.reason : 'The author excluded this host.'}

+ :
{projection.markdown}
} +
)} + }

This leaf is a document the host reads as written; there is nothing to run. Edit the source and the published build picks it up.

diff --git a/packages/workbench/src/skill-client.ts b/packages/workbench/src/skill-client.ts index 9c4d9797e..fd849eca8 100644 --- a/packages/workbench/src/skill-client.ts +++ b/packages/workbench/src/skill-client.ts @@ -3,9 +3,11 @@ import type { JsonValue } from '../../agent-bundle/src/contracts/strict-json.ts' import type { SourceProvenance } from '../../agent-bundle/src/contracts/project.ts'; import type { ServedSkillDocument, + ServedStaticDocument, SkillDocumentBase, SkillDocumentResource, SkillDocumentTree, + StaticDocumentProjection, } from '../../agent-bundle/src/contracts/skills.ts'; import { @@ -90,6 +92,64 @@ const strings = (value: unknown): readonly string[] => { return Object.freeze([...value]); }; +const capabilityEvidence = (value: unknown): { readonly observedVersion: string; readonly target: string } => { + if (!hasAllowedKeys(value, ['observedVersion', 'target'])) throw invalidResponse(); + return Object.freeze({ + observedVersion: readString(value, 'observedVersion'), + target: readString(value, 'target'), + }); +}; + +const capability = (value: unknown): StaticDocumentProjection['capability'] => { + if (!isRecord(value)) throw invalidResponse(); + switch (value.state) { + case 'supported': + if (!hasAllowedKeys(value, ['evidence', 'state'])) throw invalidResponse(); + return Object.freeze({ evidence: capabilityEvidence(value.evidence), state: value.state }); + case 'degraded': + if (!hasAllowedKeys(value, ['reason', 'state'], ['evidence'])) throw invalidResponse(); + return Object.freeze({ + ...(value.evidence === undefined ? {} : { evidence: capabilityEvidence(value.evidence) }), + reason: readString(value, 'reason'), + state: value.state, + }); + case 'prohibited': + case 'unavailable': + if (!hasAllowedKeys(value, ['reason', 'state'])) throw invalidResponse(); + return Object.freeze({ reason: readString(value, 'reason'), state: value.state }); + default: + throw invalidResponse(); + } +}; + +const staticProjection = (value: unknown): StaticDocumentProjection => { + if (!hasAllowedKeys(value, ['capability', 'target'], ['markdown', 'path'])) throw invalidResponse(); + return Object.freeze({ + capability: capability(value.capability), + ...(readOptionalString(value, 'markdown') === undefined ? {} : { markdown: readString(value, 'markdown') }), + ...(readOptionalString(value, 'path') === undefined ? {} : { path: readString(value, 'path') }), + target: readString(value, 'target'), + }); +}; + +const staticDocument = (value: unknown): ServedStaticDocument => { + if (!hasAllowedKeys(value, ['body', 'frontmatter', 'id', 'kind', 'markdown', 'name', 'projections', 'provenance']) || + !isRecord(value.frontmatter) || !Array.isArray(value.projections) || + (value.kind !== 'command' && value.kind !== 'rule')) { + throw invalidResponse(); + } + return Object.freeze({ + body: readString(value, 'body'), + frontmatter: value.frontmatter, + id: readString(value, 'id'), + kind: value.kind, + markdown: readString(value, 'markdown'), + name: readString(value, 'name'), + projections: Object.freeze(value.projections.map(staticProjection)), + provenance: provenance(value.provenance), + }); +}; + /** Decodes the only unversioned Skill DTO contract emitted by the foreground server. */ const skillDocument = (value: unknown): ServedSkillDocument => { if (!isRecord(value)) throw invalidResponse(); @@ -121,10 +181,17 @@ const skillDocument = (value: unknown): ServedSkillDocument => { }; const skillTree = (value: unknown, kind: SkillDocumentBase['kind']): SkillDocumentTree => { - if (!hasAllowedKeys(value, ['diagnostics', 'skills']) || !Array.isArray(value.skills)) throw invalidResponse(); + if (!hasAllowedKeys(value, ['diagnostics', 'skills', 'staticDocuments']) || + !Array.isArray(value.skills) || !Array.isArray(value.staticDocuments)) { + throw invalidResponse(); + } const skills = value.skills.map(skillDocument); if (skills.some((skill) => skill.base.kind !== kind)) throw invalidResponse(); - return Object.freeze({ diagnostics: diagnostics(value.diagnostics), skills: Object.freeze(skills) }); + return Object.freeze({ + diagnostics: diagnostics(value.diagnostics), + skills: Object.freeze(skills), + staticDocuments: Object.freeze(value.staticDocuments.map(staticDocument)), + }); }; const documentResponse = (value: unknown, kind: SkillDocumentBase['kind']): ServedSkillDocument => { diff --git a/packages/workbench/tests/application-tree-model.test.ts b/packages/workbench/tests/application-tree-model.test.ts index 2341f5de5..95ae3951d 100644 --- a/packages/workbench/tests/application-tree-model.test.ts +++ b/packages/workbench/tests/application-tree-model.test.ts @@ -37,6 +37,7 @@ const skillTree: SkillDocumentTree = { provenance: { kind: 'conventional', sourcePath: 'skills/review/SKILL.md' }, resources: [], }], + staticDocuments: [], }; const inspection: ArtifactInspection = { diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index 166e48e0a..c660f3274 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -22,6 +22,7 @@ import { expectPrimaryNav, expectRenderedDocument, openWorkbench, + removeWatchedSource, runSelectedRoute, selectApplicationLeaf, workbenchTestId, @@ -87,6 +88,15 @@ e2e('drives the populated Skills Starter in real Chrome', { timeout: 90_000 }, a root: exampleRoot('skills-starter'), }); const ledger = createExampleErrorLedger(page, server.url); + const pluginRequests: string[] = []; + page.on('request', (request) => { + if (request.method() === 'POST' && ( + request.url().includes('/api/routes/invocations') || + request.url().includes('/api/mcp/sessions') + )) { + pluginRequests.push(request.url()); + } + }); try { const surface = await inspectWorkbenchSurface({ root: exampleRoot('skills-starter') }); await openWorkbench(page, server.url, '/'); @@ -101,6 +111,15 @@ e2e('drives the populated Skills Starter in real Chrome', { timeout: 90_000 }, a await expect(page.getByRole('heading', { level: 1, name: leaf.label, exact: true })).toBeVisible({ timeout: browserTimeout }); await expect(workbenchTestId(page, 'renderedDocument')).toBeVisible({ timeout: browserTimeout }); } + for (const kind of ['command', 'rule'] as const) { + const leaf = applicationLeaves(surface.application).find((entry) => entry.ref.kind === kind); + if (leaf === undefined) throw new Error(`Skills Starter surface is missing its ${kind} leaf.`); + await selectApplicationLeaf(page, server.url, leaf); + await expect(workbenchTestId(page, 'routeRun')).toHaveCount(0); + await expect(workbenchTestId(page, 'staticAuthoredDocument')).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: 'Host projections' })).toBeVisible({ timeout: browserTimeout }); + } + expect(pluginRequests).toEqual([]); await captureExampleState(page, 'skills-starter', 'skills-populated'); await openWorkbench(page, server.url, '/advanced/artifact'); @@ -122,6 +141,7 @@ e2e('reveals, retains, repairs, and removes capabilities without reloading Chrom const project = await copyExample('skills-starter'); const configPath = join(project.root, 'agent-bundle.config.ts'); const hookSource = join(project.root, 'src', 'hooks', 'session-start.ts'); + const ruleSource = join(project.root, 'src', 'rules', 'release-safety.mdc'); const originalConfig = await readFile(configPath, 'utf8'); const healthyHook = `export default () => ({\n additionalContext: 'Review the current operational evidence before changing production.',\n outcome: 'continue' as const,\n});\n`; const hookConfig = originalConfig.replace( @@ -142,6 +162,17 @@ e2e('reveals, retains, repairs, and removes capabilities without reloading Chrom await expectPrimaryNav(page); const before = await inspectWorkbenchSurface({ root: project.root }); expect(applicationLeaves(before.application).some((leaf) => leaf.ref.kind === 'event')).toBe(false); + const ruleLeaf = applicationLeaves(before.application).find((leaf) => leaf.ref.kind === 'rule'); + if (ruleLeaf === undefined) throw new Error('Skills Starter surface is missing its release rule.'); + + await selectApplicationLeaf(page, server.url, ruleLeaf); + const editedRule = `${await readFile(ruleSource, 'utf8')}\nVerify generated projections after every edit.\n`; + await editWatchedSource(server, project.root, ruleSource, editedRule, 'succeeded'); + await waitForWorkbenchIdle(page); + await expect(workbenchTestId(page, 'staticAuthoredDocument')).toContainText('Verify generated projections after every edit.'); + await removeWatchedSource(server, ruleSource); + await openWorkbench(page, server.url, '/'); + await expect(applicationLeafItem(page, ruleLeaf)).toHaveCount(0, { timeout: browserTimeout }); await editWatchedSource(server, project.root, configPath, hookConfig, 'succeeded'); await waitForWorkbenchIdle(page); diff --git a/packages/workbench/tests/skill-client.test.ts b/packages/workbench/tests/skill-client.test.ts index 1c5af3e36..daf3f1d1d 100644 --- a/packages/workbench/tests/skill-client.test.ts +++ b/packages/workbench/tests/skill-client.test.ts @@ -45,6 +45,31 @@ const generatedDocument = Object.freeze({ resources: Object.freeze([Object.freeze({ bytes: 42, relativePath: 'assets/diagram.svg' })]), }); +const staticDocument = Object.freeze({ + body: 'Review the release.', + frontmatter: Object.freeze({ description: 'Review release evidence' }), + id: 'command:review-release', + kind: 'command' as const, + markdown: 'Review the release.', + name: 'review-release', + projections: Object.freeze([ + Object.freeze({ + capability: Object.freeze({ + evidence: Object.freeze({ observedVersion: '2.1.260', target: 'claude' }), + state: 'supported' as const, + }), + markdown: 'Review the release.', + path: 'commands/review-release.md', + target: 'claude', + }), + Object.freeze({ + capability: Object.freeze({ reason: 'No command surface.', state: 'unavailable' as const }), + target: 'portable', + }), + ]), + provenance: Object.freeze({ kind: 'conventional' as const, sourcePath: 'src/commands/review-release.md' }), +}); + it('reads source and explicit generated documents only from typed workbench routes', async () => { const calls: string[] = []; const client = new SkillClient({ @@ -78,13 +103,17 @@ it('decodes and freezes canonical detached Skill DTOs from source and generated fetch: async (input) => String(input).includes('/epochs/') ? response({ document: generatedDocument }) : String(input).endsWith('/source') - ? response({ diagnostics: [], skills: [sourceDocument] }) + ? response({ diagnostics: [], skills: [sourceDocument], staticDocuments: [staticDocument] }) : response({ document: sourceDocument }), }); await expect(client.source('skill:review')).resolves.toEqual(sourceDocument); await expect(client.generated('epoch-01', 'portable', 'skill:review')).resolves.toEqual(generatedDocument); - await expect(client.sourceTree()).resolves.toEqual({ diagnostics: [], skills: [sourceDocument] }); + await expect(client.sourceTree()).resolves.toEqual({ + diagnostics: [], + skills: [sourceDocument], + staticDocuments: [staticDocument], + }); const document = await client.source('skill:review'); expect(Object.isFrozen(document)).toBe(true); diff --git a/packages/workbench/tests/support/workbench-acceptance.ts b/packages/workbench/tests/support/workbench-acceptance.ts index 1222a4bc9..c5bbdaa47 100644 --- a/packages/workbench/tests/support/workbench-acceptance.ts +++ b/packages/workbench/tests/support/workbench-acceptance.ts @@ -4,7 +4,11 @@ import type { Locator, Page } from 'playwright-core'; import type { CompletedBuildAttempt } from '../../../agent-bundle/src/dev/types.ts'; import { workbenchLeafPath } from '../../../agent-bundle/src/test/index.ts'; import { timeScale } from '../../../agent-bundle/tests/support/time-scale.ts'; -import { replaceWatchedSourceAndAwaitRebuild, type WatchedBuildSession } from '../../../agent-bundle/tests/support/watched-files.ts'; +import { + removeWatchedSourceAndAwaitRebuild, + replaceWatchedSourceAndAwaitRebuild, + type WatchedBuildSession, +} from '../../../agent-bundle/tests/support/watched-files.ts'; import { applicationLeaves, type ApplicationLeaf, type ApplicationTree } from '../../src/application/application-tree-model.ts'; import { routeInputLabel } from '../../src/routes/routes-model.ts'; import { waitForWorkbenchIdle, workbenchUrl } from './workbench-e2e.ts'; @@ -32,6 +36,7 @@ export const workbenchTestIds = Object.freeze({ routeStatus: 'route-status', routeWorkspace: 'route-workspace', shellBuildStatus: 'shell-build-status', + staticAuthoredDocument: 'static-authored-document', unknownRoute: 'unknown-route', workbenchLoading: 'workbench-loading', workbenchNav: 'workbench-nav', @@ -241,6 +246,17 @@ export const editWatchedSource = async ( await waitForBuildIdle(server, timeout); }; +export const removeWatchedSource = async ( + server: WatchedBuildSession, + path: string, + timeout = rebuildTimeout, +): Promise => { + await waitForBuildIdle(server, timeout); + const attempt = await removeWatchedSourceAndAwaitRebuild(server, path, { timeoutMs: timeout }); + expect(attempt.outcome, `rebuild after deleting ${path}: ${JSON.stringify(attempt.diagnostics)}`).toBe('succeeded'); + await waitForBuildIdle(server, timeout); +}; + export const runSelectedRoute = async (page: Page, timeout = browserTimeout): Promise => { await workbenchTestId(page, 'routeRun').click(); const status = workbenchTestId(page, 'routeStatus'); diff --git a/packages/workbench/tests/workbench-capabilities.test.ts b/packages/workbench/tests/workbench-capabilities.test.ts index e37a6f7a2..f43010288 100644 --- a/packages/workbench/tests/workbench-capabilities.test.ts +++ b/packages/workbench/tests/workbench-capabilities.test.ts @@ -175,7 +175,11 @@ const clientsFor = ({ }), }, skillClient: { - sourceTree: async () => ({ diagnostics: [], skills: Array.from({ length: skills }, () => skill) }), + sourceTree: async () => ({ + diagnostics: [], + skills: Array.from({ length: skills }, () => skill), + staticDocuments: [], + }), }, }); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 21137d5d5..c6724cacd 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -159,6 +159,7 @@ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/packed-host-install-proof.test.ts', 'packages/agent-bundle/tests/packed-native-smoke.test.ts', 'packages/agent-bundle/tests/packed-readonly-state-root.test.ts', + 'packages/agent-bundle/tests/packed-small-plugin.test.ts', 'packages/agent-bundle/tests/packed-stdio-projection.test.ts', 'packages/agent-bundle/tests/packed-web-command.test.ts', 'packages/agent-bundle/tests/public-api-packed.test.ts', diff --git a/website/docs/en/examples/hooks-and-scripts.mdx b/website/docs/en/examples/hooks-and-scripts.mdx index f3a5eeca2..900e1ea09 100644 --- a/website/docs/en/examples/hooks-and-scripts.mdx +++ b/website/docs/en/examples/hooks-and-scripts.mdx @@ -18,8 +18,8 @@ an authored script. ## What it proves - **A hook is authored as a handler, not a host document.** `src/hooks/session-start.ts` is one - module. The build lowers it into each host's own hook document shape and emits the wrapper each - host's document runs from the shared `hooks/` directory. See + module that calls the application-owned `releaseContext` function. The build bundles that plain + Node dependency into each host wrapper; it does not start an MCP server or render worker. See [Hooks](../guide/authoring/hooks.mdx). - **Both script declaration modes.** `verify-release` ships by convention — any unclaimed plain script under `src/scripts/` is discovered — while `detect-risk` stays explicitly configured @@ -40,6 +40,7 @@ an authored script. | Path | What it is | | --- | --- | | `src/hooks/session-start.ts` | The `sessionStart` handler that directs a release session through both checks. | +| `src/release-context.ts` | The shared application function used by the plain Hook handler. | | `src/scripts/verify-release.ts` | The manifest-backed packaging check, discovered by convention. | | `src/scripts/detect-risk.ts` | The risk-register check, explicitly configured to restrict its targets. | | `release/release-manifest.json` | The packaged release manifest, copied as an asset. | diff --git a/website/docs/en/examples/skills-starter.mdx b/website/docs/en/examples/skills-starter.mdx index 12e0a9b3d..7d1f34348 100644 --- a/website/docs/en/examples/skills-starter.mdx +++ b/website/docs/en/examples/skills-starter.mdx @@ -4,27 +4,28 @@ description: 'The Skills Starter example: three engineering-operations Skills di # Skills Starter -A Skills-only plugin. It proves that a bundle worth installing can be authored without declaring -anything but its identity and its targets — and that Skill quality can still be backed by -recorded evidence rather than a claim. +A static plugin of Skills, resources, one command, and one rule. It proves that useful host +content needs no MCP server, App route, state owner, or executable renderer. - **Run from the repository root:** `pnpm example:skills` - **Package:** `@agent-bundle-example/skills-starter` - **Public dependencies:** `agent-bundle` (`workspace:*`) -- **Targets:** `portable`, `codex`, `claude` +- **Targets:** `portable`, `codex`, `claude`, `cursor` - **Credentials:** none — both eval suites are deterministic and read checked-in fixtures - **Source:** [`examples/skills-starter`](https://github.com/ScriptedAlchemy/agent-bundle/tree/main/examples/skills-starter) ## What it proves -- **Convention discovery.** `agent-bundle.config.ts` declares the plugin and its three targets - and nothing else. Every `src/skills/*/SKILL.md` directory is found by convention, so the config - never lists a Skill. That is the authoring model described in +- **Convention discovery.** `agent-bundle.config.ts` declares the plugin and its four targets. + Skills, commands, and rules under `src/` are found by convention. That is the authoring model described in [Skills](../guide/authoring/skills.mdx). -- **One source, three host projections.** The same authored documents are lowered for the - portable, Codex, and Claude projections of one composite plugin root — `skills/` is emitted once +- **One source, four host projections.** The same authored documents are lowered for the + portable, Codex, Claude, and Cursor projections of one composite plugin root — `skills/` is emitted once and every selected host reads it. The Workbench's Source and Generated views show whether a host copied a document or adapted it. +- **No invented runtime.** The built artifact has no MCP executable, App runtime, Flight worker, + state owner, or notice kernel. The Workbench opens static leaves directly and reports unsupported + host projections instead of faking an invocation. - **Deterministic eval evidence.** The eval suites run through the deterministic harness against checked-in fixtures, so a Skill's coverage is a recorded run rather than an assertion. Coverage is labeled *indirect* because a deterministic harness cannot observe host Skill activation. @@ -36,6 +37,8 @@ recorded evidence rather than a claim. | `src/skills/incident-triage/` | A production incident from first signal through containment, evidence collection, and a handoff-ready update. | | `src/skills/dependency-upgrade/` | Dependency upgrade planning with API, runtime, rollout, and rollback checks. | | `src/skills/release-review/` | The evidence, severity, workflow, and final-report requirements for an explicit release review. | +| `src/commands/review-release.md` | A Claude and Cursor command that starts the release review. | +| `src/rules/release-safety.mdc` | A Cursor rule that keeps release decisions evidence-based. | | `evals/release-readiness.eval.ts` | The `release-readiness` suite and its deterministic `release-artifact-is-ready` case. | | `evals/engineering-operations.eval.ts` | The `engineering-operations` suite: `incident-handoff-is-actionable` and `upgrade-plan-has-rollback`. | @@ -48,13 +51,13 @@ it is needed. 1. **Application → Skills** lists `dependency-upgrade`, `incident-triage`, and `release-review`. Select one to render its document, then use the inspector to browse linked checklists and report templates or compare Source and Generated output. -2. **Advanced → Artifact** shows the composite plugin root. Switching the target between - portable, Codex, and Claude changes which projection is in focus, but every choice shows the - same tree — the one directory all three hosts read; enable details for each file's provenance. -3. **Advanced → Evals → Runs** defaults to the `release-readiness` suite. Run +2. **Application → Rules / Commands** opens authored content and each host projection directly. + Unsupported hosts show their capability reason; there is no Run action or plugin process. +3. **Advanced → Artifact** shows the composite plugin root and per-file provenance. +4. **Advanced → Evals → Runs** defaults to the `release-readiness` suite. Run `release-artifact-is-ready` and inspect the passing trial; it consumes only the checked-in evidence fixture. -4. To practice repair, make a reversible edit to the release policy, press **Rebuild**, and wait +5. To practice repair, make a reversible edit to the release policy, press **Rebuild**, and wait for a Failed or Idle result rather than a Building state. On failure, open **Problems** from the header count while Application keeps the last-good tree. Restore the checked-in policy and rebuild. The earlier eval is now stale for the changed build — rerun `release-readiness` diff --git a/website/docs/zh/examples/hooks-and-scripts.mdx b/website/docs/zh/examples/hooks-and-scripts.mdx index be3808de2..676738d9f 100644 --- a/website/docs/zh/examples/hooks-and-scripts.mdx +++ b/website/docs/zh/examples/hooks-and-scripts.mdx @@ -16,8 +16,8 @@ description: '钩子与脚本示例:一个 session-start 钩子、两个脚本 ## 它证明什么 -- **钩子是写成处理函数,而不是宿主文档。** `src/hooks/session-start.ts` 只是一个模块。构建会把它降级为 - 各宿主自己的钩子文档形状,并在共享的 `hooks/` 目录中输出各宿主文档所运行的包装器。见 +- **钩子是写成处理函数,而不是宿主文档。** `src/hooks/session-start.ts` 调用应用自有的 + `releaseContext` 函数。构建把这个普通 Node 依赖捆入各宿主包装器,不启动 MCP 服务器或渲染 worker。见 [钩子](../guide/authoring/hooks.mdx)。 - **两种脚本声明方式都在。** `verify-release` 按约定发布——`src/scripts/` 下任何未被声明占用的普通脚本 都会被发现——而 `detect-risk` 保持显式配置,因为它要把自己的 target 限制为 `portable`。示例故意让两种 @@ -35,6 +35,7 @@ description: '钩子与脚本示例:一个 session-start 钩子、两个脚本 | 路径 | 是什么 | | --- | --- | | `src/hooks/session-start.ts` | `sessionStart` 处理函数,把发布会话导向那两项检查。 | +| `src/release-context.ts` | 普通 Hook 处理函数调用的共享应用函数。 | | `src/scripts/verify-release.ts` | 以清单为依据的打包检查,按约定被发现。 | | `src/scripts/detect-risk.ts` | 风险登记表检查,显式配置以限制它的 target。 | | `release/release-manifest.json` | 被作为资源复制的打包发布清单。 | diff --git a/website/docs/zh/examples/skills-starter.mdx b/website/docs/zh/examples/skills-starter.mdx index 6a649ebfd..55235e2c3 100644 --- a/website/docs/zh/examples/skills-starter.mdx +++ b/website/docs/zh/examples/skills-starter.mdx @@ -4,24 +4,26 @@ description: 'Skills 起步项目示例:三个按约定发现的工程运维 S # Skills 起步项目 -一个只有 Skill 的插件。它证明:一个值得安装的捆绑包,除了自身标识与 target 之外可以什么都不声明—— -而 Skill 的质量仍然可以由记录下来的证据支撑,而不只是一句声称。 +一个由 Skill、资源、一条命令和一条规则组成的静态插件。它证明:有用的宿主内容不需要 MCP +服务器、App 路由、状态所有者或可执行渲染器。 - **在仓库根目录运行:** `pnpm example:skills` - **包名:** `@agent-bundle-example/skills-starter` - **公开依赖:** `agent-bundle`(`workspace:*`) -- **Target:** `portable`、`codex`、`claude` +- **Target:** `portable`、`codex`、`claude`、`cursor` - **凭据:** 不需要——两个 eval 套件都是确定性的,只读取签入的夹具 - **源码:** [`examples/skills-starter`](https://github.com/ScriptedAlchemy/agent-bundle/tree/main/examples/skills-starter) ## 它证明什么 -- **约定发现。** `agent-bundle.config.ts` 只声明插件与它的三个 target,别的什么都没有。每个 - `src/skills/*/SKILL.md` 目录都按约定被发现,因此配置从不列出任何 Skill。这正是 +- **约定发现。** `agent-bundle.config.ts` 声明插件与四个 target。`src/` 下的 Skill、命令和规则 + 都按约定发现。这正是 [Skills](../guide/authoring/skills.mdx) 中描述的编写模型。 -- **一份源码,三种宿主投影。** 同一批编写好的文档为同一个复合插件根目录中的 portable、Codex 与 Claude +- **一份源码,四种宿主投影。** 同一批文档为复合插件根目录中的 portable、Codex、Claude 与 Cursor 投影分别降级——`skills/` 只输出一份,由所有选中宿主共同读取。Workbench 的 Source 与 Generated 视图 会显示某个宿主究竟是复制还是改写了一份文档。 +- **不虚构运行时。** 构建产物没有 MCP 可执行文件、App 运行时、Flight worker、状态所有者或 notice + kernel。Workbench 直接打开静态叶子,并如实说明不支持的宿主投影,而不是伪造调用。 - **确定性的 eval 证据。** eval 套件通过确定性 harness 针对签入夹具运行,因此某个 Skill 的覆盖是一次 记录下来的运行,而不是一句断言。覆盖被标记为*间接*,因为确定性 harness 无法观察宿主端的 Skill 激活。 @@ -32,6 +34,8 @@ description: 'Skills 起步项目示例:三个按约定发现的工程运维 S | `src/skills/incident-triage/` | 一次生产事故,从最初信号到止损、证据收集,直到可直接交接的进展更新。 | | `src/skills/dependency-upgrade/` | 依赖升级规划,包含 API、运行时、灰度与回滚检查。 | | `src/skills/release-review/` | 一次显式发布评审所需的证据、严重级别、流程与最终报告要求。 | +| `src/commands/review-release.md` | 启动发布评审的 Claude 与 Cursor 命令。 | +| `src/rules/release-safety.mdc` | 让发布决策保持证据导向的 Cursor 规则。 | | `evals/release-readiness.eval.ts` | `release-readiness` 套件及其确定性用例 `release-artifact-is-ready`。 | | `evals/engineering-operations.eval.ts` | `engineering-operations` 套件:`incident-handoff-is-actionable` 与 `upgrade-plan-has-rollback`。 | @@ -42,13 +46,13 @@ description: 'Skills 起步项目示例:三个按约定发现的工程运维 S 1. **Application → Skills** 列出 `dependency-upgrade`、`incident-triage` 与 `release-review`。 选中其中一个以渲染其文档,然后用检查器浏览链接的清单与报告模板,或对比 Source 与 Generated 输出。 -2. **Advanced → Artifact** 显示复合插件根目录。在 portable、Codex 与 Claude 之间切换 target 只是改变 - 聚焦的投影,每种选择看到的都是同一棵树——三个宿主共同读取的那一个目录;为每个文件启用详情以查看 - provenance。 -3. **Advanced → Evals → Runs** 默认选中 `release-readiness` 套件。运行 +2. **Application → Rules / Commands** 直接打开编写内容和每个宿主投影。不支持的宿主显示能力原因, + 且没有 Run 操作或插件进程。 +3. **Advanced → Artifact** 显示复合插件根目录和每个文件的 provenance。 +4. **Advanced → Evals → Runs** 默认选中 `release-readiness` 套件。运行 `release-artifact-is-ready` 并查看通过的试次; 它只消费签入的证据夹具。 -4. 想演练修复,就对发布策略做一处可逆修改,按 **Rebuild**,并等待 Failed 或 Idle 结果,而不是 Building +5. 想演练修复,就对发布策略做一处可逆修改,按 **Rebuild**,并等待 Failed 或 Idle 结果,而不是 Building 状态。失败时从页眉计数打开 **Problems**,同时 Application 保留上一个可用的树。恢复签入的策略 并重建。此时先前那次 eval 对改动后的构建已经过期——重新运行 `release-readiness`,记录当前已修复 状态下的证据。 From a955c5cc611c8f50891e68df6634adb6cfe644c6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 00:34:49 +0000 Subject: [PATCH 2/5] chore: add small-plugin changeset --- .changeset/small-static-plugins.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/small-static-plugins.md diff --git a/.changeset/small-static-plugins.md b/.changeset/small-static-plugins.md new file mode 100644 index 000000000..1a1695ed4 --- /dev/null +++ b/.changeset/small-static-plugins.md @@ -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). From 38f12147b26ffbab56bf593475fdcd306e50eb10 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 00:55:40 +0000 Subject: [PATCH 3/5] fix: address small-plugin self-review --- examples/skills-starter/README.md | 4 +- .../skills-starter/agent-bundle.config.ts | 2 +- .../src/commands/review-release.md | 4 ++ .../src/dev/skill-document-service.ts | 6 ++- .../tests/examples-contract.test.ts | 9 +--- .../agent-bundle/tests/host-adapters.test.ts | 23 +++------ .../tests/packed-small-plugin.test.ts | 50 +++++++++++++------ .../tests/workbench-surface.test.ts | 2 +- website/docs/en/examples/skills-starter.mdx | 10 ++-- .../docs/en/guide/concepts/architecture.mdx | 9 ++-- .../docs/en/guide/start/project-structure.mdx | 6 +-- website/docs/zh/examples/skills-starter.mdx | 8 +-- .../docs/zh/guide/concepts/architecture.mdx | 9 ++-- .../docs/zh/guide/start/project-structure.mdx | 6 +-- 14 files changed, 79 insertions(+), 69 deletions(-) diff --git a/examples/skills-starter/README.md b/examples/skills-starter/README.md index 83a182a28..5463107f5 100644 --- a/examples/skills-starter/README.md +++ b/examples/skills-starter/README.md @@ -12,8 +12,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, Claude, - and Cursor targets. Skills, commands, and rules under `src/` are discovered +- `agent-bundle.config.ts` declares the plugin and its portable, Codex, and + 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 diff --git a/examples/skills-starter/agent-bundle.config.ts b/examples/skills-starter/agent-bundle.config.ts index 6a64f2d56..621f59888 100644 --- a/examples/skills-starter/agent-bundle.config.ts +++ b/examples/skills-starter/agent-bundle.config.ts @@ -6,5 +6,5 @@ export default defineConfig({ name: 'skills-starter', version: '1.0.0', }, - targets: ['portable', 'codex', 'claude', 'cursor'], + targets: ['portable', 'codex', 'cursor'], }); diff --git a/examples/skills-starter/src/commands/review-release.md b/examples/skills-starter/src/commands/review-release.md index 26890d7d0..caa1ed4a9 100644 --- a/examples/skills-starter/src/commands/review-release.md +++ b/examples/skills-starter/src/commands/review-release.md @@ -1,3 +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. diff --git a/packages/agent-bundle/src/dev/skill-document-service.ts b/packages/agent-bundle/src/dev/skill-document-service.ts index 92c2e1c98..c548bcb53 100644 --- a/packages/agent-bundle/src/dev/skill-document-service.ts +++ b/packages/agent-bundle/src/dev/skill-document-service.ts @@ -161,7 +161,11 @@ const staticDocument = ( const expectedPath = `${capabilityName}/${document.name}.${kind === 'command' ? 'md' : 'mdc'}`; const projections = model.targets.map(({ name: target }): StaticDocumentProjection => { const adapter = registry.get(target); - const capability = kind === 'command' ? adapter.capabilities.commands : adapter.capabilities.rules; + const capability = (kind === 'command' ? adapter.capabilities.commands : adapter.capabilities.rules) + ?? Object.freeze({ + reason: `Target ${JSON.stringify(target)} does not declare a ${capabilityName} capability.`, + state: 'unavailable' as const, + }); const entry = plans.get(target)!.entries.find((candidate) => candidate.relativePath === expectedPath && candidate.sourceInputs.includes(document.source)); return Object.freeze({ diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index 73d8b0b96..9ab192e1d 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -25,7 +25,7 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { model: { metadata: { name: 'skills-starter' }, scripts: [], - targets: [{ name: 'claude' }, { name: 'codex' }, { name: 'cursor' }, { name: 'portable' }], + targets: [{ name: 'codex' }, { name: 'cursor' }, { name: 'portable' }], }, state: 'ready', }); @@ -36,12 +36,7 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { expect(inspection.projectContext.packageVersion).toBeUndefined(); expect(projectVersionLabel(inspection.projectContext)).toContain('development fallback'); const built = await build({ output, root }); - const validation = await validate({ artifact: output, root }); - expect(validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); - expect(validation.diagnostics).toContainEqual(expect.objectContaining({ - code: 'AB6020', - target: 'claude', - })); + await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); expect(built.build.manifest.executables).toEqual({ bins: [], hooks: [], diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index f5fbf75b6..dfc33b641 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -1226,15 +1226,13 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen expect(registry.defaultTargetNames()).toEqual(['portable']); expect(Object.isFrozen(plugin)).toBe(true); - // Both projections share one composite root (#555): Codex keeps its hook - // and MCP documents beside its manifest, and shields Claude's conventional - // `hooks/hooks.json` with an empty document of its own. The install surface - // is composed once over the selection, not planned per host. + // Both projections share one composite root (#555): Codex keeps its MCP + // document beside its manifest. No Hook reaches Claude, so there is no + // conventional document to shield. The install surface is composed once. const codex = planEntries(plugin, 'codex'); const claude = planEntries(plugin, 'claude'); expect(codex.map((entry) => entry.relativePath)).toEqual([ codexArtifactPaths.marketplace, - codexArtifactPaths.hooksManifest, codexArtifactPaths.mcp, codexArtifactPaths.plugin, 'skills/review/SKILL.md', @@ -1255,18 +1253,13 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen kind: 'write', relativePath: codexArtifactPaths.marketplace, }, - { - content: '{"hooks":{}}\n', - kind: 'write', - relativePath: codexArtifactPaths.hooksManifest, - }, { content: '{"mcpServers":{"http":{"headers":{"Authorization":"Bearer literal"},"type":"streamable-http","url":"https://mcp.example.test/stream"},"stdio":{"args":["--root","./tools/server.mjs"],"command":"node","cwd":"./","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"./","CACHE_DIR":"cache"},"type":"stdio"}}}\n', kind: 'write', relativePath: codexArtifactPaths.mcp, }, { - content: '{"author":{"name":"review-tools"},"description":"Review code and explain findings.","hooks":"./.codex-plugin/hooks.json","interface":{"capabilities":["mcp","hooks","skills"],"category":"Productivity","defaultPrompt":["Help me use review-tools."],"developerName":"review-tools","displayName":"review-tools","longDescription":"Review code and explain findings.","shortDescription":"Review code and explain findings."},"mcpServers":"./.codex-plugin/mcp.json","name":"review-tools","skills":"./skills/","version":"1.2.3"}\n', + content: '{"author":{"name":"review-tools"},"description":"Review code and explain findings.","interface":{"capabilities":["mcp","skills"],"category":"Productivity","defaultPrompt":["Help me use review-tools."],"developerName":"review-tools","displayName":"review-tools","longDescription":"Review code and explain findings.","shortDescription":"Review code and explain findings."},"mcpServers":"./.codex-plugin/mcp.json","name":"review-tools","skills":"./skills/","version":"1.2.3"}\n', kind: 'write', relativePath: codexArtifactPaths.plugin, }, @@ -1295,7 +1288,6 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen { bytes: 8, kind: 'copy', relativePath: 'skills/review/references/guide.md', source: '/workspace/src/skills/review/references/guide.md' }, ]); expect(codex.map((entry) => entry.sourceInputs)).toEqual([ - ['/workspace/agent-bundle.config.ts'], ['/workspace/agent-bundle.config.ts'], ['/workspace/agent-bundle.config.ts'], ['/workspace/agent-bundle.config.ts', '/workspace/src/skills/review/SKILL.md'], @@ -2696,11 +2688,10 @@ it('keeps Codex plugin and marketplace interface validator contracts separate', readonly interface: Record; }; - // The root is shared with Claude, whose conventional `hooks/hooks.json` - // Codex would otherwise discover, so Codex points at an empty hooks - // document of its own and declares the capability (#555). + // The root is shared with Claude, but no Hook reaches Claude and therefore + // no conventional hooks document needs shielding (#555). expect(pluginManifest.interface).toMatchObject({ - capabilities: ['mcp', 'hooks', 'skills'], + capabilities: ['mcp', 'skills'], defaultPrompt: ['Help me use review-tools.'], developerName: 'review-tools', }); diff --git a/packages/agent-bundle/tests/packed-small-plugin.test.ts b/packages/agent-bundle/tests/packed-small-plugin.test.ts index 530283290..21f1eae1d 100644 --- a/packages/agent-bundle/tests/packed-small-plugin.test.ts +++ b/packages/agent-bundle/tests/packed-small-plugin.test.ts @@ -36,8 +36,9 @@ const run = ( cli: string, root: string, args: readonly string[], + env: NodeJS.ProcessEnv, ): Promise<{ readonly stderr: string; readonly stdout: string }> => - execFile(cli, [...args], { cwd: root, env: installedEnvironment() }); + execFile(cli, [...args], { cwd: root, env }); const assertSmallRuntime = async ( artifact: string, @@ -56,7 +57,7 @@ const assertSmallRuntime = async ( expect.stringMatching(/-flight\.mjs$/u), expect.stringMatching(/(?:^|\/)(?:app-renderer|flight|notices?|sqlite|state)(?:\/|\.|-|$)/u), ])); - expect(files.filter((path) => /(?:^|\/)mcp(?:-apps)?(?:\.json|\/)/u.test(path))).toEqual([]); + expect(files.filter((path) => /(?:^|\/)\.?mcp(?:-apps)?(?:\.json|\/)/u.test(path))).toEqual([]); if (expectedHooks === 0) { expect(files.filter((path) => /hooks\.json$/u.test(path))).toEqual([]); } @@ -68,9 +69,25 @@ it('keeps packed static and plain-hook plugins free of undeclared runtimes', asy const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-small-plugin-')); const staticRoot = join(consumer, 'static'); const hookRoot = join(consumer, 'plain-hook'); - const processStarts: string[] = []; + const processTrace = join(consumer, 'plugin-processes.txt'); + const processTracer = join(consumer, 'trace-plugin-process.cjs'); try { + await Promise.all([ + writeFile(processTrace, ''), + writeFile(processTracer, [ + "const { appendFileSync } = require('node:fs');", + "const { sep } = require('node:path');", + "if (process.argv[1]?.includes(`${sep}hooks${sep}`)) appendFileSync(process.env.AGENT_BUNDLE_PROCESS_TRACE, `${process.pid} ${process.argv[1]}\\n`);", + '', + ].join('\n')), + ]); + const baseEnvironment = installedEnvironment(); + const environment = { + ...baseEnvironment, + AGENT_BUNDLE_PROCESS_TRACE: processTrace, + NODE_OPTIONS: [baseEnvironment.NODE_OPTIONS, `--require=${processTracer}`].filter(Boolean).join(' '), + }; await Promise.all([ cp(join(examples, 'skills-starter'), staticRoot, { filter: (source) => !['.agent-bundle', 'node_modules'].includes(basename(source)), @@ -97,21 +114,21 @@ it('keeps packed static and plain-hook plugins free of undeclared runtimes', asy const hookConfig = (await readFile(join(hookRoot, 'agent-bundle.config.ts'), 'utf8')) .replace( ' plugin:', - " hooks: { sessionStart: { handler: './src/hooks/session-start.ts', targets: ['claude', 'codex'] } },\n plugin:", + " hooks: { sessionStart: { handler: './src/hooks/session-start.ts', targets: ['codex'] } },\n plugin:", ) .replace("name: 'skills-starter'", "name: 'skills-starter-hook'"); await writeFile(join(hookRoot, 'agent-bundle.config.ts'), hookConfig); await Promise.all([staticRoot, hookRoot].map((root) => execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { cwd: root, - env: installedEnvironment(), + env: environment, }))); - for (const [root, expectedHooks] of [[staticRoot, 0], [hookRoot, 2]] as const) { + for (const [root, expectedHooks] of [[staticRoot, 0], [hookRoot, 1]] as const) { const cli = join(root, 'node_modules', '.bin', 'agent-bundle'); const artifact = join(root, 'artifact'); const relocated = join(root, 'relocated'); - const { stdout: inspection } = await run(cli, root, ['inspect', '--json', '--root', root]); + const { stdout: inspection } = await run(cli, root, ['inspect', '--json', '--root', root], environment); const model = (JSON.parse(inspection) as { readonly model: { readonly mcpApps: readonly unknown[]; readonly mcpServers: readonly unknown[]; @@ -120,14 +137,14 @@ it('keeps packed static and plain-hook plugins free of undeclared runtimes', asy expect(model.mcpApps).toEqual([]); expect(model.mcpServers).toEqual([]); expect(model).not.toHaveProperty('state'); - await run(cli, root, ['build', '--root', root, '--output', artifact]); - await run(cli, root, ['validate', '--root', root, '--artifact', artifact]); + await run(cli, root, ['build', '--root', root, '--output', artifact], environment); + await run(cli, root, ['validate', '--root', root, '--artifact', artifact], environment); const manifest = await assertSmallRuntime(artifact, expectedHooks); await rename(artifact, relocated); - await run(cli, root, ['validate', '--root', root, '--artifact', relocated]); + await run(cli, root, ['validate', '--root', root, '--artifact', relocated], environment); if (expectedHooks === 0) { - expect(processStarts).toEqual([]); + expect(await readFile(processTrace, 'utf8')).toBe(''); const authored = (await readdir(join(root, 'src'), { recursive: true, withFileTypes: true })) .filter((entry) => entry.isFile()) .map((entry) => entry.name); @@ -135,18 +152,17 @@ it('keeps packed static and plain-hook plugins free of undeclared runtimes', asy const rebuilt = join(root, 'rebuilt'); const commandSource = join(root, 'src', 'commands', 'review-release.md'); await writeFile(commandSource, `${await readFile(commandSource, 'utf8')}\nReport the selected host projection.\n`); - await run(cli, root, ['build', '--root', root, '--output', rebuilt]); + await run(cli, root, ['build', '--root', root, '--output', rebuilt], environment); await expect(readFile(join(rebuilt, 'commands', 'review-release.md'), 'utf8')) .resolves.toContain('Report the selected host projection.'); await rm(join(root, 'src', 'rules', 'release-safety.mdc')); - await run(cli, root, ['build', '--root', root, '--output', rebuilt]); + await run(cli, root, ['build', '--root', root, '--output', rebuilt], environment); await expect(access(join(rebuilt, 'rules', 'release-safety.mdc'))).rejects.toThrow(); continue; } const hook = manifest.executables.hooks.find((entry) => entry.host === 'codex'); if (hook === undefined) throw new Error('Packed plain-hook artifact has no Codex hook executable.'); - processStarts.push(hook.path); const { stdout } = await run(cli, root, [ 'hooks', 'simulate', '--json', '--root', root, '--artifact', relocated, '--target', hook.host, '--hook', hook.id, @@ -156,13 +172,15 @@ it('keeps packed static and plain-hook plugins free of undeclared runtimes', asy source: 'acceptance', transcriptPath: join(root, 'transcript.jsonl'), }), - ]); + ], environment); expect(JSON.parse(stdout)).toMatchObject({ additionalContext: expect.stringContaining('packed-small'), outcome: 'continue', }); + expect((await readFile(processTrace, 'utf8')).trim().split('\n')).toEqual([ + expect.stringContaining(hook.path), + ]); } - expect(processStarts).toHaveLength(1); } finally { await rm(consumer, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/workbench-surface.test.ts b/packages/agent-bundle/tests/workbench-surface.test.ts index 7a3c5b5a7..eba6a050b 100644 --- a/packages/agent-bundle/tests/workbench-surface.test.ts +++ b/packages/agent-bundle/tests/workbench-surface.test.ts @@ -245,7 +245,7 @@ describe('the Workbench surface of the configured-only examples', () => { ], }); expect(surface.advanced).toEqual(['evals', 'artifact', 'hosts', 'logs']); - expect(surface.counts).toMatchObject({ hooks: 0, mcpServers: 0, scripts: 0, skills: 3, targets: 4 }); + expect(surface.counts).toMatchObject({ hooks: 0, mcpServers: 0, scripts: 0, skills: 3, targets: 3 }); }); }); diff --git a/website/docs/en/examples/skills-starter.mdx b/website/docs/en/examples/skills-starter.mdx index 7d1f34348..d60ff8b7c 100644 --- a/website/docs/en/examples/skills-starter.mdx +++ b/website/docs/en/examples/skills-starter.mdx @@ -10,17 +10,17 @@ content needs no MCP server, App route, state owner, or executable renderer. - **Run from the repository root:** `pnpm example:skills` - **Package:** `@agent-bundle-example/skills-starter` - **Public dependencies:** `agent-bundle` (`workspace:*`) -- **Targets:** `portable`, `codex`, `claude`, `cursor` +- **Targets:** `portable`, `codex`, `cursor` - **Credentials:** none — both eval suites are deterministic and read checked-in fixtures - **Source:** [`examples/skills-starter`](https://github.com/ScriptedAlchemy/agent-bundle/tree/main/examples/skills-starter) ## What it proves -- **Convention discovery.** `agent-bundle.config.ts` declares the plugin and its four targets. +- **Convention discovery.** `agent-bundle.config.ts` declares the plugin and its three targets. Skills, commands, and rules under `src/` are found by convention. That is the authoring model described in [Skills](../guide/authoring/skills.mdx). -- **One source, four host projections.** The same authored documents are lowered for the - portable, Codex, Claude, and Cursor projections of one composite plugin root — `skills/` is emitted once +- **One source, three host projections.** The same authored documents are lowered for the + portable, Codex, and Cursor projections of one composite plugin root — `skills/` is emitted once and every selected host reads it. The Workbench's Source and Generated views show whether a host copied a document or adapted it. - **No invented runtime.** The built artifact has no MCP executable, App runtime, Flight worker, @@ -37,7 +37,7 @@ content needs no MCP server, App route, state owner, or executable renderer. | `src/skills/incident-triage/` | A production incident from first signal through containment, evidence collection, and a handoff-ready update. | | `src/skills/dependency-upgrade/` | Dependency upgrade planning with API, runtime, rollout, and rollback checks. | | `src/skills/release-review/` | The evidence, severity, workflow, and final-report requirements for an explicit release review. | -| `src/commands/review-release.md` | A Claude and Cursor command that starts the release review. | +| `src/commands/review-release.md` | A Cursor command that starts the release review. | | `src/rules/release-safety.mdc` | A Cursor rule that keeps release decisions evidence-based. | | `evals/release-readiness.eval.ts` | The `release-readiness` suite and its deterministic `release-artifact-is-ready` case. | | `evals/engineering-operations.eval.ts` | The `engineering-operations` suite: `incident-handoff-is-actionable` and `upgrade-plan-has-rollback`. | diff --git a/website/docs/en/guide/concepts/architecture.mdx b/website/docs/en/guide/concepts/architecture.mdx index 56969a822..40ebb5a96 100644 --- a/website/docs/en/guide/concepts/architecture.mdx +++ b/website/docs/en/guide/concepts/architecture.mdx @@ -195,8 +195,7 @@ distribution form, or the application. `adapters/composite-layout.ts` owns the selection helpers: `sortedProjections`, `projectionIdentity` (sorted names joined by `+`, for -example `claude+codex`), `hookWrapperPath`, and -`folderDiscoveryShadowed`. +example `claude+codex`), and `hookWrapperPath`. Declaration-level `targets` on a command, rule, hook, or script still mean "this component reaches these hosts." A component is included when that @@ -249,9 +248,9 @@ then `codex`, `claude`, and `cursor`. There is no `plugin` adapter. (`hookWrapperPath`). - Codex and Cursor hook/MCP documents live beside their manifests (`.codex-plugin/hooks.json`, `.cursor-plugin/mcp.json`, …). When another - selected host claims the conventional `hooks/hooks.json` / `.mcp.json` / - `mcp.json` path, those projections emit an empty shield document so folder - discovery cannot load the other host's file. + selected host has a Hook or MCP server that reaches the conventional + `hooks/hooks.json` / `.mcp.json` / `mcp.json` path, those projections emit + an empty shield document so folder discovery cannot load that file. The `CompositePlan` the build stages carries `entries`, `hookEntries`, `selected`, `identity`, `projections` (one planned host each), `cliBin` diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index c610a9e85..64e2a8be1 100644 --- a/website/docs/en/guide/start/project-structure.mdx +++ b/website/docs/en/guide/start/project-structure.mdx @@ -135,9 +135,9 @@ artifact/ Host manifests live in their dotfolders; `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `assets/` are emitted once and shared. Hook and MCP documents appear when the project declares -hooks or MCP servers — plus one empty Codex or Cursor document whenever another selected host -claims the conventional `hooks/hooks.json`, `.mcp.json`, or `mcp.json` path, so that host's folder discovery -never loads the other host's file — and `bin/` when it has a routed CLI, when +hooks or MCP servers — plus one empty Codex or Cursor document when a Hook or MCP server reaches +another selected host's conventional `hooks/hooks.json`, `.mcp.json`, or `mcp.json` path, so folder +discovery never loads the other host's file — and `bin/` when it has a routed CLI, when [`web`](../../reference/configuration.mdx#web) is configured, or both. Two selected hosts that would write the same path with different bytes cannot share the root, and the build fails with `AB4103`; a command or rule scoped to some of the selected hosts but sitting in a diff --git a/website/docs/zh/examples/skills-starter.mdx b/website/docs/zh/examples/skills-starter.mdx index 55235e2c3..b4559c406 100644 --- a/website/docs/zh/examples/skills-starter.mdx +++ b/website/docs/zh/examples/skills-starter.mdx @@ -10,16 +10,16 @@ description: 'Skills 起步项目示例:三个按约定发现的工程运维 S - **在仓库根目录运行:** `pnpm example:skills` - **包名:** `@agent-bundle-example/skills-starter` - **公开依赖:** `agent-bundle`(`workspace:*`) -- **Target:** `portable`、`codex`、`claude`、`cursor` +- **Target:** `portable`、`codex`、`cursor` - **凭据:** 不需要——两个 eval 套件都是确定性的,只读取签入的夹具 - **源码:** [`examples/skills-starter`](https://github.com/ScriptedAlchemy/agent-bundle/tree/main/examples/skills-starter) ## 它证明什么 -- **约定发现。** `agent-bundle.config.ts` 声明插件与四个 target。`src/` 下的 Skill、命令和规则 +- **约定发现。** `agent-bundle.config.ts` 声明插件与三个 target。`src/` 下的 Skill、命令和规则 都按约定发现。这正是 [Skills](../guide/authoring/skills.mdx) 中描述的编写模型。 -- **一份源码,四种宿主投影。** 同一批文档为复合插件根目录中的 portable、Codex、Claude 与 Cursor +- **一份源码,三种宿主投影。** 同一批文档为复合插件根目录中的 portable、Codex 与 Cursor 投影分别降级——`skills/` 只输出一份,由所有选中宿主共同读取。Workbench 的 Source 与 Generated 视图 会显示某个宿主究竟是复制还是改写了一份文档。 - **不虚构运行时。** 构建产物没有 MCP 可执行文件、App 运行时、Flight worker、状态所有者或 notice @@ -34,7 +34,7 @@ description: 'Skills 起步项目示例:三个按约定发现的工程运维 S | `src/skills/incident-triage/` | 一次生产事故,从最初信号到止损、证据收集,直到可直接交接的进展更新。 | | `src/skills/dependency-upgrade/` | 依赖升级规划,包含 API、运行时、灰度与回滚检查。 | | `src/skills/release-review/` | 一次显式发布评审所需的证据、严重级别、流程与最终报告要求。 | -| `src/commands/review-release.md` | 启动发布评审的 Claude 与 Cursor 命令。 | +| `src/commands/review-release.md` | 启动发布评审的 Cursor 命令。 | | `src/rules/release-safety.mdc` | 让发布决策保持证据导向的 Cursor 规则。 | | `evals/release-readiness.eval.ts` | `release-readiness` 套件及其确定性用例 `release-artifact-is-ready`。 | | `evals/engineering-operations.eval.ts` | `engineering-operations` 套件:`incident-handoff-is-actionable` 与 `upgrade-plan-has-rollback`。 | diff --git a/website/docs/zh/guide/concepts/architecture.mdx b/website/docs/zh/guide/concepts/architecture.mdx index f97c1ab2f..8f376e6ce 100644 --- a/website/docs/zh/guide/concepts/architecture.mdx +++ b/website/docs/zh/guide/concepts/architecture.mdx @@ -172,8 +172,7 @@ Skill、命令、规则、配置中声明的钩子、手写的 MCP 入口以及 身份。`inspect --bundler` 仍然组合完整的所选集合。 `adapters/composite-layout.ts` 拥有选择相关的辅助函数:`sortedProjections`、 -`projectionIdentity`(排序后的名称以 `+` 连接,例如 `claude+codex`)、`hookWrapperPath` -与 `folderDiscoveryShadowed`。 +`projectionIdentity`(排序后的名称以 `+` 连接,例如 `claude+codex`)与 `hookWrapperPath`。 命令、规则、钩子或脚本上声明级的 `targets` 仍然表示“这个组件到达这些宿主”。当该集合 与所选集合有交集时,组件就会被包含。命令或规则若只限定在所选宿主的子集上,却位于另一个所选 @@ -217,9 +216,9 @@ Skill、命令、规则、配置中声明的钩子、手写的 MCP 入口以及 (`hooks/..mjs`);只到达单个宿主的钩子保留 `hooks/.mjs` (`hookWrapperPath`)。 - Codex 与 Cursor 的钩子/MCP 文档与各自的清单并列(`.codex-plugin/hooks.json`、 - `.cursor-plugin/mcp.json`……)。当另一个所选宿主认领了约定的 `hooks/hooks.json` / - `.mcp.json` / `mcp.json` 路径时,这些投影会发出一份空的屏蔽文档,使目录发现无法加载 - 另一个宿主的文件。 + `.cursor-plugin/mcp.json`……)。当另一个所选宿主有到达约定 `hooks/hooks.json` / + `.mcp.json` / `mcp.json` 路径的钩子或 MCP 服务器时,这些投影会发出一份空的屏蔽文档, + 使目录发现无法加载该文件。 构建阶段拿到的 `CompositePlan` 携带 `entries`、`hookEntries`、`selected`、`identity`、 `projections`(每个所选宿主一份已规划的投影)、`cliBin`(当任一所选宿主接纳路由式 CLI diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index 1a41373b2..4fef417db 100644 --- a/website/docs/zh/guide/start/project-structure.mdx +++ b/website/docs/zh/guide/start/project-structure.mdx @@ -128,9 +128,9 @@ artifact/ ``` 宿主清单位于各自的点目录中;`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 只输出一次、 -所有宿主共用。钩子与 MCP 文档在项目声明了钩子或 MCP 服务器时出现——此外,只要另一个所选宿主占用了 -约定路径 `hooks/hooks.json`、`.mcp.json` 或 `mcp.json`,Codex 或 Cursor 就会额外输出一份空文档,使该宿主的目录 -发现永远不会加载到其他宿主的文件——`bin/` 在项目有路由式 CLI、配置了 +所有宿主共用。钩子与 MCP 文档在项目声明了钩子或 MCP 服务器时出现——此外,当钩子或 MCP 服务器到达 +另一个所选宿主的约定路径 `hooks/hooks.json`、`.mcp.json` 或 `mcp.json` 时,Codex 或 Cursor 会额外输出一份 +空文档,使目录发现不会加载其他宿主的文件——`bin/` 在项目有路由式 CLI、配置了 [`web`](../../reference/configuration.mdx#web),或两者兼有时出现。两个所选宿主若要以不同字节写出同一路径,就无法共用根目录,构建会以 `AB4103` 失败;一个只面向 部分所选宿主的命令或规则,却位于另一个所选宿主会扫描的目录中,则是 `AB4105`。两者的恢复方式 相同:让该组件对每个所选宿主都完全一致,或把这些宿主分别构建到不同的产物中。 From 28023c2da19593212900871fe996443b540054ed Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 01:09:26 +0000 Subject: [PATCH 4/5] docs: finish small-plugin self-review --- packages/agent-bundle/tests/examples-contract.test.ts | 1 + website/docs/en/reference/targets-artifacts.mdx | 6 +++--- website/docs/zh/reference/targets-artifacts.mdx | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index 9ab192e1d..58323f193 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -29,6 +29,7 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { }, state: 'ready', }); + expect(inspection.diagnostics).toEqual([]); if (inspection.state !== 'ready') throw new Error('unreachable'); // Identity stages 1-2 (#94): no package.json version, so the release // axis is absent and displays fall back to the labeled dev form. diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index 4106b9a8a..1aba9b0db 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -80,9 +80,9 @@ layout. Claude Code and the portable Agent Plugins format load their documents f conventional plugin-root locations and cannot be redirected; Codex and Cursor manifests carry explicit `hooks` and MCP pointers, so their documents sit beside their manifests. Both of those hosts also fall back to folder discovery of the conventional paths when the pointer is absent, so -a Codex or Cursor projection with no document of its own still points at an empty one whenever -another selected host claims the conventional path — Cursor never loads Claude Code's -`hooks/hooks.json`. +a Codex or Cursor projection with no document of its own still points at an empty one when a Hook +or MCP server reaches another selected host's conventional path — Cursor never loads Claude +Code's `hooks/hooks.json`. ### Hook wrappers diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index d5fadd5d4..e9f5001e6 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -71,8 +71,8 @@ artifact/ 无论选择了哪些宿主,这些路径都是固定的,因此单宿主根目录与四宿主根目录共用同一种布局。Claude Code 与 portable 的 Agent Plugins 格式从约定的插件根位置加载文档,无法重定向;Codex 与 Cursor 的清单携带显式的 `hooks` 与 MCP 指针,因此它们的文档紧挨着各自的清单。这两个宿主在指针缺失时还会回退到对约定路径的目录 -发现,因此当另一个所选宿主占用了约定路径时,没有自己文档的 Codex 或 Cursor 投影仍会指向一份空文档—— -Cursor 绝不会加载 Claude Code 的 `hooks/hooks.json`。 +发现,因此当钩子或 MCP 服务器到达另一个所选宿主的约定路径时,没有自己文档的 Codex 或 Cursor 投影仍会 +指向一份空文档——Cursor 绝不会加载 Claude Code 的 `hooks/hooks.json`。 ### 钩子 wrapper From 97e8f188b91b46edf0e7ca96aeb99c14fe57bff2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 01:14:24 +0000 Subject: [PATCH 5/5] test: follow Skills Starter targets --- packages/workbench/tests/examples-real.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index c660f3274..809967eac 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -125,7 +125,7 @@ e2e('drives the populated Skills Starter in real Chrome', { timeout: 90_000 }, a await openWorkbench(page, server.url, '/advanced/artifact'); await expect(page.getByRole('heading', { name: 'Emitted files' })).toBeVisible({ timeout: browserTimeout }); await expect(page.locator('.artifact-table tbody tr').first()).toBeVisible({ timeout: browserTimeout }); - for (const host of ['portable', 'codex', 'claude']) { + for (const host of ['portable', 'codex', 'cursor']) { await expect(page.locator(`#artifact-projection option[value="${host}"]`)).toBeAttached({ timeout: browserTimeout }); } await captureExampleState(page, 'skills-starter', 'artifacts-populated');