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
2 changes: 1 addition & 1 deletion packages/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Each source is a `{ name, path }` pair (both required). A relative `path` resolv

**Precedence.** A later-declared source shadows an earlier one, and any source shadows the library, so a source can override a same-slug library artifact. Repeating a source `name` remaps its path and moves it ahead of the sources declared before it. Because paths are `.agents/`-relative, commit only repo-relative source paths in `codeassembly.yaml`; confine machine-specific and absolute paths to `codeassembly.local.yaml`. A higher-precedence tier's `root: true` discards previously-declared sources exactly as it discards `rulebooks`, `skills`, `subagents`, and `collections`.

This release resolves **rulebooks** through sources — a rulebook's body and its `dependencies:` closure both resolve from the source that owns it, and a source rulebook's ownership, sentinel, and retraction semantics are identical to a library rulebook's. A declared **skill** or **subagent** that resolves from a non-library source fails loudly as not-yet-supported, naming the source, rather than silently resolving. A declared source that is missing or not a directory fails the run — dry-run included — before any file is written, and a slug found in no source or the library fails with an error naming every location searched.
Rulebooks, skills, and subagents all resolve through sources: an artifact's body and its `dependencies:` closure resolve from the source that owns it, with ownership and retraction semantics identical to a library artifact's. A source-resolved skill or subagent expands its `<!-- include: … -->` directives against its own source root — it can reuse partials within its own source tree, but a target that resolves outside that root fails. A declared **collection** that resolves from a non-library source fails loudly as not-yet-supported, naming the source. A declared source that is missing or not a directory fails the run — dry-run included — before any file is written, and a slug found in no source or the library fails with an error naming every location searched.

### Scopes

Expand Down
37 changes: 37 additions & 0 deletions packages/agents/src/commands/__tests__/sync-sources.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ describe('sync with a declared source (real library fallback)', () => {
}

const neutralPath = (slug: string): string => path.join(projectRoot, '.agents', 'rulebooks', `${slug}.md`);
const skillPath = (slug: string): string => path.join(projectRoot, '.claude', 'skills', slug, 'SKILL.md');
const subagentPath = (slug: string): string => path.join(projectRoot, '.claude', 'agents', `${slug}.md`);

it('deploys a source ambient rulebook and a real library rulebook together, then retracts the source one', async () => {
await writeFile(
Expand All @@ -67,4 +69,39 @@ describe('sync with a declared source (real library fallback)', () => {
'<!-- rulebook:org-rules -->',
);
});

it('deploys a source skill and source subagent — expanding a source-local include — then retracts them', async () => {
await mkdir(path.join(sourceDir, 'skills', 'org-skill', '_partials'), { recursive: true });
await writeFile(
path.join(sourceDir, 'skills', 'org-skill', '_partials', 'frag.md'),
'Shared org fragment.\n',
'utf8',
);
await writeFile(
path.join(sourceDir, 'skills', 'org-skill', 'SKILL.md'),
'---\nname: org-skill\n---\n\n# Org skill\n\n<!-- include: _partials/frag.md / -->\n',
'utf8',
);
await mkdir(path.join(sourceDir, 'subagents'), { recursive: true });
await writeFile(
path.join(sourceDir, 'subagents', 'org-agent.md'),
'---\nname: org-agent\ndescription: Org agent\n---\n\n# Org agent\n\nOrg-provided agent.\n',
'utf8',
);
await declare('skills:\n use:\n - org-skill\nsubagents:\n use:\n - org-agent\n');

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

const skillMd = await readFile(skillPath('org-skill'), 'utf8');
expect(skillMd).toContain('<!-- codeassembly-skill:org-skill -->');
// The include resolves against the source root, proving the source-local partial is expanded.
expect(skillMd).toContain('Shared org fragment.');
expect(await readFile(subagentPath('org-agent'), 'utf8')).toContain('<!-- codeassembly-subagent:org-agent -->');

await declare('skills:\n use: []\nsubagents:\n use: []\n');
await syncCommand(makeOptions(), projectRoot, resolveContentDir());

expect(existsSync(skillPath('org-skill'))).toBe(false);
expect(existsSync(subagentPath('org-agent'))).toBe(false);
});
});
44 changes: 40 additions & 4 deletions packages/agents/src/commands/__tests__/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,14 +519,50 @@ describe(syncCommand, () => {
},
);

it('fails a declared source skill as not-yet-supported, naming the source', async () => {
it('deploys a declared source skill from its source into the harness skills dir', async () => {
await writeSourceSkill('source-skill');
await declareWithSource('skills:\n use:\n - source-skill\n');

await expect(syncCommand(makeOptions(), projectRoot, contentDir)).rejects.toThrow(
/source-skill.*source "org".*not yet supported/s,
await syncCommand(makeOptions(), projectRoot, contentDir);

expect(await readFile(skillPath('source-skill'), 'utf8')).toContain('<!-- codeassembly-skill:source-skill -->');
});

it('deploys a declared source subagent from its source into the harness subagents dir', async () => {
await mkdir(path.join(sourceDir, 'subagents'), { recursive: true });
await writeFile(
path.join(sourceDir, 'subagents', 'source-agent.md'),
'---\nname: source-agent\ndescription: Org agent\n---\n\n# Source agent\n',
'utf8',
);
await declareWithSource('subagents:\n use:\n - source-agent\n');

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

const deployed = await readFile(path.join(projectRoot, '.claude', 'agents', 'source-agent.md'), 'utf8');
expect(deployed).toContain('<!-- codeassembly-subagent:source-agent -->');
});

it('deploys a source skill over a same-slug library skill, source-first', async () => {
const libraryDir = path.join(contentDir, 'skills', 'shared-skill');
await mkdir(libraryDir, { recursive: true });
await writeFile(
path.join(libraryDir, 'SKILL.md'),
'---\nname: shared-skill\n---\n\n# Library shared skill\n',
'utf8',
);
const sourceSkillDir = path.join(sourceDir, 'skills', 'shared-skill');
await mkdir(sourceSkillDir, { recursive: true });
await writeFile(
path.join(sourceSkillDir, 'SKILL.md'),
'---\nname: shared-skill\n---\n\n# Source shared skill\n',
'utf8',
);
expect(existsSync(skillPath('source-skill'))).toBe(false);
await declareWithSource('skills:\n use:\n - shared-skill\n');

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

expect(await readFile(skillPath('shared-skill'), 'utf8')).toContain('# Source shared skill');
});

it('rejects an invalid source in dry-run, before previewing any write', async () => {
Expand Down
3 changes: 1 addition & 2 deletions packages/agents/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,7 @@ async function installSkillEntry(
let expandedFileContent: string | undefined;
let renderedDir: ReadonlyArray<RenderedSkillEntry> | undefined;
if (srcStats.isDirectory()) {
renderedDir = await renderSkillDirectory(srcPath, path.basename(destPath), {
contentDir,
renderedDir = await renderSkillDirectory(srcPath, path.basename(destPath), contentDir, {
toolMapping,
pathPrefix: skillsPrefix,
homeDir,
Expand Down
12 changes: 3 additions & 9 deletions packages/agents/src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,19 +157,15 @@ async function reconcileDomain(
);
const declaredRulebooks = closure.rulebooks;

const librarySkillsDir = path.join(contentDir, 'skills');
const librarySubagentsDir = path.join(contentDir, 'subagents');
const neutralDir = path.join(domain.baseDir, '.agents', 'rulebooks');
const ambientHostPath = domain.ambientHostPath;

// Resolve and validate every declared rulebook, skill, and subagent before writing anything, so a missing library
// file, invalid frontmatter, or a still-`install` artifact fails the whole run rather than leaving a partial sync.
const resolved = await Promise.all(declaredRulebooks.map((slug) => resolveRulebook(slug, resolver)));
assertNoSkillNameCollisions(resolved);
const resolvedSkills = await Promise.all(closure.skills.map((slug) => resolveDeclaredSkill(slug, librarySkillsDir)));
const resolvedSubagents = await Promise.all(
closure.subagents.map((slug) => resolveDeclaredSubagent(slug, librarySubagentsDir)),
);
const resolvedSkills = await Promise.all(closure.skills.map((slug) => resolveDeclaredSkill(slug, resolver)));
const resolvedSubagents = await Promise.all(closure.subagents.map((slug) => resolveDeclaredSubagent(slug, resolver)));

// Reconcile two surfaces against the filesystem independently. Neutral files track the declared set;
// PROJECT.md tracks the desired *ambient* set. Keying PROJECT.md on declaration alone would strand a block
Expand Down Expand Up @@ -719,7 +715,7 @@ async function assertDeclaredSkillsRender(
if (!skillTargetsHarness(skill, target.harnessId)) {
continue;
}
await renderSkillDirectory(skill.srcDir, skill.slug, target.deployContext);
await renderSkillDirectory(skill.srcDir, skill.slug, skill.contentRoot, target.deployContext);
}
}
}
Expand Down Expand Up @@ -878,7 +874,6 @@ async function resolveSubagentTarget(
return {
subagentsDir: resolveHarnessPaths(harnessId, projectRoot).subagentsDir,
deployContext: {
contentDir,
overlayYaml,
toolMapping: loadToolMapping(overlayYaml),
homeDir: harnessConfig.homeDir,
Expand All @@ -905,7 +900,6 @@ async function resolveSkillTarget(
harnessId,
skillsDir: resolveHarnessPaths(harnessId, projectRoot).skillsDir,
deployContext: {
contentDir,
toolMapping: loadToolMapping(overlayYaml),
pathPrefix: `${harnessConfig.homeDir}/${harnessConfig.skillsDirName}`,
homeDir: harnessConfig.homeDir,
Expand Down
19 changes: 10 additions & 9 deletions packages/agents/src/lib/__tests__/dependency-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,23 +277,24 @@ describe(resolveClosure, () => {
await expect(searched).rejects.toThrow(new RegExp(path.join(contentDir, 'guidance', 'rulebooks', 'ghost.md')));
});

it('fails a source-resolved skill as not-yet-supported, naming the source and no include error', async () => {
await writeArtifactWithBody(sourceDir, 'skill', 'source-skill', 'Invoke {skill:missing-include}.');
it('resolves a source skill, expanding its body against the source root to reach a library-provided edge', async () => {
await writeSkillPartial(sourceDir, 'source-skill', 'frag.md', 'Invoke {skill:library-helper}.');
await writeArtifactWithBody(sourceDir, 'skill', 'source-skill', '<!-- include: _partials/frag.md / -->');
await writeArtifact(contentDir, 'skill', 'library-helper');
const resolver = createSourceResolver([{ name: 'org', dir: sourceDir }], contentDir);

const attempt = resolveClosure({ skill: ['source-skill'] }, resolver);
const closure = await resolveClosure({ skill: ['source-skill'] }, resolver);

await expect(attempt).rejects.toThrow(/External-source skill "source-skill".*source "org".*not yet supported/s);
await expect(attempt).rejects.not.toThrow(/include/i);
expect(closure.skills.toSorted()).toEqual(['library-helper', 'source-skill']);
});

it('fails a source-resolved subagent as not-yet-supported, naming the source', async () => {
it('resolves a source subagent from its source', async () => {
await writeSubagent(sourceDir, 'source-agent', []);
const resolver = createSourceResolver([{ name: 'org', dir: sourceDir }], contentDir);

await expect(resolveClosure({ subagent: ['source-agent'] }, resolver)).rejects.toThrow(
/External-source subagent "source-agent".*source "org".*not yet supported/s,
);
const closure = await resolveClosure({ subagent: ['source-agent'] }, resolver);

expect(closure.subagents).toEqual(['source-agent']);
});

it('fails a source-resolved collection as not-yet-supported before expanding its members', async () => {
Expand Down
35 changes: 18 additions & 17 deletions packages/agents/src/lib/__tests__/skill-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { libraryResolver } from '../content-sources.ts';
import { expandIncludes } from '../directive-expander.ts';
import { rewriteMarkdownPaths, rewriteTemplateVariables } from '../path-rewriter.ts';
import { deploySkill, resolveDeclaredSkill } from '../skill-deploy.ts';
Expand Down Expand Up @@ -143,7 +144,6 @@ describe(deploySkill, () => {

function context(toolMapping: ReadonlyMap<string, string> = new Map()): SkillDeployContext {
return {
contentDir: librarySkillsDir,
toolMapping,
pathPrefix: '.claude/skills',
homeDir: '.claude',
Expand All @@ -163,72 +163,73 @@ describe(deploySkill, () => {
}

/** Builds a ResolvedSkill without the deploy-field check, so deploySkill tests can use minimal fixtures. */
function resolvedSkill(slug: string): { slug: string; srcDir: string } {
return { slug, srcDir: path.join(librarySkillsDir, slug) };
function resolvedSkill(slug: string): { slug: string; srcDir: string; contentRoot: string } {
return { slug, srcDir: path.join(librarySkillsDir, slug), contentRoot: librarySkillsDir };
}

// endregion | Helpers
});

describe(resolveDeclaredSkill, () => {
let librarySkillsDir: string;
let contentDir: string;

beforeEach(() => {
librarySkillsDir = path.join(tmpdir(), `agents-test-sd-lib-${Date.now()}-${Math.random().toString(36).slice(2)}`);
contentDir = path.join(tmpdir(), `agents-test-sd-lib-${Date.now()}-${Math.random().toString(36).slice(2)}`);
});

afterEach(async () => {
await rm(librarySkillsDir, { recursive: true, force: true });
await rm(contentDir, { recursive: true, force: true });
});

/** Writes a library skill `<slug>/SKILL.md`, optionally inserting extra frontmatter lines after `name:`. */
/** Writes a library skill `skills/<slug>/SKILL.md`, optionally inserting extra frontmatter lines after `name:`. */
async function writeLibrarySkill(slug: string, extraFrontmatter = ''): Promise<void> {
const dir = path.join(librarySkillsDir, slug);
const dir = path.join(contentDir, 'skills', slug);
await mkdir(dir, { recursive: true });
const frontmatter = extraFrontmatter === '' ? `name: ${slug}` : `name: ${slug}\n${extraFrontmatter}`;
await writeFile(path.join(dir, 'SKILL.md'), `---\n${frontmatter}\n---\n\n# ${slug}\n\nBody.\n`, 'utf8');
}

it('resolves a declared skill to its slug and source directory', async () => {
it('resolves a declared skill to its slug, source directory, and content root', async () => {
await writeLibrarySkill('people-report');

const resolved = await resolveDeclaredSkill('people-report', librarySkillsDir);
const resolved = await resolveDeclaredSkill('people-report', libraryResolver(contentDir));

expect(resolved.slug).toBe('people-report');
expect(resolved.srcDir).toBe(path.join(librarySkillsDir, 'people-report'));
expect(resolved.srcDir).toBe(path.join(contentDir, 'skills', 'people-report'));
expect(resolved.contentRoot).toBe(contentDir);
});

it('leaves the target harnesses undefined when no `harnesses:` field is present', async () => {
await writeLibrarySkill('people-report');

const resolved = await resolveDeclaredSkill('people-report', librarySkillsDir);
const resolved = await resolveDeclaredSkill('people-report', libraryResolver(contentDir));

expect(resolved.targetHarnesses).toBeUndefined();
});

it('reads a list-valued `harnesses:` field into the target set', async () => {
await writeLibrarySkill('brainstorming', 'harnesses: [rovodev]');

const resolved = await resolveDeclaredSkill('brainstorming', librarySkillsDir);
const resolved = await resolveDeclaredSkill('brainstorming', libraryResolver(contentDir));

expect(resolved.targetHarnesses).toEqual(['rovodev']);
});

it('normalizes a scalar `harnesses:` field into a single-element target set', async () => {
await writeLibrarySkill('review-permissions', 'harnesses: claude');

const resolved = await resolveDeclaredSkill('review-permissions', librarySkillsDir);
const resolved = await resolveDeclaredSkill('review-permissions', libraryResolver(contentDir));

expect(resolved.targetHarnesses).toEqual(['claude']);
});

it('throws naming the slug and the bad id when `harnesses:` lists an unknown harness', async () => {
await writeLibrarySkill('exotic', 'harnesses: [codex]');

await expect(resolveDeclaredSkill('exotic', librarySkillsDir)).rejects.toThrow(/exotic.*codex/s);
await expect(resolveDeclaredSkill('exotic', libraryResolver(contentDir))).rejects.toThrow(/exotic.*codex/s);
});

it('throws a clear error naming the slug when the skill is missing from the library', async () => {
await expect(resolveDeclaredSkill('ghost', librarySkillsDir)).rejects.toThrow(/ghost/);
it('throws an error naming the slug and every location searched when the skill resolves nowhere', async () => {
await expect(resolveDeclaredSkill('ghost', libraryResolver(contentDir))).rejects.toThrow(/ghost/);
});
});
Loading
Loading