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 @@ -108,7 +108,7 @@ The declaration resolves in two independent **domains**, each with its own base
1. **User-global** — `~/.agents/codeassembly.yaml`, created by `init --global` (declares `all` by default).
2. **User-global-local** — `~/.agents/codeassembly.local.yaml`, for personal overrides that survive reinstalls.

A higher tier adds to and overrides the tiers below it _within the same domain_: `use` adds an entry, `drop` removes one a broader tier in that domain contributed, and `root: true` discards everything from broader tiers in that domain. The domains never cross — a project tier cannot `drop` a user-global entry, and bare `sync` never writes the home directories (it refuses to run when invoked from the home directory, directing you to `sync --global`). Ambient rulebooks inline into `.agents/PROJECT.md` in the repo domain and `~/.agents/GLOBAL.md` in the home domain.
A higher tier adds to and overrides the tiers below it _within the same domain_: `use` adds an entry, `drop` removes one a broader tier in that domain contributed, and `root: true` discards everything from broader tiers in that domain. The domains never cross — a project tier cannot `drop` a user-global entry, and bare `sync` never writes the home directories (it refuses to run when invoked from the home directory, directing you to `sync --global`). Ambient rulebooks inline into `.agents/PROJECT.md` in the repo domain and `~/.agents/GLOBAL.md` in the home domain. In the repo domain, project-scoped Rovo Dev skills are also indexed into a project-local `.rovodev/prompts.yml` so they surface in Rovo Dev's available-skills list; `sync` owns a single sentinel-delimited region in that file and leaves any hand-authored entries outside it untouched.

When upgrading from a build where `install` deployed the catalog, run `install` once before `sync --global`: the new `install` prunes the catalog skills it previously planted, and `sync --global` then re-deploys them as sync-owned. Running `sync --global` first stops at a refuse-to-overwrite error on those still-`install`-owned files.

Expand Down
121 changes: 121 additions & 0 deletions packages/agents/src/commands/__tests__/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,127 @@ describe(syncCommand, () => {
expect(existsSync(subagentPath('canary'))).toBe(false);
});
});

describe('project Rovo Dev prompts.yml', () => {
/** Writes a fixture skill into the temp content library, with optional extra frontmatter line(s). */
async function writeLibrarySkill(slug: string, frontmatter = ''): Promise<void> {
const dir = path.join(contentDir, 'skills', slug);
await mkdir(dir, { recursive: true });
const extra = frontmatter === '' ? '' : `${frontmatter}\n`;
await writeFile(path.join(dir, 'SKILL.md'), `---\nname: ${slug}\n${extra}---\n\n# ${slug}\n\nBody.\n`, 'utf8');
}

/** Writes the project-scope codeassembly.yaml declaring the given skill slugs. */
async function declareSkills(...slugs: ReadonlyArray<string>): Promise<void> {
await mkdir(path.join(projectRoot, '.agents'), { recursive: true });
const useBlock =
slugs.length === 0 ? ' use: []\n' : ` use:\n${slugs.map((slug) => ` - ${slug}`).join('\n')}\n`;
await writeFile(path.join(projectRoot, '.agents', 'codeassembly.yaml'), `skills:\n${useBlock}`, 'utf8');
}

const promptsYmlPath = (): string => path.join(projectRoot, '.rovodev', 'prompts.yml');

/** Seeds a hand-authored `prompts.yml` carrying a single foreign entry and no codeassembly region. */
async function seedHandAuthoredPromptsYml(): Promise<void> {
await mkdir(path.join(projectRoot, '.rovodev'), { recursive: true });
await writeFile(
promptsYmlPath(),
"prompts:\n - name: 'hand-authored'\n description: 'kept'\n content_file: custom.md\n",
'utf8',
);
}

it('writes a region indexing the user-invocable Rovo Dev skills, excluding non-invocable ones', async () => {
await writeLibrarySkill('public-skill', 'description: Public skill');
await writeLibrarySkill('internal-skill', 'user-invocable: false');
await declareSkills('public-skill', 'internal-skill');

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

const prompts = await readFile(promptsYmlPath(), 'utf8');
expect(prompts).toContain('# codeassembly:managed:start');
expect(prompts).toContain('# codeassembly:managed:end');
expect(prompts).toContain("name: 'public-skill'");
expect(prompts).toContain('content_file: skills/public-skill/SKILL.md');
expect(prompts).not.toContain('internal-skill');
});

it('leaves prompts.yml byte-identical on re-sync with no skill changes', async () => {
await writeLibrarySkill('public-skill', 'description: Public skill');
await declareSkills('public-skill');
await syncCommand(makeOptions({ harness: 'rovodev' }), projectRoot, contentDir);
const first = await readFile(promptsYmlPath(), 'utf8');

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

expect(await readFile(promptsYmlPath(), 'utf8')).toBe(first);
});

it('merges the region into a hand-authored prompts.yml, preserving foreign entries', async () => {
await writeLibrarySkill('public-skill', 'description: Public skill');
await declareSkills('public-skill');
await seedHandAuthoredPromptsYml();

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

const prompts = await readFile(promptsYmlPath(), 'utf8');
expect(prompts).toContain("name: 'hand-authored'");
expect(prompts).toContain('content_file: custom.md');
expect(prompts).toContain("name: 'public-skill'");
expect(prompts).toContain('# codeassembly:managed:start');
});

it('removes the region and deletes the file when undeclaring leaves nothing foreign', async () => {
await writeLibrarySkill('public-skill', 'description: Public skill');
await declareSkills('public-skill');
await syncCommand(makeOptions({ harness: 'rovodev' }), projectRoot, contentDir);
expect(existsSync(promptsYmlPath())).toBe(true);

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

expect(existsSync(promptsYmlPath())).toBe(false);
});

it('strips only the region and keeps the file when foreign entries remain', async () => {
await writeLibrarySkill('public-skill', 'description: Public skill');
await declareSkills('public-skill');
await seedHandAuthoredPromptsYml();
await syncCommand(makeOptions({ harness: 'rovodev' }), projectRoot, contentDir);
expect(await readFile(promptsYmlPath(), 'utf8')).toContain('# codeassembly:managed:start');

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

const prompts = await readFile(promptsYmlPath(), 'utf8');
expect(prompts).toContain("name: 'hand-authored'");
expect(prompts).not.toContain('# codeassembly:managed:start');
});

it('refuses to corrupt a flow-style hand-authored prompts.yml, leaving it unchanged', async () => {
await writeLibrarySkill('public-skill', 'description: Public skill');
await declareSkills('public-skill');
await mkdir(path.join(projectRoot, '.rovodev'), { recursive: true });
const flowAuthored = "prompts: [{ name: 'foreign', content_file: custom.md }]\n";
await writeFile(promptsYmlPath(), flowAuthored, 'utf8');

await expect(syncCommand(makeOptions({ harness: 'rovodev' }), projectRoot, contentDir)).rejects.toThrow(
/block-style/,
);

expect(await readFile(promptsYmlPath(), 'utf8')).toBe(flowAuthored);
});

it('leaves a region-less prompts.yml untouched when no Rovo Dev skills are declared', async () => {
await seedHandAuthoredPromptsYml();
const handAuthored = await readFile(promptsYmlPath(), 'utf8');
await declareSkills();

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

expect(await readFile(promptsYmlPath(), 'utf8')).toBe(handAuthored);
});
});
});

describe(syncGlobalCommand, () => {
Expand Down
39 changes: 38 additions & 1 deletion packages/agents/src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { resolveClosure } from '../lib/dependency-resolver.ts';
import { readFileOrEmpty, writeIfChanged } from '../lib/fs-helpers.ts';
import { HARNESSES, resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.ts';
import { loadHarnessOverlay } from '../lib/harness-overlay.ts';
import { renderPromptsYml } from '../lib/prompts-yml.ts';
import { collectPromptEntries, renderPromptEntries, renderPromptsYml } from '../lib/prompts-yml.ts';
import { hasPromptsRegion, injectPromptsRegion, removePromptsRegion } from '../lib/prompts-yml-region.ts';
import { parseRulebookFile } from '../lib/rulebook-schema.ts';
import { extractRulebookSkillSlug, renderSkillFile, resolveSkillName } from '../lib/rulebook-skill.ts';
import { extractInstalledSlugs, injectRulebook, removeRulebook } from '../lib/sentinel-inliner.ts';
Expand Down Expand Up @@ -323,6 +324,7 @@ async function reconcileDomain(
await reconcileDeclaredSubagents(harnessSubagentTargets, subagentOrphansByDir, resolvedSubagents);

await refreshHomePromptsYml(options, domain);
await refreshProjectPromptsYml(options, domain);

const skillRetractions = skillOrphansByDir.reduce((total, harness) => total + harness.orphans.length, 0);
const skillFilesWritten = desiredSkillDirs.size * harnessSkillDirs.length;
Expand Down Expand Up @@ -670,6 +672,41 @@ async function refreshHomePromptsYml(options: InstallOptions, domain: SyncDomain
}
}

/**
* Generates the repo-domain Rovo Dev `prompts.yml` so project-scoped skills appear in its available-skills list. The
* deployed skills are projected into a codeassembly-owned region merged into the shared file, preserving any foreign
* entries. When no project-scoped skills remain, the region is stripped — and the file deleted when nothing foreign is
* left. A no-op for the home domain and for non-Rovo Dev harnesses; a file carrying no codeassembly region is never
* touched.
*/
async function refreshProjectPromptsYml(options: InstallOptions, domain: SyncDomain): Promise<void> {
if (domain.label !== 'project') {
return;
}
for (const harnessId of resolveHarnessIds(options.harness, domain.baseDir)) {
if (harnessId !== 'rovodev') {
continue;
}
const { harnessHome, skillsDir } = resolveHarnessPaths(harnessId, domain.baseDir);
const promptsPath = path.join(harnessHome, 'prompts.yml');
const entries = await collectPromptEntries(skillsDir);
const existing = await readFileOrEmpty(promptsPath);

if (entries !== undefined && entries.length > 0) {
await writeIfChanged(promptsPath, injectPromptsRegion(existing, renderPromptEntries(entries)));
continue;
}

// No project-scoped skills: strip our region, deleting the file when nothing foreign survives. A file we never
// owned (no region) is left untouched.
if (!hasPromptsRegion(existing)) {
continue;
}
const stripped = removePromptsRegion(existing);
await (stripped.trim() === '' ? rm(promptsPath, { force: true }) : writeIfChanged(promptsPath, stripped));
}
}

/** The writes and retractions the dry-run reporter previews, gathered from the pre-write reconciliation. */
interface DryRunPlan {
readonly ambientHostName: string;
Expand Down
91 changes: 91 additions & 0 deletions packages/agents/src/lib/__tests__/prompts-yml-region.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';

import { hasPromptsRegion, injectPromptsRegion, removePromptsRegion } from '../prompts-yml-region.ts';

/** A rendered entry body, as `renderPromptEntries` produces it: indented list items with a trailing newline. */
const BODY = " - name: 'x'\n description: 'd'\n content_file: skills/x/SKILL.md\n";
/** The sentinel-wrapped region for `BODY`, with no surrounding newlines. */
const REGION = ` # codeassembly:managed:start\n${BODY} # codeassembly:managed:end`;

const FOREIGN = "prompts:\n - name: 'foreign'\n description: 'h'\n content_file: foo.md\n";

describe(hasPromptsRegion, () => {
it('detects a complete sentinel pair', () => {
expect(hasPromptsRegion(injectPromptsRegion('', BODY))).toBe(true);
});

it('returns false for a region-less file', () => {
expect(hasPromptsRegion(FOREIGN)).toBe(false);
});

it('returns false for empty content', () => {
expect(hasPromptsRegion('')).toBe(false);
});

it('returns false for an unpaired open marker', () => {
expect(hasPromptsRegion('prompts:\n # codeassembly:managed:start\n - name: x\n')).toBe(false);
});
});

describe(injectPromptsRegion, () => {
it('creates prompts: and the region when content is empty', () => {
expect(injectPromptsRegion('', BODY)).toBe(`prompts:\n${REGION}\n`);
});

it('appends the region after foreign prompts: items, preserving them', () => {
expect(injectPromptsRegion(FOREIGN, BODY)).toBe(`${FOREIGN}${REGION}\n`);
});

it('creates a prompts: key after unrelated top-level content', () => {
expect(injectPromptsRegion('other: value\n', BODY)).toBe(`other: value\nprompts:\n${REGION}\n`);
});

it('inserts the region before a top-level key that follows the prompts: block', () => {
const mixed = `${FOREIGN}other: value\n`;
expect(injectPromptsRegion(mixed, BODY)).toBe(`${FOREIGN}${REGION}\nother: value\n`);
});

it('is byte-identical when re-inserting an identical body', () => {
const once = injectPromptsRegion('', BODY);
expect(injectPromptsRegion(once, BODY)).toBe(once);
});

it('replaces an existing region in place when the body changes', () => {
const newBody = " - name: 'y'\n description: 'e'\n content_file: skills/y/SKILL.md\n";
const newRegion = ` # codeassembly:managed:start\n${newBody} # codeassembly:managed:end`;

const updated = injectPromptsRegion(injectPromptsRegion('', BODY), newBody);

expect(updated).toBe(`prompts:\n${newRegion}\n`);
expect(updated).not.toContain("name: 'x'");
});

it('normalizes an empty flow-style prompts: [] to a block header before inserting', () => {
expect(injectPromptsRegion('prompts: []\n', BODY)).toBe(`prompts:\n${REGION}\n`);
});

it('refuses an inline-valued prompts: rather than appending a duplicate key', () => {
expect(() => injectPromptsRegion("prompts: [{ name: 'foreign', content_file: foo.md }]\n", BODY)).toThrow(
/block-style/,
);
});
});

describe(removePromptsRegion, () => {
it('strips the region while preserving foreign items', () => {
expect(removePromptsRegion(injectPromptsRegion(FOREIGN, BODY))).toBe(FOREIGN);
});

it('collapses an emptied prompts: block to empty content when nothing foreign remains', () => {
expect(removePromptsRegion(injectPromptsRegion('', BODY))).toBe('');
});

it('drops only the prompts: block, keeping unrelated top-level content', () => {
expect(removePromptsRegion(injectPromptsRegion('other: value\n', BODY))).toBe('other: value\n');
});

it('returns content unchanged when no region is present', () => {
expect(removePromptsRegion(FOREIGN)).toBe(FOREIGN);
expect(removePromptsRegion('# hand-authored\n')).toBe('# hand-authored\n');
});
});
70 changes: 69 additions & 1 deletion packages/agents/src/lib/__tests__/prompts-yml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,75 @@ import path from 'node:path';

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

import { renderPromptsYml } from '../prompts-yml.ts';
import { collectPromptEntries, renderPromptEntries, renderPromptsYml } from '../prompts-yml.ts';

describe(collectPromptEntries, () => {
let skillsDir: string;

beforeEach(async () => {
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
skillsDir = path.join(tmpdir(), `agents-test-collect-${stamp}`);
await mkdir(skillsDir, { recursive: true });
});

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

/** Writes a fixture skill directory with the given frontmatter line(s) into the temp skills dir. */
async function writeSkill(name: string, frontmatter: string): Promise<void> {
const dir = path.join(skillsDir, name);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'SKILL.md'), `---\nname: ${name}\n${frontmatter}\n---\n\n# ${name}\n`, 'utf8');
}

it('returns undefined when the skills directory is absent', async () => {
expect(await collectPromptEntries(path.join(skillsDir, 'missing'))).toBeUndefined();
});

it('collects user-invocable skills sorted by name, with content-file paths and unquoted descriptions', async () => {
await writeSkill('beta', "description: 'Beta does things'");
await writeSkill('alpha', 'description: Alpha desc');

expect(await collectPromptEntries(skillsDir)).toEqual([
{ name: 'alpha', description: 'Alpha desc', contentFile: 'skills/alpha/SKILL.md' },
{ name: 'beta', description: 'Beta does things', contentFile: 'skills/beta/SKILL.md' },
]);
});

it('excludes skills marked user-invocable: false', async () => {
await writeSkill('internal', 'user-invocable: false');
await writeSkill('public', 'description: Public');

const entries = await collectPromptEntries(skillsDir);

expect(entries?.map((entry) => entry.name)).toEqual(['public']);
});
});

describe(renderPromptEntries, () => {
it('returns an empty string when there are no entries', () => {
expect(renderPromptEntries([])).toBe('');
});

it('renders entries as indented list items, headerless, with a trailing newline', () => {
expect(
renderPromptEntries([
{ name: 'alpha', description: 'Alpha desc', contentFile: 'skills/alpha/SKILL.md' },
{ name: 'beta', description: 'Beta does things', contentFile: 'skills/beta/SKILL.md' },
]),
).toBe(
" - name: 'alpha'\n description: 'Alpha desc'\n content_file: skills/alpha/SKILL.md\n" +
" - name: 'beta'\n description: 'Beta does things'\n content_file: skills/beta/SKILL.md\n",
);
});

it('single-quotes descriptions with internal quotes doubled', () => {
expect(renderPromptEntries([{ name: 'q', description: "it's fine", contentFile: 'skills/q/SKILL.md' }])).toContain(
"description: 'it''s fine'",
);
});
});

describe(renderPromptsYml, () => {
let skillsDir: string;
Expand Down
Loading
Loading