From 32ae6e81fbba816393e3058b94058cb8a82c338d Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 26 Jun 2026 03:09:25 -0700 Subject: [PATCH 1/8] agents|refactor: Extract domain-parameterized sync reconciler Restructure the `sync` reconciler so its base directory and ambient-host file become parameters supplied by the caller, with the `sync` command reduced to a thin wrapper. A single reconciler can now serve additional deployment domains without duplicating its logic. --- packages/agents/src/commands/sync.ts | 73 +++++++++++++++++++--------- 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/packages/agents/src/commands/sync.ts b/packages/agents/src/commands/sync.ts index 31696688..7cc4c4af 100644 --- a/packages/agents/src/commands/sync.ts +++ b/packages/agents/src/commands/sync.ts @@ -43,13 +43,17 @@ interface HarnessSubagentTarget { readonly deployContext: SubagentDeployContext; } +/** The one per-domain difference: the base dir to resolve and deploy under, and the file that hosts ambient blocks. */ +export interface SyncDomain { + readonly baseDir: string; + readonly ambientHostPath: string; + readonly label: 'project' | 'global'; +} + /** - * Resolves the project's `codeassembly.yaml` scope chain, materializes each declared rulebook's neutral body to - * `.agents/rulebooks/.md`, inlines `ambient` rulebooks into `.agents/PROJECT.md`, writes `skill` rulebooks - * as thin-wrapper skills into each targeted harness's project-local skills dir, deploys declared skills and declared - * subagents (the latter through the harness transform) into those harness dirs, and retracts anything no longer - * declared. Installed state is derived from the filesystem, not a manifest, which keeps the command idempotent. - * An absent `codeassembly.yaml` is a total no-op. + * Resolves a project's `codeassembly.yaml` scope chain and reconciles it into that project's harness dirs (the repo + * domain). A thin wrapper over `reconcileDomain` that supplies the repo `SyncDomain`. An absent `codeassembly.yaml` + * is a total no-op. * * @param projectRoot The project whose `.agents/` directory is synced (defaults to the current directory). * @param contentDirOverride Override for the library source (defaults to the package content dir). @@ -59,7 +63,28 @@ export async function syncCommand( projectRoot: string = process.cwd(), contentDirOverride?: string, ): Promise { - const declaration = await resolveDeclaration({ cwd: projectRoot }); + await reconcileDomain( + options, + { baseDir: projectRoot, ambientHostPath: path.join(projectRoot, '.agents', 'PROJECT.md'), label: 'project' }, + contentDirOverride, + ); +} + +/** + * Resolves the `codeassembly.yaml` scope chain under `domain.baseDir`, materializes each declared rulebook's neutral + * body to `/.agents/rulebooks/.md`, inlines `ambient` rulebooks into `domain.ambientHostPath`, writes + * `skill` rulebooks as thin-wrapper skills into each targeted harness's skills dir, deploys declared skills and + * subagents (the latter through the harness transform) into those harness dirs, and retracts anything no longer + * declared. Installed state is derived from the filesystem, not a manifest, which keeps the command idempotent. An + * absent `codeassembly.yaml` is a total no-op. Repo and home domains share this one reconciler, differing only in + * the `SyncDomain` they pass. + */ +async function reconcileDomain( + options: InstallOptions, + domain: SyncDomain, + contentDirOverride?: string, +): Promise { + const declaration = await resolveDeclaration({ cwd: domain.baseDir }); if (declaration === undefined) { console.info('No .agents/codeassembly.yaml found. Nothing to sync.'); return; @@ -83,8 +108,8 @@ export async function syncCommand( const librarySrcDir = path.join(contentDir, 'guidance', 'rulebooks'); const librarySkillsDir = path.join(contentDir, 'skills'); const librarySubagentsDir = path.join(contentDir, 'subagents'); - const neutralDir = path.join(projectRoot, '.agents', 'rulebooks'); - const projectMdPath = path.join(projectRoot, '.agents', 'PROJECT.md'); + 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. @@ -113,8 +138,8 @@ export async function syncCommand( // Skill delivery targets project-local harness skills dirs, gated by detection (or `--harness`). Passing // `projectRoot` as the base is what keeps the skills project-scoped, and keeps tests out of the real home dir. - const harnessSkillDirs = resolveHarnessIds(options.harness, projectRoot).map( - (harnessId) => resolveHarnessPaths(harnessId, projectRoot).skillsDir, + const harnessSkillDirs = resolveHarnessIds(options.harness, domain.baseDir).map( + (harnessId) => resolveHarnessPaths(harnessId, domain.baseDir).skillsDir, ); // Subagent delivery targets each harness's project-local subagents dir, loading that harness's overlay and tool @@ -122,14 +147,14 @@ export async function syncCommand( // transform is harness-specific, and subagents live in a distinct flat dir from skills. const declaredSubagentSet = new Set(resolvedSubagents.map((subagent) => subagent.slug)); const harnessSubagentTargets = await Promise.all( - resolveHarnessIds(options.harness, projectRoot).map((harnessId) => - resolveSubagentTarget(harnessId, projectRoot, contentDir), + resolveHarnessIds(options.harness, domain.baseDir).map((harnessId) => + resolveSubagentTarget(harnessId, domain.baseDir, contentDir), ), ); - const existingProjectMd = await readFileOrEmpty(projectMdPath); + const existingAmbientHost = await readFileOrEmpty(ambientHostPath); const neutralOrphans = (await listNeutralSlugs(neutralDir)).filter((slug) => !declaredSet.has(slug)); - const inlineOrphans = extractInstalledSlugs(existingProjectMd).filter((slug) => !desiredAmbient.has(slug)); + const inlineOrphans = extractInstalledSlugs(existingAmbientHost).filter((slug) => !desiredAmbient.has(slug)); // A skill dir is sync-owned only when its `SKILL.md` carries the provenance marker; that gate is what keeps // hand-authored skills safe. An owned dir is an orphan when its marker slug no longer maps to that directory — // because the rulebook is no longer skill-delivered, or because its resolved skill name (and dir) changed. @@ -166,6 +191,7 @@ export async function syncCommand( if (options.dryRun) { reportDryRun({ + ambientHostName: path.basename(ambientHostPath), resolved, retracted: [...new Set([...neutralOrphans, ...inlineOrphans])], harnessSkillDirs, @@ -183,16 +209,16 @@ export async function syncCommand( await mkdir(neutralDir, { recursive: true }); } - // PROJECT.md is read once, mutated in memory across all inject/remove operations, and written once. - let projectMd = existingProjectMd; + // The ambient host file is read once, mutated in memory across all inject/remove operations, and written once. + let ambientHost = existingAmbientHost; for (const rulebook of resolved) { await writeIfChanged(path.join(neutralDir, `${rulebook.slug}.md`), rulebook.body); if (rulebook.ambient) { - projectMd = injectRulebook(projectMd, rulebook.slug, rulebook.body); + ambientHost = injectRulebook(ambientHost, rulebook.slug, rulebook.body); } } for (const slug of inlineOrphans) { - projectMd = removeRulebook(projectMd, slug); + ambientHost = removeRulebook(ambientHost, slug); } // `.agents/rulebooks/` is sync-owned, so deleting an undeclared neutral file here is safe, not user data loss. @@ -200,9 +226,9 @@ export async function syncCommand( await rm(path.join(neutralDir, `${slug}.md`), { force: true }); } - if (projectMd !== existingProjectMd) { - await mkdir(path.dirname(projectMdPath), { recursive: true }); - await writeFile(projectMdPath, projectMd, 'utf8'); + if (ambientHost !== existingAmbientHost) { + await mkdir(path.dirname(ambientHostPath), { recursive: true }); + await writeFile(ambientHostPath, ambientHost, 'utf8'); } // Reconcile skill files per targeted harness: Retract sync-owned skill dirs that are no longer current, then @@ -458,6 +484,7 @@ async function reconcileDeclaredSubagents( /** The writes and retractions the dry-run reporter previews, gathered from the pre-write reconciliation. */ interface DryRunPlan { + readonly ambientHostName: string; readonly resolved: ReadonlyArray; readonly retracted: ReadonlyArray; readonly harnessSkillDirs: ReadonlyArray; @@ -473,7 +500,7 @@ interface DryRunPlan { function reportDryRun(plan: DryRunPlan): void { console.info('[dry-run] sync would:'); for (const rulebook of plan.resolved) { - const inline = rulebook.ambient ? ' (+ inline into PROJECT.md)' : ''; + const inline = rulebook.ambient ? ` (+ inline into ${plan.ambientHostName})` : ''; console.info(` write .agents/rulebooks/${rulebook.slug}.md${inline}`); if (rulebook.skill) { for (const skillsDir of plan.harnessSkillDirs) { From 5b70374bd1a3e3e9d6a7a22549e0dfdca7e5c5bd Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 26 Jun 2026 03:13:35 -0700 Subject: [PATCH 2/8] agents|feat: Add sync --global to deploy the user-global tier to home Adds `sync --global`, which deploys the user-global tier (`~/.agents/codeassembly.yaml`) into the home harness directories so guidance declared once at the user level applies across every project without per-repo setup. Ambient rulebooks inline into `~/.agents/GLOBAL.md`. --- packages/agents/src/cli.ts | 18 +++-- .../src/commands/__tests__/sync.test.ts | 75 ++++++++++++++++++- packages/agents/src/commands/sync.ts | 22 ++++++ 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/agents/src/cli.ts b/packages/agents/src/cli.ts index e136d849..c84f1c88 100644 --- a/packages/agents/src/cli.ts +++ b/packages/agents/src/cli.ts @@ -7,7 +7,7 @@ import { initCommand } from './commands/init.ts'; import { installCommand } from './commands/install.ts'; import { libraryListCommand, printLibraryUsage } from './commands/library-list.ts'; import { statusCommand } from './commands/status.ts'; -import { syncCommand } from './commands/sync.ts'; +import { syncCommand, syncGlobalCommand } from './commands/sync.ts'; import { uninstallCommand } from './commands/uninstall.ts'; import type { HarnessId, InstallOptions } from './lib/types.ts'; @@ -17,7 +17,7 @@ const VALID_HARNESS_IDS = new Set(['claude', 'rovodev', 'all']); * Main CLI entry point. */ async function main(): Promise { - const { command, subcommand, options, help } = parseArgs(process.argv); + const { command, subcommand, options, help, global } = parseArgs(process.argv); if (help || !command) { printUsage(); @@ -33,7 +33,7 @@ async function main(): Promise { await initCommand(options); break; case 'sync': - await syncCommand(options); + await (global ? syncGlobalCommand(options) : syncCommand(options)); break; case 'uninstall': await uninstallCommand({ harness: options.harness, force: options.force }); @@ -86,6 +86,7 @@ function parseArgs(argv: ReadonlyArray): { subcommand: string; options: InstallOptions; help: boolean; + global: boolean; } { const args = argv.slice(2); let command = ''; @@ -95,6 +96,7 @@ function parseArgs(argv: ReadonlyArray): { let force = false; let dryRun = false; let help = false; + let global = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -114,6 +116,9 @@ function parseArgs(argv: ReadonlyArray): { case 'dry-run': dryRun = true; break; + case 'global': + global = true; + break; case 'harness': { const result = parseHarnessArg(args, i); harness = result.harness; @@ -137,16 +142,18 @@ function parseArgs(argv: ReadonlyArray): { subcommand, options: { harness, link, force, dryRun }, help, + global, }; } -function parseFlag(arg: string): 'help' | 'link' | 'force' | 'dry-run' | 'harness' | null { - const flags: Record = { +function parseFlag(arg: string): 'help' | 'link' | 'force' | 'dry-run' | 'global' | 'harness' | null { + const flags: Record = { '--help': 'help', '-h': 'help', '--link': 'link', '--force': 'force', '--dry-run': 'dry-run', + '--global': 'global', '--harness': 'harness', }; return flags[arg] ?? null; @@ -188,6 +195,7 @@ Options: --link Use symlinks instead of copies (install only) --force Overwrite or remove modified files (install/uninstall) --dry-run Show what would be done without making changes (install, sync, init) + --global Sync the user-global tier (~/.agents/codeassembly.yaml) into the home harness dirs (sync only) --help, -h Show this help message`); } diff --git a/packages/agents/src/commands/__tests__/sync.test.ts b/packages/agents/src/commands/__tests__/sync.test.ts index 95a09254..e0b7e09f 100644 --- a/packages/agents/src/commands/__tests__/sync.test.ts +++ b/packages/agents/src/commands/__tests__/sync.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { resolveContentDir } from '../../lib/content-resolver.ts'; import type { InstallOptions } from '../../lib/types.ts'; -import { syncCommand } from '../sync.ts'; +import { syncCommand, syncGlobalCommand } from '../sync.ts'; describe(syncCommand, () => { let projectRoot: string; @@ -715,3 +715,76 @@ describe(syncCommand, () => { }); }); }); + +describe(syncGlobalCommand, () => { + let homeDir: string; + let contentDir: string; + + beforeEach(async () => { + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + homeDir = path.join(tmpdir(), `agents-test-sync-home-${stamp}`); + contentDir = path.join(tmpdir(), `agents-test-sync-home-content-${stamp}`); + await mkdir(homeDir, { recursive: true }); + await mkdir(path.join(contentDir, 'guidance', 'rulebooks'), { recursive: true }); + }); + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + await rm(contentDir, { recursive: true, force: true }); + }); + + function makeOptions(overrides: Partial = {}): InstallOptions { + return { harness: 'claude', link: false, force: false, dryRun: false, ...overrides }; + } + + /** Writes a fixture rulebook into the temp content library. */ + async function writeLibraryRulebook(slug: string, frontmatter: string, body: string): Promise { + const file = path.join(contentDir, 'guidance', 'rulebooks', `${slug}.md`); + await writeFile(file, `---\nslug: ${slug}\n${frontmatter}\n---\n\n${body}\n`, 'utf8'); + } + + /** Writes a fixture declared skill into the temp content library. */ + async function writeLibrarySkill(slug: string): Promise { + const dir = path.join(contentDir, 'skills', slug); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, 'SKILL.md'), + `---\nname: ${slug}\ndeploy: declared\n---\n\n# ${slug}\n\nBody.\n`, + 'utf8', + ); + } + + /** Writes the user-global codeassembly.yaml under the temp home's `.agents/`. */ + async function declareRaw(content: string): Promise { + await mkdir(path.join(homeDir, '.agents'), { recursive: true }); + await writeFile(path.join(homeDir, '.agents', 'codeassembly.yaml'), content, 'utf8'); + } + + it('when no ~/.agents/codeassembly.yaml exists, makes no changes', async () => { + await syncGlobalCommand(makeOptions(), homeDir, contentDir); + + expect(existsSync(path.join(homeDir, '.agents', 'rulebooks'))).toBe(false); + }); + + it('deploys a declared skill into the home harness skills dir with the ownership marker', async () => { + await writeLibrarySkill('people-report'); + await declareRaw('skills:\n use:\n - people-report\n'); + + await syncGlobalCommand(makeOptions(), homeDir, contentDir); + + const skill = await readFile(path.join(homeDir, '.claude', 'skills', 'people-report', 'SKILL.md'), 'utf8'); + expect(skill).toContain(''); + }); + + it('inlines ambient rulebooks into ~/.agents/GLOBAL.md, never PROJECT.md', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await declareRaw('rulebooks:\n use:\n - alpha\n'); + + await syncGlobalCommand(makeOptions(), homeDir, contentDir); + + const globalMd = await readFile(path.join(homeDir, '.agents', 'GLOBAL.md'), 'utf8'); + expect(globalMd).toContain(''); + expect(globalMd).toContain('Alpha rules.'); + expect(existsSync(path.join(homeDir, '.agents', 'PROJECT.md'))).toBe(false); + }); +}); diff --git a/packages/agents/src/commands/sync.ts b/packages/agents/src/commands/sync.ts index 7cc4c4af..2c48ea95 100644 --- a/packages/agents/src/commands/sync.ts +++ b/packages/agents/src/commands/sync.ts @@ -1,5 +1,6 @@ import type { Dirent } from 'node:fs'; import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; import path from 'node:path'; import process from 'node:process'; @@ -70,6 +71,27 @@ export async function syncCommand( ); } +/** + * Resolves the user-global `~/.agents/codeassembly.yaml` scope chain and reconciles it into the home harness dirs (the + * home domain). A thin wrapper over `reconcileDomain` that supplies the home `SyncDomain`. Ambient blocks land in + * `~/.agents/GLOBAL.md` so install's whole-file `~/.agents/AGENTS.md` is never co-written. An absent declaration is a + * total no-op. + * + * @param homeDir The home directory whose `.agents/` is synced (defaults to the OS home dir; injected in tests). + * @param contentDirOverride Override for the library source (defaults to the package content dir). + */ +export async function syncGlobalCommand( + options: InstallOptions, + homeDir: string = homedir(), + contentDirOverride?: string, +): Promise { + await reconcileDomain( + options, + { baseDir: homeDir, ambientHostPath: path.join(homeDir, '.agents', 'GLOBAL.md'), label: 'global' }, + contentDirOverride, + ); +} + /** * Resolves the `codeassembly.yaml` scope chain under `domain.baseDir`, materializes each declared rulebook's neutral * body to `/.agents/rulebooks/.md`, inlines `ambient` rulebooks into `domain.ambientHostPath`, writes From 9d714fc61418e1bbdc781db088c1b9e37877058c Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 26 Jun 2026 03:18:34 -0700 Subject: [PATCH 3/8] agents|feat: Guard sync against foreign overwrites and home-rooted runs `sync` now refuses to overwrite any skill or subagent file that lacks its ownership marker, so install-managed and hand-authored files are never clobbered. Bare `sync` also refuses to run when the working directory is the home directory, directing the user to `sync --global`. --- .../src/commands/__tests__/sync.test.ts | 17 +++- packages/agents/src/commands/sync.ts | 87 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/agents/src/commands/__tests__/sync.test.ts b/packages/agents/src/commands/__tests__/sync.test.ts index e0b7e09f..fc0f810d 100644 --- a/packages/agents/src/commands/__tests__/sync.test.ts +++ b/packages/agents/src/commands/__tests__/sync.test.ts @@ -1,6 +1,6 @@ import { existsSync, statSync } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import path from 'node:path'; import { unindent } from '@williamthorsen/toolbelt.strings/candidate'; @@ -787,4 +787,19 @@ describe(syncGlobalCommand, () => { expect(globalMd).toContain('Alpha rules.'); expect(existsSync(path.join(homeDir, '.agents', 'PROJECT.md'))).toBe(false); }); + + it('refuses to overwrite a home skill that lacks the sync ownership marker', async () => { + await writeLibrarySkill('people-report'); + await declareRaw('skills:\n use:\n - people-report\n'); + const target = path.join(homeDir, '.claude', 'skills', 'people-report'); + await mkdir(target, { recursive: true }); + await writeFile(path.join(target, 'SKILL.md'), '---\nname: people-report\n---\n\n# Hand-authored\n', 'utf8'); + + await expect(syncGlobalCommand(makeOptions(), homeDir, contentDir)).rejects.toThrow(/not owned by sync/i); + expect(await readFile(path.join(target, 'SKILL.md'), 'utf8')).toContain('Hand-authored'); + }); + + it('refuses a bare sync run rooted at the home directory, directing to --global', async () => { + await expect(syncCommand(makeOptions(), homedir(), contentDir)).rejects.toThrow(/--global/); + }); }); diff --git a/packages/agents/src/commands/sync.ts b/packages/agents/src/commands/sync.ts index 2c48ea95..946b6aea 100644 --- a/packages/agents/src/commands/sync.ts +++ b/packages/agents/src/commands/sync.ts @@ -64,6 +64,12 @@ export async function syncCommand( projectRoot: string = process.cwd(), contentDirOverride?: string, ): Promise { + if (path.resolve(projectRoot) === path.resolve(homedir())) { + throw new Error( + 'Refusing to run `sync` in the home directory: that would deploy the user-global tier through the project ' + + 'path. Run `sync --global` to sync the user-global tier into the home harness dirs.', + ); + } await reconcileDomain( options, { baseDir: projectRoot, ambientHostPath: path.join(projectRoot, '.agents', 'PROJECT.md'), label: 'project' }, @@ -211,6 +217,13 @@ async function reconcileDomain( })), ); + // Before any write or delete, fail closed on any skill or subagent target that already exists without this sync's + // ownership marker — an install-managed or hand-authored file. The marker-gated retraction scans keep such files + // from being deleted; this keeps them from being overwritten. Runs in dry-run too, so a preview surfaces the conflict. + await assertNoForeignOwnedTargets( + collectOwnedTargets(harnessSkillDirs, resolved, resolvedSkills, harnessSubagentTargets, resolvedSubagents), + ); + if (options.dryRun) { reportDryRun({ ambientHostName: path.basename(ambientHostPath), @@ -311,6 +324,80 @@ async function reconcileDomain( // region | Helpers +/** A planned write whose destination must be sync-owned (or absent) before the write proceeds. */ +interface OwnedTarget { + readonly filePath: string; + readonly isOwned: (content: string) => boolean; +} + +/** + * Throws when any planned target already exists without this sync's ownership marker — an install-managed or + * hand-authored file. Failing here, before any write or delete, is what keeps a same-named foreign file from being + * overwritten; the marker-gated retraction scans separately keep it from being deleted. Absent targets are safe. + */ +async function assertNoForeignOwnedTargets(targets: ReadonlyArray): Promise { + const foreign: Array = []; + for (const target of targets) { + let content: string; + try { + content = await readFile(target.filePath, 'utf8'); + } catch (error: unknown) { + if (isMissingFile(error)) { + continue; + } + throw error; + } + if (!target.isOwned(content)) { + foreign.push(target.filePath); + } + } + if (foreign.length > 0) { + throw new Error( + `Refusing to overwrite ${foreign.length} file(s) not owned by sync (install-managed or hand-authored): ` + + `${foreign.join(', ')}. Rename or remove them, or retire the conflicting install artifact, then re-run.`, + ); + } +} + +/** + * Collects the skill and subagent destinations a sync would write, each paired with the predicate that recognizes its + * own ownership marker, so the pre-write guard can reject any that already exist foreign-owned. + */ +function collectOwnedTargets( + harnessSkillDirs: ReadonlyArray, + resolved: ReadonlyArray, + resolvedSkills: ReadonlyArray, + harnessSubagentTargets: ReadonlyArray, + resolvedSubagents: ReadonlyArray, +): ReadonlyArray { + const targets: Array = []; + for (const skillsDir of harnessSkillDirs) { + for (const rulebook of resolved) { + if (rulebook.skill) { + targets.push({ + filePath: path.join(skillsDir, rulebook.skillName, 'SKILL.md'), + isOwned: (content) => extractRulebookSkillSlug(content) !== undefined, + }); + } + } + for (const skill of resolvedSkills) { + targets.push({ + filePath: path.join(skillsDir, skill.slug, 'SKILL.md'), + isOwned: (content) => skillMarker.extractSlug(content) !== undefined, + }); + } + } + for (const target of harnessSubagentTargets) { + for (const subagent of resolvedSubagents) { + targets.push({ + filePath: path.join(target.subagentsDir, `${subagent.slug}.md`), + isOwned: (content) => subagentMarker.extractSlug(content) !== undefined, + }); + } + } + return targets; +} + /** * Throws when a rulebook-skill directory name and a declared-skill directory name collide, which would let the two * delivery namespaces clobber each other in a shared project-local skills dir. Failing here, before any write, From 69712dd487a7c148e3349670b717797542dc7918 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 26 Jun 2026 03:21:03 -0700 Subject: [PATCH 4/8] agents|feat: Point shared AGENTS.md at ~/.agents/GLOBAL.md The installed `~/.agents/AGENTS.md` now points agents to `~/.agents/GLOBAL.md`, so the user-global ambient guidance that `sync --global` deploys is loaded across every project. --- packages/agents/content/guidance/shared/AGENTS.md | 1 + .../agents/src/commands/__tests__/sync.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/agents/content/guidance/shared/AGENTS.md b/packages/agents/content/guidance/shared/AGENTS.md index e6ca08fe..16b40e8f 100644 --- a/packages/agents/content/guidance/shared/AGENTS.md +++ b/packages/agents/content/guidance/shared/AGENTS.md @@ -6,6 +6,7 @@ Always act as a conscientious and courteous collaborator. Follow best practices ## Project discovery +- Read ~/.agents/GLOBAL.md (if it exists) for user-global guidance - Read .agents/PROJECT.md (if it exists) for project information - Read .agents/preferences.yaml (if it exists) for agent settings diff --git a/packages/agents/src/commands/__tests__/sync.test.ts b/packages/agents/src/commands/__tests__/sync.test.ts index fc0f810d..e94ae582 100644 --- a/packages/agents/src/commands/__tests__/sync.test.ts +++ b/packages/agents/src/commands/__tests__/sync.test.ts @@ -802,4 +802,18 @@ describe(syncGlobalCommand, () => { it('refuses a bare sync run rooted at the home directory, directing to --global', async () => { await expect(syncCommand(makeOptions(), homedir(), contentDir)).rejects.toThrow(/--global/); }); + + it('retracts a home ambient block on undeclare and never writes ~/.agents/AGENTS.md', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await declareRaw('rulebooks:\n use:\n - alpha\n'); + await syncGlobalCommand(makeOptions(), homeDir, contentDir); + expect(await readFile(path.join(homeDir, '.agents', 'GLOBAL.md'), 'utf8')).toContain(''); + + await declareRaw('rulebooks:\n use: []\n'); + await syncGlobalCommand(makeOptions(), homeDir, contentDir); + + const globalMd = await readFile(path.join(homeDir, '.agents', 'GLOBAL.md'), 'utf8'); + expect(globalMd).not.toContain(''); + expect(existsSync(path.join(homeDir, '.agents', 'AGENTS.md'))).toBe(false); + }); }); From 3ec212d14a64116ec946b035ed6e85ef7ef712a5 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 26 Jun 2026 03:26:04 -0700 Subject: [PATCH 5/8] agents|feat: Keep ~/.rovodev/prompts.yml current after sync --global `sync --global` now regenerates `~/.rovodev/prompts.yml`, so a Rovo Dev skill deployed to the home directory appears in its available-skills list immediately rather than only after the next `install`. --- .../src/commands/__tests__/sync.test.ts | 11 +++ packages/agents/src/commands/install.ts | 76 +-------------- packages/agents/src/commands/sync.ts | 24 +++++ .../src/lib/__tests__/prompts-yml.test.ts | 55 +++++++++++ packages/agents/src/lib/prompts-yml.ts | 97 +++++++++++++++++++ 5 files changed, 190 insertions(+), 73 deletions(-) create mode 100644 packages/agents/src/lib/__tests__/prompts-yml.test.ts create mode 100644 packages/agents/src/lib/prompts-yml.ts diff --git a/packages/agents/src/commands/__tests__/sync.test.ts b/packages/agents/src/commands/__tests__/sync.test.ts index e94ae582..92c31a56 100644 --- a/packages/agents/src/commands/__tests__/sync.test.ts +++ b/packages/agents/src/commands/__tests__/sync.test.ts @@ -816,4 +816,15 @@ describe(syncGlobalCommand, () => { expect(globalMd).not.toContain(''); expect(existsSync(path.join(homeDir, '.agents', 'AGENTS.md'))).toBe(false); }); + + it('refreshes ~/.rovodev/prompts.yml with home-deployed Rovo Dev skills', async () => { + await writeLibrarySkill('people-report'); + await declareRaw('skills:\n use:\n - people-report\n'); + + await syncGlobalCommand(makeOptions({ harness: 'rovodev' }), homeDir, contentDir); + + const prompts = await readFile(path.join(homeDir, '.rovodev', 'prompts.yml'), 'utf8'); + expect(prompts).toContain("name: 'people-report'"); + expect(prompts).toContain('content_file: skills/people-report/SKILL.md'); + }); }); diff --git a/packages/agents/src/commands/install.ts b/packages/agents/src/commands/install.ts index b5e8b7a2..292f572d 100644 --- a/packages/agents/src/commands/install.ts +++ b/packages/agents/src/commands/install.ts @@ -5,7 +5,6 @@ import { resolveContentDir } from '../lib/content-resolver.ts'; import { readDeploy } from '../lib/deploy-frontmatter.ts'; import { expandIncludes } from '../lib/directive-expander.ts'; import { pruneOrphanedEntries } from '../lib/entry-remover.ts'; -import { parseFrontmatter } from '../lib/frontmatter-merger.ts'; import { HARNESSES, resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.js'; import { checkSymlinkSafety, copyItem, linkItem, removeItem, unlinkIfSymlink } from '../lib/installer.ts'; import { @@ -23,6 +22,7 @@ import { injectProvenanceMarker, } from '../lib/marker-injector.js'; import { rewritePathsInDirectory, rewritePathsInFile } from '../lib/path-rewriter.js'; +import { renderPromptsYml } from '../lib/prompts-yml.ts'; import { loadSubagentOverlay, renderSubagentForHarness } from '../lib/subagent-transform.ts'; import { loadToolMapping, rewriteToolNames } from '../lib/tool-name-rewriter.js'; import { isEnoent, isMissingFile } from '../lib/type-guards.ts'; @@ -554,82 +554,12 @@ async function generatePromptsYml( }; } - // Gather skill metadata from installed skills directory - let skillDirEntries: ReadonlyArray; - try { - skillDirEntries = await readdir(paths.skillsDir); - } catch (error: unknown) { - if (!isEnoent(error)) { - throw error; - } + const yamlContent = await renderPromptsYml(paths.skillsDir); + if (yamlContent === undefined) { console.warn(` ⚠️ Warning: skills directory not found, skipping prompts.yml generation: ${paths.skillsDir}`); return undefined; } - const sortedSkillNames = [...skillDirEntries].toSorted(); - - const promptEntries: Array<{ name: string; description: string; contentFile: string }> = []; - - for (const skillName of sortedSkillNames) { - const skillMdPath = path.join(paths.skillsDir, skillName, 'SKILL.md'); - let skillContent: string; - try { - skillContent = await readFile(skillMdPath, 'utf8'); - } catch (error: unknown) { - // Tolerate any non-directory entry in the destination skills directory (e.g. a `.DS_Store` left by Finder): - // joining `SKILL.md` onto a regular file raises `ENOTDIR`; a directory without `SKILL.md` raises `ENOENT`. - // Either way the entry is not a skill and should be skipped. - if (isMissingFile(error)) { - continue; - } - throw error; - } - - const { lines } = parseFrontmatter(skillContent); - - // Extract user-invocable and description from frontmatter lines - let userInvocable = true; // Default: included unless explicitly false - let description = ''; - for (const line of lines) { - if (line.startsWith('user-invocable:')) { - const value = line.slice('user-invocable:'.length).trim(); - userInvocable = value !== 'false'; - } - if (line.startsWith('description:')) { - description = line.slice('description:'.length).trim(); - // Strip surrounding quotes if present and unescape internal escapes - if (description.startsWith("'") && description.endsWith("'")) { - description = description.slice(1, -1).replaceAll("''", "'"); - } else if (description.startsWith('"') && description.endsWith('"')) { - description = description.slice(1, -1).replaceAll(String.raw`\"`, '"'); - } - } - } - - if (!userInvocable) { - continue; - } - - promptEntries.push({ - name: skillName, - description, - contentFile: `skills/${skillName}/SKILL.md`, - }); - } - - // Build YAML content with deterministic template literals. Description values are single-quoted with internal single - // quotes escaped (doubled) to prevent YAML-special characters from producing invalid output. - const yamlLines = ['prompts:']; - for (const entry of promptEntries) { - const escapedDescription = entry.description.replaceAll("'", "''"); - yamlLines.push( - ` - name: '${entry.name}'`, - ` description: '${escapedDescription}'`, - ` content_file: ${entry.contentFile}`, - ); - } - const yamlContent = yamlLines.join('\n') + '\n'; - // Check for user modifications before overwriting. // Files at 'current' drift are always regenerated to pick up any newly added skills. const existingEntry = existingByPath.get(relativePath); diff --git a/packages/agents/src/commands/sync.ts b/packages/agents/src/commands/sync.ts index 946b6aea..74012229 100644 --- a/packages/agents/src/commands/sync.ts +++ b/packages/agents/src/commands/sync.ts @@ -10,6 +10,7 @@ import { resolveContentDir } from '../lib/content-resolver.ts'; import { resolveClosure } from '../lib/dependency-resolver.ts'; import { readFileOrEmpty, writeIfChanged } from '../lib/fs-helpers.ts'; import { HARNESSES, resolveHarnessIds, resolveHarnessPaths } from '../lib/harness.ts'; +import { renderPromptsYml } from '../lib/prompts-yml.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'; @@ -303,6 +304,8 @@ async function reconcileDomain( // transform applied and the ownership marker stamped. await reconcileDeclaredSubagents(harnessSubagentTargets, subagentOrphansByDir, resolvedSubagents); + await refreshHomePromptsYml(options, domain); + const skillRetractions = skillOrphansByDir.reduce((total, harness) => total + harness.orphans.length, 0); const skillFilesWritten = desiredSkillDirs.size * harnessSkillDirs.length; const declaredSkillRetractions = declaredSkillOrphansByDir.reduce( @@ -591,6 +594,27 @@ async function reconcileDeclaredSubagents( } } +/** + * Regenerates the home-domain Rovo Dev `prompts.yml` so home-deployed skills appear in its available-skills list. As a + * pure projection of the on-disk skills dir, it also drops any just-retracted skill. A no-op for the repo domain (which + * keeps no such index) and for non-Rovo Dev harnesses. + */ +async function refreshHomePromptsYml(options: InstallOptions, domain: SyncDomain): Promise { + if (domain.label !== 'global') { + return; + } + for (const harnessId of resolveHarnessIds(options.harness, domain.baseDir)) { + if (harnessId !== 'rovodev') { + continue; + } + const { harnessHome, skillsDir } = resolveHarnessPaths(harnessId, domain.baseDir); + const promptsYml = await renderPromptsYml(skillsDir); + if (promptsYml !== undefined) { + await writeIfChanged(path.join(harnessHome, 'prompts.yml'), promptsYml); + } + } +} + /** The writes and retractions the dry-run reporter previews, gathered from the pre-write reconciliation. */ interface DryRunPlan { readonly ambientHostName: string; diff --git a/packages/agents/src/lib/__tests__/prompts-yml.test.ts b/packages/agents/src/lib/__tests__/prompts-yml.test.ts new file mode 100644 index 00000000..81f77d84 --- /dev/null +++ b/packages/agents/src/lib/__tests__/prompts-yml.test.ts @@ -0,0 +1,55 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { renderPromptsYml } from '../prompts-yml.ts'; + +describe(renderPromptsYml, () => { + let skillsDir: string; + + beforeEach(async () => { + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + skillsDir = path.join(tmpdir(), `agents-test-prompts-${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 renderPromptsYml(path.join(skillsDir, 'missing'))).toBeUndefined(); + }); + + it('lists user-invocable skills sorted by name, unquoting descriptions', async () => { + await writeSkill('beta', "description: 'Beta does things'"); + await writeSkill('alpha', 'description: Alpha desc'); + + const yaml = await renderPromptsYml(skillsDir); + + expect(yaml).toBe( + 'prompts:\n' + + " - 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('excludes skills marked user-invocable: false', async () => { + await writeSkill('internal', 'user-invocable: false'); + await writeSkill('public', 'description: Public'); + + const yaml = await renderPromptsYml(skillsDir); + + expect(yaml).toContain("name: 'public'"); + expect(yaml).not.toContain('internal'); + }); +}); diff --git a/packages/agents/src/lib/prompts-yml.ts b/packages/agents/src/lib/prompts-yml.ts new file mode 100644 index 00000000..06b8a3c5 --- /dev/null +++ b/packages/agents/src/lib/prompts-yml.ts @@ -0,0 +1,97 @@ +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; + +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 { + 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. + */ +export async function renderPromptsYml(skillsDir: string): Promise { + let skillDirEntries: ReadonlyArray; + try { + skillDirEntries = await readdir(skillsDir); + } catch (error: unknown) { + if (isEnoent(error)) { + return undefined; + } + throw error; + } + + const promptEntries: Array = []; + for (const skillName of [...skillDirEntries].toSorted()) { + let skillContent: string; + try { + skillContent = await readFile(path.join(skillsDir, skillName, 'SKILL.md'), 'utf8'); + } catch (error: unknown) { + // Tolerate any non-skill entry: a regular file raises ENOTDIR on the SKILL.md read-through, a dir without a + // SKILL.md raises ENOENT. Either way it is not a skill and is skipped. + if (isMissingFile(error)) { + continue; + } + throw error; + } + + const { userInvocable, description } = readPromptMetadata(skillContent); + if (!userInvocable) { + continue; + } + promptEntries.push({ name: skillName, description, contentFile: `skills/${skillName}/SKILL.md` }); + } + + return renderYaml(promptEntries); +} + +// region | Helpers + +/** Reads a skill's `user-invocable` (default true) and `description` (default empty) from its frontmatter lines. */ +function readPromptMetadata(skillContent: string): { userInvocable: boolean; description: string } { + const { lines } = parseFrontmatter(skillContent); + let userInvocable = true; + let description = ''; + for (const line of lines) { + if (line.startsWith('user-invocable:')) { + userInvocable = line.slice('user-invocable:'.length).trim() !== 'false'; + } + if (line.startsWith('description:')) { + description = unquoteYamlScalar(line.slice('description:'.length).trim()); + } + } + 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("'")) { + return value.slice(1, -1).replaceAll("''", "'"); + } + if (value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1).replaceAll(String.raw`\"`, '"'); + } + return value; +} + +// endregion | Helpers From cc8ce87f8cca4efe87372d36e2633539881d9f2b Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 26 Jun 2026 03:28:07 -0700 Subject: [PATCH 6/8] agents|feat: Declare recommended in the installed user-global config Installs `~/.agents/codeassembly.yaml` declaring the `recommended` collection, so `sync --global` deploys the batteries-included set into the home harness dirs out of the box. Removing the declaration, or dropping members in `~/.agents/codeassembly.local.yaml`, opts out. --- .../agents/content/guidance/shared/codeassembly.yaml | 6 ++++++ packages/agents/src/commands/__tests__/sync.test.ts | 9 +++++++++ 2 files changed, 15 insertions(+) create mode 100644 packages/agents/content/guidance/shared/codeassembly.yaml diff --git a/packages/agents/content/guidance/shared/codeassembly.yaml b/packages/agents/content/guidance/shared/codeassembly.yaml new file mode 100644 index 00000000..23e3ad04 --- /dev/null +++ b/packages/agents/content/guidance/shared/codeassembly.yaml @@ -0,0 +1,6 @@ +# Installed by codeassembly-agents into ~/.agents/codeassembly.yaml; edits are overwritten on the next install. +# This is the user-global declaration tier: `sync --global` deploys its closure into the home harness dirs. +# To override (for example, to drop a recommended member), use ~/.agents/codeassembly.local.yaml. +collections: + use: + - recommended diff --git a/packages/agents/src/commands/__tests__/sync.test.ts b/packages/agents/src/commands/__tests__/sync.test.ts index 92c31a56..c2736c62 100644 --- a/packages/agents/src/commands/__tests__/sync.test.ts +++ b/packages/agents/src/commands/__tests__/sync.test.ts @@ -827,4 +827,13 @@ describe(syncGlobalCommand, () => { expect(prompts).toContain("name: 'people-report'"); expect(prompts).toContain('content_file: skills/people-report/SKILL.md'); }); + + it('deploys the real recommended collection to home via the user-global declaration', async () => { + await declareRaw('collections:\n use:\n - recommended\n'); + + await syncGlobalCommand(makeOptions({ harness: 'claude' }), homeDir, resolveContentDir()); + + expect(existsSync(path.join(homeDir, '.claude', 'skills', 'people-report', 'SKILL.md'))).toBe(true); + expect(existsSync(path.join(homeDir, '.claude', 'agents', 'canary.md'))).toBe(true); + }); }); From fd8b5ade57a4a4dbb2044bb0f17e3619d3e38728 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 26 Jun 2026 03:30:30 -0700 Subject: [PATCH 7/8] agents|docs: Document sync --global and the two-domain scope model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the `sync --global` home-deployment workflow — the repo and user-global declaration domains, the `~/.agents/GLOBAL.md` ambient host, and that `drop` resolves within a single domain — and removes the stale note that home delivery was unimplemented. --- packages/agents/README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/agents/README.md b/packages/agents/README.md index 36f2f32d..cef180dc 100644 --- a/packages/agents/README.md +++ b/packages/agents/README.md @@ -20,7 +20,7 @@ Global options: `--harness ` (default `all`), `--link`, `- ## Project declaration -A project opts into shared artifacts through `.agents/codeassembly.yaml`. Run `codeassembly-agents init` to scaffold one, declare the artifacts you want, then run `codeassembly-agents sync` to materialize them. +A project opts into shared artifacts through `.agents/codeassembly.yaml`. Run `codeassembly-agents init` to scaffold one, declare the artifacts you want, then run `codeassembly-agents sync` to materialize them. The same declaration format resolves in two independent domains — the repo (via `sync`) and the user-global home (via `sync --global`); see [Scopes](#scopes). ### Format @@ -40,9 +40,9 @@ subagents: A declared rulebook is materialized into `.agents/rulebooks/.md` and, depending on its delivery mode, inlined into `.agents/PROJECT.md` and/or delivered as a `consult-` skill in each detected harness. -A declared skill is deployed verbatim into each detected harness's project-local skills directory (`.claude/skills//`), carrying a `` ownership marker so `sync` can retract it once it is no longer declared. Only a skill whose `SKILL.md` frontmatter sets `deploy: declared` can be deployed this way; a skill without the field installs unconditionally into the user-global harness directories instead (see the [`deploy` field](#the-deploy-field) below). Skill deployment is project-scoped: declared skills land in the project's harness directories, not the user-global ones. +A declared skill is deployed verbatim into each detected harness's project-local skills directory (`.claude/skills//`), carrying a `` ownership marker so `sync` can retract it once it is no longer declared. Only a skill whose `SKILL.md` frontmatter sets `deploy: declared` can be deployed this way; a skill without the field installs unconditionally into the user-global harness directories instead (see the [`deploy` field](#the-deploy-field) below). Bare `sync` deploys into the project's harness directories; `sync --global` resolves the user-global tier and deploys the same way into the home harness directories instead (see [Scopes](#scopes)). -A declared subagent is deployed into each detected harness's project-local subagents directory (`.claude/agents/.md`), with the harness transform applied (frontmatter `_defaults` merge, `{tool:…}` rewrite, `{harness_home_dir}` rewrite) and a `` ownership marker so `sync` can retract it once it is no longer declared. As with skills, only a subagent whose frontmatter sets `deploy: declared` is deployed this way; a subagent without the field installs unconditionally. Subagent deployment is project-scoped: a declared subagent resolves only where it is declared. Global (home) delivery for subagents is tracked by #857; until then, real, globally-dispatched subagents stay on `install` and are not migrated. +A declared subagent is deployed into each detected harness's project-local subagents directory (`.claude/agents/.md`), with the harness transform applied (frontmatter `_defaults` merge, `{tool:…}` rewrite, `{harness_home_dir}` rewrite) and a `` ownership marker so `sync` can retract it once it is no longer declared. As with skills, only a subagent whose frontmatter sets `deploy: declared` is deployed this way; a subagent without the field installs unconditionally. A declared subagent deploys into the repo under `sync` and into the home harness directories under `sync --global`. The default catalog still ships through the unconditional `install` path; migrating it onto `declared` so `sync --global` carries it is the remaining step. `rulebooks`, `skills`, `subagents`, and `collections` are all deployed. @@ -56,7 +56,7 @@ collections: - recommended ``` -Dropping or omitting a collection — or `root: true` — excludes its entire closure. The shipped `recommended` collection bundles the default declared artifacts and is opt-in: a project gets it only by declaring it. +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. ### Dependencies @@ -90,12 +90,19 @@ The field defaults to `install` when absent — the fail-safe default that keeps ### Scopes -The declaration resolves across two tiers, lowest to highest precedence: +The declaration resolves in two independent **domains**, each with its own base and local tiers and its own deployment target. The tiers within a domain run lowest to highest precedence. + +**Repo domain** — `codeassembly-agents sync`, deploying into the repo: 1. **Project** — `.agents/codeassembly.yaml`, committed and shared with the team. 2. **Project-local** — `.agents/codeassembly.local.yaml`, gitignored, for personal overrides. -A higher tier adds to and overrides the tiers below it: `use` adds a rulebook, `drop` removes one inherited from a broader scope, and `root: true` discards everything declared in broader scopes, starting fresh from that file. +**Home domain** — `codeassembly-agents sync --global`, deploying into the home harness directories (`~/.claude`, `~/.rovodev`) and `~/.agents/`: + +1. **User-global** — `~/.agents/codeassembly.yaml`, install-managed (declares `recommended` 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. ## Preferences From 6da3a71ec4585f9bfff053153c93c28373212669 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Fri, 26 Jun 2026 04:24:33 -0700 Subject: [PATCH 8/8] agents|tests: Pin home neutral dir as wholesale sync-owned Adds a `sync --global` regression test confirming `~/.agents/rulebooks/` is reconciled as a wholesale sync-managed directory: an undeclared neutral file is removed while declared ones are kept. Pins the home domain to the same neutral-file ownership the repo domain already documents. --- packages/agents/src/commands/__tests__/sync.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/agents/src/commands/__tests__/sync.test.ts b/packages/agents/src/commands/__tests__/sync.test.ts index c2736c62..41d8410d 100644 --- a/packages/agents/src/commands/__tests__/sync.test.ts +++ b/packages/agents/src/commands/__tests__/sync.test.ts @@ -836,4 +836,16 @@ describe(syncGlobalCommand, () => { expect(existsSync(path.join(homeDir, '.claude', 'skills', 'people-report', 'SKILL.md'))).toBe(true); expect(existsSync(path.join(homeDir, '.claude', 'agents', 'canary.md'))).toBe(true); }); + + it('reconciles ~/.agents/rulebooks/ as wholesale sync-owned, removing an undeclared neutral file', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await declareRaw('rulebooks:\n use:\n - alpha\n'); + await syncGlobalCommand(makeOptions(), homeDir, contentDir); + await writeFile(path.join(homeDir, '.agents', 'rulebooks', 'stray.md'), '# Stray\n', 'utf8'); + + await syncGlobalCommand(makeOptions(), homeDir, contentDir); + + expect(existsSync(path.join(homeDir, '.agents', 'rulebooks', 'stray.md'))).toBe(false); + expect(existsSync(path.join(homeDir, '.agents', 'rulebooks', 'alpha.md'))).toBe(true); + }); });