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 @@ -236,7 +236,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. In both domains, the deployed Rovo Dev skills are indexed into `.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, in the home file as well as the project file.
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; in the home domain they are injected into the ambient region of each targeted harness's guidance file (`~/.claude/CLAUDE.md`, `~/.rovodev/AGENTS.md`), which the harness loads mechanically. The region's location comes from `install`'s rendered template and its content belongs to `sync --global`: `install` preserves the region across re-renders and ignores it for drift detection, while hand edits elsewhere in those files still count as drift. Run `install` once before the first `sync --global` so the region exists to fill; a guidance file without the region is skipped with a warning. `sync --global` also retires a legacy `~/.agents/GLOBAL.md`, removing its sync-owned blocks and deleting the file unless it holds hand-written content. For per-machine ambient guidance that should stay out of source control, declare a machine-local source (see [Sources](#sources)) holding a personal rulebook with `delivery: ambient`. In both domains, the deployed Rovo Dev skills are indexed into `.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, in the home file as well as the project file.

When upgrading from a build where `install` deployed the catalog, run `install` once before `sync --global`: the new `install` prunes the skills and the whole-file `prompts.yml` it previously planted, and `sync --global` then re-deploys the skills as sync-owned and rewrites `prompts.yml` as a merged region. Running `sync --global` first stops at a refuse-to-overwrite error on those still-`install`-owned skill files, and would merge its region beneath the stale whole-file `prompts.yml` entries until the next `install` prunes them.

Expand Down
12 changes: 5 additions & 7 deletions packages/agents/content/guidance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@ This README is for maintainers of this directory. It is not referenced by any `<

Inlined verbatim into every rendered platform guidance file via the directive expander, which means it reaches every agent invocation — including subagents — as ambient context.

## Ambient hosts: `~/.agents/GLOBAL.md` and `.agents/PROJECT.md`
## Ambient delivery: harness regions and `.agents/PROJECT.md`

A rulebook with `delivery: ambient` is not inlined into the wholesale-generated guidance files (`~/.claude/CLAUDE.md`, `~/.rovodev/AGENTS.md`, `~/.agents/AGENTS.md`), which `install`/`sync` rewrite in full. Instead `sync` inlines its body into a scope-specific _ambient host_, wrapped in `<!-- rulebook:<slug> -->` … `<!-- /rulebook:<slug> -->` sentinels:
A rulebook with `delivery: ambient` is delivered by `sync` into a scope-specific target. Only the home domain's delivery is mechanical end to end; the project domain's reach depends on a bridge each repo wires itself. The two domains:

- **Home / all-projects scope** (`sync --global`) → `~/.agents/GLOBAL.md`
- **Project scope** (`sync`)`.agents/PROJECT.md`
- **Home / all-projects scope** (`sync --global`): the body is injected, wrapped in `<!-- rulebook:<slug> -->` sentinels, into the ambient region (`<!-- codeassembly-ambient:start -->` / `<!-- codeassembly-ambient:end -->`) inside each targeted harness's mechanically-loaded guidance file (`~/.claude/CLAUDE.md`, `~/.rovodev/AGENTS.md`), so home-scope ambient content reaches agents with no agent-elective read anywhere on the path. The templates in `_harnesses/` carry the empty region, so the region's location is `install`'s decision while its content is `sync`'s: `sync --global` regenerates the region wholesale each run, and `install` splices the region's content into every re-render and excludes it from drift hashing (`ambient-region.ts`). Hand edits outside the region still count as drift. Run `install` before the first `sync --global`; a guidance file that is missing or carries no region is skipped with a warning naming the fix.
- **Project scope** (`sync`): the body is inlined into the ambient host `.agents/PROJECT.md`. `sync` rewrites only the sentinel-delimited blocks (`sentinel-inliner.ts`) and preserves everything outside them, so the host doubles as a safe home for hand-written project-local guidance. The host reaches agents through a bridge the repo maintains: the `Read .agents/PROJECT.md (if it exists)` discovery line in the shared instructions, or a per-repo `@.agents/PROJECT.md` import in that repo's `.claude/CLAUDE.md`. Both are elective or hand-wired, so project-scope delivery carries no mechanical guarantee until the repo wires the import.

Agents load these hosts via the bridge lines in `shared/AGENTS.md` (`Read ~/.agents/GLOBAL.md (if it exists) …`, `Read .agents/PROJECT.md (if it exists) …`), so ambient rulebooks reach every harness without any content landing in the generated files.

`sync` only ever rewrites the sentinel-delimited blocks (`sentinel-inliner.ts`): it inserts or replaces one block per currently-ambient rulebook and removes blocks whose rulebook is no longer ambient. Content outside the markers is preserved untouched, so an ambient host doubles as a safe home for hand-written, machine-local (`GLOBAL.md`) or project-local (`PROJECT.md`) guidance. A host with no ambient rulebooks deployed is simply empty — which is the usual state until a rulebook opts into `ambient` delivery.
`~/.agents/GLOBAL.md` is retired as the home ambient host: `sync --global` strips the sync-owned blocks from a legacy copy and deletes the file once nothing hand-written remains. For machine-local guidance that should stay out of source control, declare a machine-local source in `~/.agents/codeassembly.yaml` (a directory shaped like the library's `content/`, with rulebooks at `guidance/rulebooks/<slug>.md`) and give the rulebook `delivery: ambient`; it then rides the same injection as library rulebooks.
3 changes: 3 additions & 0 deletions packages/agents/content/guidance/_harnesses/claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
Read AGENTS.md (if it exists) in the working directory and treat it as fully equivalent to CLAUDE.md.

<!-- include: ../../shared/AGENTS.md / -->

<!-- codeassembly-ambient:start -->
<!-- codeassembly-ambient:end -->
3 changes: 3 additions & 0 deletions packages/agents/content/guidance/_harnesses/rovodev/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
<!-- include: ../../shared/AGENTS.md / -->
<!-- include: ./codeassembly-guidance.md / -->

<!-- codeassembly-ambient:start -->
<!-- codeassembly-ambient:end -->
2 changes: 0 additions & 2 deletions packages/agents/content/guidance/shared/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +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

## Interactive work

Expand Down
13 changes: 12 additions & 1 deletion packages/agents/src/commands/__tests__/build-content-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,24 @@ const DEFAULT_SHARED_GUIDANCE: Record<string, string> = {

const DEFAULT_HARNESS_GUIDANCE: Record<HarnessId, Record<string, string>> = {
claude: {
'CLAUDE.md': ['Fixture claude preamble.', '', '<!-- include: ../../shared/AGENTS.md / -->', ''].join('\n'),
'CLAUDE.md': [
'Fixture claude preamble.',
'',
'<!-- include: ../../shared/AGENTS.md / -->',
'',
'<!-- codeassembly-ambient:start -->',
'<!-- codeassembly-ambient:end -->',
'',
].join('\n'),
},
rovodev: {
'AGENTS.md': [
'<!-- include: ../../shared/AGENTS.md / -->',
'<!-- include: ./codeassembly-guidance.md / -->',
'',
'<!-- codeassembly-ambient:start -->',
'<!-- codeassembly-ambient:end -->',
'',
].join('\n'),
'codeassembly-guidance.md': '## Fixture interaction\n\nRovodev-specific body.\n',
},
Expand Down
90 changes: 89 additions & 1 deletion packages/agents/src/commands/__tests__/install-guidance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import path from 'node:path';

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

import { getManifestPath, readManifest } from '../../lib/manifest.ts';
import { extractAmbientRegionContent, hasAmbientRegion, injectAmbientRegion } from '../../lib/ambient-region.ts';
import { computeContentHash, getManifestPath, readManifest } from '../../lib/manifest.ts';
import type { InstallOptions } from '../../lib/types.ts';
import { installCommand } from '../install.ts';
import { statusCommand } from '../status.ts';
Expand Down Expand Up @@ -164,6 +165,19 @@ describe('guidance installation', () => {
expect(harnessIndex).toBeGreaterThan(sharedIndex);
});

it('renders an empty ambient region into the guidance file of each harness', async () => {
const claudeHome = await setupClaudeHome();
const rovodevHome = await setupRovodevHome();

await installCommand(makeOptions({ harness: 'all' }), tempDir, contentDir);

for (const guidancePath of [path.join(claudeHome, 'CLAUDE.md'), path.join(rovodevHome, 'AGENTS.md')]) {
const content = await readFile(guidancePath, 'utf8');
expect(hasAmbientRegion(content)).toBe(true);
expect(extractAmbientRegionContent(content)).toBe('');
}
});

it('tracks harness guidance in harness manifest entries', async () => {
await setupClaudeHome();

Expand All @@ -190,6 +204,80 @@ describe('guidance installation', () => {
});
});

describe('ambient region preservation', () => {
const AMBIENT_BODY = '<!-- rulebook:writing-prefs -->\nNo em-dashes.\n<!-- /rulebook:writing-prefs -->';

/** Fills the installed file's ambient region as a `sync --global` run would. */
async function fillAmbientRegion(guidancePath: string): Promise<void> {
await writeFile(guidancePath, injectAmbientRegion(await readFile(guidancePath, 'utf8'), AMBIENT_BODY), 'utf8');
}

it('splices sync-written region content into a re-rendered guidance file', async () => {
const claudeHome = await setupClaudeHome();
await installCommand(makeOptions(), tempDir, contentDir);
const claudeMd = path.join(claudeHome, 'CLAUDE.md');
await fillAmbientRegion(claudeMd);

// A changed template forces a genuine re-render, so preservation is exercised as a splice, not as a skip.
await buildContentTree(contentDir, {
harnessGuidance: {
claude: {
'CLAUDE.md': [
'Fixture claude preamble v2.',
'',
'<!-- codeassembly-ambient:start -->',
'<!-- codeassembly-ambient:end -->',
'',
].join('\n'),
},
},
});
await installCommand(makeOptions(), tempDir, contentDir);

const content = await readFile(claudeMd, 'utf8');
expect(content).toContain('Fixture claude preamble v2.');
expect(extractAmbientRegionContent(content)).toBe(AMBIENT_BODY);
});

it('does not report sync-written region content as drift', async () => {
const claudeHome = await setupClaudeHome();
await installCommand(makeOptions(), tempDir, contentDir);
const claudeMd = path.join(claudeHome, 'CLAUDE.md');
await fillAmbientRegion(claudeMd);

const infoSpy = vi.spyOn(console, 'info');
await statusCommand({ harness: 'claude' }, tempDir);

const output = infoSpy.mock.calls.map((call) => call.join(' ')).join('\n');
expect(output).not.toContain('modified: CLAUDE.md');

infoSpy.mockRestore();
});

it('still reports a hand edit outside the region as drift', async () => {
const claudeHome = await setupClaudeHome();
await installCommand(makeOptions(), tempDir, contentDir);
const claudeMd = path.join(claudeHome, 'CLAUDE.md');
const modified = (await readFile(claudeMd, 'utf8')) + '\n<!-- user modification -->\n';
await writeFile(claudeMd, modified, 'utf8');

await installCommand(makeOptions(), tempDir, contentDir);

expect(await readFile(claudeMd, 'utf8')).toBe(modified);
});

it('hashes a guidance file independently of its region content', async () => {
const claudeHome = await setupClaudeHome();
await installCommand(makeOptions(), tempDir, contentDir);
const claudeMd = path.join(claudeHome, 'CLAUDE.md');

const emptyRegionHash = await computeContentHash(claudeMd);
await fillAmbientRegion(claudeMd);

expect(await computeContentHash(claudeMd)).toBe(emptyRegionHash);
});
});

describe('uninstall', () => {
it('removes shared guidance files', async () => {
await setupClaudeHome();
Expand Down
Loading
Loading