From 0f7e324fcab588688dd13f20a08b8581f600c4e0 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sat, 6 Jun 2026 01:19:15 -0700 Subject: [PATCH 1/8] agents|internal: Add rulebook schema, manifest, and sentinel inliner Provide the foundations for the rulebook library: validation of rulebook source-file frontmatter, a reader for a project's `.agents/rulebooks.yaml`, and idempotent insertion and removal of per-rulebook regions in `.agents/PROJECT.md`. The `init` and `sync` commands build on these. --- packages/agents/package.json | 3 +- .../src/lib/__tests__/rulebook-schema.test.ts | 68 +++++++++++++ .../lib/__tests__/rulebooks-manifest.test.ts | 71 ++++++++++++++ .../lib/__tests__/sentinel-inliner.test.ts | 98 +++++++++++++++++++ packages/agents/src/lib/rulebook-schema.ts | 45 +++++++++ packages/agents/src/lib/rulebooks-manifest.ts | 54 ++++++++++ packages/agents/src/lib/sentinel-inliner.ts | 91 +++++++++++++++++ pnpm-lock.yaml | 3 + 8 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 packages/agents/src/lib/__tests__/rulebook-schema.test.ts create mode 100644 packages/agents/src/lib/__tests__/rulebooks-manifest.test.ts create mode 100644 packages/agents/src/lib/__tests__/sentinel-inliner.test.ts create mode 100644 packages/agents/src/lib/rulebook-schema.ts create mode 100644 packages/agents/src/lib/rulebooks-manifest.ts create mode 100644 packages/agents/src/lib/sentinel-inliner.ts diff --git a/packages/agents/package.json b/packages/agents/package.json index de4f9c08..530f32a1 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -21,7 +21,8 @@ "@codeassembly/kb": "workspace:*", "js-yaml": "4.2.0", "ulid": "3.0.2", - "yaml": "2.9.0" + "yaml": "2.9.0", + "zod": "4.4.3" }, "devDependencies": { "@hyperjump/json-schema": "1.17.6", diff --git a/packages/agents/src/lib/__tests__/rulebook-schema.test.ts b/packages/agents/src/lib/__tests__/rulebook-schema.test.ts new file mode 100644 index 00000000..72da85c0 --- /dev/null +++ b/packages/agents/src/lib/__tests__/rulebook-schema.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { parseRulebookFile } from '../rulebook-schema.ts'; + +/** Wraps frontmatter and a body into a rulebook source file. */ +function rulebookFile(frontmatter: string, body = '# Shell conventions\n\nUse strict mode.'): string { + return `---\n${frontmatter}\n---\n\n${body}\n`; +} + +describe(parseRulebookFile, () => { + it('parses the operational fields from valid frontmatter', () => { + const { rulebook } = parseRulebookFile( + rulebookFile('slug: shell-conventions\ndescription: Shell rules\ndelivery: ambient\nversion: 1'), + ); + + expect(rulebook.slug).toBe('shell-conventions'); + expect(rulebook.description).toBe('Shell rules'); + expect(rulebook.delivery).toEqual(['ambient']); + expect(rulebook.version).toBe('1'); + }); + + it('returns the body with the frontmatter stripped', () => { + const { body } = parseRulebookFile(rulebookFile('slug: shell-conventions')); + + expect(body).toContain('# Shell conventions'); + expect(body).not.toContain('slug:'); + }); + + it('when delivery is a list, normalizes it to an array', () => { + const { rulebook } = parseRulebookFile(rulebookFile('slug: x\ndelivery: [ambient, skill]')); + + expect(rulebook.delivery).toEqual(['ambient', 'skill']); + }); + + it('when delivery is omitted, defaults to ambient', () => { + const { rulebook } = parseRulebookFile(rulebookFile('slug: x')); + + expect(rulebook.delivery).toEqual(['ambient']); + }); + + it('coerces a numeric version to a string', () => { + const { rulebook } = parseRulebookFile(rulebookFile('slug: x\nversion: 3')); + + expect(rulebook.version).toBe('3'); + }); + + it('tolerates unknown classification fields without throwing', () => { + const { rulebook } = parseRulebookFile( + rulebookFile('slug: shell-conventions\nlanguages: [bash]\ntags: [shell, style]'), + ); + + expect(rulebook.slug).toBe('shell-conventions'); + }); + + it('throws when slug is missing', () => { + expect(() => parseRulebookFile(rulebookFile('description: no slug here'))).toThrow(/slug/); + }); + + it('throws when slug is not kebab-case', () => { + expect(() => parseRulebookFile(rulebookFile('slug: Shell_Conventions'))).toThrow(/slug/); + }); + + it('names the source in the error when validation fails', () => { + expect(() => parseRulebookFile(rulebookFile('description: no slug'), 'rulebooks/bad.md')).toThrow( + /rulebooks\/bad\.md/, + ); + }); +}); diff --git a/packages/agents/src/lib/__tests__/rulebooks-manifest.test.ts b/packages/agents/src/lib/__tests__/rulebooks-manifest.test.ts new file mode 100644 index 00000000..136f5640 --- /dev/null +++ b/packages/agents/src/lib/__tests__/rulebooks-manifest.test.ts @@ -0,0 +1,71 @@ +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 { readRulebooksManifest } from '../rulebooks-manifest.ts'; + +describe(readRulebooksManifest, () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = path.join(tmpdir(), `agents-test-manifest-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(projectRoot, { recursive: true }); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + /** Writes `.agents/rulebooks.yaml` under the temp project root. */ + async function writeManifest(content: string): Promise { + const agentsDir = path.join(projectRoot, '.agents'); + await mkdir(agentsDir, { recursive: true }); + await writeFile(path.join(agentsDir, 'rulebooks.yaml'), content, 'utf8'); + } + + it('when the manifest file is absent, returns undefined', async () => { + expect(await readRulebooksManifest(projectRoot)).toBeUndefined(); + }); + + it('when the file is present but empty, returns an empty array', async () => { + await writeManifest(''); + expect(await readRulebooksManifest(projectRoot)).toEqual([]); + }); + + it('when the rulebooks list is empty, returns an empty array', async () => { + await writeManifest('rulebooks: []\n'); + expect(await readRulebooksManifest(projectRoot)).toEqual([]); + }); + + it('reads shorthand string entries', async () => { + await writeManifest('rulebooks:\n - shell-conventions\n - typescript\n'); + expect(await readRulebooksManifest(projectRoot)).toEqual(['shell-conventions', 'typescript']); + }); + + it('reads structured entries via the name key', async () => { + await writeManifest('rulebooks:\n - name: shell-conventions\n'); + expect(await readRulebooksManifest(projectRoot)).toEqual(['shell-conventions']); + }); + + it('tolerates unknown keys on structured entries', async () => { + await writeManifest('rulebooks:\n - name: shell-conventions\n source: npm\n note: future\n'); + expect(await readRulebooksManifest(projectRoot)).toEqual(['shell-conventions']); + }); + + it('deduplicates repeated slugs, preserving first occurrence', async () => { + await writeManifest('rulebooks:\n - alpha\n - beta\n - alpha\n'); + expect(await readRulebooksManifest(projectRoot)).toEqual(['alpha', 'beta']); + }); + + it('throws when an entry is neither a string nor has a name', async () => { + await writeManifest('rulebooks:\n - 42\n'); + await expect(readRulebooksManifest(projectRoot)).rejects.toThrow(/entry/i); + }); + + it('throws when rulebooks is not a list', async () => { + await writeManifest('rulebooks: not-a-list\n'); + await expect(readRulebooksManifest(projectRoot)).rejects.toThrow(/list/i); + }); +}); diff --git a/packages/agents/src/lib/__tests__/sentinel-inliner.test.ts b/packages/agents/src/lib/__tests__/sentinel-inliner.test.ts new file mode 100644 index 00000000..a01b4324 --- /dev/null +++ b/packages/agents/src/lib/__tests__/sentinel-inliner.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { extractInstalledSlugs, injectRulebook, removeRulebook } from '../sentinel-inliner.ts'; + +describe(injectRulebook, () => { + it('when content is empty, returns the block with a trailing newline', () => { + expect(injectRulebook('', 'shell', 'Body text')).toBe( + '\nBody text\n\n', + ); + }); + + it('appends the block after existing content separated by a blank line', () => { + expect(injectRulebook('# Title\n', 'shell', 'Body text')).toBe( + '# Title\n\n\nBody text\n\n', + ); + }); + + it('normalizes a missing trailing newline on existing content before appending', () => { + expect(injectRulebook('# Title', 'shell', 'Body text')).toBe( + '# Title\n\n\nBody text\n\n', + ); + }); + + it('when the same slug and body are re-inserted, leaves the document byte-identical', () => { + const once = injectRulebook('# Title\n', 'shell', 'Body text'); + expect(injectRulebook(once, 'shell', 'Body text')).toBe(once); + }); + + it('trims surrounding whitespace from the body', () => { + expect(injectRulebook('', 'shell', '\n Body line \n\n')).toBe( + '\nBody line\n\n', + ); + }); + + it('preserves blank lines inside the body', () => { + expect(injectRulebook('', 'shell', 'Line 1\n\nLine 2')).toBe( + '\nLine 1\n\nLine 2\n\n', + ); + }); + + it('replaces the body in place when the slug already exists', () => { + const once = injectRulebook('# Title\n', 'shell', 'Old body'); + const updated = injectRulebook(once, 'shell', 'New body'); + + expect(updated).not.toContain('Old body'); + expect(updated).toContain('New body'); + expect(extractInstalledSlugs(updated)).toEqual(['shell']); + }); + + it('preserves an existing block when inserting a different slug', () => { + const withAlpha = injectRulebook('# Title\n', 'alpha', 'A body'); + const withBoth = injectRulebook(withAlpha, 'beta', 'B body'); + + expect(withBoth).toContain('\nA body\n'); + expect(extractInstalledSlugs(withBoth)).toEqual(['alpha', 'beta']); + }); +}); + +describe(removeRulebook, () => { + it('removes the block and its separator, round-tripping to the original content', () => { + const once = injectRulebook('# Title\n', 'shell', 'Body text'); + expect(removeRulebook(once, 'shell')).toBe('# Title\n'); + }); + + it('when the slug is absent, returns the content unchanged', () => { + expect(removeRulebook('# Title\n', 'shell')).toBe('# Title\n'); + }); + + it('removes only the named block, leaving the others intact', () => { + const withAlpha = injectRulebook('# Title\n', 'alpha', 'A body'); + const withBoth = injectRulebook(withAlpha, 'beta', 'B body'); + + const afterRemoval = removeRulebook(withBoth, 'alpha'); + + expect(extractInstalledSlugs(afterRemoval)).toEqual(['beta']); + expect(afterRemoval).toBe(injectRulebook('# Title\n', 'beta', 'B body')); + }); + + it('when removing a block from an empty-origin document, returns an empty string', () => { + const only = injectRulebook('', 'shell', 'Body text'); + expect(removeRulebook(only, 'shell')).toBe(''); + }); +}); + +describe(extractInstalledSlugs, () => { + it('returns slugs that have a complete marker pair, in document order', () => { + const withBoth = injectRulebook(injectRulebook('', 'alpha', 'A'), 'beta', 'B'); + expect(extractInstalledSlugs(withBoth)).toEqual(['alpha', 'beta']); + }); + + it('when there are no markers, returns an empty array', () => { + expect(extractInstalledSlugs('# Title\n')).toEqual([]); + }); + + it('ignores an unpaired open marker', () => { + expect(extractInstalledSlugs('\nBody text\n')).toEqual([]); + }); +}); diff --git a/packages/agents/src/lib/rulebook-schema.ts b/packages/agents/src/lib/rulebook-schema.ts new file mode 100644 index 00000000..51e4e99a --- /dev/null +++ b/packages/agents/src/lib/rulebook-schema.ts @@ -0,0 +1,45 @@ +import { parse as parseYaml } from 'yaml'; +import { z } from 'zod'; + +import { parseFrontmatter } from './frontmatter-merger.ts'; + +/** + * Frontmatter schema for a rulebook source file. The operational fields drive the resolver; unknown keys + * (e.g. future classification metadata) are tolerated rather than rejected. `delivery` is normalized to an + * array, and `version` is treated as an opaque string, never parsed as semver. + */ +export const RulebookFrontmatterSchema = z.object({ + slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'slug must be lowercase kebab-case (e.g. shell-conventions)'), + description: z.string().optional(), + delivery: z + .union([z.string(), z.array(z.string())]) + .default('ambient') + .transform((value) => (typeof value === 'string' ? [value] : value)), + version: z + .union([z.string(), z.number()]) + .optional() + .transform((value) => (value === undefined ? undefined : String(value))), +}); + +/** A validated rulebook's operational frontmatter. */ +export type Rulebook = z.infer; + +/** + * Splits a rulebook source file into its validated operational frontmatter and its neutral body (frontmatter + * removed). Throws a readable error, naming `sourceLabel` when provided, if the frontmatter fails validation. + */ +export function parseRulebookFile(content: string, sourceLabel?: string): { rulebook: Rulebook; body: string } { + const { lines, body } = parseFrontmatter(content); + const frontmatter: unknown = parseYaml(lines.join('\n')); + const result = RulebookFrontmatterSchema.safeParse(frontmatter); + + if (!result.success) { + const detail = result.error.issues + .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('; '); + const where = sourceLabel === undefined ? '' : ` in ${sourceLabel}`; + throw new Error(`Invalid rulebook frontmatter${where}: ${detail}`); + } + + return { rulebook: result.data, body }; +} diff --git a/packages/agents/src/lib/rulebooks-manifest.ts b/packages/agents/src/lib/rulebooks-manifest.ts new file mode 100644 index 00000000..c9715357 --- /dev/null +++ b/packages/agents/src/lib/rulebooks-manifest.ts @@ -0,0 +1,54 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { parse as parseYaml } from 'yaml'; + +import { isEnoent, isRecord } from './type-guards.ts'; + +/** + * Reads the project-scope rulebook declaration at `{projectRoot}/.agents/rulebooks.yaml` and returns the + * deduplicated list of declared slugs. Returns `undefined` when the file is absent (a total no-op for `sync`), + * distinct from a present-but-empty declaration, which returns `[]` (reconcile to nothing declared). + */ +export async function readRulebooksManifest(projectRoot: string): Promise | undefined> { + const manifestPath = path.join(projectRoot, '.agents', 'rulebooks.yaml'); + + let raw: string; + try { + raw = await readFile(manifestPath, 'utf8'); + } catch (error: unknown) { + if (isEnoent(error)) { + return undefined; + } + throw error; + } + + const parsed: unknown = parseYaml(raw); + if (!isRecord(parsed)) { + return []; + } + + const declared = parsed.rulebooks; + if (declared === undefined || declared === null) { + return []; + } + if (!Array.isArray(declared)) { + throw new TypeError(`Invalid rulebooks.yaml: "rulebooks" must be a list (in ${manifestPath})`); + } + + const slugs = declared.map((entry) => resolveEntrySlug(entry, manifestPath)); + return [...new Set(slugs)]; +} + +/** Extracts the slug from a declaration entry: a bare string (shorthand) or `{ name: }` (structured). */ +function resolveEntrySlug(entry: unknown, manifestPath: string): string { + if (typeof entry === 'string') { + return entry; + } + if (isRecord(entry) && typeof entry.name === 'string') { + return entry.name; + } + throw new Error( + `Invalid rulebook entry in ${manifestPath}: expected a slug string or { name: }, got ${JSON.stringify(entry)}`, + ); +} diff --git a/packages/agents/src/lib/sentinel-inliner.ts b/packages/agents/src/lib/sentinel-inliner.ts new file mode 100644 index 00000000..0cb2ad35 --- /dev/null +++ b/packages/agents/src/lib/sentinel-inliner.ts @@ -0,0 +1,91 @@ +/** + * Idempotent management of per-rulebook sentinel blocks within a host document (e.g. `.agents/PROJECT.md`). + * Each rulebook owns a region delimited by `` / `` markers. + * Every function is a pure string transform with no filesystem access. + */ + +function openMarker(slug: string): string { + return ``; +} + +function closeMarker(slug: string): string { + return ``; +} + +/** + * Inserts or replaces the sentinel block for `slug`. An existing block is replaced in place; otherwise the + * block is appended, separated from preceding content by a single blank line. Re-inserting an identical slug + * and body yields a byte-identical document, which is what keeps `sync` diff-free on re-run. + */ +export function injectRulebook(content: string, slug: string, body: string): string { + const block = renderBlock(slug, body); + const existing = blockPattern(slug); + + if (existing.test(content)) { + // Replace via a function to avoid `$`-sequences in the body being interpreted as replacement patterns. + return content.replace(existing, () => block); + } + + if (content === '') { + return `${block}\n`; + } + + const base = content.replace(/\n+$/, ''); + return `${base}\n\n${block}\n`; +} + +/** + * Removes the sentinel block for `slug` together with the blank-line separator that precedes it, leaving the + * surrounding document clean. Returns the content unchanged when the slug is not present. + */ +export function removeRulebook(content: string, slug: string): string { + if (!blockPattern(slug).test(content)) { + return content; + } + + const block = blockSource(slug); + const withLeadingSeparator = new RegExp(String.raw`\n\n${block}`); + if (withLeadingSeparator.test(content)) { + return content.replace(withLeadingSeparator, ''); + } + + const atStart = new RegExp(String.raw`^${block}\n?`); + if (atStart.test(content)) { + return content.replace(atStart, ''); + } + + return content.replace(new RegExp(block), ''); +} + +/** Returns the slugs whose blocks have a complete open/close marker pair, in document order. */ +export function extractInstalledSlugs(content: string): ReadonlyArray { + const pattern = /[\s\S]*?/g; + const slugs: Array = []; + for (const match of content.matchAll(pattern)) { + const slug = match[1]; + if (slug !== undefined) { + slugs.push(slug); + } + } + return slugs; +} + +/** Renders the canonical block for a slug: open marker, trimmed body, close marker. */ +function renderBlock(slug: string, body: string): string { + return `${openMarker(slug)}\n${body.trim()}\n${closeMarker(slug)}`; +} + +/** Regex source matching a slug's full block (markers inclusive, body matched lazily). */ +function blockSource(slug: string): string { + return String.raw`${escapeRegExp(openMarker(slug))}[\s\S]*?${escapeRegExp(closeMarker(slug))}`; +} + +/** A non-global RegExp matching a slug's full block. */ +function blockPattern(slug: string): RegExp { + return new RegExp(blockSource(slug)); +} + +/** Escapes a string for literal use inside a RegExp. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c68e5ea5..afa702cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: yaml: specifier: 2.9.0 version: 2.9.0 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@hyperjump/json-schema': specifier: 1.17.6 From c24a98068f7017b93f51daba726d47dcd68d4a67 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sat, 6 Jun 2026 01:19:26 -0700 Subject: [PATCH 2/8] agents|feat: Add init and sync commands for project rulebooks Add `codeassembly-agents init` to scaffold a project's `.agents/rulebooks.yaml`, and `codeassembly-agents sync` to apply it: each declared rulebook is written into `.agents/rulebooks/` and `ambient`-delivery rulebooks are inlined into `.agents/PROJECT.md`. Re-running `sync` makes no further changes, and dropping a rulebook from `rulebooks.yaml` retracts it on the next run. `init` refuses to overwrite an existing file, and `sync` accepts `--dry-run`. Migrates `shell-conventions` into the rulebook library as the first entry. --- .../guidance/rulebooks/shell-conventions.md | 246 ++++++++++++++++++ packages/agents/src/__tests__/cli.test.ts | 52 +++- packages/agents/src/cli.ts | 12 +- .../src/commands/__tests__/init.test.ts | 58 +++++ .../src/commands/__tests__/sync.test.ts | 165 ++++++++++++ packages/agents/src/commands/init.ts | 38 +++ packages/agents/src/commands/sync.ts | 149 +++++++++++ 7 files changed, 715 insertions(+), 5 deletions(-) create mode 100644 packages/agents/content/guidance/rulebooks/shell-conventions.md create mode 100644 packages/agents/src/commands/__tests__/init.test.ts create mode 100644 packages/agents/src/commands/__tests__/sync.test.ts create mode 100644 packages/agents/src/commands/init.ts create mode 100644 packages/agents/src/commands/sync.ts diff --git a/packages/agents/content/guidance/rulebooks/shell-conventions.md b/packages/agents/content/guidance/rulebooks/shell-conventions.md new file mode 100644 index 00000000..a8c05959 --- /dev/null +++ b/packages/agents/content/guidance/rulebooks/shell-conventions.md @@ -0,0 +1,246 @@ +--- +slug: shell-conventions +description: Conventions for writing production-quality bash scripts in this repository. +delivery: ambient +version: 1 +--- + +# Shell script conventions + +Conventions for writing production-quality bash scripts in this repository. These complement the universal style rules (imperative-mood comments, verb-led function names, primary logic first, long-form CLI options). + +## Script anatomy + +Bash does not support forward function references — a function must be defined before it is called during execution. To keep primary logic visually prominent while ensuring all functions are defined before use, wrap the main flow in a `main()` function and call `main "$@"` at the bottom. + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# script-name.sh — One-line purpose. +# +# Extended description: What the script does, behavioral notes, +# composability characteristics. +# +# Usage: +# script-name.sh [optional] +# script-name.sh --help + +readonly PROG="$(basename "$0")" + +# Main flow +main() { + # Show help (manual check — getopts cannot parse long options) + if [[ "${1:-}" == "--help" ]]; then + show_usage 0 + fi + + # Parse options + while getopts ":h" opt; do + case $opt in + h) show_usage 0 ;; + *) + echo "$PROG: Unknown option -$OPTARG" >&2 + show_usage + ;; + esac + done + shift $((OPTIND - 1)) + + # Validate arguments + # ... + + # Check dependencies + # ... + + # Core logic + # ... +} + +# region | Helper functions + +# Displays command-line syntax. Can exit with or without an error code. +show_usage() { + cat >&2 < [options] + $PROG --help + +Arguments: + Description (required) + +Options: + -h, --help Show this help + +Dependencies: + tool Why it is needed + +Examples: + $PROG my-value + result=\$($PROG my-value) && echo "\$result" +USAGE + exit "${1:-1}" +} +# endregion | Helper functions + +main "$@" +``` + +### Key structural rules + +- **Shebang**: `#!/usr/bin/env bash` (not `/bin/bash`). +- **Strict mode**: `set -euo pipefail` unless there is a documented reason to omit a flag (e.g., `((count++))` returns 1 when count is 0 under `set -e`). +- **Header docblock**: Purpose, behavior notes, usage synopsis. Written as comments at the top of the file, before any code. +- **`readonly PROG`**: Use `$PROG` in all user-facing messages for consistency. +- **`main()` wrapper**: Wrap the main flow in a `main()` function. Call `main "$@"` at the bottom of the file, after all function definitions. +- **Main flow first**: Option parsing, validation, dependencies, core logic — inside `main()`. +- **Helpers at end**: Place helper functions after `main()`, before the `main "$@"` call. + +## Help and exit codes + +| Situation | Exit code | +| ---------------------------------------- | ---------------------- | +| `--help` or `-h` (explicit help request) | 0 | +| Bad input, missing arguments | 1 | +| Unknown command / subcommand | 2 | +| Required external command not found | 127 (POSIX convention) | + +`show_usage` accepts an optional exit code parameter, defaulting to 1: + +```bash +show_usage() { + cat >&2 <&2; show_usage ;; + *) POSITIONAL="$1" ;; + esac + shift +done +``` + +**Subcommand scripts**: Parse the subcommand first, then flags: + +```bash +if [[ $# -lt 1 ]]; then + show_usage +fi +cmd="$1"; shift +# ... parse flags ... +case "$cmd" in +list) do_list ;; +prune) do_prune ;; +*) echo "$PROG: Unknown command '$cmd'" >&2; show_usage ;; +esac +``` + +## Error messages + +- **Always to stderr**: `echo "..." >&2` +- **Prefix with `$PROG:`**, so that piped output identifies the source. +- **Be actionable**: Tell the user what to do, not just what went wrong. + +```bash +# Bad +echo "Error: Not found" + +# Good +echo "$PROG: Stable worktree not found at $path" >&2 +echo "Create it with: git worktree add $path main" >&2 +``` + +## Dependency checks + +Preflight-check external commands before using them: + +```bash +for cmd in wt jq; do + if ! command -v "$cmd" &>/dev/null; then + echo "$PROG: Required command '$cmd' not found" >&2 + exit 127 + fi +done +``` + +Use `command -v` (POSIX), not `which` (non-standard behavior across platforms). + +## The `functions/` library + +Reusable utilities live in `functions/` and are sourced at runtime. Scripts resolve their own repo root so they always use co-versioned code, regardless of which worktree they run from: + +```bash +_self="$0"; [[ "$_self" != */* ]] && _self="$(command -v "$0")" +readonly repo_dir="$(cd "$(dirname "$_self")" && git rev-parse --show-toplevel)" + +source "$repo_dir/functions/symlinks.sh" +source "$repo_dir/functions/output.sh" +``` + +`WT_CONFIG_REPO_DIR` is reserved for cases that specifically require the `.live` worktree (e.g., symlink targets that apps read at runtime). Do not use it for sourcing functions. + +### Available modules + +| Module | Provides | +| --------------- | ----------------------------------------------------------------------------------------------------- | +| `args.sh` | `require_command`, `require_arg` | +| `colors.sh` | Terminal color variables: `green`, `yellow`, `red`, `normal` | +| `env-vars.sh` | `assert_nonempty`, `set_env_vars` | +| `errors.sh` | `die`, `die_with_usage` | +| `git-checks.sh` | `assert_git_repo`, `assert_branch_exists`, `assert_clean_worktree` | +| `output.sh` | `run_silent` (buffer output, print only on failure) | +| `strings.sh` | `to_kebab_case` | +| `symlinks.sh` | `create_symlink`, `verify_symlink`, `ensure_symlinks`, `ensure_parent_directory`, `assert_fso_exists` | +| `yaml.sh` | `parse_yaml_value` (two-level YAML value extraction without yq) | + +Agent-specific modules live in `agents/functions/`: + +| Module | Provides | +| ----------------- | ------------------------------------------------------------------------- | +| `project-slug.sh` | `resolve_project_slug`, `derive_slug_from_remote`, `persist_project_slug` | + +**Extend rather than inline.** If you write a utility that could be reused across scripts, add it to an existing module or create a new one in `functions/`. + +## Common mistakes + +| Mistake | Fix | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `set -e` alone | Use `set -euo pipefail` — `-u` catches typos in variable names, `pipefail` catches mid-pipeline failures | +| `usage()` as function name | Use `show_usage()` — functions start with verbs | +| `show_usage` always exits 1 | Accept exit code parameter: `exit "${1:-1}"` | +| `which cmd` to check availability | Use `command -v cmd` (POSIX-portable) | +| Error messages to stdout | Always `>&2` | +| Interpolating user input into `jq` | Use `jq --arg name "$value"` for safe binding | +| `${var//pat/repl}` with dynamic `repl` | Add `shopt -u patsub_replacement 2>/dev/null \|\| true`; bash 5.2+ expands `&` in `repl` to the matched text | +| Hard-coded values that vary | Accept as arguments or use `readonly` defaults at the top | +| Duplicating logic across scripts | Extract to `functions/` and source it | diff --git a/packages/agents/src/__tests__/cli.test.ts b/packages/agents/src/__tests__/cli.test.ts index 64333d94..97d87e60 100644 --- a/packages/agents/src/__tests__/cli.test.ts +++ b/packages/agents/src/__tests__/cli.test.ts @@ -1,7 +1,11 @@ import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { promisify } from 'node:util'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { isRecord } from '../lib/type-guards.ts'; @@ -31,10 +35,10 @@ interface CliResult { readonly exitCode: number; } -/** Runs the CLI via tsx and captures stdout, stderr, and exit code. */ -async function runCli(...args: Array): Promise { +/** Runs the CLI via tsx in an optional working directory, capturing stdout, stderr, and exit code. */ +async function runCliIn(cwd: string | undefined, ...args: Array): Promise { try { - const { stdout, stderr } = await execFileAsync('tsx', [CLI_PATH, ...args]); + const { stdout, stderr } = await execFileAsync('tsx', [CLI_PATH, ...args], cwd === undefined ? {} : { cwd }); return { stdout, stderr, exitCode: 0 }; } catch (error: unknown) { if (isExecError(error)) { @@ -44,6 +48,11 @@ async function runCli(...args: Array): Promise { } } +/** Runs the CLI via tsx in the default working directory. */ +async function runCli(...args: Array): Promise { + return runCliIn(undefined, ...args); +} + describe('CLI generate routing', () => { it('exits 1 and prints generate usage when no subcommand is given', async () => { const result = await runCli('generate'); @@ -59,3 +68,38 @@ describe('CLI generate routing', () => { expect(result.stderr).toContain('Unknown generate target "nonexistent"'); }); }); + +describe('CLI rulebook routing', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = path.join(tmpdir(), `agents-test-cli-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(projectRoot, { recursive: true }); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + it('lists the init and sync commands in --help', async () => { + const result = await runCli('--help'); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('init'); + expect(result.stdout).toContain('sync'); + }); + + it('dispatches sync, reporting a no-op when no rulebooks.yaml exists', async () => { + const result = await runCliIn(projectRoot, 'sync'); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Nothing to sync'); + }); + + it('dispatches init, scaffolding rulebooks.yaml in the project', async () => { + const result = await runCliIn(projectRoot, 'init'); + + expect(result.exitCode).toBe(0); + expect(existsSync(path.join(projectRoot, '.agents', 'rulebooks.yaml'))).toBe(true); + }); +}); diff --git a/packages/agents/src/cli.ts b/packages/agents/src/cli.ts index 6a04a798..2aaa5f20 100644 --- a/packages/agents/src/cli.ts +++ b/packages/agents/src/cli.ts @@ -3,8 +3,10 @@ import process from 'node:process'; import { generateLabelMap, printGenerateUsage } from './commands/generate-label-map.js'; +import { initCommand } from './commands/init.ts'; import { installCommand } from './commands/install.js'; import { statusCommand } from './commands/status.js'; +import { syncCommand } from './commands/sync.ts'; import { uninstallCommand } from './commands/uninstall.js'; import type { InstallOptions, PlatformId } from './lib/types.js'; @@ -112,6 +114,8 @@ function printUsage(): void { Commands: install Install guidance, skills, and subagents into platform directories + init Scaffold an empty .agents/rulebooks.yaml in the current project + sync Resolve .agents/rulebooks.yaml and materialize declared rulebooks uninstall Remove installed guidance, skills, and subagents status Show the current state of installed items generate Generate a configuration file (e.g., label-map) @@ -120,7 +124,7 @@ Options: --platform Target platform: claude, rovodev, or all (default: all) --link Use symlinks instead of copies (install only) --force Overwrite modified files (install/uninstall) - --dry-run Show what would be done without making changes (install only) + --dry-run Show what would be done without making changes (install, sync) --help, -h Show this help message`); } @@ -140,6 +144,12 @@ async function main(): Promise { case 'install': await installCommand(options); break; + case 'init': + await initCommand(options); + break; + case 'sync': + await syncCommand(options); + break; case 'uninstall': await uninstallCommand({ platform: options.platform, force: options.force }); break; diff --git a/packages/agents/src/commands/__tests__/init.test.ts b/packages/agents/src/commands/__tests__/init.test.ts new file mode 100644 index 00000000..6bd72b7d --- /dev/null +++ b/packages/agents/src/commands/__tests__/init.test.ts @@ -0,0 +1,58 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, 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 { readRulebooksManifest } from '../../lib/rulebooks-manifest.ts'; +import type { InstallOptions } from '../../lib/types.ts'; +import { initCommand } from '../init.ts'; + +describe(initCommand, () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = path.join(tmpdir(), `agents-test-init-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(projectRoot, { recursive: true }); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + function makeOptions(overrides: Partial = {}): InstallOptions { + return { platform: 'claude', link: false, force: false, dryRun: false, ...overrides }; + } + + const manifestPath = (): string => path.join(projectRoot, '.agents', 'rulebooks.yaml'); + + it('creates rulebooks.yaml with an empty declaration, creating .agents if absent', async () => { + await initCommand(makeOptions(), projectRoot); + + const content = await readFile(manifestPath(), 'utf8'); + expect(content).toContain('rulebooks: []'); + }); + + it('scaffolds a file that parses to zero declared rulebooks', async () => { + await initCommand(makeOptions(), projectRoot); + + expect(await readRulebooksManifest(projectRoot)).toEqual([]); + }); + + it('refuses to overwrite an existing rulebooks.yaml', async () => { + await mkdir(path.join(projectRoot, '.agents'), { recursive: true }); + await writeFile(manifestPath(), 'rulebooks:\n - shell-conventions\n', 'utf8'); + + await expect(initCommand(makeOptions(), projectRoot)).rejects.toThrow(/overwrite/i); + + const preserved = await readFile(manifestPath(), 'utf8'); + expect(preserved).toContain('shell-conventions'); + }); + + it('in dry-run mode, does not create the file', async () => { + await initCommand(makeOptions({ dryRun: true }), projectRoot); + + expect(existsSync(manifestPath())).toBe(false); + }); +}); diff --git a/packages/agents/src/commands/__tests__/sync.test.ts b/packages/agents/src/commands/__tests__/sync.test.ts new file mode 100644 index 00000000..26d7b562 --- /dev/null +++ b/packages/agents/src/commands/__tests__/sync.test.ts @@ -0,0 +1,165 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, 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 { resolveContentDir } from '../../lib/content-resolver.ts'; +import type { InstallOptions } from '../../lib/types.ts'; +import { syncCommand } from '../sync.ts'; + +describe(syncCommand, () => { + let projectRoot: string; + let contentDir: string; + + beforeEach(async () => { + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + projectRoot = path.join(tmpdir(), `agents-test-sync-proj-${stamp}`); + contentDir = path.join(tmpdir(), `agents-test-sync-content-${stamp}`); + await mkdir(projectRoot, { recursive: true }); + await mkdir(path.join(contentDir, 'guidance', 'rulebooks'), { recursive: true }); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + await rm(contentDir, { recursive: true, force: true }); + }); + + function makeOptions(overrides: Partial = {}): InstallOptions { + return { platform: '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 the project-scope rulebooks.yaml. */ + async function writeManifest(content: string): Promise { + await mkdir(path.join(projectRoot, '.agents'), { recursive: true }); + await writeFile(path.join(projectRoot, '.agents', 'rulebooks.yaml'), content, 'utf8'); + } + + function neutralPath(slug: string): string { + return path.join(projectRoot, '.agents', 'rulebooks', `${slug}.md`); + } + + const projectMdPath = (): string => path.join(projectRoot, '.agents', 'PROJECT.md'); + + it('when no rulebooks.yaml exists, makes no changes', async () => { + await syncCommand(makeOptions(), projectRoot, contentDir); + + expect(existsSync(path.join(projectRoot, '.agents', 'rulebooks'))).toBe(false); + expect(existsSync(projectMdPath())).toBe(false); + }); + + it('writes the neutral body with frontmatter stripped for a declared rulebook', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', '# Alpha\n\nAlpha rules.'); + await writeManifest('rulebooks:\n - alpha\n'); + + await syncCommand(makeOptions(), projectRoot, contentDir); + + const neutral = await readFile(neutralPath('alpha'), 'utf8'); + expect(neutral).toBe('# Alpha\n\nAlpha rules.\n'); + expect(neutral).not.toContain('slug:'); + }); + + it('inlines an ambient rulebook into PROJECT.md between sentinels, creating the file', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', '# Alpha\n\nAlpha rules.'); + await writeManifest('rulebooks:\n - alpha\n'); + + await syncCommand(makeOptions(), projectRoot, contentDir); + + const projectMd = await readFile(projectMdPath(), 'utf8'); + expect(projectMd).toContain(''); + expect(projectMd).toContain(''); + expect(projectMd).toContain('Alpha rules.'); + }); + + it('preserves hand-authored PROJECT.md content when inlining', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await writeManifest('rulebooks:\n - alpha\n'); + await mkdir(path.join(projectRoot, '.agents'), { recursive: true }); + await writeFile(projectMdPath(), '# Project\n\nHand-authored intro.\n', 'utf8'); + + await syncCommand(makeOptions(), projectRoot, contentDir); + + const projectMd = await readFile(projectMdPath(), 'utf8'); + expect(projectMd).toContain('Hand-authored intro.'); + expect(projectMd).toContain(''); + }); + + it('when re-run with the same manifest, produces no file changes', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', '# Alpha\n\nAlpha rules.'); + await writeManifest('rulebooks:\n - alpha\n'); + + await syncCommand(makeOptions(), projectRoot, contentDir); + const firstProjectMd = await readFile(projectMdPath(), 'utf8'); + const firstNeutral = await readFile(neutralPath('alpha'), 'utf8'); + + await syncCommand(makeOptions(), projectRoot, contentDir); + const secondProjectMd = await readFile(projectMdPath(), 'utf8'); + const secondNeutral = await readFile(neutralPath('alpha'), 'utf8'); + + expect(secondProjectMd).toBe(firstProjectMd); + expect(secondNeutral).toBe(firstNeutral); + }); + + it('retracts a rulebook that is no longer declared', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await writeLibraryRulebook('beta', 'delivery: ambient', 'Beta rules.'); + await writeManifest('rulebooks:\n - alpha\n - beta\n'); + await syncCommand(makeOptions(), projectRoot, contentDir); + + expect(existsSync(neutralPath('beta'))).toBe(true); + + await writeManifest('rulebooks:\n - alpha\n'); + await syncCommand(makeOptions(), projectRoot, contentDir); + + expect(existsSync(neutralPath('beta'))).toBe(false); + expect(existsSync(neutralPath('alpha'))).toBe(true); + const projectMd = await readFile(projectMdPath(), 'utf8'); + expect(projectMd).not.toContain(''); + expect(projectMd).toContain(''); + }); + + it('throws when a declared rulebook has no library file', async () => { + await writeManifest('rulebooks:\n - ghost\n'); + + await expect(syncCommand(makeOptions(), projectRoot, contentDir)).rejects.toThrow(/ghost/); + }); + + it('materializes a skill-only rulebook without inlining it into PROJECT.md', async () => { + await writeLibraryRulebook('gamma', 'delivery: skill', 'Gamma rules.'); + await writeManifest('rulebooks:\n - gamma\n'); + + await syncCommand(makeOptions(), projectRoot, contentDir); + + expect(existsSync(neutralPath('gamma'))).toBe(true); + expect(existsSync(projectMdPath())).toBe(false); + }); + + it('in dry-run mode, writes nothing to disk', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await writeManifest('rulebooks:\n - alpha\n'); + + await syncCommand(makeOptions({ dryRun: true }), projectRoot, contentDir); + + expect(existsSync(neutralPath('alpha'))).toBe(false); + expect(existsSync(projectMdPath())).toBe(false); + }); + + it('materializes the real shell-conventions rulebook from the package content', async () => { + await writeManifest('rulebooks:\n - shell-conventions\n'); + + await syncCommand(makeOptions(), projectRoot, resolveContentDir()); + + const neutral = await readFile(neutralPath('shell-conventions'), 'utf8'); + expect(neutral).toContain('# Shell script conventions'); + expect(neutral).not.toContain('slug:'); + const projectMd = await readFile(projectMdPath(), 'utf8'); + expect(projectMd).toContain(''); + }); +}); diff --git a/packages/agents/src/commands/init.ts b/packages/agents/src/commands/init.ts new file mode 100644 index 00000000..4c718d76 --- /dev/null +++ b/packages/agents/src/commands/init.ts @@ -0,0 +1,38 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +import type { InstallOptions } from '../lib/types.ts'; + +const MANIFEST_TEMPLATE = `# Rulebooks this project opts into. List slugs under \`rulebooks:\`, then run \`codeassembly-agents sync\`. +rulebooks: [] +`; + +/** + * Scaffolds a project-scope `.agents/rulebooks.yaml` with an empty declaration, creating `.agents/` if absent. + * Refuses to overwrite an existing file. Honors `--dry-run` by reporting the intended action without writing. + * + * @param projectRoot The project to scaffold (defaults to the current directory). + */ +export async function initCommand(options: InstallOptions, projectRoot: string = process.cwd()): Promise { + const manifestPath = path.join(projectRoot, '.agents', 'rulebooks.yaml'); + const alreadyExists = existsSync(manifestPath); + + if (options.dryRun) { + console.info( + alreadyExists + ? `[dry-run] ${manifestPath} already exists; init would refuse to overwrite it.` + : `[dry-run] init would create ${manifestPath}.`, + ); + return; + } + + if (alreadyExists) { + throw new Error(`A rulebooks.yaml already exists at ${manifestPath}; refusing to overwrite it.`); + } + + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, MANIFEST_TEMPLATE, 'utf8'); + console.info(`Created ${manifestPath}`); +} diff --git a/packages/agents/src/commands/sync.ts b/packages/agents/src/commands/sync.ts new file mode 100644 index 00000000..f06e94d7 --- /dev/null +++ b/packages/agents/src/commands/sync.ts @@ -0,0 +1,149 @@ +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +import { resolveContentDir } from '../lib/content-resolver.ts'; +import { parseRulebookFile } from '../lib/rulebook-schema.ts'; +import { readRulebooksManifest } from '../lib/rulebooks-manifest.ts'; +import { extractInstalledSlugs, injectRulebook, removeRulebook } from '../lib/sentinel-inliner.ts'; +import { isEnoent } from '../lib/type-guards.ts'; +import type { InstallOptions } from '../lib/types.ts'; + +/** A declared rulebook resolved against the library: its neutral body and whether it delivers ambiently. */ +interface ResolvedRulebook { + readonly slug: string; + readonly body: string; + readonly ambient: boolean; +} + +/** + * Resolves the project-scope `.agents/rulebooks.yaml`, materializes each declared rulebook's neutral body to + * `.agents/rulebooks/.md`, inlines `ambient` rulebooks into `.agents/PROJECT.md`, and retracts rulebooks + * that are no longer declared. Installed state is derived from the filesystem, not a manifest, which keeps the + * command idempotent. An absent `rulebooks.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 rulebook library source (defaults to the package content dir). + */ +export async function syncCommand( + options: InstallOptions, + projectRoot: string = process.cwd(), + contentDirOverride?: string, +): Promise { + const declared = await readRulebooksManifest(projectRoot); + if (declared === undefined) { + console.info('No .agents/rulebooks.yaml found. Nothing to sync.'); + return; + } + + const librarySrcDir = path.join(contentDirOverride ?? resolveContentDir(), 'guidance', 'rulebooks'); + const neutralDir = path.join(projectRoot, '.agents', 'rulebooks'); + const projectMdPath = path.join(projectRoot, '.agents', 'PROJECT.md'); + + // Resolve and validate every declared rulebook before writing anything, so a missing library file or invalid + // frontmatter fails the whole run rather than leaving a partial sync behind. + const resolved = await Promise.all(declared.map((slug) => resolveRulebook(slug, librarySrcDir))); + + // Derive what is currently installed from the filesystem: neutral files plus inlined sentinel blocks. + const existingProjectMd = await readFileOrEmpty(projectMdPath); + const installed = new Set([ + ...extractInstalledSlugs(existingProjectMd), + ...(await listNeutralSlugs(neutralDir)), + ]); + const declaredSet = new Set(declared); + const orphans = [...installed].filter((slug) => !declaredSet.has(slug)); + + if (options.dryRun) { + reportDryRun(resolved, orphans); + return; + } + + if (resolved.length > 0) { + await mkdir(neutralDir, { recursive: true }); + } + + // PROJECT.md is read once, mutated in memory across all inject/remove operations, and written once. + let projectMd = existingProjectMd; + 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); + } + } + + // `.agents/rulebooks/` is sync-owned, so deleting an undeclared neutral file here is safe, not user data loss. + for (const slug of orphans) { + projectMd = removeRulebook(projectMd, slug); + await rm(path.join(neutralDir, `${slug}.md`), { force: true }); + } + + if (projectMd !== existingProjectMd) { + await mkdir(path.dirname(projectMdPath), { recursive: true }); + await writeFile(projectMdPath, projectMd, 'utf8'); + } + + console.info(`Synced ${resolved.length} rulebook(s); retracted ${orphans.length}.`); +} + +/** Reads a rulebook from the library, validates its frontmatter, and returns its neutral body and delivery. */ +async function resolveRulebook(slug: string, librarySrcDir: string): Promise { + const srcPath = path.join(librarySrcDir, `${slug}.md`); + let content: string; + try { + content = await readFile(srcPath, 'utf8'); + } catch (error: unknown) { + if (isEnoent(error)) { + throw new Error(`Declared rulebook "${slug}" was not found in the library at ${srcPath}`); + } + throw error; + } + + const { rulebook, body } = parseRulebookFile(content, `${slug}.md`); + return { slug, body: `${body.trim()}\n`, ambient: rulebook.delivery.includes('ambient') }; +} + +/** Reads a file, returning an empty string when it does not exist. */ +async function readFileOrEmpty(filePath: string): Promise { + try { + return await readFile(filePath, 'utf8'); + } catch (error: unknown) { + if (isEnoent(error)) { + return ''; + } + throw error; + } +} + +/** Lists the slugs of materialized neutral files, returning an empty list when the directory is absent. */ +async function listNeutralSlugs(neutralDir: string): Promise> { + let entries: ReadonlyArray; + try { + entries = await readdir(neutralDir); + } catch (error: unknown) { + if (isEnoent(error)) { + return []; + } + throw error; + } + return entries.filter((entry) => entry.endsWith('.md')).map((entry) => entry.slice(0, -'.md'.length)); +} + +/** Writes `content` to `filePath` only when it differs from the current contents, keeping re-runs diff-free. */ +async function writeIfChanged(filePath: string, content: string): Promise { + if ((await readFileOrEmpty(filePath)) === content) { + return; + } + await writeFile(filePath, content, 'utf8'); +} + +/** Prints the writes and retractions a real run would perform. */ +function reportDryRun(resolved: ReadonlyArray, orphans: ReadonlyArray): void { + console.info('[dry-run] sync would:'); + for (const rulebook of resolved) { + const inline = rulebook.ambient ? ' (+ inline into PROJECT.md)' : ''; + console.info(` write .agents/rulebooks/${rulebook.slug}.md${inline}`); + } + for (const slug of orphans) { + console.info(` retract ${slug} (remove neutral file and PROJECT.md block)`); + } +} From 1000bfd082dfd64735facb1fee65a9b5d53fc98d Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sat, 6 Jun 2026 01:49:09 -0700 Subject: [PATCH 3/8] agents|fix: Reconcile PROJECT.md against the resolved ambient set Editing a rulebook's `delivery` to drop `ambient` now retracts its inlined block from `.agents/PROJECT.md` on the next `sync`, rather than leaving it stranded while the rulebook stays declared. --- .../src/commands/__tests__/sync.test.ts | 27 ++++++++++++++++ packages/agents/src/commands/sync.ts | 31 ++++++++++--------- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/agents/src/commands/__tests__/sync.test.ts b/packages/agents/src/commands/__tests__/sync.test.ts index 26d7b562..5c23c16a 100644 --- a/packages/agents/src/commands/__tests__/sync.test.ts +++ b/packages/agents/src/commands/__tests__/sync.test.ts @@ -125,6 +125,33 @@ describe(syncCommand, () => { expect(projectMd).toContain(''); }); + it('retracts the inlined block when a rulebook delivery changes away from ambient', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await writeManifest('rulebooks:\n - alpha\n'); + await syncCommand(makeOptions(), projectRoot, contentDir); + expect(await readFile(projectMdPath(), 'utf8')).toContain(''); + + await writeLibraryRulebook('alpha', 'delivery: skill', 'Alpha rules.'); + await syncCommand(makeOptions(), projectRoot, contentDir); + + expect(existsSync(neutralPath('alpha'))).toBe(true); + const projectMd = await readFile(projectMdPath(), 'utf8'); + expect(projectMd).not.toContain(''); + }); + + it('when the manifest is emptied, retracts every block and neutral file', async () => { + await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.'); + await writeManifest('rulebooks:\n - alpha\n'); + await syncCommand(makeOptions(), projectRoot, contentDir); + expect(existsSync(neutralPath('alpha'))).toBe(true); + + await writeManifest('rulebooks: []\n'); + await syncCommand(makeOptions(), projectRoot, contentDir); + + expect(existsSync(neutralPath('alpha'))).toBe(false); + expect(await readFile(projectMdPath(), 'utf8')).not.toContain(''); + }); + it('throws when a declared rulebook has no library file', async () => { await writeManifest('rulebooks:\n - ghost\n'); diff --git a/packages/agents/src/commands/sync.ts b/packages/agents/src/commands/sync.ts index f06e94d7..2c8a0d5c 100644 --- a/packages/agents/src/commands/sync.ts +++ b/packages/agents/src/commands/sync.ts @@ -44,17 +44,18 @@ export async function syncCommand( // frontmatter fails the whole run rather than leaving a partial sync behind. const resolved = await Promise.all(declared.map((slug) => resolveRulebook(slug, librarySrcDir))); - // Derive what is currently installed from the filesystem: neutral files plus inlined sentinel blocks. - const existingProjectMd = await readFileOrEmpty(projectMdPath); - const installed = new Set([ - ...extractInstalledSlugs(existingProjectMd), - ...(await listNeutralSlugs(neutralDir)), - ]); + // Reconcile two surfaces against the filesystem independently. Neutral files track the declared set; + // PROJECT.md tracks the desired *ambient* set. Keying PROJECT.md on declaration alone would strand a block + // whose rulebook is still declared but whose delivery no longer includes `ambient`. const declaredSet = new Set(declared); - const orphans = [...installed].filter((slug) => !declaredSet.has(slug)); + const desiredAmbient = new Set(resolved.filter((rulebook) => rulebook.ambient).map((rulebook) => rulebook.slug)); + + const existingProjectMd = await readFileOrEmpty(projectMdPath); + const neutralOrphans = (await listNeutralSlugs(neutralDir)).filter((slug) => !declaredSet.has(slug)); + const inlineOrphans = extractInstalledSlugs(existingProjectMd).filter((slug) => !desiredAmbient.has(slug)); if (options.dryRun) { - reportDryRun(resolved, orphans); + reportDryRun(resolved, [...new Set([...neutralOrphans, ...inlineOrphans])]); return; } @@ -70,10 +71,12 @@ export async function syncCommand( projectMd = injectRulebook(projectMd, rulebook.slug, rulebook.body); } } + for (const slug of inlineOrphans) { + projectMd = removeRulebook(projectMd, slug); + } // `.agents/rulebooks/` is sync-owned, so deleting an undeclared neutral file here is safe, not user data loss. - for (const slug of orphans) { - projectMd = removeRulebook(projectMd, slug); + for (const slug of neutralOrphans) { await rm(path.join(neutralDir, `${slug}.md`), { force: true }); } @@ -82,7 +85,7 @@ export async function syncCommand( await writeFile(projectMdPath, projectMd, 'utf8'); } - console.info(`Synced ${resolved.length} rulebook(s); retracted ${orphans.length}.`); + console.info(`Synced ${resolved.length} rulebook(s); retracted ${neutralOrphans.length} file(s).`); } /** Reads a rulebook from the library, validates its frontmatter, and returns its neutral body and delivery. */ @@ -137,13 +140,13 @@ async function writeIfChanged(filePath: string, content: string): Promise } /** Prints the writes and retractions a real run would perform. */ -function reportDryRun(resolved: ReadonlyArray, orphans: ReadonlyArray): void { +function reportDryRun(resolved: ReadonlyArray, retracted: ReadonlyArray): void { console.info('[dry-run] sync would:'); for (const rulebook of resolved) { const inline = rulebook.ambient ? ' (+ inline into PROJECT.md)' : ''; console.info(` write .agents/rulebooks/${rulebook.slug}.md${inline}`); } - for (const slug of orphans) { - console.info(` retract ${slug} (remove neutral file and PROJECT.md block)`); + for (const slug of retracted) { + console.info(` retract ${slug} (no longer declared, or no longer ambient)`); } } From 9a0c72774f6557626567402acee78413b3f28da1 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sat, 6 Jun 2026 01:49:11 -0700 Subject: [PATCH 4/8] agents|fix: Throw on a non-mapping rulebooks.yaml top level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `rulebooks.yaml` whose top level is not a mapping — a bare list or stray scalar, e.g. forgetting the `rulebooks:` key — now fails with a clear error instead of being read as an empty declaration that silently retracts every installed rulebook. --- .../src/lib/__tests__/rulebooks-manifest.test.ts | 10 ++++++++++ packages/agents/src/lib/rulebooks-manifest.ts | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/agents/src/lib/__tests__/rulebooks-manifest.test.ts b/packages/agents/src/lib/__tests__/rulebooks-manifest.test.ts index 136f5640..e7399dcd 100644 --- a/packages/agents/src/lib/__tests__/rulebooks-manifest.test.ts +++ b/packages/agents/src/lib/__tests__/rulebooks-manifest.test.ts @@ -68,4 +68,14 @@ describe(readRulebooksManifest, () => { await writeManifest('rulebooks: not-a-list\n'); await expect(readRulebooksManifest(projectRoot)).rejects.toThrow(/list/i); }); + + it('when the file is comment-only, returns an empty array', async () => { + await writeManifest('# just a comment, no declarations yet\n'); + expect(await readRulebooksManifest(projectRoot)).toEqual([]); + }); + + it('throws when the top level is a bare list instead of a rulebooks mapping', async () => { + await writeManifest('- shell-conventions\n- typescript\n'); + await expect(readRulebooksManifest(projectRoot)).rejects.toThrow(/mapping|rulebooks/i); + }); }); diff --git a/packages/agents/src/lib/rulebooks-manifest.ts b/packages/agents/src/lib/rulebooks-manifest.ts index c9715357..d6012f7b 100644 --- a/packages/agents/src/lib/rulebooks-manifest.ts +++ b/packages/agents/src/lib/rulebooks-manifest.ts @@ -24,9 +24,15 @@ export async function readRulebooksManifest(projectRoot: string): Promise Date: Sat, 6 Jun 2026 01:49:12 -0700 Subject: [PATCH 5/8] agents|fix: Drop leading blank when removing a block-initial rulebook Removing the first rulebook block from a `.agents/PROJECT.md` that has no hand-authored header no longer leaves a stray leading blank line. --- .../agents/src/lib/__tests__/sentinel-inliner.test.ts | 9 +++++++++ packages/agents/src/lib/sentinel-inliner.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/agents/src/lib/__tests__/sentinel-inliner.test.ts b/packages/agents/src/lib/__tests__/sentinel-inliner.test.ts index a01b4324..dc65508c 100644 --- a/packages/agents/src/lib/__tests__/sentinel-inliner.test.ts +++ b/packages/agents/src/lib/__tests__/sentinel-inliner.test.ts @@ -80,6 +80,15 @@ describe(removeRulebook, () => { const only = injectRulebook('', 'shell', 'Body text'); expect(removeRulebook(only, 'shell')).toBe(''); }); + + it('when removing the first of two blocks in a header-less document, leaves no leading blank line', () => { + const withBoth = injectRulebook(injectRulebook('', 'alpha', 'A body'), 'beta', 'B body'); + + const afterRemoval = removeRulebook(withBoth, 'alpha'); + + expect(afterRemoval.startsWith('\n')).toBe(false); + expect(extractInstalledSlugs(afterRemoval)).toEqual(['beta']); + }); }); describe(extractInstalledSlugs, () => { diff --git a/packages/agents/src/lib/sentinel-inliner.ts b/packages/agents/src/lib/sentinel-inliner.ts index 0cb2ad35..2bb0bdc8 100644 --- a/packages/agents/src/lib/sentinel-inliner.ts +++ b/packages/agents/src/lib/sentinel-inliner.ts @@ -49,7 +49,7 @@ export function removeRulebook(content: string, slug: string): string { return content.replace(withLeadingSeparator, ''); } - const atStart = new RegExp(String.raw`^${block}\n?`); + const atStart = new RegExp(String.raw`^${block}\n*`); if (atStart.test(content)) { return content.replace(atStart, ''); } From 45d33c3aa5ec6df2fa32c09db9ffa20467e65cf2 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sat, 6 Jun 2026 01:49:24 -0700 Subject: [PATCH 6/8] agents|docs: Note that unknown rulebook frontmatter keys are dropped Clarifies that unknown keys in a rulebook's frontmatter are accepted but dropped, not preserved on the parsed result, so a later reader doesn't expect classification metadata to survive parsing. --- packages/agents/src/lib/rulebook-schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agents/src/lib/rulebook-schema.ts b/packages/agents/src/lib/rulebook-schema.ts index 51e4e99a..e0cc861c 100644 --- a/packages/agents/src/lib/rulebook-schema.ts +++ b/packages/agents/src/lib/rulebook-schema.ts @@ -5,8 +5,8 @@ import { parseFrontmatter } from './frontmatter-merger.ts'; /** * Frontmatter schema for a rulebook source file. The operational fields drive the resolver; unknown keys - * (e.g. future classification metadata) are tolerated rather than rejected. `delivery` is normalized to an - * array, and `version` is treated as an opaque string, never parsed as semver. + * (e.g. future classification metadata) are accepted but dropped, not preserved on the parsed object. + * `delivery` is normalized to an array, and `version` is treated as an opaque string, never parsed as semver. */ export const RulebookFrontmatterSchema = z.object({ slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'slug must be lowercase kebab-case (e.g. shell-conventions)'), From 53b2d9a5223b84ec7fc0a6e5b6bc35e6fc88afee Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sat, 6 Jun 2026 01:49:25 -0700 Subject: [PATCH 7/8] agents|refactor: Normalize cli.ts import specifiers to .ts The agents CLI entry module now uses honest `.ts` import specifiers throughout instead of mixing `.js` and `.ts`. The `--help` output also lists `init` on the `--dry-run` line alongside `install` and `sync`. --- packages/agents/src/cli.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/agents/src/cli.ts b/packages/agents/src/cli.ts index 2aaa5f20..3b7adfb7 100644 --- a/packages/agents/src/cli.ts +++ b/packages/agents/src/cli.ts @@ -2,13 +2,13 @@ /* eslint unicorn/no-process-exit: off */ import process from 'node:process'; -import { generateLabelMap, printGenerateUsage } from './commands/generate-label-map.js'; +import { generateLabelMap, printGenerateUsage } from './commands/generate-label-map.ts'; import { initCommand } from './commands/init.ts'; -import { installCommand } from './commands/install.js'; -import { statusCommand } from './commands/status.js'; +import { installCommand } from './commands/install.ts'; +import { statusCommand } from './commands/status.ts'; import { syncCommand } from './commands/sync.ts'; -import { uninstallCommand } from './commands/uninstall.js'; -import type { InstallOptions, PlatformId } from './lib/types.js'; +import { uninstallCommand } from './commands/uninstall.ts'; +import type { InstallOptions, PlatformId } from './lib/types.ts'; const VALID_PLATFORM_IDS = new Set(['claude', 'rovodev', 'all']); @@ -124,7 +124,7 @@ Options: --platform Target platform: claude, rovodev, or all (default: all) --link Use symlinks instead of copies (install only) --force Overwrite modified files (install/uninstall) - --dry-run Show what would be done without making changes (install, sync) + --dry-run Show what would be done without making changes (install, sync, init) --help, -h Show this help message`); } From c321b4235d6da71c5084d84878301edae5dc2789 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sat, 6 Jun 2026 01:57:39 -0700 Subject: [PATCH 8/8] agents|refactor: Sort functions alphabetically --- packages/agents/src/cli.ts | 158 +++++++++--------- packages/agents/src/commands/sync.ts | 56 ++++--- .../lib/__tests__/sentinel-inliner.test.ts | 30 ++-- packages/agents/src/lib/sentinel-inliner.ts | 42 ++--- 4 files changed, 148 insertions(+), 138 deletions(-) diff --git a/packages/agents/src/cli.ts b/packages/agents/src/cli.ts index 3b7adfb7..bd5f1ee1 100644 --- a/packages/agents/src/cli.ts +++ b/packages/agents/src/cli.ts @@ -12,41 +12,65 @@ import type { InstallOptions, PlatformId } from './lib/types.ts'; const VALID_PLATFORM_IDS = new Set(['claude', 'rovodev', 'all']); -function isValidPlatform(value: string): value is PlatformId | 'all' { - return VALID_PLATFORM_IDS.has(value); -} +/** + * Main CLI entry point. + */ +async function main(): Promise { + const { command, subcommand, options, help } = parseArgs(process.argv); -function parsePlatformArg( - args: ReadonlyArray, - index: number, -): { platform: PlatformId | 'all'; nextIndex: number } { - const nextArg = args[index + 1]; - if (!nextArg || nextArg.startsWith('--')) { - console.error('Error: --platform requires a value (claude, rovodev, or all)'); - process.exit(1); + if (help || !command) { + printUsage(); + process.exit(help ? 0 : 1); } - if (!isValidPlatform(nextArg)) { - console.error(`Error: Invalid platform "${nextArg}". Valid options: claude, rovodev, all`); + + try { + switch (command) { + case 'install': + await installCommand(options); + break; + case 'init': + await initCommand(options); + break; + case 'sync': + await syncCommand(options); + break; + case 'uninstall': + await uninstallCommand({ platform: options.platform, force: options.force }); + break; + case 'status': + await statusCommand({ platform: options.platform }); + break; + case 'generate': + if (subcommand === 'label-map') { + await generateLabelMap({ force: options.force }); + } else { + if (subcommand) console.error(`Error: Unknown generate target "${subcommand}"`); + printGenerateUsage(); + process.exit(1); + } + break; + default: + console.error(`Error: Unknown command "${command}"`); + printUsage(); + process.exit(1); + } + } catch (error) { + if (error instanceof Error) { + console.error(`Error: ${error.message}`); + } else { + console.error('An unexpected error occurred'); + } process.exit(1); } - return { platform: nextArg, nextIndex: index + 1 }; } -function parseFlag(arg: string): 'help' | 'link' | 'force' | 'dry-run' | 'platform' | null { - const flags: Record = { - '--help': 'help', - '-h': 'help', - '--link': 'link', - '--force': 'force', - '--dry-run': 'dry-run', - '--platform': 'platform', - }; - return flags[arg] ?? null; +// region | Helpers + +function isValidPlatform(value: string): value is PlatformId | 'all' { + return VALID_PLATFORM_IDS.has(value); } -/** - * Parses CLI arguments into a structured options object. - */ +/** Parses CLI arguments into a structured options object. */ function parseArgs(argv: ReadonlyArray): { command: string; subcommand: string; @@ -106,6 +130,34 @@ function parseArgs(argv: ReadonlyArray): { }; } +function parseFlag(arg: string): 'help' | 'link' | 'force' | 'dry-run' | 'platform' | null { + const flags: Record = { + '--help': 'help', + '-h': 'help', + '--link': 'link', + '--force': 'force', + '--dry-run': 'dry-run', + '--platform': 'platform', + }; + return flags[arg] ?? null; +} + +function parsePlatformArg( + args: ReadonlyArray, + index: number, +): { platform: PlatformId | 'all'; nextIndex: number } { + const nextArg = args[index + 1]; + if (!nextArg || nextArg.startsWith('--')) { + console.error('Error: --platform requires a value (claude, rovodev, or all)'); + process.exit(1); + } + if (!isValidPlatform(nextArg)) { + console.error(`Error: Invalid platform "${nextArg}". Valid options: claude, rovodev, all`); + process.exit(1); + } + return { platform: nextArg, nextIndex: index + 1 }; +} + /** * Prints usage information to stdout. */ @@ -128,56 +180,6 @@ Options: --help, -h Show this help message`); } -/** - * Main CLI entry point. - */ -async function main(): Promise { - const { command, subcommand, options, help } = parseArgs(process.argv); - - if (help || !command) { - printUsage(); - process.exit(help ? 0 : 1); - } - - try { - switch (command) { - case 'install': - await installCommand(options); - break; - case 'init': - await initCommand(options); - break; - case 'sync': - await syncCommand(options); - break; - case 'uninstall': - await uninstallCommand({ platform: options.platform, force: options.force }); - break; - case 'status': - await statusCommand({ platform: options.platform }); - break; - case 'generate': - if (subcommand === 'label-map') { - await generateLabelMap({ force: options.force }); - } else { - if (subcommand) console.error(`Error: Unknown generate target "${subcommand}"`); - printGenerateUsage(); - process.exit(1); - } - break; - default: - console.error(`Error: Unknown command "${command}"`); - printUsage(); - process.exit(1); - } - } catch (error) { - if (error instanceof Error) { - console.error(`Error: ${error.message}`); - } else { - console.error('An unexpected error occurred'); - } - process.exit(1); - } -} +// endregion | Helpers await main(); diff --git a/packages/agents/src/commands/sync.ts b/packages/agents/src/commands/sync.ts index 2c8a0d5c..c6287104 100644 --- a/packages/agents/src/commands/sync.ts +++ b/packages/agents/src/commands/sync.ts @@ -88,21 +88,20 @@ export async function syncCommand( console.info(`Synced ${resolved.length} rulebook(s); retracted ${neutralOrphans.length} file(s).`); } -/** Reads a rulebook from the library, validates its frontmatter, and returns its neutral body and delivery. */ -async function resolveRulebook(slug: string, librarySrcDir: string): Promise { - const srcPath = path.join(librarySrcDir, `${slug}.md`); - let content: string; +// region | Helpers + +/** Lists the slugs of materialized neutral files, returning an empty list when the directory is absent. */ +async function listNeutralSlugs(neutralDir: string): Promise> { + let entries: ReadonlyArray; try { - content = await readFile(srcPath, 'utf8'); + entries = await readdir(neutralDir); } catch (error: unknown) { if (isEnoent(error)) { - throw new Error(`Declared rulebook "${slug}" was not found in the library at ${srcPath}`); + return []; } throw error; } - - const { rulebook, body } = parseRulebookFile(content, `${slug}.md`); - return { slug, body: `${body.trim()}\n`, ambient: rulebook.delivery.includes('ambient') }; + return entries.filter((entry) => entry.endsWith('.md')).map((entry) => entry.slice(0, -'.md'.length)); } /** Reads a file, returning an empty string when it does not exist. */ @@ -117,18 +116,33 @@ async function readFileOrEmpty(filePath: string): Promise { } } -/** Lists the slugs of materialized neutral files, returning an empty list when the directory is absent. */ -async function listNeutralSlugs(neutralDir: string): Promise> { - let entries: ReadonlyArray; +/** Prints the writes and retractions a real run would perform. */ +function reportDryRun(resolved: ReadonlyArray, retracted: ReadonlyArray): void { + console.info('[dry-run] sync would:'); + for (const rulebook of resolved) { + const inline = rulebook.ambient ? ' (+ inline into PROJECT.md)' : ''; + console.info(` write .agents/rulebooks/${rulebook.slug}.md${inline}`); + } + for (const slug of retracted) { + console.info(` retract ${slug} (no longer declared, or no longer ambient)`); + } +} + +/** Reads a rulebook from the library, validates its frontmatter, and returns its neutral body and delivery. */ +async function resolveRulebook(slug: string, librarySrcDir: string): Promise { + const srcPath = path.join(librarySrcDir, `${slug}.md`); + let content: string; try { - entries = await readdir(neutralDir); + content = await readFile(srcPath, 'utf8'); } catch (error: unknown) { if (isEnoent(error)) { - return []; + throw new Error(`Declared rulebook "${slug}" was not found in the library at ${srcPath}`); } throw error; } - return entries.filter((entry) => entry.endsWith('.md')).map((entry) => entry.slice(0, -'.md'.length)); + + const { rulebook, body } = parseRulebookFile(content, `${slug}.md`); + return { slug, body: `${body.trim()}\n`, ambient: rulebook.delivery.includes('ambient') }; } /** Writes `content` to `filePath` only when it differs from the current contents, keeping re-runs diff-free. */ @@ -139,14 +153,4 @@ async function writeIfChanged(filePath: string, content: string): Promise await writeFile(filePath, content, 'utf8'); } -/** Prints the writes and retractions a real run would perform. */ -function reportDryRun(resolved: ReadonlyArray, retracted: ReadonlyArray): void { - console.info('[dry-run] sync would:'); - for (const rulebook of resolved) { - const inline = rulebook.ambient ? ' (+ inline into PROJECT.md)' : ''; - console.info(` write .agents/rulebooks/${rulebook.slug}.md${inline}`); - } - for (const slug of retracted) { - console.info(` retract ${slug} (no longer declared, or no longer ambient)`); - } -} +// endregion | Helpers diff --git a/packages/agents/src/lib/__tests__/sentinel-inliner.test.ts b/packages/agents/src/lib/__tests__/sentinel-inliner.test.ts index dc65508c..e9a190ba 100644 --- a/packages/agents/src/lib/__tests__/sentinel-inliner.test.ts +++ b/packages/agents/src/lib/__tests__/sentinel-inliner.test.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest'; import { extractInstalledSlugs, injectRulebook, removeRulebook } from '../sentinel-inliner.ts'; +describe(extractInstalledSlugs, () => { + it('returns slugs that have a complete marker pair, in document order', () => { + const withBoth = injectRulebook(injectRulebook('', 'alpha', 'A'), 'beta', 'B'); + expect(extractInstalledSlugs(withBoth)).toEqual(['alpha', 'beta']); + }); + + it('when there are no markers, returns an empty array', () => { + expect(extractInstalledSlugs('# Title\n')).toEqual([]); + }); + + it('ignores an unpaired open marker', () => { + expect(extractInstalledSlugs('\nBody text\n')).toEqual([]); + }); +}); + describe(injectRulebook, () => { it('when content is empty, returns the block with a trailing newline', () => { expect(injectRulebook('', 'shell', 'Body text')).toBe( @@ -90,18 +105,3 @@ describe(removeRulebook, () => { expect(extractInstalledSlugs(afterRemoval)).toEqual(['beta']); }); }); - -describe(extractInstalledSlugs, () => { - it('returns slugs that have a complete marker pair, in document order', () => { - const withBoth = injectRulebook(injectRulebook('', 'alpha', 'A'), 'beta', 'B'); - expect(extractInstalledSlugs(withBoth)).toEqual(['alpha', 'beta']); - }); - - it('when there are no markers, returns an empty array', () => { - expect(extractInstalledSlugs('# Title\n')).toEqual([]); - }); - - it('ignores an unpaired open marker', () => { - expect(extractInstalledSlugs('\nBody text\n')).toEqual([]); - }); -}); diff --git a/packages/agents/src/lib/sentinel-inliner.ts b/packages/agents/src/lib/sentinel-inliner.ts index 2bb0bdc8..5ee5e775 100644 --- a/packages/agents/src/lib/sentinel-inliner.ts +++ b/packages/agents/src/lib/sentinel-inliner.ts @@ -4,12 +4,17 @@ * Every function is a pure string transform with no filesystem access. */ -function openMarker(slug: string): string { - return ``; -} - -function closeMarker(slug: string): string { - return ``; +/** Returns the slugs whose blocks have a complete open/close marker pair, in document order. */ +export function extractInstalledSlugs(content: string): ReadonlyArray { + const pattern = /[\s\S]*?/g; + const slugs: Array = []; + for (const match of content.matchAll(pattern)) { + const slug = match[1]; + if (slug !== undefined) { + slugs.push(slug); + } + } + return slugs; } /** @@ -57,19 +62,6 @@ export function removeRulebook(content: string, slug: string): string { return content.replace(new RegExp(block), ''); } -/** Returns the slugs whose blocks have a complete open/close marker pair, in document order. */ -export function extractInstalledSlugs(content: string): ReadonlyArray { - const pattern = /[\s\S]*?/g; - const slugs: Array = []; - for (const match of content.matchAll(pattern)) { - const slug = match[1]; - if (slug !== undefined) { - slugs.push(slug); - } - } - return slugs; -} - /** Renders the canonical block for a slug: open marker, trimmed body, close marker. */ function renderBlock(slug: string, body: string): string { return `${openMarker(slug)}\n${body.trim()}\n${closeMarker(slug)}`; @@ -89,3 +81,15 @@ function blockPattern(slug: string): RegExp { function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); } + +// region | Helpers + +function closeMarker(slug: string): string { + return ``; +} + +function openMarker(slug: string): string { + return ``; +} + +// endregion | Helpers