Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ jobs:
- run: yarn test
- run: yarn build
- run: yarn smoke
- run: yarn hook-smoke
- run: yarn pack:smoke
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
node_modules
dist
*.log
.DS_Store
docs-tmp
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,35 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
false`; bound with `--filter-timeout` / `filterTimeoutMs` (default 10000 ms).
Filter errors surface in the new `ReviewResult.warnings`. New exports:
`makeFilterExecutor`, `FilterResult`, `FilterExecutor`, `DiscoverOptions`.
- `agent-rules-hook`: a Claude Code `PostToolUse` hook that applies the same
`.agent/rules/*.md` rules live, injecting a matching rule's body into the
agent's context whenever a Read/Write/Edit touches a file covered by its
`globs` (and `filter`) — a continuous complement to the diff-review CLI/slash
command, reusing the same rule-parsing, glob-matching, and filter-execution
engine. Informational only (no blocking); `reviewSkip` does not exclude a
rule from injection (it only gates the diff-review path); each rule is
injected at most once per session, deduped by its source file path
(`AgentRule.filePath`), not `rule.name`, which isn't guaranteed unique across
the rules tree. A relative `rulesDir` resolves against the project root, not
the hook process's own working directory. New `agent-rules setup`
subcommand merges the hook into a project's `.claude/settings.json`
(creating it if needed, idempotent when the hook is already registered under
its own matcher, leaves other hooks/settings untouched, never throws on a
malformed existing file); see the README for the manual JSON snippet. New
exports: `buildHookContext`, `toRepoRelativePath`, `mergeHookSettings`,
`HOOK_MATCHER`, `HOOK_COMMAND`.
- `AgentRule.filePath`: the absolute source path of each rule, set by
`loadRules`; included in `--list --output json`.
- Git-dependency installs (`"@casa/agent-rules": "github:..."`) now actually
work: `dist/` is committed (no longer `.gitignore`d) and a `prepare: yarn
build` script rebuilds it from source, so installing directly from a GitHub
commit — instead of the published npm package — no longer silently ships an
empty/stale `dist/`.
- `zod` is now a `peerDependency` (`^4.0.0`) instead of a bundled `dependency`,
so consumers that already depend on zod (e.g. for their own schemas) don't
get a second, separate copy installed alongside theirs.
- New exports: `resolveTransport`, `ResolveOptions`, `ResolvedTransport` (the
CLI's model-transport resolution, for programmatic reuse).

## [0.1.0] - 2026-06-26

Expand Down
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,59 @@ Then run `/agent-rules` in a session. It reviews your working-tree changes again
the rules in `.agent/rules`. Edit the copied command to change the rules directory
or the diff source (e.g. `--staged`).

## Live context injection (hook)

The slash command above reviews a diff on demand. `agent-rules-hook` applies
the same `.agent/rules/` rule files continuously instead: whenever Claude Code
reads, writes, or edits a file whose `globs` (and `filter`) match, the rule's
body is injected straight into the agent's context — no diff, no model call,
just the matching rule text surfacing while the agent works. It's purely
informational (there's no way for a rule to block a write); each rule is
injected at most once per session, regardless of how many times a matching
file is touched. `reviewSkip` has no effect here — it only excludes a rule
from the diff-review path.

The once-per-session dedup is tracked in a small state file under the OS temp
directory, keyed by session ID — best-effort only. If that directory isn't
writable in your environment (a locked-down sandbox, an unusual `TMPDIR`), the
hook still injects matching rule content; it just can't remember what it
already showed, so a rule may repeat across a session instead of firing once.

**Setup (automated):**

```sh
npx agent-rules setup
```

This merges a `PostToolUse` hook into your project's `.claude/settings.json`
(creating the file if needed) without disturbing any other hooks or settings
already there, and is safe to run more than once. Commit the file so the rest
of the team gets the hook too.

**Setup (manual):** paste this into `.claude/settings.json` yourself:

```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Read|Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/node_modules/.bin/agent-rules-hook",
"timeout": 10
}
]
}
]
}
}
```

Pass a non-default rules directory by appending `--rules <dir>` to the
`command` string, the same flag the CLI uses.

## Transport notes (CLI)

The CLI delegates to a local agent in headless mode, so the agent must be usable
Expand All @@ -203,8 +256,9 @@ If neither resolves (and no `--exec` is given), the CLI exits 2 with guidance.
yarn install
yarn build # compile to dist/ (pure ESM + .d.ts)
yarn typecheck
yarn test # 36 unit tests (hermetic)
yarn test # unit tests (hermetic)
yarn smoke # end-to-end CLI test via a fake transport (hermetic, CI-safe)
yarn hook-smoke # end-to-end PostToolUse hook + `setup` test (hermetic, CI-safe)
yarn verify:transport # live check against a real claude/codex (manual, makes a model call)
```

Expand Down
2 changes: 2 additions & 0 deletions dist/cli.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
export {};
260 changes: 260 additions & 0 deletions dist/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
import { extractChangedFiles, getDiff } from './diff.js';
import { resolveTransport } from './exec-adapter.js';
import { discoverApplicableRules, runReview } from './runner.js';
import { mergeHookSettings } from './settings.js';
const USAGE = `agent-rules — apply Markdown-defined coding rules to a diff via a local agent CLI

Usage:
agent-rules (--working-tree | --staged | --diff <range>) [options]
agent-rules setup

Diff source (exactly one required):
--working-tree Uncommitted changes (staged + unstaged + untracked)
--staged Staged changes only
--diff <range> A git diff range, e.g. origin/main...HEAD

Options:
--rules <dir> Rules directory (default: .agent/rules)
--concurrency <n> Max rules in parallel (default: 3)
--min-impact <n> Min impact for suggestions (default: 7)
--ticket-context <text> Extra context injected into each prompt
--ticket-context-file <p> Read ticket context from a file
--output <text|json> Output format (default: text)
--list List the rules that apply to the diff and exit
(no model call; for editor/agent integrations)
--no-filters Ignore rule \`filter\` commands (treat as absent).
Use when reviewing untrusted changes.
--filter-timeout <ms> Per-filter subprocess timeout (default: 10000)
--exec <command> Override transport (any stdin->stdout command)
--transport <claude|codex> Pin which installed agent CLI to use
--model <name> Model passed to the resolved agent CLI
-h, --help Show this help
-v, --version Show version

Subcommand:
setup Wire the agent-rules PostToolUse hook into
./.claude/settings.json (creates it if missing).
Merges in; never overwrites other hooks/settings.

Exit codes: 0 = clean, 1 = blocking findings, 2 = error`;
async function main() {
if (process.argv[2] === 'setup') {
return runSetup();
}
const { values } = parseArgs({
options: {
'working-tree': { type: 'boolean' },
staged: { type: 'boolean' },
diff: { type: 'string' },
rules: { type: 'string', default: '.agent/rules' },
concurrency: { type: 'string' },
'min-impact': { type: 'string' },
'ticket-context': { type: 'string' },
'ticket-context-file': { type: 'string' },
output: { type: 'string', default: 'text' },
list: { type: 'boolean' },
'no-filters': { type: 'boolean' },
'filter-timeout': { type: 'string' },
exec: { type: 'string' },
transport: { type: 'string' },
model: { type: 'string' },
help: { type: 'boolean', short: 'h' },
version: { type: 'boolean', short: 'v' },
},
allowPositionals: false,
});
if (values.help) {
process.stdout.write(USAGE + '\n');
return 0;
}
if (values.version) {
process.stdout.write((await readVersion()) + '\n');
return 0;
}
// Recursion guard: refuse if we are running inside an agent that agent-rules
// itself spawned, to avoid an agent -> agent-rules -> agent loop.
if (process.env.AGENT_RULES_SUBPROCESS === '1') {
throw new Error('refusing to run: detected agent-rules running inside an agent it spawned (recursion guard)');
}
const source = resolveDiffSource(values);
if (values.output !== 'text' && values.output !== 'json') {
throw new Error(`invalid --output: ${String(values.output)} (expected "text" or "json")`);
}
const ticketContext = await resolveTicketContext(values);
const diff = await getDiff(source);
if (!diff.trim()) {
process.stderr.write('No changes to review.\n');
return 0;
}
const runFilters = values['no-filters'] ? false : undefined;
const filterTimeoutMs = parseIntOption(values['filter-timeout'], 'filter-timeout');
// --list: discover applicable rules and exit. No model call, so this is safe to
// run from inside an agent session (editor/slash-command integrations). Note
// that filter commands DO run here unless --no-filters is given.
if (values.list) {
const changed = extractChangedFiles(diff);
const { rules, warnings } = await discoverApplicableRules(values.rules ?? '.agent/rules', changed, { runFilters, filterTimeoutMs });
for (const w of warnings)
process.stderr.write(`warning: ${w}\n`);
if (values.output === 'json') {
const payload = rules.map((r) => ({
name: r.name,
globs: r.globs,
content: r.content,
filePath: r.filePath,
}));
process.stdout.write(JSON.stringify({ rules: payload, warnings }, null, 2) + '\n');
}
else {
process.stdout.write(formatRuleList(rules));
}
return 0;
}
if (values.transport != null && values.transport !== 'claude' && values.transport !== 'codex') {
throw new Error(`invalid --transport: ${values.transport} (expected "claude" or "codex")`);
}
const { adapter, description } = resolveTransport({
exec: values.exec,
prefer: values.transport,
model: values.model,
});
process.stderr.write(`Using transport: ${description}\n`);
const result = await runReview({
rulesDir: values.rules ?? '.agent/rules',
diff,
ticketContext,
llm: adapter,
concurrency: parseIntOption(values.concurrency, 'concurrency'),
minSuggestionImpact: parseIntOption(values['min-impact'], 'min-impact'),
runFilters,
filterTimeoutMs,
});
for (const w of result.warnings)
process.stderr.write(`warning: ${w}\n`);
if (values.output === 'json') {
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
else {
process.stdout.write(formatText(result));
}
return result.findings.some((f) => f.severity === 'blocking') ? 1 : 0;
}
/** Exactly one diff-source flag must be provided. */
function resolveDiffSource(values) {
const chosen = [
values['working-tree'] ? 'working-tree' : null,
values.staged ? 'staged' : null,
values.diff != null ? 'diff' : null,
].filter(Boolean);
if (chosen.length === 0) {
throw new Error('a diff source is required: --working-tree, --staged, or --diff <range>');
}
if (chosen.length > 1) {
throw new Error(`only one diff source allowed, got: ${chosen.join(', ')}`);
}
if (values['working-tree'])
return { type: 'working-tree' };
if (values.staged)
return { type: 'staged' };
return { type: 'range', range: String(values.diff) };
}
async function resolveTicketContext(values) {
if (values['ticket-context-file']) {
return readFile(String(values['ticket-context-file']), 'utf8');
}
if (values['ticket-context'])
return String(values['ticket-context']);
return undefined;
}
function parseIntOption(value, name) {
if (value == null)
return undefined;
const n = Number.parseInt(String(value), 10);
if (Number.isNaN(n))
throw new Error(`--${name} must be an integer`);
return n;
}
/**
* `agent-rules setup`: wire the `agent-rules-hook` PostToolUse hook into the
* current project's `.claude/settings.json`, creating the file (and its
* parent directory) if needed. Merges in — other hooks/settings already
* present are left untouched — and is idempotent.
*/
async function runSetup() {
const settingsPath = path.join(process.cwd(), '.claude', 'settings.json');
let existing = {};
try {
existing = JSON.parse(await readFile(settingsPath, 'utf8'));
}
catch (err) {
const e = err;
if (e.code !== 'ENOENT') {
process.stderr.write(`error: could not read ${settingsPath}: ${e.message}\n`);
return 2;
}
}
const { settings, changed } = mergeHookSettings(existing);
if (!changed) {
process.stdout.write(`agent-rules hook is already configured in ${settingsPath}\n`);
return 0;
}
await mkdir(path.dirname(settingsPath), { recursive: true });
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
process.stdout.write(`Added the agent-rules PostToolUse hook to ${settingsPath}\n`);
process.stdout.write('Commit this file so the rest of the team picks up the hook too.\n');
return 0;
}
function formatRuleList(rules) {
if (rules.length === 0)
return 'No applicable rules for these changes.\n';
const lines = [`${rules.length} applicable rule(s):`, ''];
for (const r of rules) {
lines.push(`## ${r.name}`);
lines.push(`globs: ${r.globs.join(', ')}`);
lines.push('');
lines.push(r.content);
lines.push('');
}
return lines.join('\n');
}
function formatText(result) {
const lines = [];
const byFile = new Map();
for (const f of result.findings) {
const list = byFile.get(f.path) ?? [];
list.push(f);
byFile.set(f.path, list);
}
for (const [file, findings] of byFile) {
lines.push(file);
for (const f of findings.sort((a, b) => a.line - b.line)) {
lines.push(` line ${f.line} [${f.severity}] ${f.ruleName}`);
for (const bodyLine of f.body.split('\n')) {
lines.push(` ${bodyLine}`);
}
lines.push('');
}
}
const blocking = result.findings.filter((f) => f.severity === 'blocking').length;
const fileCount = byFile.size;
lines.push(`${result.findings.length} finding(s) across ${fileCount} file(s) ` +
`(${blocking} blocking) from ${result.ruleCount} rule(s)`);
return lines.join('\n') + '\n';
}
async function readVersion() {
const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
return pkg.version ?? 'unknown';
}
main()
.then((code) => process.exit(code))
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`error: ${msg}\n`);
process.exit(2);
});
Loading
Loading