All articles

Agent profiles, part two: config is a build artefact

Rules, skills and hooks for eight agent profiles come from one source repository and a deterministic renderer, with a receipt proving which commit is actually running.

RevisedAdds the CodeRabbit review gate that stands in for a pull request, and the precedence rule for a third-party ruleset that contradicts my own.

The previous part covered why I run eight isolated agent homes rather than one shared config. Isolation solves the wrong-profile problem, but it multiplies every rule file, skill tree and hook by eight. None of it is hand-edited in place. It is rendered from one neutral source repository, and the render produces a receipt that answers a question that is otherwise just a belief: is this profile actually running the policy I wrote, or the one I wrote three commits ago?

The source repository

rob/agents is checked out at ~/repos/agents. No profile reads it at runtime; it only ever produces copies. The layout:

manifests/     one JSON manifest per profile, the authoritative inventory
policy/claude/ rule sources rendered into each Claude profile's rules/
policy/codex/  Codex instruction sources, assembled into a generated AGENTS.md
policy/skills/ complete skill trees rendered byte-for-byte
reference/     on-demand runbooks, copied into every manifest that declares them
templates/     hook, sanitiser, gitignore and backup templates
bin/render-profiles     deterministic renderer and installed-copy checker
bin/check-profiles      isolation and ownership validator
bin/install-launchers   idempotent launcher generator
tests/         pytest coverage for the hooks, guards and every bin/ script

Reading a manifest

A manifest is one profile’s entire inventory in a single file, which is what makes the idea concrete. Claude Personal’s names its id and home, the Forgejo repository it backs up to, and what authentication and trust mean here: restore the credentials file, and restore the table recording which project directories I have trusted.

Then the inventory proper. It lists the MCP servers this profile gets, an observability block naming an endpoint and a tenant id, the eighteen policy rule filenames in load order, the fifteen references it may pull in on demand, settings_overrides such as pinning the subagent model to a cheaper tier, and policy_skills, whole skill trees this profile gets and another does not. If you ever publish yours, redact the observability block. It identifies an account.

There is also a settings_comments field the renderer carries into the target but the runtime itself never reads: a place to write down why a setting exists. One entry there explains why an experimental agent-teams flag is kept deliberately. That field only exists because “why is this set” is a question you will ask yourself in six months and not remember answering.

One source, eight correct copies

A policy source can write {{HOME}} instead of a hard-coded path, and the renderer substitutes each profile’s real home at render time. That single substitution is what turns one rule file into eight copies with eight different, correct absolute paths, rather than eight near-identical files that drift the moment one of them is edited by hand.

Every render also writes .agent-policy-render.json into the target home, recording the source commit, a batch id, the source manifest, and a SHA-256 for every generated file. That receipt is the difference between “I’m fairly sure this profile has the latest rules” and being able to check it. A profile’s backup hook reads that stamp at session start and compares its recorded commit against rob/agents HEAD; if they differ, it prints how many commits behind the profile is, because a skipped render means every rule written since then is silently not running.

Publication has an order, and it is not atomic

Publishing a change is a fixed sequence, not a single command: edit and validate the source, commit and push it, render from the committed source into a temporary directory, copy into each home, commit and push each target with its own hook, reassert 0700 on every home root, then run the parity and isolation checks. A partial publication is reported as partial. It is never described as atomic, because it is not one.

Two gotchas from that sequence are worth carrying into any similar setup:

  • Copying with rsync -a from a source path with a trailing slash propagates the temporary tree’s default 0755 onto the profile root, silently downgrading it from 0700. The fix is to copy contents without propagating source permissions and reassert the mode afterwards, rather than trusting the copy to preserve it.
  • Never make a fix directly in a target profile and back-port it into the source later. The next render simply discards whatever you changed in the target, because the target is not where truth lives.
One source repository rendering into eight profile homes, the render receipt below it, a feedback path returning to the source, and the three checkers along the bottom.
One source, one substitution, eight correct copies, and a receipt recording the commit that produced them. The line returning along the bottom is what prints “N commits behind” at session start.

Why rules read like instructions, not explanations

Claude Code loads every file in <config-dir>/rules/ automatically and re-sends all of it on every turn of every session. That cost shapes how the rules are written: imperative, no changelog, no justification unless the justification would actually change what you do next turn. “Use --append-notes; the bare flag silently replaces the section” earns its place. Three paragraphs on how that was discovered does not.

Anything useful that only matters for some tasks goes into reference/ instead, a directory sitting alongside rules/ and never inside it. rules/ is loaded recursively, so a reference file nested in there would be re-sent every turn exactly like a rule, which defeats the point of separating them at all. Each rule ends with a pointer naming a specific trigger for reading its reference file: not “more on this topic”, but “before onboarding a new repo” or “before diagnosing a stalled sync”. A reference file nobody is pointed at is just dead weight.

The same reasoning decides when something becomes a skill instead of a rule or a reference: if it is mostly a lookup table or a runbook that only matters at the point of use, a skill’s one-line description stays in context permanently while its full body loads only when invoked. A rule stating a standing constraint stays a rule. A rule that is secretly a checklist for one specific task becomes a skill.

There is a matching pattern for memory: an always-loaded MEMORY.md index of one-line pointers, with each memory as a separate on-demand file carrying exactly one fact and its own frontmatter. Terseness pays off in the index, where every line is sent on every turn; accuracy pays off in the individual files, which are read only when the index sends you there.

A third-party ruleset you do not control needs a rule that outranks it

Not everything loaded into a session is rendered from my own source. Every profile arms a third-party ruleset at session start from a flag file, and its text is injected verbatim and updates itself upstream without asking. It is useful: it shapes output for how I actually read, lead with the action, number the steps, no preamble, no closing pleasantries, matter-of-fact tone on errors.

It also restates instructions already loaded, and in three places it contradicts them. It asks for the state to be restated every turn, which is written for a reader executing steps by hand and is wrong when the agent is doing the work itself and a task list already carries that state. It caps lists at five items, which is right for a set of options and destroys a review, an audit or a per-host table where the whole point was enumerating everything. And it asks for a specific time estimate, which is fine when you can name what the number rests on and an invented assertion when you cannot.

The fix is not to fork it or edit the installed copy, because the next upstream update discards whatever you changed and does so silently. It is a rule of my own that names the imported ruleset, states which of its instructions lose, and says why in one clause each. Ranking beats patching for anything you do not own: a patch has to be reapplied forever and fails quietly, whereas a precedence rule keeps working across every update because it never touched the thing it outranks.

The general version applies to any vendor-supplied instruction set, skill or plugin that ships its own opinions into your context. Assume you cannot change it, assume it will change under you, and write down what wins.

The hooks

Each profile home carries its own real, rendered copies of a handful of hooks. backlog-guard denies the bare --notes, --plan and --final-summary flags on the tracker CLI, because the bare form silently replaces a section where the --append-* variant adds to it, a mistake that is easy to make once a month and never notice you made. staging-guard stops a blanket git add or git commit sweeping another session’s staged work into an unrelated commit, and it had to be widened once: naming explicit paths on the add bounds what you stage and does nothing whatever to bound what a bare git commit afterwards takes, which is the whole index including whatever somebody else left in it. bash_command_validator nudges towards better tools. path-guard keeps reads and edits inside the boundaries a profile is allowed to touch.

The backup hook is the one I would actually read end to end if I were copying any of this. It self-locates: the profile’s home comes from BASH_SOURCE, and the profile id from the directory name, so one script serves eight homes with no per-profile edit. The chat repository it is allowed to touch is a fixed case statement keyed on that id, so Claude Personal can only ever reach the one repository it owns. It takes a lock with mkdir, which is atomic and needs no separate lockfile. It refuses to act unless the current branch is main, and it aborts an incomplete merge, cherry-pick or revert before doing anything else, but it never touches an in-progress rebase; that needs a human, on purpose. Session start kicks off an asynchronous pull; stop and end sanitise state, snapshot, then push. After every cycle it reasserts 0700 on the home and 0600 on the sensitive files, because the runtime itself rewrites some of them.

Two bits of that hook do the same job. A merge conflict used to abort silently and report success, which meant a profile could stop syncing in both directions with nothing anywhere to show for it. Now a conflict writes a marker file recording the timestamp, direction and the conflicting files, and the very next session start prints that the sync is broken and will not clear itself. And the policy-drift warning described above does the same thing for a skipped render: it does not fail loudly at render time, because there is no render happening to fail; it surfaces the drift the next time you actually sit down to work.

The review gate that replaces the pull request

One rule in that set is essential in a way the others are not, because it is the only thing standing where a code review would normally stand.

On my own repositories an agent commits finished work straight to main and pushes it. There is no pull request, no branch, and therefore no second pair of eyes by default. That is a deliberate trade: the ceremony of a PR flow is worth very little when the reviewer and the author are the same person, and it is worth actively negative when it delays a fix by a day. What it does remove is the one moment where something else reads the change before it lands, so something else has to read it somewhere.

That something is CodeRabbit, run from the CLI before the commit rather than after it:

coderabbit review --agent                       # findings as one JSON object per line
coderabbit review --agent --base main           # when the work is on a branch
coderabbit review --agent --include-untracked   # or new files are invisible

It runs whenever the change touches anything with branching in it: application logic, scripts, workflows, infrastructure as code, exporters. It is skipped for documentation, comments, changelogs, declarative configuration, dependency bumps and pure wiring, and the skip is stated out loud rather than left as an absence, because a review that reads a README is spend with nothing on the other side of it. The one qualification I would put on that list is that “declarative” is a statement about syntax and not about blast radius: a redirect table, a headers file, a permission policy or a lockfile is declarative and still decides who can reach what, so those get read. The list is a default for proportionality, not a category that buys an exemption. It is never run on the backup repositories, which commit live credentials on purpose and are hook-committed on every session stop, so a review step there would fire constantly against exactly the content it must never see.

Three things about it are not what you would guess from the documentation, and each of them costs a real review to learn.

The severity enum is lowercase and it runs critical, major, minor, trivial, info. There is no “Warning” and no capitalised “Critical”, so a filter written against the names in the documentation matches nothing at all and reports a clean review. Triage then goes on impact rather than on the label: critical and major are fixed before the commit with no judgement involved, and everything below is decided against what the change actually does, because a trivial naming complaint in a hot path or on a public interface matters more than a major one in a throwaway script. Every finding gets read before it gets dismissed, and the ones left alone get said out loud along with why.

The exit code proves that the process ran, not that the code is clean. It exits zero whether it found twelve problems or none, so pass and fail come from the findings. A non-zero exit means something else broke, authentication or network or a rate limit, and a run that produced no completion event is a failed review rather than a clean one.

And a large review does not fail, it disconnects. Past roughly a hundred changed files the CLI drops its socket during the connect phase, emits a connection error and analyses nothing. coderabbit doctor passes all nine of its checks while that is happening, including the one that probes WebSocket reachability, and a one-file review against the same account and service completes in the same minute, so neither of those is evidence the service is down. Retrying reproduces it exactly. The fix is to split the review along the line the rule already draws: generated artefacts, goldens and documentation are outside the gate anyway and are usually most of the bytes, so the source files get reviewed in a scratch worktree with the excluded paths restored to their base versions, split again by package if that is still too big, and every changed source file has to turn up in some completed review. The one cost of slicing is a false positive shaped like a real finding, because a finding raised against a file you excluded was written by a reviewer reading a tree where that file was never updated.

Two more small ones. Untracked files are invisible unless they are staged or --include-untracked is passed, which means a review of a change that is mostly new files can honestly report nothing. And a repository that was never onboarded under an organisation you can reach drops to the free allowance, says so in a status event, and then completes the review anyway, so a review that finished is not necessarily a review that used your plan. Attribution follows the git remote rather than the active organisation.

The pull request flow beside the flow that replaced it, the gate deciding what gets reviewed, the five-cell severity triage strip, and the three traps.
The review moves to before the commit rather than after it. The three traps along the bottom are the ones that make a review look like it happened when it did not.

The doctor family

Three separate checkers answer three different questions, deliberately not combined into one. bin/render-profiles --check-root answers parity: does every installed file match what the current source would render right now, and are there orphaned files sitting inside a skill tree the source no longer produces? bin/check-profiles answers isolation: correct remotes, correct file modes, no forbidden default home, no cross-profile reference, the right MCP and skill inventory, launchers present, no unfinished git operation. It is written dependency-free and runs on Apple’s own system Python, invoked directly as /usr/bin/python3, so a fleet-wide check never depends on whatever happens to be first on a shell’s PATH that day. The third checker lives in a separate repository and answers a documentation question rather than a config one; it is covered in the next part.

All of it, hooks and checkers alike, comes back to one thing. The failure you have to design for is the silent one. A loud failure gets fixed the moment it happens, because you are standing right there when it does. A sync that stops without saying so, a render that falls behind, a permission that drifts back to something looser: none of those interrupt you, so something has to go and look on your behalf.

Part three covers how all of this actually gets backed up, a homelab knowledge repository built on the same idea, and the tooling that distributes one shared document across every board that consumes it. Part four is the campaign model those boards carry.