Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/skill-ir-token-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agent-bundle": minor
---

Compile one Skill source into a canonical IR with a typed plugin-surface token registry and closed per-host lowering (#108).

Portable `SKILL.md` stays a byte-stable pass-through when no host extension or placeholder requires target-specific output. Claude, Cursor, and Codex receive only schema-legal documents (Claude frontmatter extensions, Cursor path/invocation fields, Codex `agents/openai.yaml`); unsupported tokens and unknown fields fail with AB3006–AB3010. Shared-vs-per-host `skills/` layout is an inspect-visible evidence decision for #101, not a hard-committed install tree. Rendered skills keep the existing `SKILL.tsx`/`SKILL.ts` build-time path — no live Flight client.
24 changes: 23 additions & 1 deletion packages/agent-bundle/src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,25 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput):
}
for (const skill of input.sharedCopyEntries === false ? [] : model.skills) {
if (!isSelected(skill.targets)) continue;
if (skill.markdown !== undefined) {
const hostDocument = skill.hostDocuments?.[targetName];
const generatedSkill = hostDocument !== undefined && !hostDocument.passThrough;
if (generatedSkill) {
entries.push({
content: hostDocument.skillMarkdown,
kind: 'write',
relativePath: `skills/${skill.name}/SKILL.md`,
sourceInputs: sourceInputs(skill.source),
});
for (const sidecar of hostDocument.sidecars) {
if (sidecar.content === undefined) continue;
entries.push({
content: sidecar.content.endsWith('\n') ? sidecar.content : `${sidecar.content}\n`,
kind: 'write',
relativePath: `skills/${skill.name}/${sidecar.relativePath}`,
sourceInputs: sourceInputs(skill.source, sidecar.source),
});
}
} else if (skill.markdown !== undefined) {
// A rendered skill's SKILL.md is compiled from its component module.
entries.push({
content: skill.markdown,
Expand All @@ -264,7 +282,11 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput):
sourceInputs: sourceInputs(skill.source),
});
}
const skipCopies = new Set(generatedSkill
? ['SKILL.md', ...hostDocument.sidecars.map((sidecar) => sidecar.relativePath)]
: []);
for (const resource of skill.resources) {
if (skipCopies.has(resource.relativePath)) continue;
entries.push({
bytes: resource.bytes,
kind: 'copy',
Expand Down
14 changes: 13 additions & 1 deletion packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ export interface ReadyInspectResult {
readonly hooks?: NormalizedPlugin['hooks'];
readonly routes?: RouteGraphInspection;
readonly skills?: NormalizedPlugin['skills'];
readonly skillTreeLayouts?: readonly {
readonly layout?: NormalizedPlugin['skills'][number]['skillTreeLayout'];
readonly skillId: string;
}[];
};
readonly state: 'ready';
}
Expand Down Expand Up @@ -527,7 +531,15 @@ export const inspect = async (options: InspectOptions): Promise<InspectResult> =
...(bundler === undefined ? {} : { bundler }),
...(options.focus === 'hooks' ? { hooks: model.hooks } : {}),
...(routes === undefined ? {} : { routes }),
...(options.focus === 'skills' ? { skills: model.skills } : {}),
...(options.focus === 'skills'
? {
skills: model.skills,
skillTreeLayouts: Object.freeze(model.skills.map((skill) => Object.freeze({
skillId: skill.id,
...(skill.skillTreeLayout === undefined ? {} : { layout: skill.skillTreeLayout }),
}))),
}
: {}),
});
return Object.freeze({
diagnostics: prepared.diagnostics,
Expand Down
11 changes: 10 additions & 1 deletion packages/agent-bundle/src/build/validate-artifact-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import type { TargetRegistry } from '../adapters/registry.ts';
import { parseSkillMarkdown, referencedResources } from '../config/skill-references.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { validateAgentSkillsFrontmatter } from '../schemas/agent-skills/contract.ts';
import {
validateClaudeSkillFrontmatter,
validateCursorSkillFrontmatter,
} from '../schemas/skill-hosts/contract.ts';
import {
artifactDiagnostic as diagnostic,
artifactDiagnosticRecoveries,
Expand Down Expand Up @@ -142,7 +146,12 @@ export const validateEmittedSkills = async (options: {
continue;
}

for (const issue of validateAgentSkillsFrontmatter(parsed.frontmatter)) {
const frontmatterIssues = skill.target === 'claude'
? validateClaudeSkillFrontmatter(parsed.frontmatter)
: skill.target === 'cursor'
? validateCursorSkillFrontmatter(parsed.frontmatter)
: validateAgentSkillsFrontmatter(parsed.frontmatter);
for (const issue of frontmatterIssues) {
const location = issue.field ?? (issue.instancePath === '' ? 'root' : issue.instancePath);
diagnostics.push(diagnostic(
'AB6015',
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-bundle/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export type { LoadedConfig, LoadConfigOptions } from './load.ts';
export { normalizeProject } from './normalize.ts';
export { parseSkill } from './skill.ts';
export type { SkillDocument, SkillResource } from './skill.ts';
export { defineSkill, Skill } from '../skills/define.ts';
export { inspectSkillProjection } from '../skills/inspect.ts';
export { parseSkillIr } from '../skills/parse-ir.ts';
export { lowerSkillIr } from '../skills/lower.ts';
export type { SkillIr, SkillHostDocument, SkillTreeLayoutDecision } from '../skills/ir.ts';
export { validateModel, validateSource } from './validate.ts';
export type AgentBundleConfig = CoreAgentBundleConfig
& ClaudeConfigExtension
Expand Down
38 changes: 38 additions & 0 deletions packages/agent-bundle/src/config/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,37 @@ import type { CompiledCliSurface } from '../routes/types.ts';
import { type DiscoveredProject, payloadDeclarationSource } from './discover.ts';
import type { LoadedConfig } from './load.ts';
import type { CanonicalAgentEvent } from '../routes/public.ts';
import type { SkillIr } from '../skills/ir.ts';
import { decideSkillTreeLayout, lowerSkillIr, lowerSkillIrForHosts } from '../skills/lower.ts';
import { parseSkillIr } from '../skills/parse-ir.ts';
import type { SkillHost } from '../skills/tokens.ts';
import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './script-routes.ts';

const isSkillHost = (name: string): name is SkillHost =>
name === 'claude' || name === 'codex' || name === 'cursor' || name === 'portable';

const loweringHosts = (targetNames: readonly string[]): SkillHost[] => {
const hosts = new Set<SkillHost>();
for (const name of targetNames) {
if (name === 'plugin') {
hosts.add('claude');
hosts.add('codex');
} else if (isSkillHost(name)) {
hosts.add(name);
}
}
return [...hosts];
};

const pluginSharedDocument = (skillIr: SkillIr) => {
const claude = lowerSkillIr(skillIr, 'claude');
const codex = lowerSkillIr(skillIr, 'codex');
if (claude.passThrough && codex.passThrough && claude.skillMarkdown === codex.skillMarkdown) {
return claude;
}
return lowerSkillIr(skillIr, 'portable');
};

const unique = (values: readonly string[]): string[] => [...new Set(values)];

const sortedUnique = (values: readonly string[]): string[] =>
Expand Down Expand Up @@ -886,6 +915,7 @@ export const normalizeProject = async (
kind: 'config',
sourcePath: loaded.configPath,
};
const skillHosts = loweringHosts(targetNames);
const skills: NormalizedSkill[] = discovered.skills.map((skill) => {
const frontmatter = structuredClone(skill.frontmatter);
const declaredName = frontmatter.name;
Expand All @@ -894,17 +924,25 @@ export const normalizeProject = async (
? declaredName
: basename(skill.dir);
const description = frontmatter.description;
const skillIr = parseSkillIr(skill);
const hostDocuments = {
...lowerSkillIrForHosts(skillIr, skillHosts),
...(targetNames.includes('plugin') ? { plugin: pluginSharedDocument(skillIr) } : {}),
};

return {
body: skill.body,
...(typeof description === 'string' ? { description } : {}),
dir: skill.dir,
frontmatter,
hostDocuments,
id: `skill:${name}`,
...(skill.rendered === true ? { markdown: skill.markdown } : {}),
name,
provenance: skillProvenance(loaded, skill.source),
resources: skill.resources.map((resource) => ({ ...resource })),
skillIr,
skillTreeLayout: decideSkillTreeLayout(hostDocuments),
source: skill.source,
targets: [...targetNames],
};
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-bundle/src/config/rendered-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const isRenderedSkillSourceName = (fileName: string): boolean =>
(renderedSkillFileNames as readonly string[]).includes(fileName);

export interface CompiledRenderedSkill {
readonly authoredTargets?: unknown;
readonly body: string;
readonly frontmatter: Record<string, unknown>;
/** The full compiled document: YAML frontmatter followed by the rendered body. */
Expand Down Expand Up @@ -120,8 +121,15 @@ export const compileRenderedSkill = async (source: string): Promise<RenderedSkil
}

const snapshot = structuredClone(frontmatter);
const skillExport = moduleExports.skill;
const authoredTargets = isPlainRecord(moduleExports.targets)
? structuredClone(moduleExports.targets)
: isPlainRecord(skillExport) && isPlainRecord(skillExport.targets)
? structuredClone(skillExport.targets)
: undefined;
return {
document: {
...(authoredTargets === undefined ? {} : { authoredTargets }),
body,
frontmatter: snapshot,
markdown: `---\n${serializedFrontmatter}---\n\n${body}`,
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-bundle/src/config/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export interface SkillDocument {
rendered?: true;
resources: SkillResource[];
source: string;
/** Typed `targets` export from a rendered skill, or peeled `targets` frontmatter. */
authoredTargets?: unknown;
}

const findProjectRoot = async (skillDir: string): Promise<string> => {
Expand Down Expand Up @@ -137,6 +139,9 @@ const parseRenderedSkill = async (
};
}
return {
...(compiled.document.authoredTargets === undefined
? {}
: { authoredTargets: compiled.document.authoredTargets }),
body: compiled.document.body,
diagnostics: [],
dir,
Expand Down
48 changes: 43 additions & 5 deletions packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './scri
import type { SkillDocument } from './skill.ts';
import { referencedResources } from './skill-references.ts';
import { validateAgentSkillsFrontmatter } from '../schemas/agent-skills/contract.ts';
import { parseSkillIr } from '../skills/parse-ir.ts';

const sourceDiagnostic = (
code: string,
Expand Down Expand Up @@ -831,10 +832,18 @@ const validateMcp = (
};

const validateSkill = (skill: SkillDocument): Diagnostic[] => {
const diagnostics = [...skill.diagnostics];
const name = skill.frontmatter.name;

diagnostics.push(...validateAgentSkillsFrontmatter(skill.frontmatter).map((issue) => {
const ir = parseSkillIr(skill);
const diagnostics = [...ir.diagnostics];
const name = ir.portable.name ?? skill.frontmatter.name;

diagnostics.push(...validateAgentSkillsFrontmatter({
...(ir.portable.allowedTools === undefined ? {} : { 'allowed-tools': ir.portable.allowedTools }),
...(ir.portable.compatibility === undefined ? {} : { compatibility: ir.portable.compatibility }),
...(ir.portable.description === undefined ? {} : { description: ir.portable.description }),
...(ir.portable.license === undefined ? {} : { license: ir.portable.license }),
...(ir.portable.metadata === undefined ? {} : { metadata: ir.portable.metadata }),
...(ir.portable.name === undefined ? {} : { name: ir.portable.name }),
Comment on lines +839 to +845

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the authored portable fields before sanitizing them

Validate the original portable subset rather than this reconstructed IR object. portableFrom drops invalid optional values (for example license: 42, compatibility: 42, or non-string metadata entries), so agent-bundle validate now reports these sources as valid even though the Agent Skills schema previously rejected them; a pass-through skill can then retain the invalid raw frontmatter until artifact validation runs during a build.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #205 (merged as 079a77d): the pinned Agent Skills frontmatter validation now runs on the raw authored portable fields before Skill IR sanitization, so invalid optional values fail with the AB4007-family diagnostics instead of surviving to build. Regression test in normalization.test.ts.

}).map((issue) => {
const location = issue.field ?? (issue.instancePath === '' ? 'root' : issue.instancePath);
return sourceDiagnostic(
issue.field === 'name' ? 'AB4002' : issue.field === 'description' ? 'AB4003' : 'AB4007',
Expand Down Expand Up @@ -1693,6 +1702,14 @@ export const validateModel = (
}
}

for (const skill of model.skills) {
for (const document of Object.values(skill.hostDocuments ?? {})) {
diagnostics.push(...document.diagnostics.filter((diagnostic) =>
diagnostic.code === 'AB3008' || diagnostic.code === 'AB3009' || diagnostic.code === 'AB3010',
));
}
}

for (const hook of model.hooks) {
for (const target of hook.targets) {
if (!registry.has(target)) {
Expand Down Expand Up @@ -1849,14 +1866,35 @@ export const validateModel = (
};
for (const target of model.targets) {
for (const skill of model.skills) {
if (skill.markdown !== undefined) {
const hostDocument = skill.hostDocuments?.[target.name];
const generatedSkill = hostDocument !== undefined && !hostDocument.passThrough;
if (generatedSkill) {
recordOutput(
posix.join(target.name, 'skills', skill.name, 'SKILL.md'),
skill.source,
target.name,
);
for (const sidecar of hostDocument.sidecars) {
recordOutput(
posix.join(target.name, 'skills', skill.name, sidecar.relativePath),
sidecar.source ?? skill.source,
target.name,
);
}
} else if (skill.markdown !== undefined) {
recordOutput(
posix.join(target.name, 'skills', skill.name, 'SKILL.md'),
skill.source,
target.name,
);
}
const generatedSidecars = new Set(
generatedSkill ? hostDocument.sidecars.map((sidecar) => sidecar.relativePath) : [],
);
for (const resource of skill.resources) {
if (generatedSkill && (resource.relativePath === 'SKILL.md' || generatedSidecars.has(resource.relativePath))) {
continue;
}
recordOutput(
posix.join(target.name, 'skills', skill.name, resource.relativePath),
resource.source,
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-bundle/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
CanonicalAgentEvent,
} from '../routes/public.ts';
import type { CompiledAgentRoute, CompiledCliCommand } from '../routes/types.ts';
import type { SkillHostDocument, SkillIr, SkillTreeLayoutDecision } from '../skills/ir.ts';
import type { CapabilityState } from './capabilities.ts';

export interface AgentBundlePluginConfig {
Expand Down Expand Up @@ -299,6 +300,11 @@ export interface NormalizedSkill {
readonly description?: string;
readonly dir: string;
readonly frontmatter: Readonly<Record<string, unknown>>;
/**
* Per-host lowered Skill documents. The artifact planner emits these
* instead of the authored bytes when `passThrough` is false.
*/
readonly hostDocuments?: Readonly<Record<string, SkillHostDocument>>;
readonly id: string;
/**
* The compiled SKILL.md document of a rendered skill (`SKILL.tsx`
Expand All @@ -309,6 +315,8 @@ export interface NormalizedSkill {
readonly name: string;
readonly provenance: SourceProvenance;
readonly resources: readonly NormalizedSkillResource[];
readonly skillIr?: SkillIr;
readonly skillTreeLayout?: SkillTreeLayoutDecision;
readonly source: string;
readonly targets: readonly string[];
}
Expand Down
15 changes: 15 additions & 0 deletions packages/agent-bundle/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ import type { PortableConfigExtension } from './adapters/portable.ts';
import type { AgentBundleConfig as CoreAgentBundleConfig } from './core/types.ts';

export { defineConfig, pathTokens, pluginRootEnvAnchor } from './core/types.ts';
export { defineSkill, Skill } from './skills/define.ts';
export {
classifySkillToken,
skillTokenSpellings,
} from './skills/tokens.ts';
export type {
ClaudeSkillExtension,
CodexSkillExtension,
CursorSkillExtension,
DefinedSkill,
SkillHost,
SkillIr,
SkillTokenId,
SkillTreeLayoutDecision,
} from './skills/index.ts';
export { canonicalAgentEvents } from './routes/public.ts';
export type {
AgentEventCanonicalIdentity,
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-bundle/src/schemas/skill-hosts/PROVENANCE.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"derivedSchemas": {
"claude-skill-frontmatter.schema.json": {
"bytes": 1837,
"sha256": "9c21ad7aaf73625e2b6d4e7da2fe96fb2d2b9e872c4bdfd7d2d37a7726d47221",
"url": "https://code.claude.com/docs/en/skills"
},
"codex-openai-yaml.schema.json": {
"bytes": 1387,
"sha256": "39e7ebf8eb004fd99b2cdf4b45019c37d464269fca6f2d4297f91a6b58be3d1a",
"url": "https://learn.chatgpt.com/docs/build-skills"
},
"cursor-skill-frontmatter.schema.json": {
"bytes": 1269,
"sha256": "f1fc5befced2cdb8a74af4859bd8fdd25266bfa4994b6884a6e42b22f960a40f",
"url": "https://prod.cursor.com/docs/skills"
}
},
"normativeTextWinsOnConflict": true,
"notes": "Closed, documentation-derived Skill host schemas for #108. Claude fields are the Claude Code Skill frontmatter extensions documented against the 2.1.250 adapter pin. Cursor fields are the Skills page (paths, disable-model-invocation, icon, color, legacy globs) against the 2026-08-28 Cursor pin; plugin-config ${VAR} interpolation is intentionally absent from Skill Markdown. Codex agents/openai.yaml is the documented interface/policy/dependencies sidecar; Codex documents no Skill Markdown interpolation engine. These schemas are not host-published machine-readable artifacts; they reject unknown fields the way the portable Agent Skills schema does.",
"observedVersions": {
"claude": "2.1.250",
"codex": "0.147.0",
"cursor": "2026-08-28"
},
"retrievedAt": "2026-09-01",
"validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Package builds do not download schemas or invoke host-side validators."
}
Loading
Loading