From 9cc655d2d35abea82cd3f92af0ee3e8dd759f8c6 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 29 Jun 2026 22:31:04 -0700 Subject: [PATCH 1/5] agents|refactor: Extract reusable prompt-entry projection and renderer Decomposes the Rovo Dev `prompts.yml` renderer into a reusable skill-directory projection and a headerless entry renderer, so the project-scoped index can build on the same projection without duplicating the directory scan. --- .../src/lib/__tests__/prompts-yml.test.ts | 70 ++++++++++++++++++- packages/agents/src/lib/prompts-yml.ts | 56 +++++++++------ 2 files changed, 105 insertions(+), 21 deletions(-) diff --git a/packages/agents/src/lib/__tests__/prompts-yml.test.ts b/packages/agents/src/lib/__tests__/prompts-yml.test.ts index 81f77d84..efcab1eb 100644 --- a/packages/agents/src/lib/__tests__/prompts-yml.test.ts +++ b/packages/agents/src/lib/__tests__/prompts-yml.test.ts @@ -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 { + 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; diff --git a/packages/agents/src/lib/prompts-yml.ts b/packages/agents/src/lib/prompts-yml.ts index 06b8a3c5..06777e75 100644 --- a/packages/agents/src/lib/prompts-yml.ts +++ b/packages/agents/src/lib/prompts-yml.ts @@ -5,19 +5,18 @@ import { parseFrontmatter } from './frontmatter-merger.ts'; import { isEnoent, isMissingFile } from './type-guards.ts'; /** One entry in the rendered index: a user-invocable skill paired with its description and content file. */ -interface PromptEntry { +export interface PromptEntry { readonly name: string; readonly description: string; readonly contentFile: string; } /** - * Renders the Rovo Dev `prompts.yml` index for the skills under `skillsDir`: one entry per skill directory, sorted by - * name, excluding any whose `SKILL.md` declares `user-invocable: false`. Returns `undefined` when the directory is - * absent. A pure projection of the on-disk skills dir — install and sync produce byte-identical output, so either may - * regenerate it. + * Scans the skills under `skillsDir` into the prompt entries that back the Rovo Dev index: one entry per skill + * directory, sorted by name, excluding any whose `SKILL.md` declares `user-invocable: false`. Returns `undefined` + * when the directory is absent. The shared projection both the whole-file and region renderers build on. */ -export async function renderPromptsYml(skillsDir: string): Promise { +export async function collectPromptEntries(skillsDir: string): Promise | undefined> { let skillDirEntries: ReadonlyArray; try { skillDirEntries = await readdir(skillsDir); @@ -49,7 +48,37 @@ export async function renderPromptsYml(skillsDir: string): Promise): string { + const yamlLines: Array = []; + for (const entry of entries) { + yamlLines.push( + ` - name: '${entry.name}'`, + ` description: '${entry.description.replaceAll("'", "''")}'`, + ` content_file: ${entry.contentFile}`, + ); + } + return yamlLines.length === 0 ? '' : yamlLines.join('\n') + '\n'; +} + +/** + * Renders the whole-file Rovo Dev `prompts.yml` index for the skills under `skillsDir` (`prompts:` header plus the + * entry list). Returns `undefined` when the directory is absent. A pure projection of the on-disk skills dir — install + * and sync produce byte-identical output, so either may regenerate it. + */ +export async function renderPromptsYml(skillsDir: string): Promise { + const entries = await collectPromptEntries(skillsDir); + if (entries === undefined) { + return undefined; + } + return `prompts:\n${renderPromptEntries(entries)}`; } // region | Helpers @@ -70,19 +99,6 @@ function readPromptMetadata(skillContent: string): { userInvocable: boolean; des return { userInvocable, description }; } -/** Builds the deterministic `prompts.yml` body, single-quoting descriptions with internal quotes doubled. */ -function renderYaml(promptEntries: ReadonlyArray): string { - const yamlLines = ['prompts:']; - for (const entry of promptEntries) { - yamlLines.push( - ` - name: '${entry.name}'`, - ` description: '${entry.description.replaceAll("'", "''")}'`, - ` content_file: ${entry.contentFile}`, - ); - } - return yamlLines.join('\n') + '\n'; -} - /** Strips surrounding single or double quotes from a YAML scalar, unescaping the doubled or backslash forms. */ function unquoteYamlScalar(value: string): string { if (value.startsWith("'") && value.endsWith("'")) { From 244adb7003c0203bd12d2952aa3884283a0a635d Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 29 Jun 2026 22:35:15 -0700 Subject: [PATCH 2/5] agents|internal: Add prompts.yml region merge primitive Adds a pure-transform primitive that manages a single codeassembly-owned region inside a Rovo Dev `prompts.yml`, delimited by comment-marker sentinels. Injecting appends or replaces the region in place, preserving foreign list items and other top-level keys; removing it strips the region and collapses an emptied `prompts:` key. Re-injecting an identical body leaves the file byte-identical. --- .../lib/__tests__/prompts-yml-region.test.ts | 81 +++++++++++ packages/agents/src/lib/prompts-yml-region.ts | 130 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 packages/agents/src/lib/__tests__/prompts-yml-region.test.ts create mode 100644 packages/agents/src/lib/prompts-yml-region.ts diff --git a/packages/agents/src/lib/__tests__/prompts-yml-region.test.ts b/packages/agents/src/lib/__tests__/prompts-yml-region.test.ts new file mode 100644 index 00000000..7f04d525 --- /dev/null +++ b/packages/agents/src/lib/__tests__/prompts-yml-region.test.ts @@ -0,0 +1,81 @@ +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'"); + }); +}); + +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'); + }); +}); diff --git a/packages/agents/src/lib/prompts-yml-region.ts b/packages/agents/src/lib/prompts-yml-region.ts new file mode 100644 index 00000000..2234bf46 --- /dev/null +++ b/packages/agents/src/lib/prompts-yml-region.ts @@ -0,0 +1,130 @@ +/** + * Idempotent management of a single codeassembly-owned region within a Rovo Dev `prompts.yml`. The region lives inside + * the `prompts:` sequence, delimited by `# codeassembly:managed:start` / `# codeassembly:managed:end` comment markers + * that double as the ownership marker. Everything outside the region — foreign list items and other top-level keys — + * is preserved verbatim. Every function is a pure string transform with no filesystem access. + */ + +const OPEN_MARKER = ' # codeassembly:managed:start'; +const CLOSE_MARKER = ' # codeassembly:managed:end'; +const REGION_PATTERN = /^[ \t]*# codeassembly:managed:start\n[\s\S]*?^[ \t]*# codeassembly:managed:end[ \t]*$/m; + +/** True when the content holds a complete codeassembly region marker pair — the ownership check. */ +export function hasPromptsRegion(content: string): boolean { + return REGION_PATTERN.test(content); +} + +/** + * Inserts or replaces the codeassembly region carrying `regionBody` (the rendered entry list). An existing region is + * replaced in place; otherwise the region is appended at the end of the `prompts:` sequence — after any foreign items + * and before any following top-level key — creating a `prompts:` key when the file has none. Re-inserting an identical + * body yields byte-identical content, which is what keeps `sync` diff-free on re-run. + */ +export function injectPromptsRegion(content: string, regionBody: string): string { + const region = renderRegion(regionBody); + + if (hasPromptsRegion(content)) { + // Replace via a function so `$`-sequences in the body are not treated as replacement patterns. + return content.replace(REGION_PATTERN, () => region); + } + + const lines = splitContentLines(content); + const regionLines = region.split('\n'); + const promptsIdx = lines.findIndex(isPromptsKey); + + if (promptsIdx === -1) { + return joinContentLines([...lines, 'prompts:', ...regionLines]); + } + + // Walk to the end of the prompts block: indented list items and their children, skipping interleaved blank lines, + // stopping at the next column-0 key or EOF. The region is placed just after the last item, before that boundary. + let insertAfter = promptsIdx; + for (let i = promptsIdx + 1; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined || line.trim() === '') { + continue; + } + if (/^\s/.test(line)) { + insertAfter = i; + continue; + } + break; + } + + return joinContentLines([...lines.slice(0, insertAfter + 1), ...regionLines, ...lines.slice(insertAfter + 1)]); +} + +/** + * Strips the codeassembly region. When that leaves the `prompts:` key with no remaining list items, the now-empty + * `prompts:` line is dropped too. Foreign items and other top-level content survive. Returns the content unchanged + * when no region is present. + */ +export function removePromptsRegion(content: string): string { + if (!hasPromptsRegion(content)) { + return content; + } + + const lines = splitContentLines(content); + const startIdx = lines.findIndex(isOpenMarker); + let endIdx = startIdx; + for (let i = startIdx + 1; i < lines.length; i++) { + const line = lines[i]; + if (line !== undefined && isCloseMarker(line)) { + endIdx = i; + break; + } + } + + const remaining = [...lines.slice(0, startIdx), ...lines.slice(endIdx + 1)]; + const promptsIdx = remaining.findIndex(isPromptsKey); + if (promptsIdx !== -1 && !hasIndentedContentAfter(remaining, promptsIdx)) { + remaining.splice(promptsIdx, 1); + } + + return joinContentLines(remaining); +} + +// region | Helpers + +/** True when any line between `promptsIdx` and the next column-0 key (or EOF) is indented, non-blank content. */ +function hasIndentedContentAfter(lines: ReadonlyArray, promptsIdx: number): boolean { + for (let i = promptsIdx + 1; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined || line.trim() === '') { + continue; + } + return /^\s/.test(line); + } + return false; +} + +function isCloseMarker(line: string): boolean { + return line.trim() === '# codeassembly:managed:end'; +} + +function isOpenMarker(line: string): boolean { + return line.trim() === '# codeassembly:managed:start'; +} + +/** True for a top-level `prompts:` key (column 0, no trailing value). */ +function isPromptsKey(line: string): boolean { + return /^prompts:[ \t]*$/.test(line); +} + +/** Rejoins content lines, restoring a single trailing newline; returns an empty string for no lines. */ +function joinContentLines(lines: ReadonlyArray): string { + return lines.length === 0 ? '' : `${lines.join('\n')}\n`; +} + +/** Wraps the rendered entry body in the region markers, with no surrounding newlines. */ +function renderRegion(regionBody: string): string { + const body = regionBody.replace(/\n+$/, ''); + return body === '' ? `${OPEN_MARKER}\n${CLOSE_MARKER}` : `${OPEN_MARKER}\n${body}\n${CLOSE_MARKER}`; +} + +/** Splits content into lines, dropping the artifact empty element a trailing newline would produce. */ +function splitContentLines(content: string): Array { + return content === '' ? [] : content.replace(/\n$/, '').split('\n'); +} + +// endregion | Helpers From 7e933c6041d25eb94eba7afa797f44e22e7ec79d Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 29 Jun 2026 22:41:11 -0700 Subject: [PATCH 3/5] agents|feat: Generate project-scoped Rovo Dev prompts.yml on sync Project-scoped Rovo Dev skills now appear in Rovo Dev's available-skills list: `sync` generates `/.rovodev/prompts.yml` indexing the deployed user-invocable skills. The index is merged as a codeassembly-owned region within the shared file, so any hand-authored entries are preserved; undeclaring all project skills strips the region and removes the file when nothing else remains. --- .../src/commands/__tests__/sync.test.ts | 107 ++++++++++++++++++ packages/agents/src/commands/sync.ts | 39 ++++++- 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/packages/agents/src/commands/__tests__/sync.test.ts b/packages/agents/src/commands/__tests__/sync.test.ts index 82170474..7db2e02a 100644 --- a/packages/agents/src/commands/__tests__/sync.test.ts +++ b/packages/agents/src/commands/__tests__/sync.test.ts @@ -733,6 +733,113 @@ 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 { + 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): Promise { + 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 { + 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('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, () => { diff --git a/packages/agents/src/commands/sync.ts b/packages/agents/src/commands/sync.ts index efa9fb2b..a2ac131a 100644 --- a/packages/agents/src/commands/sync.ts +++ b/packages/agents/src/commands/sync.ts @@ -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'; @@ -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; @@ -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 { + 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; From a4cd39beb98de0860987bd60badd40db12120e55 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 29 Jun 2026 22:42:18 -0700 Subject: [PATCH 4/5] agents|docs: Document project-local Rovo Dev prompts.yml output Documents the project-local `.rovodev/prompts.yml` that `sync` generates in the repo domain to index project-scoped Rovo Dev skills, and its non-clobbering, sentinel-delimited region ownership. --- packages/agents/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents/README.md b/packages/agents/README.md index d320e6f1..f2757746 100644 --- a/packages/agents/README.md +++ b/packages/agents/README.md @@ -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. From ffb5791324e906ea476fa3b695fc09171d329e77 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 29 Jun 2026 23:46:39 -0700 Subject: [PATCH 5/5] agents|fix: Avoid corrupting a flow-style prompts.yml on region merge A hand-authored `prompts.yml` that uses an inline or flow-style `prompts:` is no longer corrupted when `sync` merges the codeassembly region: an empty `prompts: []` is normalized to a block sequence, and any other inline-valued `prompts:` aborts the sync with an actionable error instead of appending a second `prompts:` key that would leave the file unloadable. --- .../src/commands/__tests__/sync.test.ts | 14 +++++++ .../lib/__tests__/prompts-yml-region.test.ts | 10 +++++ packages/agents/src/lib/prompts-yml-region.ts | 38 +++++++++++++++---- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/agents/src/commands/__tests__/sync.test.ts b/packages/agents/src/commands/__tests__/sync.test.ts index 7db2e02a..293205ed 100644 --- a/packages/agents/src/commands/__tests__/sync.test.ts +++ b/packages/agents/src/commands/__tests__/sync.test.ts @@ -830,6 +830,20 @@ describe(syncCommand, () => { 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'); diff --git a/packages/agents/src/lib/__tests__/prompts-yml-region.test.ts b/packages/agents/src/lib/__tests__/prompts-yml-region.test.ts index 7f04d525..f89f859d 100644 --- a/packages/agents/src/lib/__tests__/prompts-yml-region.test.ts +++ b/packages/agents/src/lib/__tests__/prompts-yml-region.test.ts @@ -59,6 +59,16 @@ describe(injectPromptsRegion, () => { 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, () => { diff --git a/packages/agents/src/lib/prompts-yml-region.ts b/packages/agents/src/lib/prompts-yml-region.ts index 2234bf46..b0ac9180 100644 --- a/packages/agents/src/lib/prompts-yml-region.ts +++ b/packages/agents/src/lib/prompts-yml-region.ts @@ -17,8 +17,10 @@ export function hasPromptsRegion(content: string): boolean { /** * Inserts or replaces the codeassembly region carrying `regionBody` (the rendered entry list). An existing region is * replaced in place; otherwise the region is appended at the end of the `prompts:` sequence — after any foreign items - * and before any following top-level key — creating a `prompts:` key when the file has none. Re-inserting an identical - * body yields byte-identical content, which is what keeps `sync` diff-free on re-run. + * and before any following top-level key — creating a `prompts:` key when the file has none. An empty flow-style + * `prompts: []` is normalized to a block header first; an inline-valued `prompts:` carrying content is refused (throws) + * rather than silently duplicating the key into an unloadable file. Re-inserting an identical body yields byte-identical + * content, which is what keeps `sync` diff-free on re-run. */ export function injectPromptsRegion(content: string, regionBody: string): string { const region = renderRegion(regionBody); @@ -30,12 +32,24 @@ export function injectPromptsRegion(content: string, regionBody: string): string const lines = splitContentLines(content); const regionLines = region.split('\n'); - const promptsIdx = lines.findIndex(isPromptsKey); + const promptsIdx = lines.findIndex(isTopLevelPromptsKey); if (promptsIdx === -1) { return joinContentLines([...lines, 'prompts:', ...regionLines]); } + const promptsLine = lines[promptsIdx] ?? ''; + if (!isBlockPromptsHeader(promptsLine)) { + if (!isEmptyFlowPrompts(promptsLine)) { + throw new Error( + "Cannot merge the codeassembly region into an inline 'prompts:' value in prompts.yml; convert it to a " + + "block-style 'prompts:' sequence, then re-run.", + ); + } + // An empty flow sequence holds no foreign items, so rewrite it as a block header and insert the region below. + lines[promptsIdx] = 'prompts:'; + } + // Walk to the end of the prompts block: indented list items and their children, skipping interleaved blank lines, // stopping at the next column-0 key or EOF. The region is placed just after the last item, before that boundary. let insertAfter = promptsIdx; @@ -76,7 +90,7 @@ export function removePromptsRegion(content: string): string { } const remaining = [...lines.slice(0, startIdx), ...lines.slice(endIdx + 1)]; - const promptsIdx = remaining.findIndex(isPromptsKey); + const promptsIdx = remaining.findIndex(isBlockPromptsHeader); if (promptsIdx !== -1 && !hasIndentedContentAfter(remaining, promptsIdx)) { remaining.splice(promptsIdx, 1); } @@ -98,17 +112,27 @@ function hasIndentedContentAfter(lines: ReadonlyArray, promptsIdx: numbe return false; } +/** True for a block-style `prompts:` header — column 0, no inline value. */ +function isBlockPromptsHeader(line: string): boolean { + return /^prompts:[ \t]*$/.test(line); +} + function isCloseMarker(line: string): boolean { return line.trim() === '# codeassembly:managed:end'; } +/** True for an empty flow-style sequence (`prompts: []`), which carries no foreign items. */ +function isEmptyFlowPrompts(line: string): boolean { + return /^prompts:[ \t]*\[[ \t]*\][ \t]*$/.test(line); +} + function isOpenMarker(line: string): boolean { return line.trim() === '# codeassembly:managed:start'; } -/** True for a top-level `prompts:` key (column 0, no trailing value). */ -function isPromptsKey(line: string): boolean { - return /^prompts:[ \t]*$/.test(line); +/** True for any top-level `prompts:` key, whether a block header or an inline-valued line. */ +function isTopLevelPromptsKey(line: string): boolean { + return line.startsWith('prompts:'); } /** Rejoins content lines, restoring a single trailing newline; returns an empty string for no lines. */