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: 4
version: 5
---

# 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. Two 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, 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. 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.

**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 Down Expand Up @@ -44,7 +44,17 @@ Slugs are kebab-case and letter-led (`[a-z][a-z0-9-]*`). The sigils are a typed

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 -- the types whose bodies pass through the render pass. Rulebooks (embedded without that pass) and collections keep `dependencies:` / `members:`. Reserve a `dependencies:` entry for a non-inline edge; use a token for any invocation that appears in the body. _(Convention; not enforced.)_
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.)_

## 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 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.

## Collections

Expand Down
64 changes: 51 additions & 13 deletions packages/agents/src/__tests__/content-link-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { existsSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';

import { beforeAll, describe, expect, it } from 'vitest';

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

// A Markdown link in installable content is rewritten at install time by `rewriteMarkdownPaths`, which resolves a
// relative target against the *host* file's directory — the skill or subagent the link renders into, not the partial
Expand All @@ -18,16 +21,26 @@ import { expandIncludes } from '../lib/directive-expander.ts';
//
// Host roots only. A `_partials/` file is never installed standalone, and its links are authored against the host that
// inlines it — checking one in isolation would misresolve every `../` it carries. Include expansion below reaches them
// through each host, which is the only context where they mean anything. `guidance/` is copied verbatim with no link
// rewriting, so it is out of scope.
const HOST_ROOTS: ReadonlyArray<string> = ['skills', 'subagents'];
// through each host, which is the only context where they mean anything.
//
// `guidance/rulebooks/` is a host root because `sync` renders a rulebook body per harness and resolves its links the
// same way this test does: against the file's own place in the content tree. The rest of `guidance/` stays out of
// scope for the opposite reason. `_harnesses/` files are rewritten at install time, but anchored at the harness home
// they install into rather than at their source directory, so resolving one here against the source tree would
// misreport every link it carries. `shared/` installs verbatim to a harness-neutral location, which no rewritten path
// could name a harness in.
//
// A rulebook carries a second requirement the file-existence check cannot express: its target must be rooted in a tree
// that deploys under a harness home. A link to `subagents/canary.md` names a file that exists, so it satisfies
// everything above, and still fails every `sync`. The last suite below closes that gap over shipped rulebooks.
const RULEBOOK_ROOT = 'guidance/rulebooks';

const HOST_ROOTS: ReadonlyArray<string> = [RULEBOOK_ROOT, 'skills', 'subagents'];

const CONTENT_ROOT = new URL('../../content/', import.meta.url).pathname;

const HEADING_REGEX = /^#{1,6}\s+(.+?)\s*$/gm;

const MARKDOWN_LINK_REGEX = /\[[^\]]*\]\(([^)]+)\)/g;

type Reason = 'ambiguous-anchor' | 'dead-anchor' | 'missing-file';

interface Violation {
Expand Down Expand Up @@ -76,8 +89,10 @@ async function findViolations(): Promise<ReadonlyArray<Violation>> {
const file = path.relative(CONTENT_ROOT, hostFile);

for (const match of body.matchAll(MARKDOWN_LINK_REGEX)) {
const target = match[1];
if (target === undefined || !isRelativeTarget(target)) {
// The rewriter's own set, plus anchor-only targets: those name no file to rewrite, but they do name a fragment
// this test resolves against the host's own headings.
const target = match[2];
if (target === undefined || !(isRewritableLinkTarget(target) || target.startsWith('#'))) {
continue;
}

Expand Down Expand Up @@ -123,11 +138,6 @@ function formatViolations(violations: ReadonlyArray<Violation>): string {
return [header, ...lines].join('\n');
}

/** Reports whether a link target is one the install pipeline resolves — a relative path, an anchor, or both. */
function isRelativeTarget(target: string): boolean {
return !(/^https?:\/\//.test(target) || target.startsWith('/') || target.startsWith('~') || target.startsWith('{'));
}

async function readHeadingSlugs(
file: string,
cache: Map<string, ReadonlyMap<string, number>>,
Expand Down Expand Up @@ -191,3 +201,31 @@ describe('installable-content link resolution', () => {
expect(anchors, formatViolations(anchors)).toEqual([]);
});
});

describe('shipped rulebook link deliverability', () => {
it('every rulebook link target is rooted in a tree that deploys under a harness home', 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.
*/
async function findRulebookRejections(): Promise<ReadonlyArray<string>> {
const rulebookFiles: Array<string> = [];
await collectHostFiles(path.join(CONTENT_ROOT, RULEBOOK_ROOT), rulebookFiles);

const rejections: Array<string> = [];
for (const file of rulebookFiles) {
const slug = path.basename(file, '.md');
const { body } = parseRulebookFile(await readFile(file, 'utf8'), `${slug}.md`);
try {
renderRulebookBody(body, slug, { homeDir: '.claude', harnessId: 'claude' });
} catch (error) {
rejections.push(error instanceof Error ? error.message : String(error));
}
}
return rejections;
}
93 changes: 91 additions & 2 deletions packages/agents/src/commands/__tests__/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,95 @@ describe(syncCommand, () => {
});
});

describe('rulebook body rendering', () => {
/** Creates both harness home dirs so `harness: 'all'` detects claude and rovodev. */
async function detectBothHarnesses(): Promise<void> {
await mkdir(path.join(projectRoot, '.claude'), { recursive: true });
await mkdir(path.join(projectRoot, '.rovodev'), { recursive: true });
}

it('rewrites a relative link into the target harness absolute path in skill delivery', async () => {
await writeLibraryRulebook('alpha', 'delivery: skill', 'See [concision](../../skills/_data/concision.md).');
await declareRulebooks('alpha');

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

expect(await readFile(skillPath('consult-alpha'), 'utf8')).toContain(
'See [concision](~/.claude/skills/_data/concision.md).',
);
});

it('preserves an anchor fragment on a rewritten target', async () => {
await writeLibraryRulebook('alpha', 'delivery: skill', 'See [block](../../skills/_data/action-items.md#block).');
await declareRulebooks('alpha');

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

expect(await readFile(skillPath('consult-alpha'), 'utf8')).toContain(
'(~/.claude/skills/_data/action-items.md#block)',
);
});

it('expands harness template variables in the delivered body', async () => {
await writeLibraryRulebook('alpha', 'delivery: skill', 'Run {harness_home_dir}/scripts/x.sh as {harness_id}.');
await declareRulebooks('alpha');

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

expect(await readFile(skillPath('consult-alpha'), 'utf8')).toContain('Run ~/.claude/scripts/x.sh as claude.');
});

it('gives each harness its own absolute path, in both skill and ambient delivery', async () => {
await detectBothHarnesses();
await writeLibraryRulebook(
'alpha',
'delivery: [ambient, skill]',
'See [concision](../../skills/_data/concision.md).',
);
await declareRulebooks('alpha');

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

expect(await readFile(skillPath('consult-alpha', '.claude'), 'utf8')).toContain(
'~/.claude/skills/_data/concision.md',
);
expect(await readFile(skillPath('consult-alpha', '.rovodev'), 'utf8')).toContain(
'~/.rovodev/skills/_data/concision.md',
);
expect(await readFile(localHostPath('CLAUDE.local.md'), 'utf8')).toContain('~/.claude/skills/_data/concision.md');
expect(await readFile(localHostPath('AGENTS.local.md'), 'utf8')).toContain(
'~/.rovodev/skills/_data/concision.md',
);
});

it('fails the run when a link target is not under a linkable root, naming the rulebook and the target', async () => {
await writeLibraryRulebook('alpha', 'delivery: skill', 'See [canary](../../subagents/canary.md).');
await declareRulebooks('alpha');

await expect(syncCommand(makeOptions(), projectRoot, contentDir)).rejects.toThrow(
/alpha[\s\S]*subagents\/canary\.md/,
);
});

it('fails the run when a link target escapes the content root', async () => {
await writeLibraryRulebook('alpha', 'delivery: ambient', 'See [x](../../../elsewhere/a.md).');
await declareRulebooks('alpha');

await expect(syncCommand(makeOptions(), projectRoot, contentDir)).rejects.toThrow(/escapes the content root/);
});

it('fails a dry run on a bad link target, writing nothing', async () => {
await writeLibraryRulebook('alpha', 'delivery: [ambient, skill]', 'See [canary](../../subagents/canary.md).');
await declareRulebooks('alpha');

await expect(syncCommand(makeOptions({ dryRun: true }), projectRoot, contentDir)).rejects.toThrow(
/unusable Markdown link target/,
);
expect(existsSync(skillPath('consult-alpha'))).toBe(false);
expect(existsSync(localHostPath())).toBe(false);
});
});

describe('declared subagents', () => {
const CLAUDE_OVERLAY = unindent`
_tools:
Expand All @@ -1164,7 +1253,7 @@ describe(syncCommand, () => {
permissionMode: bypassPermissions

`;
const ROVODEV_OVERLAY = unindent`
const ROVO_OVERLAY = unindent`
_tools:
Read: open_files

Expand All @@ -1181,7 +1270,7 @@ describe(syncCommand, () => {
const dataDir = path.join(contentDir, 'subagents', '_data');
await mkdir(dataDir, { recursive: true });
await writeFile(path.join(dataDir, 'claude.yaml'), CLAUDE_OVERLAY, 'utf8');
await writeFile(path.join(dataDir, 'rovodev.yaml'), ROVODEV_OVERLAY, 'utf8');
await writeFile(path.join(dataDir, 'rovodev.yaml'), ROVO_OVERLAY, 'utf8');
}

/** Writes a fixture subagent `<slug>.md` into the temp content library's `subagents/`. */
Expand Down
Loading
Loading