Repository rules and docs, delivered to your coding agent. Injects Cursor-style rules into Claude Code sessions by prompt keyword, edited path, or alwaysApply…

Repository rules and docs, delivered to your coding agent.
Rule Fairy is a plugin for Claude Code and Codex. It reads the rules a repository already keeps for Cursor (.cursor/rules/**/*.mdc, or any directories you configure) and injects the relevant ones into the agent's context: when a prompt mentions a rule's keywords, when the agent edits a file the rule's globs match, or on the first prompt for rules marked alwaysApply. Rules can import sections of your documentation by heading, and injected rules come back after the context is compacted. The same matcher powers a review of a diff or a plan against the rules that apply to it.
Clojure/conj 2026 talk: Just-in-Time Context for Agents (slides, PDF).
Claude Code has .claude/rules/ with paths: globs, Codex has AGENTS.md, Cursor has .cursor/rules/. Rule Fairy exists for what they leave out:
AGENTS.md, CLAUDE.md and the rules.promptAnyOf, promptRequires), not only when a file is touched.@doc/guide.md#section pulls one section of a larger document into a rule; overlapping imports merge and <!-- agent-context: omit --> blocks stay out. Native imports take whole files. The rendered rule keeps a line where the import stood, naming the reference, because the section itself is delivered in the Required documentation blocks, possibly in another shard./compact, Claude Code re-injects only the root CLAUDE.md, and path-scoped rules return only when a matching file is read again. Rule Fairy reads what the conversation still holds and delivers a matched rule again once a compaction has emptied it; on Codex it counts compactions per session.Babashka (bb) must be on the PATH of the process that runs your agent. The hooks look in ~/.local/bin, /opt/homebrew/bin and /usr/local/bin as well, for agents launched from an editor or app with a minimal PATH.
Claude Code 2.1.287 or later, from GitHub or from a local checkout. The Claude Code side of the plugin is a mod, a module Claude Code loads in its own process; an older Claude Code loads no hooks from it and injects nothing, while the review skills still work. Rule Fairy 0.2.3 is the last version with the older settings hooks.
claude plugin marketplace add Cyrik/rule-fairy # or: claude plugin marketplace add ./rule-fairy
claude plugin install rule-fairy@rule-fairy
Codex:
codex plugin marketplace add Cyrik/rule-fairy
codex plugin add rule-fairy@rule-fairy
Running sessions pick up hooks on restart; a Claude Code session also picks up an install or update made from the shell after /reload-plugins. If the repository still registers the older project-local hooks in .claude/settings.json or .codex/hooks.json, remove them so rules are not injected twice.
The review skills are /rule-fairy:review and /rule-fairy:review-plan in Claude Code and $review and $review-plan in Codex. Both also trigger from a plain request such as "review this PR against our rules" or "check this plan against the project conventions". They need bb; reviewing a pull request also needs the GitHub CLI (gh), logged in.
prompt.submit, Codex UserPromptSubmit): selects every alwaysApply rule plus every rule whose promptAnyOf terms appear in the prompt (all promptRequires terms must appear too), renders them with their documentation imports, and injects them with the prompt. On Claude Code a prompt the person did not write, such as a background task's report, another session's message or another plugin's prompt, selects nothing: keyword rules match what the person asked.tool.call on Edit, Write, MultiEdit, NotebookEdit and MCP edit tools, Codex PostToolUse): matches the edited path against every rule's globs and injects the matches. On Claude Code the rules arrive with the tool's result; on Codex the injection stops the planned turn so the model considers the rules before moving on.tool.call on Bash, Codex PostToolUse on Bash): a shell command can write files no tool input names, such as a heredoc, sed -i or mv. After the command the hook asks git which files changed since the session's last shell check, matches them against the globs and injects the rules with the post-edit contract: apply them on the next pass, revise the change where it conflicts. The check advances with every shell command and starts at the first prompt. The check reads the working tree, so a commit, checkout, rebase or pull that moves HEAD brings nothing, and a file written and committed in the same command is not seen. A change stays pending until its rules have reached the agent, so a rule that fails to build or a delivery cut short is retried by the next command. On Claude Code, the injected context names the changed paths that fit a 1,000-character budget and counts the rest, so a rebase's hundreds of paths stay one line. Outside a git checkout nothing is detected. On by default for Claude Code and off for Codex; :shell-hook in rule-fairy.edn switches either (see Configuration)..rule-fairy/<harness>/ in the project and ignores itself in git.additionalContext at 2,500 tokens by default; the registration sets additionalContextLimit to 0.rule-fairy:review, both harnesses): fetches a diff (a pull request through gh, uncommitted work, or a branch against its base), builds a bundle of the instruction files, rules and documentation that apply to the changed paths with duplicates collapsed, and reviews the diff against it. On Claude Code the review runs in the read-only rule-fairy:rules-reviewer agent; on Codex the skill follows the same procedure inline. Findings cite the rule; nothing is edited, committed or posted.rule-fairy:review-plan, both harnesses): the same check before any code exists. It works out which files a plan will create or edit, from a file-list section or from the paths the text names, builds the bundle for those paths, and judges each step against it. Reruns after the plan changes..claude-plugin/plugin.json, .codex-plugin/plugin.json: the manifests. .claude-plugin/ is Claude Code's; .codex-plugin/ is Codex's, carrying the hooks registration and the listing interface, with skills/ discovered by convention. There is deliberately no portable root plugin.json: Codex CLI 0.154.0 loads no hooks at all from a package that has one, whether they are declared in the overlay, in the root file or in its extensions.com.openai block, although its docs say the overlay applies. .claude-plugin/marketplace.json makes the repository its own marketplace for both harnesses. The three files carry the same version, bumped with every push meant for installed users, because Claude Code's plugin update acts only on a version change.hooks/hooks.json, hooks/codex-hooks.json: the Claude Code hooks manifest names the mod's module; the Codex one registers commands. Every process the hooks start runs hooks/run, a small shell wrapper that finds the plugin root, hands it to the script as RULE_FAIRY_PLUGIN_ROOT, makes sure bb is on the PATH, and starts Babashka with the plugin's own bb.edn so a consuming repository's bb.edn never reaches the classpath.hooks/claude/: register.ts, the mod, one in-process hook per event that runs mod.bb once per delivery and attaches what it prints; mod.bb, the entry point, which selects the rules for a prompt, an edit or a shell command; common.bb (project root, session state, the delivery, the glob cache, shell-change detection); common_test.bb; and register.test.ts, the mod's tests under claude plugin test.hooks/codex/: common.bb, prompt.bb, post_edit.bb (edits and shell commands alike), and common_test.bb. The adapters intentionally have different delivery contracts.src/rule_fairy/rules.clj: the engine. Rules source, frontmatter parsing, glob and prompt matching, documentation imports with overlap merging, rendering, and block splitting. markdown.clj holds the line-level Markdown structure it shares with the bundle code (fences, headings, sections, units); config.clj reads rule-fairy.edn.src/rule_fairy/instructions.clj: harness instruction files for the review bundle. AGENTS.md, CLAUDE.md and relatives per touched directory, @path imports the way Claude Code follows them, block comments stripped, one copy per real file. src/rule_fairy/dedup.clj: section and paragraph deduplication across a bundle, each omission replaced by a line naming the kept copy.src/rule_fairy/session_state.clj: per-session state shared by the adapters: the shell-check marker and pending paths for both, Codex's transcript metrics, Claude Code's delivery times. src/rule_fairy/transcript.clj: incremental compaction counting with a scan cursor for Codex, and the reader that names the rules the whole deliveries among a set of frames injected, for Claude Code. src/rule_fairy/changes.clj: the files changed in a checkout since a moment, from git status plus modification and inode change times, which is how the shell hook finds what a command wrote.src/rule_fairy/bundle.clj: the review bundle. Instruction files, matching and always-on rules and their documentation for a path list, deduplicated, rendered with a header naming what went in, and written under the self-ignored .rule-fairy/review/<mode>/, plus the rules revision recorded next to it. plan.clj extracts the paths a plan intends to touch; git.clj is the small amount of git the review scripts share.skills/review/: the rule-fairy:review skill, discovered by both harnesses from this directory. SKILL.md is what the agent follows; scripts/run starts scripts/review.bb with the plugin's own bb.edn. review fetch takes a pull request through gh, the working tree against HEAD, or everything since the merge base with a branch, and writes patch.diff with a meta.json naming the code revision, the rules revision and the changed paths. The working-tree modes stage the tree into a throwaway index for one diff, so staged, unstaged and untracked changes appear once each and renames made outside git are detected. review bundle reads the patch and writes the rules bundle; review clean removes the run's artifacts. references/procedure.md is the evidence discipline the reviewer follows.skills/review-plan/: the rule-fairy:review-plan skill. review-plan paths <plan> shows which files the plan touches and which tokens were dropped; review-plan bundle <plan> [--paths ...] writes the plan bundle and its meta.json under .rule-fairy/review/plan/; review-plan clean removes them. It shares the procedure file with the review skill.agents/rules-reviewer.md: the read-only reviewer Claude Code runs for both skills. Codex plugins have no agents, so there the skills review inline.test/: engine, Markdown, instruction-file, dedup, bundle, git, plan, transcript, session-state, changes and plugin-layout suites.reference/: two rules documenting the hook machinery and the .mdc format for rule authors.Rules are read from .cursor/rules/**/*.mdc by default, so a repository that already uses Cursor rules needs nothing. An optional rule-fairy.edn at the project root adds or moves directories and extensions:
{:rules {:dirs [".cursor/rules" "docs/rules"]
:extensions [".mdc" ".md"]}}
Any file under any listed directory with any listed extension is a rule. Directories are relative to the project root; both keys are optional and anything else is rejected. Rule names are relative to their own directory, so the same name in two directories is an error. The frontmatter fields stay Cursor's (description, globs, alwaysApply) plus the extensions promptAnyOf and promptRequires. The format is documented for rule authors in reference/mdc.mdc.
The same file switches the shell hook per harness. It is on for Claude Code and off for Codex unless set:
{:shell-hook {:claude true :codex true}}
Codex makes far more shell calls than edits and rarely edits through the shell, so the check would cost every call for a rare catch; a repository where Codex does write through the shell turns it on here. A switched-off hook still starts, because the plugin's registration is fixed, but exits before any git call.
Cursor itself is assumed to keep working with these files, but it is not fully supported at the moment. Cursor ignores the two extension fields and includes @path imports whole rather than by heading; what it does with a heading import it cannot resolve, and whether saving a rule in its editor strips unknown fields, has not been checked. If you rely on Cursor, keep your rules valid for it and treat the extensions as Rule Fairy's.
The review bundle also reads harness instruction files. By default it looks for AGENTS.override.md, AGENTS.md, .claude/AGENTS.md, CLAUDE.md, .claude/CLAUDE.md and CLAUDE.local.md in the project root and in every directory above a changed file, follows their @path imports the way Claude Code does, and includes each real file once. That is the union of what either harness reads, with one exception: an AGENTS.md beside an AGENTS.override.md is left out when a CLAUDE file exists at the root or in that directory, because Codex reads only the override and Claude Code then reads only its CLAUDE files. To restrict or reorder them:
{:instructions {:files ["AGENTS.md" "CLAUDE.md"]}}
An empty vector includes no instruction files. Order matters: when two files share a section or a long paragraph, the earlier one keeps it and the later one gets a line naming where the kept copy is.
The hooks write session state and glob caches under .rule-fairy/claude/ and .rule-fairy/codex/ in the project, and review bundles go under .rule-fairy/review/<mode>/. Each of those directories carries a .gitignore ignoring its own content, so nothing needs to be added to the repository's ignore rules. The hooks also record the directory the plugin runs from in .rule-fairy/<harness>/plugin-root, one absolute path on one line, for a skill of the project that wants the plugin's review scripts (see Using the review from another skill).
Context injected by the hooks is labelled [rule-fairy injected: <rule>], [rule-fairy matched: alwaysApply ...], [rule-fairy matched: keyword ...], [rule-fairy matched: glob on <path>], [rule-fairy shard n/m <delivery>], and [rule-fairy error: ...].
A project's own review skill can run the rules pass with the plugin's scripts instead of collecting rules itself. The hooks record where the plugin runs from in .rule-fairy/<harness>/plugin-root (claude or codex), one absolute path, written on the first prompt of a session. Read it; a missing file, or one naming a directory that no longer exists, is a setup error to report, not a reason to look for the plugin elsewhere. Under that root, skills/review/scripts/run is the launcher and skills/review/references/procedure.md the procedure. From the project root:
"<root>/skills/review/scripts/run" fetch --base main # or --local, or --pr <n> [--checkout]
"<root>/skills/review/scripts/run" bundle
"<root>/skills/review/scripts/run" clean # once every pass has read the artifacts
fetch writes .rule-fairy/review/diff/patch.diff and meta.json (the code revision, the mode and, under rules, the rules revision); bundle writes rules.md beside them and completes rules. A failed command exits non-zero and leaves no new artifact: report it as a coverage failure rather than reuse an older bundle. The commands invoke no skill, post nothing and touch no file of the caller's, with one exception: --checkout checks the pull request out detached and needs the user's explicit authorisation, as the review skill says. clean removes only that directory, when the caller says so. To review, hand a reader the three files and the procedure as absolute paths: in Claude Code the rule-fairy:rules-reviewer agent with the prompt from the review skill, elsewhere a subagent that follows the procedure. It returns findings and coverage to its caller, which builds the report.
bb test
Runs fifteen Babashka suites: engine, Markdown, instruction files, dedup, bundle, git, plan, transcript, session state, Claude hooks, Codex hooks, the two skills' scripts and the plugin layout, then the mod's TypeScript tests through claude plugin test where Claude Code is installed. Every suite builds its rules, documents, and state in temporary directories; the git and review script suites build real repositories, the latter with a branch, uncommitted and untracked changes, and a fake gh on the PATH. The Claude hook suite runs mod.bb through hooks/run against a temporary project, the way the mod does, and the Codex suite runs the registered commands from hooks/codex-hooks.json through a shell, with a broken bb.edn planted in the project to prove it does not affect the hooks. The registration test reads RULE_FAIRY_SETTINGS_FILE to validate another hooks manifest. The mod's tests answer its session, process and log calls from the test, so no Babashka runs there. The skill script suites run each skill's scripts/run shim as a subprocess, the way the skill text tells an agent to. The layout suite checks that every skill and agent file carries the frontmatter both harnesses need and that the files the skill text names exist. claude plugin validate . lists the mod's hooks and the calls it makes. Loading the plugin once with claude --plugin-dir . makes Claude Code write its type declarations under .claude-plugin/types/, and bunx tsc -p tsconfig.json --noEmit then type-checks the module and its tests against them.
alwaysApply: true rules are selected by the prompt hooks on every prompt and delivered through the normal session dedup, so they arrive with the first prompt and return after compaction. Consuming repositories should not duplicate that content in CLAUDE.md or AGENTS.md..cursor/rules/**/*.mdc stays the default because Cursor requires it and Claude Code's /init already recognises it.hooks/claude/register.ts 161 lines1// The Claude Code mod of Rule Fairy: a function hook per event in place of
2// the settings hooks, so one delivery is one process and the 10,000-character
3// cap on a settings hook's output no longer applies. Each hook hands its
4// event to hooks/claude/mod.bb through hooks/run and attaches the context
5// entries that prints: after the prompt on prompt.submit, with the tool's
6// result on tool.call. With the event goes the head of every Rule Fairy
7// frame the conversation already holds, read from the Messages API form of
8// the session, so a session that inherited its context, as a fork does,
9// starts knowing what is already delivered. A subagent's tool calls deliver
10// nothing, as the settings hooks delivered nothing there, and nor does a
11// prompt another agent or process composed. A delivery that fails, its
12// process exiting non-zero or the hook itself throwing or timing out,
13// attaches one error entry naming the failure and logs a line to the
14// transcript, so the model and the person both see it, and the prompt or
15// tool goes on as if unhooked. Once a prompt has entered the session no
16// context can attach any more, so a failure after that point is logged only.
17import type { EngineInterface, HookFailure, Register } from 'claude-code'
18
19type Kind = 'prompt' | 'edit' | 'shell'
20
21const EDIT_TOOL = /^(Edit|Write|MultiEdit|NotebookEdit)$|^mcp__.*(edit|write|patch)/
22
23// Prompts the person did not write: a background task's report, another
24// session's message, a coordinator's hand-off, an observer's note or a
25// plugin's own prompt. Keyword rules match what the person asked, so these
26// deliver nothing. The settings hook had no way to tell them apart and
27// matched rules against a subagent's report.
28const COMPOSED_BY_OTHERS = new Set([
29 'task-notification',
30 'peer',
31 'peer-send-message',
32 'projects-relay',
33 'coordinator',
34 'observer',
35 'observer-activity',
36 'plugin',
37])
38
39// A frame as the model reads it: Claude Code wraps a hook's context in a
40// system reminder naming the event, and the frame's shard header and marker
41// lines follow. Only those lines are captured; the rule bodies stay behind.
42const FRAME_HEAD = /<system-reminder>\n[\w.:]+ hook additional context: (\[rule-fairy shard [^\n]*(?:\n\[rule-fairy [a-z]+: [^\n]*)*)/g
43
44// How much of a failed process's error output the entry carries: Babashka
45// prints the error's type, message and location first and the stack last.
46const STDERR_HEAD_CHARS = 1_000
47
48type Block = { type?: string; text?: string; content?: unknown }
49
50function texts(content: unknown): string[] {
51 if (typeof content === 'string') return [content]
52 if (!Array.isArray(content)) return []
53 return (content as Block[]).flatMap(block =>
54 block.type === 'tool_result' ? texts(block.content) : typeof block.text === 'string' ? [block.text] : [],
55 )
56}
57
58export function frameHeads(messages: unknown): string[] {
59 if (!Array.isArray(messages)) return []
60 return (messages as Block[]).flatMap(message =>
61 texts(message.content).flatMap(text => [...text.matchAll(FRAME_HEAD)].map(found => found[1] ?? '')),
62 )
63}
64
65// The one context entry a failed delivery attaches: the failure named the
66// way an oversized rule's entry names its reason, the head of the detail,
67// and the line that tells the model nothing was recorded.
68export function failureEntry(kind: Kind, failure: string, detail: string): string {
69 const head = detail.trim().slice(0, STDERR_HEAD_CHARS)
70 return [
71 `[rule-fairy error: ${kind} delivery ${failure}]`,
72 ...(head === '' ? [] : [head]),
73 'No matched rules were injected or marked as injected.',
74 ].join('\n')
75}
76
77function reportFailure($: EngineInterface, kind: Kind, failure: string, detail: string): string {
78 $.ui.log(`rule-fairy: ${kind} delivery ${failure}`, { to: 'transcript' })
79 return failureEntry(kind, failure, detail)
80}
81
82function hookFailure(error: HookFailure): string {
83 return error.kind === 'timeout' ? 'timed out' : 'threw'
84}
85
86async function deliver(
87 $: EngineInterface,
88 kind: Kind,
89 input: Record<string, unknown>,
90): Promise<readonly string[]> {
91 const root = $.plugin.root
92 const [sessionId, projectDir, cwd, messages] = await Promise.all([
93 $.session.id(),
94 $.session.root(),
95 $.session.cwd(),
96 $.session.messages({ as: 'api' }),
97 ])
98 const ran = await $.process.run([`${root}/hooks/run`, 'claude/mod.bb'], {
99 cwd,
100 env: { CLAUDE_PLUGIN_ROOT: root, CLAUDE_PROJECT_DIR: projectDir },
101 stdin: JSON.stringify({
102 kind,
103 input: { session_id: sessionId, cwd, in_context_frames: frameHeads(messages), ...input },
104 }),
105 timeoutMs: 60_000,
106 })
107 if (ran.exitCode !== 0) return [reportFailure($, kind, `failed (exit ${ran.exitCode})`, ran.stderr)]
108 const printed: { context?: string[] } = JSON.parse(ran.stdout)
109 return printed.context ?? []
110}
111
112function withContext<T extends { context?: readonly string[] }>(
113 carrier: T,
114 context: readonly string[],
115): T {
116 return context.length === 0
117 ? carrier
118 : { ...carrier, context: [...(carrier.context ?? []), ...context] }
119}
120
121// The tool's result with the hook failure's entry attached; a denied call
122// stays as it is.
123function withFailure<R extends { deny?: string; context?: readonly string[] }>(
124 $: EngineInterface,
125 kind: Kind,
126 result: R,
127 error: HookFailure,
128): R {
129 const entry = reportFailure($, kind, hookFailure(error), error.message ?? '')
130 return result.deny !== undefined ? result : withContext(result, [entry])
131}
132
133export const register: Register = on => {
134 on('prompt.submit', async ($, e, next) => {
135 if (COMPOSED_BY_OTHERS.has(e.origin.kind)) return next(e)
136 const context = await deliver($, 'prompt', { prompt: e.text })
137 return next(withContext(e, context))
138 }).catch(($, e, next) => {
139 const entry = reportFailure($, 'prompt', hookFailure(next.error), next.error.message ?? '')
140 return next.called ? next(e) : next(withContext(e, [entry]))
141 })
142
143 on('tool.call', { tool: EDIT_TOOL }, async ($, e, next) => {
144 const result = await next(e)
145 if (e.agentId !== undefined || result.deny !== undefined) return result
146 const { tool, tool_use_id, agentId, ...toolInput } = e
147 const context = await deliver($, 'edit', { tool_name: tool, tool_input: toolInput })
148 return withContext(result, context)
149 }).catch(async ($, e, next) => withFailure($, 'edit', await next(e), next.error))
150
151 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
152 const result = await next(e)
153 if (e.agentId !== undefined || result.deny !== undefined) return result
154 const context = await deliver($, 'shell', {
155 tool_name: 'Bash',
156 tool_input: { command: e.command },
157 })
158 return withContext(result, context)
159 }).catch(async ($, e, next) => withFailure($, 'shell', await next(e), next.error))
160}
161