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
24 changes: 21 additions & 3 deletions packages/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,37 @@ A declared subagent is deployed into each detected harness's project-local subag

### Collections

A collection is a dependency-only aggregate: it deploys no file of its own, but declaring it pulls in its members' transitive dependency closure, which `sync` then deploys. Declare one like any other type:
A collection is a traversal-only aggregate: it deploys no file of its own, but declaring it pulls in its members' transitive closure, which `sync` then deploys. Declare one like any other type:

```yaml
collections:
use:
- recommended
```

A collection lists its constituents under a `members:` key — either an explicit per-type block (the same shape `dependencies:` uses) or the computed token `'@library'`:

```yaml
members:
skills:
- capture-feedback
subagents:
- canary
```

`members:` is collections-only; rulebooks, skills, and subagents declare prerequisite edges under `dependencies:` instead. Declaring `dependencies:` on a collection, or `members:` on any other type, is an error that names the offending artifact.

Dropping or omitting a collection — or `root: true` — excludes its entire closure; dropping a single member that a collection contributed is not supported, so opt out of the whole collection or declare members à la carte instead. The shipped `recommended` collection bundles the default declared artifacts. The installed user-global declaration (`~/.agents/codeassembly.yaml`) declares it, so `sync --global` deploys it into the home directories out of the box; a project adds it for repo deployment by declaring it explicitly.

#### The `@library` token

A collection whose `members:` is the string `'@library'` resolves to every deployable artifact in the content library (all rulebooks, skills, and subagents), computed at resolution time so a newly added artifact joins automatically with no edit. The `@` sigil marks a computed directive rather than a literal slug, so the value must be YAML-quoted (`'@library'`). Collections are excluded from the result: the resolver never emits them, and "every collection" would be self-referential.

The shipped `all` collection carries `'@library'`; declaring `collections: use: [all]` deploys the whole catalog.

### Dependencies

Any artifact may declare dependencies on others in its frontmatter, grouped by artifact type. Resolution follows these edges transitively — deduped, with cycle detection — so declaring one artifact pulls in its whole closure:
A rulebook, skill, or subagent may declare dependencies on other artifacts in its frontmatter, grouped by artifact type. Resolution follows these edges transitively — deduped, with cycle detection — so declaring one artifact pulls in its whole closure:

```yaml
dependencies:
Expand All @@ -72,7 +90,7 @@ dependencies:
- canary
```

A collection is simply an artifact whose only payload is this block.
The resolver follows `members:` and `dependencies:` identically; the split is semantic — a collection _contains_ members, while an artifact _depends on_ prerequisites.

### The `deploy` field

Expand Down
9 changes: 9 additions & 0 deletions packages/agents/content/collections/all.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
name: all
description: The whole content library — every rulebook, skill, and subagent, computed automatically.
members: '@library'
---

# All

The whole-catalog collection. `members: '@library'` resolves to every rulebook, skill, and subagent in the content library, computed at resolution time so a newly added artifact joins automatically with no edit here. Collections are excluded — the resolver never emits them, and "every collection" would be self-referential.
4 changes: 2 additions & 2 deletions packages/agents/content/collections/recommended.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: recommended
description: The batteries-included default set of artifacts, opt-in via codeassembly.yaml.
dependencies:
members:
skills:
- capture-feedback
- people-report
Expand All @@ -11,4 +11,4 @@ dependencies:

# Recommended

A dependency-only aggregate: declaring it in `codeassembly.yaml` pulls in its members' transitive closure, which `sync` then deploys. It currently bundles the declared proof artifacts to exercise collection resolution end-to-end; the full default set joins it once global delivery lands.
A members collection: declaring it in `codeassembly.yaml` pulls in its members' transitive closure, which `sync` then deploys. It currently bundles the declared proof artifacts to exercise collection resolution end-to-end; the full default set joins it once global delivery lands.
10 changes: 5 additions & 5 deletions packages/agents/src/commands/__tests__/sync-collections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,18 @@ async function declareCollections(projectRoot: string, ...slugs: ReadonlyArray<s
await writeFile(path.join(projectRoot, '.agents', 'codeassembly.yaml'), `collections:\n${useBlock}`, 'utf8');
}

/** Writes a dependency-only collection `<slug>.md` into the temp content library. */
/** Writes a members-based collection `<slug>.md` into the temp content library. */
async function writeCollection(
contentDir: string,
slug: string,
dependencies: { skills?: ReadonlyArray<string>; subagents?: ReadonlyArray<string> },
members: { skills?: ReadonlyArray<string>; subagents?: ReadonlyArray<string> },
): Promise<void> {
const dir = path.join(contentDir, 'collections');
await mkdir(dir, { recursive: true });
const blocks = Object.entries(dependencies).map(
([key, slugs]) => ` ${key}:\n${slugs.map((dependency) => ` - ${dependency}`).join('\n')}`,
const blocks = Object.entries(members).map(
([key, slugs]) => ` ${key}:\n${slugs.map((member) => ` - ${member}`).join('\n')}`,
);
const frontmatter = `name: ${slug}\ndependencies:\n${blocks.join('\n')}\n`;
const frontmatter = `name: ${slug}\nmembers:\n${blocks.join('\n')}\n`;
await writeFile(path.join(dir, `${slug}.md`), `---\n${frontmatter}---\n\n# ${slug}\n`, 'utf8');
}

Expand Down
58 changes: 10 additions & 48 deletions packages/agents/src/commands/library-list.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Dirent } from 'node:fs';
import { readdir, readFile } from 'node:fs/promises';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';

Expand All @@ -9,8 +8,10 @@ import { ARTIFACT_TYPES, type ArtifactType } from '../lib/artifact-types.ts';
import { resolveContentDir } from '../lib/content-resolver.ts';
import { readDeploy } from '../lib/deploy-frontmatter.ts';
import { parseFrontmatter } from '../lib/frontmatter-merger.ts';
import { listVisibleMarkdownFiles } from '../lib/fs-helpers.ts';
import { listSkillDirectories } from '../lib/library-catalog.ts';
import { parseRulebookFile } from '../lib/rulebook-schema.ts';
import { isEnoent, isMissingFile, isRecord } from '../lib/type-guards.ts';
import { isRecord } from '../lib/type-guards.ts';

/** A single artifact's normalized listing fields, before its type and emoji are attached. */
interface ArtifactEntry {
Expand Down Expand Up @@ -43,7 +44,7 @@ const ARTIFACT_DESCRIPTORS: ReadonlyArray<ArtifactDescriptor> = [
/** Rank used to group rows by type before the within-type slug sort. */
const TYPE_ORDER: Readonly<Record<ArtifactType, number>> = { rulebook: 0, skill: 1, subagent: 2, collection: 3 };

/** Delivery cell for a collection: a dependency-only aggregate has no `deploy` field and so no delivery mode. */
/** Delivery cell for a collection: a collection has no `deploy` field and so no delivery mode. */
const COLLECTION_DELIVERY = '—';

const HEADERS = { type: 'type', slug: 'slug', delivery: 'delivery', description: 'description' } as const;
Expand Down Expand Up @@ -148,16 +149,11 @@ function compareRows(a: LibraryRow, b: LibraryRow): number {
return a.slug.localeCompare(b.slug);
}

/** True when a directory entry is neither a reserved `_`-prefixed support entry nor a dotfile. */
function isVisible(name: string): boolean {
return !name.startsWith('_') && !name.startsWith('.');
}

/** Lists collection artifacts from `content/collections`, reading each markdown file's name and description. */
async function listCollections(contentDir: string): Promise<Array<ArtifactEntry>> {
const dir = path.join(contentDir, ARTIFACT_TYPES.collection.contentPath);
const entries: Array<ArtifactEntry> = [];
for (const file of await listMarkdownFiles(dir)) {
for (const file of await listVisibleMarkdownFiles(dir)) {
const content = await readFile(path.join(dir, file), 'utf8');
const entry = buildEntryOrSkip('collection', file, () => {
const meta = readNameAndDescription(content);
Expand All @@ -174,18 +170,11 @@ async function listCollections(contentDir: string): Promise<Array<ArtifactEntry>
return entries;
}

/** Returns visible (`.md`, non-`_`, non-dotfile) regular-file names directly in `dir`; empty when `dir` is absent. */
async function listMarkdownFiles(dir: string): Promise<Array<string>> {
return (await readDirEntries(dir))
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && isVisible(entry.name))
.map((entry) => entry.name);
}

/** Lists rulebook artifacts from `content/guidance/rulebooks`, parsing each via the rulebook schema. */
async function listRulebooks(contentDir: string): Promise<Array<ArtifactEntry>> {
const dir = path.join(contentDir, ARTIFACT_TYPES.rulebook.contentPath);
const entries: Array<ArtifactEntry> = [];
for (const file of await listMarkdownFiles(dir)) {
for (const file of await listVisibleMarkdownFiles(dir)) {
const content = await readFile(path.join(dir, file), 'utf8');
const entry = buildEntryOrSkip('rulebook', file, () => {
const { rulebook } = parseRulebookFile(content, file);
Expand All @@ -206,16 +195,8 @@ async function listRulebooks(contentDir: string): Promise<Array<ArtifactEntry>>
async function listSkills(contentDir: string): Promise<Array<ArtifactEntry>> {
const dir = path.join(contentDir, ARTIFACT_TYPES.skill.contentPath);
const entries: Array<ArtifactEntry> = [];
for (const name of await listVisibleSubdirectories(dir)) {
let content: string;
try {
content = await readFile(path.join(dir, name, 'SKILL.md'), 'utf8');
} catch (error) {
if (isMissingFile(error)) {
continue;
}
throw error;
}
for (const name of await listSkillDirectories(dir)) {
const content = await readFile(path.join(dir, name, 'SKILL.md'), 'utf8');
const entry = buildEntryOrSkip('skill', name, () => {
const meta = readNameAndDescription(content);
// The delivery column mirrors the `deploy` field: `declared` (delivered per-project by sync) or `install`.
Expand All @@ -236,7 +217,7 @@ async function listSkills(contentDir: string): Promise<Array<ArtifactEntry>> {
async function listSubagents(contentDir: string): Promise<Array<ArtifactEntry>> {
const dir = path.join(contentDir, ARTIFACT_TYPES.subagent.contentPath);
const entries: Array<ArtifactEntry> = [];
for (const file of await listMarkdownFiles(dir)) {
for (const file of await listVisibleMarkdownFiles(dir)) {
const content = await readFile(path.join(dir, file), 'utf8');
const entry = buildEntryOrSkip('subagent', file, () => {
const meta = readNameAndDescription(content);
Expand All @@ -254,31 +235,12 @@ async function listSubagents(contentDir: string): Promise<Array<ArtifactEntry>>
return entries;
}

/** Returns visible (non-`_`, non-dotfile) subdirectory names directly in `dir`; empty when `dir` is absent. */
async function listVisibleSubdirectories(dir: string): Promise<Array<string>> {
return (await readDirEntries(dir))
.filter((entry) => entry.isDirectory() && isVisible(entry.name))
.map((entry) => entry.name);
}

/** Builds a type cell (`{emoji} {label}`) padded with trailing spaces to `colWidth` display cells. */
function padType(emoji: string, label: string, colWidth: number): string {
const padding = Math.max(0, colWidth - (EMOJI_DISPLAY_WIDTH + 1 + label.length));
return `${emoji} ${label}${' '.repeat(padding)}`;
}

/** Reads `dir` with file types, returning `[]` when the directory does not exist. */
async function readDirEntries(dir: string): Promise<Array<Dirent>> {
try {
return await readdir(dir, { withFileTypes: true });
} catch (error) {
if (isEnoent(error)) {
return [];
}
throw error;
}
}

/** Extracts the `name` and `description` strings from a markdown file's frontmatter, when present. */
function readNameAndDescription(content: string): { name?: string; description?: string } {
const { lines } = parseFrontmatter(content);
Expand Down
44 changes: 43 additions & 1 deletion packages/agents/src/lib/__tests__/dependency-frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { readDependencies } from '../dependency-frontmatter.ts';
import { readDependencies, readMembers } from '../dependency-frontmatter.ts';

/** Wraps a frontmatter body in `---` delimiters with a throwaway markdown body. */
function withFrontmatter(frontmatter: string): string {
Expand Down Expand Up @@ -58,4 +58,46 @@ describe(readDependencies, () => {

expect(() => readDependencies(content)).toThrow(/mapping/);
});

it('throws when a non-collection declares members, naming the source', () => {
const content = withFrontmatter("members: '@library'");

expect(() => readDependencies(content, 'skills/people-report/SKILL.md')).toThrow(/people-report.*members/s);
});
});

describe(readMembers, () => {
it('reads the @library token as a library directive', () => {
expect(readMembers(withFrontmatter("name: all\nmembers: '@library'"))).toEqual({ kind: 'library' });
});

it('reads an explicit per-type members block as edges', () => {
const content = withFrontmatter('members:\n skills:\n - people-report\n subagents:\n - canary');

expect(readMembers(content)).toEqual({
kind: 'explicit',
edges: { skill: ['people-report'], subagent: ['canary'] },
});
});

it('treats absent or null members as an empty collection', () => {
expect(readMembers(withFrontmatter('name: empty'))).toEqual({ kind: 'explicit', edges: {} });
expect(readMembers(withFrontmatter('members:'))).toEqual({ kind: 'explicit', edges: {} });
});

it('throws naming the token and source on an unrecognized members token', () => {
expect(() => readMembers(withFrontmatter("members: '@everything'"), 'collections/all.md')).toThrow(
/all\.md.*@everything/s,
);
});

it('throws when members is neither a token nor a mapping', () => {
expect(() => readMembers(withFrontmatter('members:\n - people-report'), 'collections/all.md')).toThrow(/all\.md/);
});

it('throws when a collection also declares dependencies, naming the source', () => {
const content = withFrontmatter("members: '@library'\ndependencies:\n skills:\n - people-report");

expect(() => readMembers(content, 'collections/all.md')).toThrow(/all\.md.*dependencies/s);
});
});
70 changes: 61 additions & 9 deletions packages/agents/src/lib/__tests__/dependency-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,29 +85,81 @@ describe(resolveClosure, () => {
/skill "ghost" was not found/,
);
});

it('resolves a collection whose members is @library to the full deployable catalog', async () => {
await writeArtifact(contentDir, 'rulebook', 'typescript-conventions');
await writeArtifact(contentDir, 'skill', 'people-report');
await writeArtifact(contentDir, 'subagent', 'canary');
await writeArtifact(contentDir, 'collection', 'all', '@library');

const closure = await resolveClosure({ collection: ['all'] }, contentDir);

expect(closure.rulebooks.toSorted()).toEqual(['typescript-conventions']);
expect(closure.skills.toSorted()).toEqual(['people-report']);
expect(closure.subagents.toSorted()).toEqual(['canary']);
});

it('includes a newly added artifact in @library with no edit to the collection', async () => {
await writeArtifact(contentDir, 'skill', 'people-report');
await writeArtifact(contentDir, 'collection', 'all', '@library');

const before = await resolveClosure({ collection: ['all'] }, contentDir);
expect(before.skills.toSorted()).toEqual(['people-report']);

await writeArtifact(contentDir, 'skill', 'classify-complexity');
const after = await resolveClosure({ collection: ['all'] }, contentDir);

expect(after.skills.toSorted()).toEqual(['classify-complexity', 'people-report']);
});

it('deduplicates @library pulled by two collections under one parent', async () => {
await writeArtifact(contentDir, 'skill', 'shared');
await writeArtifact(contentDir, 'collection', 'left', '@library');
await writeArtifact(contentDir, 'collection', 'right', '@library');
await writeArtifact(contentDir, 'collection', 'top', { collection: ['left', 'right'] });

const closure = await resolveClosure({ collection: ['top'] }, contentDir);

expect(closure.skills).toEqual(['shared']);
});

it('throws naming the collection on an unrecognized members token', async () => {
const filePath = path.join(contentDir, artifactFrontmatterPath('collection', 'bad'));
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, "---\nname: bad\nmembers: '@everything'\n---\n\n# bad\n", 'utf8');

await expect(resolveClosure({ collection: ['bad'] }, contentDir)).rejects.toThrow(/collection bad.*@everything/s);
});
});

/** Writes an artifact's frontmatter file under `contentDir`, optionally with a `dependencies:` block. */
/**
* Writes an artifact's frontmatter file under `contentDir`. A collection's edges render as `members:` (either the
* `'@library'` token or a per-type block); every other type's render as `dependencies:`. Omit `edges` for a leaf.
*/
async function writeArtifact(
contentDir: string,
type: ArtifactType,
slug: string,
dependencies?: DirectArtifacts,
edges?: DirectArtifacts | '@library',
): Promise<void> {
const filePath = path.join(contentDir, artifactFrontmatterPath(type, slug));
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `---\nname: ${slug}\n${renderDependencies(dependencies)}---\n\n# ${slug}\n`, 'utf8');
await writeFile(filePath, `---\nname: ${slug}\n${renderEdges(type, edges)}---\n\n# ${slug}\n`, 'utf8');
}

/** Renders a `dependencies:` frontmatter block from a per-type slug map, or an empty string when there are none. */
function renderDependencies(dependencies: DirectArtifacts | undefined): string {
/** Renders an artifact's edge block: `members:` for a collection, `dependencies:` otherwise; empty when there are none. */
function renderEdges(type: ArtifactType, edges: DirectArtifacts | '@library' | undefined): string {
if (edges === '@library') {
return `members: '@library'\n`;
}
const key = type === 'collection' ? 'members' : 'dependencies';
const lines: Array<string> = [];
for (const type of ARTIFACT_TYPE_VALUES) {
const slugs = dependencies?.[type] ?? [];
for (const edgeType of ARTIFACT_TYPE_VALUES) {
const slugs = edges?.[edgeType] ?? [];
if (slugs.length > 0) {
const items = slugs.map((slug) => ` - ${slug}`).join('\n');
lines.push(` ${ARTIFACT_TYPES[type].key}:\n${items}`);
lines.push(` ${ARTIFACT_TYPES[edgeType].key}:\n${items}`);
}
}
return lines.length === 0 ? '' : `dependencies:\n${lines.join('\n')}\n`;
return lines.length === 0 ? '' : `${key}:\n${lines.join('\n')}\n`;
}
Loading
Loading