diff --git a/packages/agents/README.md b/packages/agents/README.md index 400ada0d..ec5b493a 100644 --- a/packages/agents/README.md +++ b/packages/agents/README.md @@ -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. diff --git a/packages/agents/content/guidance/README.md b/packages/agents/content/guidance/README.md index bf8e1069..97f587ab 100644 --- a/packages/agents/content/guidance/README.md +++ b/packages/agents/content/guidance/README.md @@ -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 `` … `` 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 `` sentinels, into the ambient region (`` / ``) 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/.md`) and give the rulebook `delivery: ambient`; it then rides the same injection as library rulebooks. diff --git a/packages/agents/content/guidance/_harnesses/claude/CLAUDE.md b/packages/agents/content/guidance/_harnesses/claude/CLAUDE.md index b2aeb61a..d4906cd6 100644 --- a/packages/agents/content/guidance/_harnesses/claude/CLAUDE.md +++ b/packages/agents/content/guidance/_harnesses/claude/CLAUDE.md @@ -1,3 +1,6 @@ Read AGENTS.md (if it exists) in the working directory and treat it as fully equivalent to CLAUDE.md. + + + diff --git a/packages/agents/content/guidance/_harnesses/rovodev/AGENTS.md b/packages/agents/content/guidance/_harnesses/rovodev/AGENTS.md index 2b0d99b2..ed97cd0f 100644 --- a/packages/agents/content/guidance/_harnesses/rovodev/AGENTS.md +++ b/packages/agents/content/guidance/_harnesses/rovodev/AGENTS.md @@ -1,2 +1,5 @@ + + + diff --git a/packages/agents/content/guidance/shared/AGENTS.md b/packages/agents/content/guidance/shared/AGENTS.md index 83d8e545..26f100ce 100644 --- a/packages/agents/content/guidance/shared/AGENTS.md +++ b/packages/agents/content/guidance/shared/AGENTS.md @@ -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 diff --git a/packages/agents/src/commands/__tests__/build-content-tree.ts b/packages/agents/src/commands/__tests__/build-content-tree.ts index 3da9a2e7..430bdceb 100644 --- a/packages/agents/src/commands/__tests__/build-content-tree.ts +++ b/packages/agents/src/commands/__tests__/build-content-tree.ts @@ -180,13 +180,24 @@ const DEFAULT_SHARED_GUIDANCE: Record = { const DEFAULT_HARNESS_GUIDANCE: Record> = { claude: { - 'CLAUDE.md': ['Fixture claude preamble.', '', '', ''].join('\n'), + 'CLAUDE.md': [ + 'Fixture claude preamble.', + '', + '', + '', + '', + '', + '', + ].join('\n'), }, rovodev: { 'AGENTS.md': [ '', '', '', + '', + '', + '', ].join('\n'), 'codeassembly-guidance.md': '## Fixture interaction\n\nRovodev-specific body.\n', }, diff --git a/packages/agents/src/commands/__tests__/install-guidance.test.ts b/packages/agents/src/commands/__tests__/install-guidance.test.ts index f7fbab35..a56b145e 100644 --- a/packages/agents/src/commands/__tests__/install-guidance.test.ts +++ b/packages/agents/src/commands/__tests__/install-guidance.test.ts @@ -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'; @@ -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(); @@ -190,6 +204,80 @@ describe('guidance installation', () => { }); }); + describe('ambient region preservation', () => { + const AMBIENT_BODY = '\nNo em-dashes.\n'; + + /** Fills the installed file's ambient region as a `sync --global` run would. */ + async function fillAmbientRegion(guidancePath: string): Promise { + 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.', + '', + '', + '', + '', + ].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\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(); diff --git a/packages/agents/src/commands/__tests__/sync.test.ts b/packages/agents/src/commands/__tests__/sync.test.ts index 84d5bdef..358cb4db 100644 --- a/packages/agents/src/commands/__tests__/sync.test.ts +++ b/packages/agents/src/commands/__tests__/sync.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { unindent } from '@williamthorsen/toolbelt.strings/candidate'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { hasAmbientRegion } from '../../lib/ambient-region.ts'; import { resolveContentDir } from '../../lib/content-resolver.ts'; import type { InstallOptions } from '../../lib/types.ts'; import { syncCommand, syncGlobalCommand } from '../sync.ts'; @@ -1246,6 +1247,19 @@ describe(syncGlobalCommand, () => { await writeFile(path.join(homeDir, '.agents', 'codeassembly.yaml'), content, 'utf8'); } + /** Seeds a rendered harness guidance file carrying an empty ambient region, as `install` renders it. */ + async function seedGuidanceFile(harnessDir: string, name: string): Promise { + const dir = path.join(homeDir, harnessDir); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, name); + await writeFile( + file, + '# Guidance\n\n\n\n', + 'utf8', + ); + return file; + } + it('when no ~/.agents/codeassembly.yaml exists, makes no changes and points at init --global', async () => { const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); let infoLines: ReadonlyArray; @@ -1270,18 +1284,118 @@ describe(syncGlobalCommand, () => { expect(skill).toContain(''); }); - it('inlines ambient rulebooks into ~/.agents/GLOBAL.md, never PROJECT.md', async () => { + it('injects ambient rulebooks into the harness guidance ambient region, never GLOBAL.md or PROJECT.md', async () => { + const claudeMd = await seedGuidanceFile('.claude', 'CLAUDE.md'); 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.'); + const content = await readFile(claudeMd, 'utf8'); + expect(content).toContain(''); + expect(content).toContain('Alpha rules.'); + expect(existsSync(path.join(homeDir, '.agents', 'GLOBAL.md'))).toBe(false); expect(existsSync(path.join(homeDir, '.agents', 'PROJECT.md'))).toBe(false); }); + it('injects the ambient region of every targeted harness guidance file', async () => { + const claudeMd = await seedGuidanceFile('.claude', 'CLAUDE.md'); + const rovodevMd = await seedGuidanceFile('.rovodev', 'AGENTS.md'); + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await declareRaw('rulebooks:\n use:\n - alpha\n'); + + await syncGlobalCommand(makeOptions({ harness: 'all' }), homeDir, contentDir); + + for (const guidanceFile of [claudeMd, rovodevMd]) { + expect(await readFile(guidanceFile, 'utf8')).toContain(''); + } + }); + + it('warns and skips ambient delivery when the guidance file is missing', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await declareRaw('rulebooks:\n use:\n - alpha\n'); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await syncGlobalCommand(makeOptions(), homeDir, contentDir); + expect(warnSpy.mock.calls.map((call) => String(call[0])).join('\n')).toContain('codeassembly-agents install'); + } finally { + warnSpy.mockRestore(); + } + + // The rest of the sync still lands: the neutral file is materialized despite the skipped ambient delivery. + expect(existsSync(path.join(homeDir, '.agents', 'rulebooks', 'alpha.md'))).toBe(true); + }); + + it('warns and skips ambient delivery when the guidance file carries no region', async () => { + const dir = path.join(homeDir, '.claude'); + await mkdir(dir, { recursive: true }); + const regionless = path.join(dir, 'CLAUDE.md'); + await writeFile(regionless, '# Guidance without a region\n', 'utf8'); + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await declareRaw('rulebooks:\n use:\n - alpha\n'); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await syncGlobalCommand(makeOptions(), homeDir, contentDir); + expect(warnSpy.mock.calls.map((call) => String(call[0])).join('\n')).toContain('no ambient region'); + } finally { + warnSpy.mockRestore(); + } + + expect(await readFile(regionless, 'utf8')).toBe('# Guidance without a region\n'); + }); + + it('previews ambient region injection in dry-run without writing', async () => { + const claudeMd = await seedGuidanceFile('.claude', 'CLAUDE.md'); + const before = await readFile(claudeMd, 'utf8'); + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await declareRaw('rulebooks:\n use:\n - alpha\n'); + + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + let output: string; + try { + await syncGlobalCommand(makeOptions({ dryRun: true }), homeDir, contentDir); + output = infoSpy.mock.calls.map((call) => String(call[0])).join('\n'); + } finally { + infoSpy.mockRestore(); + } + + expect(output).toContain('inject the ambient region in'); + expect(await readFile(claudeMd, 'utf8')).toBe(before); + }); + + it('previews the ambient-delivery skip in dry-run when the guidance file is missing', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await declareRaw('rulebooks:\n use:\n - alpha\n'); + + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + let output: string; + try { + await syncGlobalCommand(makeOptions({ dryRun: true }), homeDir, contentDir); + output = infoSpy.mock.calls.map((call) => String(call[0])).join('\n'); + } finally { + infoSpy.mockRestore(); + } + + expect(output).toContain('skip ambient delivery'); + expect(output).toContain('codeassembly-agents install'); + expect(output).not.toContain('inject the ambient region in'); + }); + + it('does not retire a legacy GLOBAL.md in dry-run', async () => { + await seedGuidanceFile('.claude', 'CLAUDE.md'); + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await declareRaw('rulebooks:\n use:\n - alpha\n'); + const legacyPath = path.join(homeDir, '.agents', 'GLOBAL.md'); + const legacyContent = '\nAlpha rules.\n\n'; + await writeFile(legacyPath, legacyContent, 'utf8'); + + await syncGlobalCommand(makeOptions({ dryRun: true }), homeDir, contentDir); + + expect(await readFile(legacyPath, 'utf8')).toBe(legacyContent); + }); + 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'); @@ -1297,20 +1411,52 @@ describe(syncGlobalCommand, () => { await expect(syncCommand(makeOptions(), homedir(), contentDir)).rejects.toThrow(/--global/); }); - it('retracts a home ambient block on undeclare and never writes ~/.agents/AGENTS.md', async () => { + it('empties the ambient region on undeclare and never writes ~/.agents/AGENTS.md', async () => { + const claudeMd = await seedGuidanceFile('.claude', 'CLAUDE.md'); 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(''); + expect(await readFile(claudeMd, '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(''); + const content = await readFile(claudeMd, 'utf8'); + expect(content).not.toContain(''); + expect(hasAmbientRegion(content)).toBe(true); expect(existsSync(path.join(homeDir, '.agents', 'AGENTS.md'))).toBe(false); }); + it('deletes a legacy GLOBAL.md holding only sync-owned blocks', async () => { + await seedGuidanceFile('.claude', 'CLAUDE.md'); + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await declareRaw('rulebooks:\n use:\n - alpha\n'); + const legacyPath = path.join(homeDir, '.agents', 'GLOBAL.md'); + await writeFile(legacyPath, '\nAlpha rules.\n\n', 'utf8'); + + await syncGlobalCommand(makeOptions(), homeDir, contentDir); + + expect(existsSync(legacyPath)).toBe(false); + }); + + it('strips sync-owned blocks from a legacy GLOBAL.md and preserves foreign content', async () => { + await seedGuidanceFile('.claude', 'CLAUDE.md'); + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await declareRaw('rulebooks:\n use:\n - alpha\n'); + const legacyPath = path.join(homeDir, '.agents', 'GLOBAL.md'); + await writeFile( + legacyPath, + '# My hand-written notes\n\n\nAlpha rules.\n\n', + 'utf8', + ); + + await syncGlobalCommand(makeOptions(), homeDir, contentDir); + + const remainder = await readFile(legacyPath, 'utf8'); + expect(remainder).toContain('# My hand-written notes'); + expect(remainder).not.toContain(''); + }); + it('refreshes ~/.rovodev/prompts.yml with home-deployed Rovo Dev skills through the managed region', async () => { await writeLibrarySkill('people-report'); await declareRaw('skills:\n use:\n - people-report\n'); diff --git a/packages/agents/src/commands/install.ts b/packages/agents/src/commands/install.ts index 6d9a7f9e..d5740c17 100644 --- a/packages/agents/src/commands/install.ts +++ b/packages/agents/src/commands/install.ts @@ -1,6 +1,7 @@ -import { chmod, mkdir, readdir, stat, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { extractAmbientRegionContent, hasAmbientRegion, injectAmbientRegion } from '../lib/ambient-region.ts'; import { resolveContentDir } from '../lib/content-resolver.ts'; import { expandIncludes } from '../lib/directive-expander.ts'; import { pruneOrphanedEntries } from '../lib/entry-remover.ts'; @@ -602,6 +603,10 @@ async function installHarnessGuidance( } } + // The wholesale re-render below would drop whatever sync wrote into the ambient region, so capture the existing + // destination's region content first; it is spliced back once the fresh render is in place. + const preservedAmbient = entry.endsWith('.md') ? await readAmbientRegionContent(destPath) : undefined; + await unlinkIfSymlink(destPath); await copyItem(srcPath, destPath); @@ -613,6 +618,16 @@ async function installHarnessGuidance( } await rewritePathsInFile(destPath, entry, harnessConfig.homeDir, harnessConfig.homeDir, harnessConfig.id); await injectMarkerInFile(destPath, buildSourceUrl(`guidance/_harnesses/${harnessId}/${entry}`)); + + // Splice the preserved region content into the fresh render. The region's location comes from the template; + // its content belongs to sync and must survive an install. A template that no longer carries the region wins: + // the content is dropped and the next `sync` re-delivers or warns. + if (preservedAmbient !== undefined && preservedAmbient !== '') { + const rendered = await readFile(destPath, 'utf8'); + if (hasAmbientRegion(rendered)) { + await writeFile(destPath, injectAmbientRegion(rendered, preservedAmbient), 'utf8'); + } + } } entries.push({ @@ -625,6 +640,21 @@ async function installHarnessGuidance( return entries; } +/** + * Reads the ambient-region content of the file at `filePath`, returning `undefined` when the file is absent or + * carries no complete region. + */ +async function readAmbientRegionContent(filePath: string): Promise { + try { + return extractAmbientRegionContent(await readFile(filePath, 'utf8')); + } catch (error: unknown) { + if (isEnoent(error)) { + return undefined; + } + throw error; + } +} + /** * Returns a POSIX-style path label for a skill source file relative to `contentDir`, used as the `contextLabel` * argument to `rewriteToolNames` so install errors include a stable, harness-independent file reference. diff --git a/packages/agents/src/commands/sync.ts b/packages/agents/src/commands/sync.ts index 87644982..97d03798 100644 --- a/packages/agents/src/commands/sync.ts +++ b/packages/agents/src/commands/sync.ts @@ -4,6 +4,7 @@ import { homedir } from 'node:os'; import path from 'node:path'; import process from 'node:process'; +import { hasAmbientRegion, injectAmbientRegion } from '../lib/ambient-region.ts'; import { makeArtifactMarker } from '../lib/artifact-marker.ts'; import { artifactFrontmatterPath, type ArtifactType } from '../lib/artifact-types.ts'; import { resolveDeclaration } from '../lib/codeassembly-manifest.ts'; @@ -75,10 +76,14 @@ interface HarnessSkillTarget { readonly deployContext: SkillDeployContext; } -/** The one per-domain difference: the base dir to resolve and deploy under, and the file that hosts ambient blocks. */ +/** The one per-domain difference: the base dir to resolve and deploy under, and where ambient blocks land. */ export interface SyncDomain { readonly baseDir: string; - readonly ambientHostPath: string; + /** + * Ambient delivery target: the repo domain inlines blocks into a single sync-managed host file, while the home + * domain injects them into the ambient region of each targeted harness's mechanically-loaded guidance file. + */ + readonly ambient: { readonly kind: 'host-file'; readonly path: string } | { readonly kind: 'harness-regions' }; readonly label: 'project' | 'global'; } @@ -103,16 +108,21 @@ export async function syncCommand( } await reconcileDomain( options, - { baseDir: projectRoot, ambientHostPath: path.join(projectRoot, '.agents', 'PROJECT.md'), label: 'project' }, + { + baseDir: projectRoot, + ambient: { kind: 'host-file', path: path.join(projectRoot, '.agents', 'PROJECT.md') }, + label: 'project', + }, contentDirOverride, ); } /** * 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. When the home declaration - * is absent, makes no changes and directs the user to `init --global`. + * home domain). A thin wrapper over `reconcileDomain` that supplies the home `SyncDomain`. Ambient blocks land in the + * ambient region of each targeted harness's guidance file (e.g. `~/.claude/CLAUDE.md`), which the harness loads + * mechanically; no agent-read host file is written. When the home declaration is absent, makes no changes and directs + * the user to `init --global`. * * @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). @@ -131,14 +141,15 @@ export async function syncGlobalCommand( } await reconcileDomain( options, - { baseDir: homeDir, ambientHostPath: path.join(homeDir, '.agents', 'GLOBAL.md'), label: 'global' }, + { baseDir: homeDir, ambient: { kind: 'harness-regions' }, label: 'global' }, contentDirOverride, ); + await retireLegacyGlobalMd(options, homeDir); } /** * 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 + * body to `/.agents/rulebooks/.md`, delivers `ambient` rulebooks to `domain.ambient`'s target, 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 @@ -177,7 +188,7 @@ async function reconcileDomain( const declaredRulebooks = closure.rulebooks; const neutralDir = path.join(domain.baseDir, '.agents', 'rulebooks'); - const ambientHostPath = domain.ambientHostPath; + const ambientHostPath = domain.ambient.kind === 'host-file' ? domain.ambient.path : undefined; // 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. @@ -224,9 +235,12 @@ async function reconcileDomain( ), ); - const existingAmbientHost = await readFileOrEmpty(ambientHostPath); + // Inline orphans exist only in host-file mode: a harness region is regenerated wholesale, so nothing lingers there. + const { content: existingAmbientHost, orphans: inlineOrphans } = await readAmbientHostState( + ambientHostPath, + desiredAmbient, + ); const neutralOrphans = (await listNeutralSlugs(neutralDir)).filter((slug) => !declaredSet.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. @@ -283,8 +297,10 @@ async function reconcileDomain( const resolutionReport = await buildResolutionReport(resolver, resolved, resolvedSkills, resolvedSubagents); if (options.dryRun) { + const ambientTarget = await describeAmbientTarget(options, domain); reportDryRun({ - ambientHostName: path.basename(ambientHostPath), + ambientHostName: ambientTarget.hostName, + ambientRegionPreviews: ambientTarget.regionPreviews, resolutionReport, resolved, retracted: [...new Set([...neutralOrphans, ...inlineOrphans])], @@ -304,16 +320,8 @@ async function reconcileDomain( await mkdir(neutralDir, { recursive: true }); } - // 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) { - ambientHost = injectRulebook(ambientHost, rulebook.slug, rulebook.body); - } - } - for (const slug of inlineOrphans) { - ambientHost = removeRulebook(ambientHost, slug); } // `.agents/rulebooks/` is sync-owned, so deleting an undeclared neutral file here is safe, not user data loss. @@ -321,10 +329,7 @@ async function reconcileDomain( await rm(path.join(neutralDir, `${slug}.md`), { force: true }); } - if (ambientHost !== existingAmbientHost) { - await mkdir(path.dirname(ambientHostPath), { recursive: true }); - await writeFile(ambientHostPath, ambientHost, 'utf8'); - } + await deliverAmbient(options, domain, resolved, existingAmbientHost, inlineOrphans); // Reconcile skill files per targeted harness: Retract sync-owned skill dirs that are no longer current, then // write every skill-delivery rulebook. Orphans were computed against the pre-write filesystem, so retracting @@ -751,6 +756,177 @@ async function assertDeclaredSkillsRender( } } +/** + * Delivers the resolved ambient rulebooks to the domain's target. In host-file mode, inlines one sentinel block per + * ambient rulebook into the single sync-managed host, removes the orphaned blocks, and writes the host once, only + * on change. In harness-regions mode, injects the ambient regions of the targeted harness guidance files. + */ +async function deliverAmbient( + options: InstallOptions, + domain: SyncDomain, + resolved: ReadonlyArray, + existingAmbientHost: string, + inlineOrphans: ReadonlyArray, +): Promise { + if (domain.ambient.kind === 'harness-regions') { + await injectAmbientRegions(options, domain, resolved); + return; + } + + let ambientHost = existingAmbientHost; + for (const rulebook of resolved) { + if (rulebook.ambient) { + ambientHost = injectRulebook(ambientHost, rulebook.slug, rulebook.body); + } + } + for (const slug of inlineOrphans) { + ambientHost = removeRulebook(ambientHost, slug); + } + if (ambientHost !== existingAmbientHost) { + await mkdir(path.dirname(domain.ambient.path), { recursive: true }); + await writeFile(domain.ambient.path, ambientHost, 'utf8'); + } +} + +/** A guidance file's readiness for ambient delivery: injectable, absent, or rendered without the region. */ +type AmbientTargetStatus = 'ready' | 'missing' | 'no-region'; + +/** A probed guidance file: its content when ready for injection, or the reason delivery must skip it. */ +type AmbientTargetState = + { readonly status: 'ready'; readonly content: string } | { readonly status: 'missing' | 'no-region' }; + +/** One guidance file's dry-run ambient preview: the injection the run would perform, or the skip it would take. */ +interface AmbientRegionPreview { + readonly guidanceFile: string; + readonly status: AmbientTargetStatus; +} + +/** + * Describes the dry-run ambient target: the host file's name in host-file mode, the probed per-file previews + * otherwise. Probing here is what keeps the preview honest — a file the real run would skip previews as a skip. + */ +async function describeAmbientTarget( + options: InstallOptions, + domain: SyncDomain, +): Promise<{ hostName: string | undefined; regionPreviews: ReadonlyArray }> { + if (domain.ambient.kind === 'host-file') { + return { hostName: path.basename(domain.ambient.path), regionPreviews: [] }; + } + const regionPreviews = await Promise.all( + resolveAmbientRegionFiles(options, domain).map(async (guidanceFile) => ({ + guidanceFile, + status: (await probeAmbientTarget(guidanceFile)).status, + })), + ); + return { hostName: undefined, regionPreviews }; +} + +/** The reason a guidance file is skipped for ambient delivery, naming the remedy. */ +function describeAmbientSkip(status: 'missing' | 'no-region', guidanceFile: string): string { + return status === 'missing' + ? `${guidanceFile} does not exist. Run \`codeassembly-agents install\`, then re-run \`sync --global\`.` + : `${guidanceFile} carries no ambient region. Run \`codeassembly-agents install\` to refresh it, then re-run \`sync --global\`.`; +} + +/** Probes a guidance file for ambient delivery: its content when the region is present, or why delivery must skip. */ +async function probeAmbientTarget(guidanceFile: string): Promise { + let content: string; + try { + content = await readFile(guidanceFile, 'utf8'); + } catch (error: unknown) { + if (isMissingFile(error)) { + return { status: 'missing' }; + } + throw error; + } + return hasAmbientRegion(content) ? { status: 'ready', content } : { status: 'no-region' }; +} + +/** + * Reads the host-file ambient state: the host's current content and the inlined slugs no longer desired there. + * In harness-regions mode (no host path) both are empty — a region is regenerated wholesale, so nothing lingers. + */ +async function readAmbientHostState( + ambientHostPath: string | undefined, + desiredAmbient: ReadonlySet, +): Promise<{ content: string; orphans: ReadonlyArray }> { + if (ambientHostPath === undefined) { + return { content: '', orphans: [] }; + } + const content = await readFileOrEmpty(ambientHostPath); + return { content, orphans: extractInstalledSlugs(content).filter((slug) => !desiredAmbient.has(slug)) }; +} + +/** + * Injects the resolved ambient rulebooks into the ambient region of each targeted harness's guidance file, + * regenerating the region's content wholesale (an empty ambient set empties the region). A guidance file that is + * missing or carries no region is skipped with a warning directing the user to `install`, so a stale install degrades + * delivery for that harness alone rather than failing the sync. + */ +async function injectAmbientRegions( + options: InstallOptions, + domain: SyncDomain, + resolved: ReadonlyArray, +): Promise { + const body = renderAmbientBody(resolved); + for (const guidanceFile of resolveAmbientRegionFiles(options, domain)) { + const target = await probeAmbientTarget(guidanceFile); + if (target.status !== 'ready') { + console.warn(`⚠️ Skipping ambient delivery: ${describeAmbientSkip(target.status, guidanceFile)}`); + continue; + } + await writeIfChanged(guidanceFile, injectAmbientRegion(target.content, body)); + } +} + +/** + * Retires the legacy `~/.agents/GLOBAL.md` ambient host: removes the sync-owned rulebook blocks it carries, deletes + * the file when nothing foreign remains, and otherwise writes the stripped remainder so hand-authored content + * survives. Ambient delivery targets the harness guidance regions now, so a lingering copy would present stale + * guidance as current. A missing file is a no-op. + */ +async function retireLegacyGlobalMd(options: InstallOptions, homeDir: string): Promise { + const legacyPath = path.join(homeDir, '.agents', 'GLOBAL.md'); + let content: string; + try { + content = await readFile(legacyPath, 'utf8'); + } catch (error: unknown) { + if (isMissingFile(error)) { + return; + } + throw error; + } + + if (options.dryRun) { + console.info(`[dry-run] sync would retire the legacy ambient host ${legacyPath}`); + return; + } + + let stripped = content; + for (const slug of extractInstalledSlugs(content)) { + stripped = removeRulebook(stripped, slug); + } + await (stripped.trim() === '' ? rm(legacyPath, { force: true }) : writeIfChanged(legacyPath, stripped)); +} + +/** Renders the ambient rulebooks as concatenated sentinel blocks — the wholesale content of an ambient region. */ +function renderAmbientBody(resolved: ReadonlyArray): string { + let body = ''; + for (const rulebook of resolved) { + if (rulebook.ambient) { + body = injectRulebook(body, rulebook.slug, rulebook.body); + } + } + return body; +} + +/** Lists the guidance files whose ambient regions a sync of `domain` targets — one per targeted harness. */ +function resolveAmbientRegionFiles(options: InstallOptions, domain: SyncDomain): ReadonlyArray { + return resolveHarnessIds(options.harness, domain.baseDir).map( + (harnessId) => resolveHarnessPaths(harnessId, domain.baseDir).guidanceFile, + ); +} + /** * Reconciles the Rovo Dev `prompts.yml` index so it lists the user-invocable skills currently in the harness skills * dir. The deployed skills are projected into a codeassembly-owned region merged into the shared file, preserving any @@ -792,7 +968,10 @@ function resolvePromptsYmlPaths(options: InstallOptions, domain: SyncDomain): Re /** The writes and retractions the dry-run reporter previews, gathered from the pre-write reconciliation. */ interface DryRunPlan { - readonly ambientHostName: string; + /** The ambient host file's name in host-file mode; `undefined` in harness-regions mode. */ + readonly ambientHostName: string | undefined; + /** The probed per-file ambient previews in harness-regions mode; empty in host-file mode. */ + readonly ambientRegionPreviews: ReadonlyArray; readonly resolutionReport: ReadonlyArray; readonly resolved: ReadonlyArray; readonly retracted: ReadonlyArray; @@ -812,9 +991,17 @@ function reportDryRun(plan: DryRunPlan): void { console.info(renderResolutionReport(plan.resolutionReport)); } console.info('[dry-run] sync would:'); + for (const preview of plan.ambientRegionPreviews) { + console.info( + preview.status === 'ready' + ? ` inject the ambient region in ${preview.guidanceFile}` + : ` skip ambient delivery: ${describeAmbientSkip(preview.status, preview.guidanceFile)}`, + ); + } for (const rulebook of plan.resolved) { - const inline = rulebook.ambient ? ` (+ inline into ${plan.ambientHostName})` : ''; - console.info(` write .agents/rulebooks/${rulebook.slug}.md${inline}`); + console.info( + ` write .agents/rulebooks/${rulebook.slug}.md${describeInlineSuffix(rulebook, plan.ambientHostName)}`, + ); if (rulebook.skill) { for (const { skillsDir } of plan.harnessSkillTargets) { console.info(` write ${path.join(skillsDir, rulebook.skillName, 'SKILL.md')}`); @@ -859,6 +1046,11 @@ function reportDryRun(plan: DryRunPlan): void { } } +/** The dry-run suffix noting a rulebook's ambient inline target, present only when a host file carries one. */ +function describeInlineSuffix(rulebook: ResolvedRulebook, ambientHostName: string | undefined): string { + return rulebook.ambient && ambientHostName !== undefined ? ` (+ inline into ${ambientHostName})` : ''; +} + /** Rank used to group resolution entries by type before the within-type slug sort, matching `library list`'s order. */ const ARTIFACT_TYPE_ORDER: Readonly> = { rulebook: 0, diff --git a/packages/agents/src/lib/__tests__/ambient-region.test.ts b/packages/agents/src/lib/__tests__/ambient-region.test.ts new file mode 100644 index 00000000..eeabfd13 --- /dev/null +++ b/packages/agents/src/lib/__tests__/ambient-region.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractAmbientRegionContent, + hasAmbientRegion, + injectAmbientRegion, + stripAmbientRegionContent, +} from '../ambient-region.ts'; + +/** A rulebook sentinel block, as sync renders ambient content into the region. */ +const BODY = + '\n# Writing preferences\n\nNo em-dashes.\n'; + +const EMPTY_REGION = '\n'; +const FILLED_REGION = `\n${BODY}\n`; + +/** A rendered guidance file whose template carries an empty region between foreign sections. */ +const GUIDANCE = `# Guidance\n\nIntro prose.\n\n${EMPTY_REGION}\n\n## Tail section\n`; + +describe(extractAmbientRegionContent, () => { + it('returns the inner content of a filled region', () => { + expect(extractAmbientRegionContent(injectAmbientRegion(GUIDANCE, BODY))).toBe(BODY); + }); + + it('returns an empty string for an empty region', () => { + expect(extractAmbientRegionContent(GUIDANCE)).toBe(''); + }); + + it('returns undefined when no region is present', () => { + expect(extractAmbientRegionContent('# Guidance\n')).toBeUndefined(); + }); + + it('returns undefined for an unclosed open marker', () => { + expect(extractAmbientRegionContent('\ncontent\n')).toBeUndefined(); + }); +}); + +describe(hasAmbientRegion, () => { + it('detects an empty region', () => { + expect(hasAmbientRegion(GUIDANCE)).toBe(true); + }); + + it('detects a filled region', () => { + expect(hasAmbientRegion(FILLED_REGION)).toBe(true); + }); + + it('returns false for region-less content', () => { + expect(hasAmbientRegion('# Guidance\n')).toBe(false); + }); + + it('returns false for empty content', () => { + expect(hasAmbientRegion('')).toBe(false); + }); + + it('returns false for an unpaired close marker', () => { + expect(hasAmbientRegion('content\n\n')).toBe(false); + }); +}); + +describe(injectAmbientRegion, () => { + it('fills an empty region, preserving surrounding content', () => { + expect(injectAmbientRegion(GUIDANCE, BODY)).toBe( + `# Guidance\n\nIntro prose.\n\n${FILLED_REGION}\n\n## Tail section\n`, + ); + }); + + it('replaces existing region content wholesale', () => { + const filled = injectAmbientRegion(GUIDANCE, BODY); + expect(injectAmbientRegion(filled, 'replacement')).toBe( + `# Guidance\n\nIntro prose.\n\n\nreplacement\n\n\n## Tail section\n`, + ); + }); + + it('is idempotent for an identical body', () => { + const once = injectAmbientRegion(GUIDANCE, BODY); + expect(injectAmbientRegion(once, BODY)).toBe(once); + }); + + it('empties the region when the body is empty', () => { + expect(injectAmbientRegion(injectAmbientRegion(GUIDANCE, BODY), '')).toBe(GUIDANCE); + }); + + it('leaves $-sequences in the body intact', () => { + expect(extractAmbientRegionContent(injectAmbientRegion(GUIDANCE, "price is $' and $1"))).toBe("price is $' and $1"); + }); + + it('throws when no region is present', () => { + expect(() => injectAmbientRegion('# Guidance\n', BODY)).toThrow(/No ambient region/); + }); +}); + +describe(stripAmbientRegionContent, () => { + it('empties a filled region, keeping the markers', () => { + expect(stripAmbientRegionContent(injectAmbientRegion(GUIDANCE, BODY))).toBe(GUIDANCE); + }); + + it('leaves an already-empty region unchanged', () => { + expect(stripAmbientRegionContent(GUIDANCE)).toBe(GUIDANCE); + }); + + it('returns region-less content unchanged', () => { + expect(stripAmbientRegionContent('# Guidance\n')).toBe('# Guidance\n'); + }); + + it('returns content with an unclosed marker unchanged', () => { + const unclosed = '\ncontent\n'; + expect(stripAmbientRegionContent(unclosed)).toBe(unclosed); + }); +}); diff --git a/packages/agents/src/lib/ambient-region.ts b/packages/agents/src/lib/ambient-region.ts new file mode 100644 index 00000000..ae8b144c --- /dev/null +++ b/packages/agents/src/lib/ambient-region.ts @@ -0,0 +1,68 @@ +/** + * Idempotent management of the codeassembly-owned ambient region within a harness guidance file (e.g. + * `~/.claude/CLAUDE.md`). The region is delimited by `` / + * `` markers. Its location is `install`'s authority (the guidance templates carry an + * empty region); its content is `sync`'s (regenerated wholesale from the resolved ambient rulebooks on each run). + * Every function is a pure string transform with no filesystem access. + */ + +export const AMBIENT_OPEN_MARKER = ''; +export const AMBIENT_CLOSE_MARKER = ''; + +const REGION_PATTERN = /^\n([\s\S]*?)^[ \t]*$/m; + +/** + * Returns the region's inner content with no surrounding newlines (an empty string for an empty region), or + * `undefined` when no complete marker pair is present. + */ +export function extractAmbientRegionContent(content: string): string | undefined { + const match = REGION_PATTERN.exec(content); + if (match === null) { + return undefined; + } + return (match[1] ?? '').replace(/\n+$/, ''); +} + +/** True when the content holds a complete ambient region marker pair. */ +export function hasAmbientRegion(content: string): boolean { + return REGION_PATTERN.test(content); +} + +/** + * Replaces the ambient region's content with `body`, keeping the markers. Throws when no complete region is present: + * the region's location belongs to the rendered guidance file, so a missing region means `install` has not rendered + * one and the caller should surface that rather than invent a location. Re-injecting an identical body yields + * byte-identical content, which is what keeps `sync` diff-free on re-run. + */ +export function injectAmbientRegion(content: string, body: string): string { + if (!hasAmbientRegion(content)) { + throw new Error( + 'No ambient region found to inject into; the guidance file carries the region only once `install` has rendered it.', + ); + } + // Replace via a function so `$`-sequences in the body are not treated as replacement patterns. + return content.replace(REGION_PATTERN, () => renderRegion(body)); +} + +/** + * Empties the ambient region's content, keeping the markers, so hashes computed over the result are insensitive to + * what `sync` wrote there. Content without a complete region is returned unchanged. + */ +export function stripAmbientRegionContent(content: string): string { + if (!hasAmbientRegion(content)) { + return content; + } + return content.replace(REGION_PATTERN, () => renderRegion('')); +} + +// region | Helpers + +/** Wraps `body` in the region markers, with no surrounding newlines; an empty body yields adjacent marker lines. */ +function renderRegion(body: string): string { + const trimmed = body.replace(/\n+$/, ''); + return trimmed === '' + ? `${AMBIENT_OPEN_MARKER}\n${AMBIENT_CLOSE_MARKER}` + : `${AMBIENT_OPEN_MARKER}\n${trimmed}\n${AMBIENT_CLOSE_MARKER}`; +} + +// endregion | Helpers diff --git a/packages/agents/src/lib/harness.ts b/packages/agents/src/lib/harness.ts index 0460e500..08a3c6d1 100644 --- a/packages/agents/src/lib/harness.ts +++ b/packages/agents/src/lib/harness.ts @@ -14,6 +14,7 @@ export const HARNESSES: Record = { scriptsDirName: 'scripts', configFileName: 'settings.json', frontmatterFile: 'claude.yaml', + guidanceFileName: 'CLAUDE.md', skillSigil: '/', subagentSigil: '', }, @@ -25,6 +26,7 @@ export const HARNESSES: Record = { scriptsDirName: 'scripts', configFileName: 'config.yml', frontmatterFile: 'rovodev.yaml', + guidanceFileName: 'AGENTS.md', skillSigil: '!', subagentSigil: '', }, @@ -67,6 +69,7 @@ export function resolveHarnessPaths( subagentsDir: string; scriptsDir: string; configFile: string; + guidanceFile: string; } { const home = baseDir ?? homedir(); const config = HARNESSES[harnessId]; @@ -77,6 +80,7 @@ export function resolveHarnessPaths( subagentsDir: path.join(harnessHome, config.subagentsDirName), scriptsDir: path.join(harnessHome, config.scriptsDirName), configFile: path.join(harnessHome, config.configFileName), + guidanceFile: path.join(harnessHome, config.guidanceFileName), }; } diff --git a/packages/agents/src/lib/manifest.ts b/packages/agents/src/lib/manifest.ts index 2a6e9de1..6603f07e 100644 --- a/packages/agents/src/lib/manifest.ts +++ b/packages/agents/src/lib/manifest.ts @@ -4,6 +4,7 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import path from 'node:path'; +import { hasAmbientRegion, stripAmbientRegionContent } from './ambient-region.ts'; import { isRecord } from './type-guards.ts'; import type { AgentsManifest, ManifestEntry } from './types.js'; @@ -82,13 +83,17 @@ export async function writeManifest(manifestPath: string, manifest: AgentsManife } /** - * Computes the SHA-256 content hash of a file. + * Computes the SHA-256 content hash of a file. Content inside an ambient region is excluded before hashing, so a + * sync-written region never reads as user drift; files without region markers hash over their raw bytes unchanged. * @param filePath Absolute path to the file. * @returns Hash string prefixed with `sha256:`. */ export async function computeContentHash(filePath: string): Promise { const content = await readFile(filePath); - const hash = createHash('sha256').update(content).digest('hex'); + const text = content.toString('utf8'); + const hash = createHash('sha256') + .update(hasAmbientRegion(text) ? stripAmbientRegionContent(text) : content) + .digest('hex'); return `sha256:${hash}`; } diff --git a/packages/agents/src/lib/types.ts b/packages/agents/src/lib/types.ts index 3b879c99..8b1a3618 100644 --- a/packages/agents/src/lib/types.ts +++ b/packages/agents/src/lib/types.ts @@ -23,6 +23,8 @@ export interface HarnessConfig { readonly configFileName: string; /** Filename of the frontmatter overlay YAML for this harness. */ readonly frontmatterFile: string; + /** Filename of the mechanically-loaded guidance file under the harness home that hosts the ambient region. */ + readonly guidanceFileName: string; /** Prefix a `{skill:}` invocation token renders to (e.g. `/` for Claude, `!` for Rovo). */ readonly skillSigil: string; /** Prefix a `{subagent:}` invocation token renders to; empty on both current harnesses (a bare slug dispatches). */