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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
slug: codeassembly-content-specification
description: The declaration contract for CodeAssembly skills, subagents, rulebooks, and collections -- frontmatter fields, dependency blocks, and invocation tokens.
delivery: skill
version: 5
version: 6
---

# CodeAssembly content specification
Expand All @@ -13,7 +13,7 @@ The declaration contract for CodeAssembly artifacts -- skills, subagents, rulebo

Every rule below belongs to one of three classes, marked where it appears.

**Validated on parse.** A malformed `slug` or `skill-name`, a `delivery` value outside `ambient`/`skill`, an unknown artifact-type key, a non-list value under one, and a `members:` block on anything but a collection each fail the run with an error naming the source file. Three more fail outside the parser: a token naming an artifact that does not exist fails the run with an error naming the slug and the directories searched, a rulebook link target outside a linkable root fails the run before anything is written, and a harness that declares no sigil is a type error at its `HarnessConfig` literal, so the build fails.
**Validated on parse.** A malformed `slug` or `skill-name`, a `delivery` value outside `ambient`/`skill`, an unknown artifact-type key, a non-list value under one, and a `members:` block on anything but a collection each fail the run with an error naming the source file. Four more fail outside the parser: a token naming an artifact that does not exist fails the run with an error naming the slug and the directories searched, a rulebook link target outside a linkable root fails the run before anything is written, a rulebook token naming a target that deploys no skill to invoke fails the same pre-write pass, and a harness that declares no sigil is a type error at its `HarnessConfig` literal, so the build fails.

**Enforced by test.** The suites in `packages/agents/src/__tests__/` read the shipped library and assert its conventions hold. A rule one of them guards names its test.

Expand All @@ -37,24 +37,31 @@ dependencies:

When a skill or subagent invocation appears inline in a skill's or subagent's body, write it as a token rather than a hardcoded harness-specific form:

- `{rulebook:<slug>}` renders to the harness skill sigil plus the skill name the named rulebook deploys under -- its `skill-name` when it declares one, `consult-<slug>` otherwise. Because it resolves through the target rather than echoing the slug, an override on the target stays honest at every call site.
- `{skill:<slug>}` renders to the harness skill sigil plus the slug -- `/<slug>` on Claude, `!<slug>` on Rovo.
- `{subagent:<slug>}` renders to the harness subagent sigil plus the slug. That sigil is empty on both current harnesses, so it renders to the bare slug, which is how a subagent is dispatched on each.

Slugs are kebab-case and letter-led (`[a-z][a-z0-9-]*`). The sigils are a typed property of each harness in `HarnessConfig`, so a new harness must declare its own rendering or the build fails.

A token is also a dependency edge: `sync` extracts the tokens from a skill's or subagent's include-expanded body and pulls each target into the deploy closure. An inline invocation is therefore expressed once, as the token -- it needs no duplicate `dependencies:` entry, and a token naming a non-existent artifact fails the run just as a missing `dependencies:` edge does. Because extraction runs on the include-expanded body, a token inside a shared `_partials` file becomes an edge for every skill that includes it.

Tokens are honored only in skills and subagents. A rulebook body does pass through a render pass, but a narrower one: it receives link and template rewriting (see [Links in rulebook bodies](#links-in-rulebook-bodies)) and not token rewriting, which is why rulebooks keep `dependencies:`, as collections keep `members:`. Reserve a `dependencies:` entry for a non-inline edge; use a token for any invocation that appears in the body. _(Convention; not enforced.)_
Rulebooks, skills, and subagents all honor tokens; collections carry no body to render. `{rulebook:<slug>}` is the exception: only a rulebook body renders one, because `install` deploys skills without resolving a declaration and so has no rulebook to resolve against. A rulebook token elsewhere fails the run, as does one naming a rulebook that deploys no skill -- an `ambient`-only target is already in the reader's context, so there is nothing to route to. Express that relationship with `dependencies:` instead.

Only `{rulebook:<slug>}` is checked for deployability. A `{skill:<slug>}` or `{subagent:<slug>}` token renders on every harness the body reaches, including one its target does not deploy to: a skill that narrows itself with `harnesses:` still renders an invocation elsewhere. Name such a skill only where the surrounding text already scopes it to that harness. _(Convention; not enforced.)_

Reserve a `dependencies:` entry for a non-inline edge; use a token for any invocation that appears in the body. _(Convention; not enforced.)_

## Links in rulebook bodies

A rulebook addresses a file by linking to it, not by naming it in prose. Author the target relative to the rulebook's own place in the content tree, which is `guidance/rulebooks/<slug>.md`, and `sync` emits the absolute path each target harness can follow. A target of `../../skills/_data/concision.md` reaches Claude as `~/.claude/skills/_data/concision.md` and Rovo as `~/.rovodev/skills/_data/concision.md`. `{harness_home_dir}` and `{harness_id}` expand per harness, including where one opens a link target.

A rulebook may link only into `skills/` and `scripts/`, the two trees whose source layout matches where they deploy under every harness home. Any other target fails the run, with an error naming the rulebook, the target as authored, and whether it resolved outside a linkable root or escaped the content root. `subagents/` is rejected because a subagent is dispatched rather than read, so no link into one is worth authoring; `_partials/`, `collections/`, and `guidance/` never deploy as files, so a link into one would name nothing. Nor can a rulebook link to the skill another rulebook delivers: that `SKILL.md` is generated rather than authored, and `dependencies:` already expresses the relationship. _(Validated on parse.)_
A rulebook may link only into `skills/` and `scripts/`, the two trees whose source layout matches where they deploy under every harness home. Any other target fails the run, with an error naming the rulebook, the target as authored, and why it was rejected. `subagents/` is rejected because a subagent is dispatched rather than read, so no link into one is worth authoring; `_partials/` and `collections/` never deploy as files, so a link into one would name nothing.

A link to a sibling rulebook is rejected too, and its error names the `{rulebook:<slug>}` token that addresses it instead. A rulebook is invoked rather than read: the skill it deploys is discovered by name, so an invocation resolves wherever it was deployed, while a path would be right in one domain and dead in the other. _(Validated on parse.)_

A target that is rooted correctly but names a file that has moved or been deleted is caught separately, by `content-link-resolution.test.ts`, which also resolves every anchor fragment to exactly one heading. _(Enforced by test.)_

One limitation is worth knowing before writing a rulebook that documents linking: rewriting runs over the whole body, so a Markdown link inside a code fence or an inline code span is rewritten along with the rest. A rulebook cannot show a relative link verbatim as an example, and must describe the target instead.
One limitation is worth knowing before writing a rulebook that documents linking: rewriting runs over the whole body, so a Markdown link inside a code fence or an inline code span is rewritten along with the rest. A rulebook cannot show a relative link verbatim as an example, and must describe the target instead. Invocation tokens rewrite the same way, so an example token keeps the `<slug>` placeholder rather than naming a real artifact.

## Collections

Expand Down
38 changes: 31 additions & 7 deletions packages/agents/src/__tests__/content-link-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import path from 'node:path';
import { beforeAll, describe, expect, it } from 'vitest';

import { expandIncludes } from '../lib/directive-expander.ts';
import type { RulebookInvocationCatalog } from '../lib/invocation-tokens.ts';
import { isRewritableLinkTarget, MARKDOWN_LINK_REGEX } from '../lib/path-rewriter.ts';
import { parseRulebookFile } from '../lib/rulebook-schema.ts';
import { resolveSkillName } from '../lib/rulebook-skill.ts';
import { renderRulebookBody } from '../lib/rulebook-transform.ts';

// A Markdown link in installable content is rewritten at install time by `rewriteMarkdownPaths`, which resolves a
Expand Down Expand Up @@ -202,27 +204,49 @@ describe('installable-content link resolution', () => {
});
});

describe('shipped rulebook link deliverability', () => {
it('every rulebook link target is rooted in a tree that deploys under a harness home', async () => {
describe('shipped rulebook reference deliverability', () => {
it('every rulebook link target and invocation token names something that deploys', async () => {
const rejections = await findRulebookRejections();
expect(rejections, rejections.join('\n')).toEqual([]);
});
});

/**
* Renders every shipped rulebook the way `sync` does, collecting the error from each that names an undeliverable link
* target. The root allowlist is lexical and harness-invariant, so one harness context stands for all of them.
* target or an unusable `{rulebook:<slug>}` token. Every shipped rulebook stands in for the deployed set, which is the
* strictest catalog available here: a token naming one that is missing or ambient-only has nothing to invoke under any
* declaration. The root allowlist and the catalog are both harness-invariant, so one harness context stands for all.
*/
async function findRulebookRejections(): Promise<ReadonlyArray<string>> {
const rulebookFiles: Array<string> = [];
await collectHostFiles(path.join(CONTENT_ROOT, RULEBOOK_ROOT), rulebookFiles);

const parsed = await Promise.all(
rulebookFiles.map(async (file) => {
const slug = path.basename(file, '.md');
const { rulebook, body } = parseRulebookFile(await readFile(file, 'utf8'), `${slug}.md`);
return {
slug,
body,
skillName: resolveSkillName(slug, rulebook['skill-name']),
skill: rulebook.delivery.includes('skill'),
};
}),
);
const rulebooks: RulebookInvocationCatalog = new Map(
parsed.map(({ slug, skillName, skill }) => [slug, { skillName, skill }]),
);

const rejections: Array<string> = [];
for (const file of rulebookFiles) {
const slug = path.basename(file, '.md');
const { body } = parseRulebookFile(await readFile(file, 'utf8'), `${slug}.md`);
for (const { slug, body } of parsed) {
try {
renderRulebookBody(body, slug, { homeDir: '.claude', harnessId: 'claude' });
renderRulebookBody(body, slug, {
homeDir: '.claude',
harnessId: 'claude',
skillSigil: '/',
subagentSigil: '',
rulebooks,
});
} catch (error) {
rejections.push(error instanceof Error ? error.message : String(error));
}
Expand Down
98 changes: 95 additions & 3 deletions packages/agents/src/commands/__tests__/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,56 @@ describe(syncCommand, () => {
expect(shared).toContain('<!-- codeassembly-rulebook:bar -->');
});

describe('rulebook invocation tokens', () => {
it('renders a rulebook token to each harness sigil in both delivery passes', async () => {
await writeLibraryRulebook('nmr-scripts', 'delivery: skill', 'Script rules.');
await writeLibraryRulebook('nmr-cheatsheet', 'delivery: [ambient, skill]', 'See {rulebook:nmr-scripts}.');
await declareRulebooks('nmr-cheatsheet', 'nmr-scripts');
await mkdir(path.join(projectRoot, '.claude'), { recursive: true });
await mkdir(path.join(projectRoot, '.rovodev'), { recursive: true });

await syncCommand(makeOptions({ harness: 'all' }), projectRoot, contentDir);

expect(await readFile(localHostPath('CLAUDE.local.md'), 'utf8')).toContain('See /consult-nmr-scripts.');
expect(await readFile(localHostPath('AGENTS.local.md'), 'utf8')).toContain('See !consult-nmr-scripts.');
expect(await readFile(skillPath('consult-nmr-cheatsheet'), 'utf8')).toContain('See /consult-nmr-scripts.');
expect(await readFile(skillPath('consult-nmr-cheatsheet', '.rovodev'), 'utf8')).toContain(
'See !consult-nmr-scripts.',
);
});

it('deploys a rulebook named only by a body token', async () => {
await writeLibraryRulebook('nmr-scripts', 'delivery: skill', 'Script rules.');
await writeLibraryRulebook('nmr-cheatsheet', 'delivery: ambient', 'See {rulebook:nmr-scripts}.');
await declareRulebooks('nmr-cheatsheet');

await syncCommand(makeOptions(), projectRoot, contentDir);

expect(await readFile(skillPath('consult-nmr-scripts'), 'utf8')).toContain('Script rules.');
});

it('renders a token through the skill-name override on its target', async () => {
await writeLibraryRulebook('shell-conventions', 'delivery: skill\nskill-name: shell-rules', 'Shell rules.');
await writeLibraryRulebook('hub', 'delivery: ambient', 'See {rulebook:shell-conventions}.');
await declareRulebooks('hub');

await syncCommand(makeOptions(), projectRoot, contentDir);

expect(await readFile(localHostPath(), 'utf8')).toContain('See /shell-rules.');
});

it('fails a dry run with nothing written when a token names an ambient-only rulebook', async () => {
await writeLibraryRulebook('nmr-cheatsheet', 'delivery: ambient', 'Cheatsheet rules.');
await writeLibraryRulebook('hub', 'delivery: ambient', 'See {rulebook:nmr-cheatsheet}.');
await declareRulebooks('hub');

await expect(syncCommand(makeOptions({ dryRun: true }), projectRoot, contentDir)).rejects.toThrow(
/\{rulebook:nmr-cheatsheet\}[\s\S]*ambient-only/,
);
expect(existsSync(localHostPath())).toBe(false);
});
});

describe('declared sources', () => {
let sourceDir: string;

Expand Down Expand Up @@ -930,6 +980,16 @@ describe(syncCommand, () => {
await writeFile(path.join(projectRoot, '.agents', 'codeassembly.yaml'), content, 'utf8');
}

it('fails a dry run with nothing written when a skill body carries a rulebook token', async () => {
await writeLibrarySkill('people-report', { body: 'See {rulebook:nmr-scripts}.' });
await declareSkills('people-report');

await expect(syncCommand(makeOptions({ dryRun: true }), projectRoot, contentDir)).rejects.toThrow(
/\{rulebook:nmr-scripts\} in skills\/people-report\/SKILL\.md[\s\S]*only in a rulebook body/,
);
expect(existsSync(skillPath('people-report'))).toBe(false);
});

it('deploys a declared skill into the project-local skills dir with the ownership marker', async () => {
await writeLibrarySkill('people-report');
await declareSkills('people-report');
Expand Down Expand Up @@ -1274,11 +1334,13 @@ describe(syncCommand, () => {
}

/** Writes a fixture subagent `<slug>.md` into the temp content library's `subagents/`. */
async function writeLibrarySubagent(slug: string): Promise<void> {
async function writeLibrarySubagent(
slug: string,
{ body = `# ${slug}\n\nUse {tool:Read}; run \`{harness_home_dir}/scripts/x.sh\`.` }: { body?: string } = {},
): Promise<void> {
const dir = path.join(contentDir, 'subagents');
await mkdir(dir, { recursive: true });
const content = `# ${slug}\n\nUse {tool:Read}; run \`{harness_home_dir}/scripts/x.sh\`.`;
await writeFile(path.join(dir, `${slug}.md`), `---\nname: ${slug}\n---\n\n${content}\n`, 'utf8');
await writeFile(path.join(dir, `${slug}.md`), `---\nname: ${slug}\n---\n\n${body}\n`, 'utf8');
}

/** Writes the project-scope codeassembly.yaml declaring the given subagent slugs. */
Expand All @@ -1289,6 +1351,36 @@ describe(syncCommand, () => {
await writeFile(path.join(projectRoot, '.agents', 'codeassembly.yaml'), `subagents:\n${useBlock}`, 'utf8');
}

it('fails a dry run with nothing written when a subagent body carries a rulebook token', async () => {
await writeOverlays();
await writeLibrarySubagent('canary', { body: 'See {rulebook:nmr-scripts}.' });
await declareSubagents('canary');

await expect(syncCommand(makeOptions({ dryRun: true }), projectRoot, contentDir)).rejects.toThrow(
/\{rulebook:nmr-scripts\} in subagents\/canary\.md[\s\S]*only in a rulebook body/,
);
expect(existsSync(subagentPath('canary'))).toBe(false);
});

it('fails a real sync before the ambient host is written when a subagent body carries a rulebook token', async () => {
// Subagents deploy last, so this pins the failure ahead of the earlier ambient and skill write passes.
await writeOverlays();
await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.');
await writeLibrarySubagent('canary', { body: 'See {rulebook:nmr-scripts}.' });
await mkdir(path.join(projectRoot, '.agents'), { recursive: true });
await writeFile(
path.join(projectRoot, '.agents', 'codeassembly.yaml'),
'rulebooks:\n use:\n - alpha\nsubagents:\n use:\n - canary\n',
'utf8',
);

await expect(syncCommand(makeOptions(), projectRoot, contentDir)).rejects.toThrow(
/\{rulebook:nmr-scripts\} in subagents\/canary\.md/,
);
expect(existsSync(localHostPath())).toBe(false);
expect(existsSync(subagentPath('canary'))).toBe(false);
});

it('deploys a declared subagent with the transform applied and the ownership marker, no provenance marker', async () => {
await writeOverlays();
await writeLibrarySubagent('canary');
Expand Down
Loading
Loading