@jcamatta/veto
v0.1.0
Published
AI reviewer agents for staged changes — pre-commit gate with structured findings
Readme
veto
AI reviewer agents for staged changes — a pre-commit gate with structured findings.
veto runs one or more reviewer personas (defined as YAML data, committed
per repo) against the staged diff before a commit lands. Each reviewer is a
Claude agent with read-only access to the repo, judged against
natural-language rules. Findings come back as strict JSON, blocking the
commit only on error severity — and the engine fails open: if the
model is unreachable, times out, or returns garbage twice, the commit
proceeds with a warning.
Reviews run on the Claude Agent SDK,
which drives the claude CLI you already have — no API key needed:
locally it runs on your Claude subscription's Agent SDK credit. Only CI
environments without a logged-in claude need an ANTHROPIC_API_KEY.
Install
npm i -D github:jcamatta/veto # per repo, like eslintveto is not on npm; it installs straight from the GitHub repo (dist/ is
built during install). Pin a tag or commit for reproducible installs:
npm i -D github:jcamatta/veto#<tag|commit>.
Requires Node >= 20 and a git repository.
Then scaffold the repo in one step:
npx veto init # detect the stack, write a starter .veto/architect.yaml, wire huskyveto init detects your stack from package.json (electron / next / react /
plain Node), writes a commented starter config with cost-tuned defaults and
example rules to replace, appends npx veto .veto/ --staged to
.husky/pre-commit when one exists (idempotently), and prints the
agent-feedback snippet for your CLAUDE.md. It refuses to overwrite existing
configs and never calls the model.
Usage
veto init # scaffold .veto/ for this repo
veto --staged # short form: defaults to <repo-root>/.veto/
veto .veto/ # all configs in a dir
veto --config=.veto/architect.yaml # one reviewer
veto --config=a.yaml --config=b.yaml # several
veto .veto/ --staged # staged diff (the default; flag documents intent)
veto .veto/ --format=json # machine-readable output (default: pretty)
veto .veto/ --no-cache # bypass the exact-replay cache
veto .veto/ --timeout=240 # per-reviewer timeout in seconds (default 90)
veto .veto/ --max-cost-usd=0.50 # abort a reviewer once its run cost crosses this (USD)
veto .veto/ --fail-on=warning # block on warnings too (default error; never = report-only)
veto check # validate configs without running a review
veto check .veto/ --config=extra.yaml # same target rules as a run
veto schema # print the reviewer-config JSON Schema
veto stats # per-rule health from the retained run history
veto stats --format=json # same, machine-readableValidating configs
veto check discovers configs exactly like a run does (positional targets,
repeated --config, or the .veto/ default), decodes each file, and prints
a per-file ok / error line. It exits 0 when every config decodes and 2
otherwise — no git diff, no agent, no credits. Use it in CI to keep every
reviewer config valid.
Editor validation
veto init writes the generated JSON Schema to .veto/schema.json and the
starter config opens with a modeline:
# yaml-language-server: $schema=./schema.jsonWith the VS Code YAML extension (or any yaml-language-server client) you
get inline validation and autocompletion while editing reviewer configs.
veto schema prints the same schema to stdout if you want to wire it up
elsewhere. The schema covers structure; cross-field rules (like unique rule
ids) are caught by veto check.
Pre-commit (husky)
Deterministic checks first — linters are cheaper and exact; the reviewer never re-litigates them:
# .husky/pre-commit
npx eslint .
git rev-parse -q --verify MERGE_HEAD >/dev/null || npx veto .veto/ --stagedThe MERGE_HEAD guard skips the review on merge commits: a merge's staged
diff is everything the merged branch brings in — code already reviewed on
its own branch — so re-reviewing it only burns credit. veto init writes
this guarded line for you.
Exit codes
| Code | Meaning |
|------|---------|
| 0 | no finding at or above the --fail-on threshold, or fail-open (agent unavailable, timeout, parse failure after retry) |
| 1 | at least one finding at or above the --fail-on threshold — commit blocked |
| 2 | engine misuse (bad config, not a git repo, bad flags) |
--fail-on sets the lowest severity that blocks (info < warning < error);
the default error keeps the behavior above. --fail-on=never makes the run
report-only: findings are still written to latest.json, but findings never
fail the run (exit 2 misuse paths are unaffected).
Reviewer config
One YAML file per reviewer, committed (conventionally under .veto/):
# .veto/architect.yaml
name: architect
mode: static # "runtime" is reserved for v2 and rejected today
model: claude-sonnet-4-6 # optional — opaque string the backend interprets
effort: medium # optional — low | medium | high | xhigh | max
maxTurns: 15 # optional — agentic turn ceiling
timeoutMs: 240000 # optional — overrides the run timeout for this reviewer
maxCostUsd: 0.50 # optional — abort once run cost crosses this (USD)
maxDiffLines: 3000 # optional — skip the reviewer if the scoped diff is larger
maxDiffFiles: 50 # optional — skip the reviewer if more files are in scope
paths: # trigger globs — no staged match ⇒ reviewer skips
- "src/**/*.ts"
ignore: # never considered by this reviewer
- "**/*.test.ts"
- "package-lock.json"
systemPrompt: |
You are a software architect reviewing a staged diff before commit...
rules: # natural-language judgment rules
- keep domain logic out of UI components
- id: no-cross-layer # optional stable id (kebab-case, unique)
instruction: no cross-layer imports
- id: tenant-id-every-query
instruction: | # long-form briefings are the intended style:
Every repository query must include the tenant id. State the rule,
its rationale, and the edge cases — the reviewing agent applies this
text literally to the diff and nothing else.
enabled: true # optional — false parks the rule, id survives
paths: ["src/modules/**"] # optional — narrows within the reviewer scope
ignore: ["**/*.spec.ts"] # optional — per-rule exclusions
- new endpoints must not duplicate existing operations (search before flagging)Keep judgment rules only. If eslint can enforce it deterministically, it belongs in eslint, which runs first in the hook.
Each instruction is the briefing handed to the reviewing agent: it sees
your diff and that text, nothing else — write it so a stranger could apply
it without asking. The legacy rule: key is still accepted and decoded
into instruction.
Give rules an id when you expect to reword them: findings cite the id
(enforced by the structured-output schema), and suppression fingerprints
hash the id — so editing the rule text never invalidates .veto/ignore
entries or the findings baseline.
The deterministic knobs stay with the author: enabled: false parks a
rule without deleting it, and per-rule paths/ignore intersect with the
reviewer scope (they can only narrow it, never escape it). A rule is
rendered only when it applies to at least one in-scope staged file, the
findings schema only accepts the rendered rules, and a finding citing a
rule on a file outside that rule's scope is dropped with a visible
FindingOutOfScope event.
On a personal subscription, model: claude-sonnet-4-6 with effort: medium
is the recommended default — cheaper, faster, and far less likely to spend
its whole timeout thinking. The model string is opaque to the engine: only
the agent adapter interprets it, so alternative backends can define their
own values.
Spend guardrails
Reviews cost credit, so veto bounds spend three ways — all fail open (they abort the review, never block the commit by themselves):
- Diff-size skip (
maxDiffLines/maxDiffFiles): an oversized scoped diff skips the reviewer before any model call, reported as adiff-too-largeskip. Large merges are the usual trigger; theMERGE_HEADhook guard already skips merges entirely. - Cost ceiling (
maxCostUsd, or--max-cost-usdfor a whole run): rides the Agent SDK's native USD budget, so the query stops mid-flight when the ceiling is crossed and the reviewer fails open with a clear reason. - Cancellation: Ctrl-C (SIGINT) aborts the in-flight SDK query, so a runaway review can always be killed by hand.
How it behaves on re-runs
The fix-and-recommit loop keeps memory keyed on (HEAD, branch, reviewer) —
it expires automatically when the commit finally lands:
- Replay cache: identical staged diff + identical config ⇒ stored
findings are replayed instantly with zero model calls (
--no-cachebypasses). - Findings baseline: after a fix, the reviewer sees its previous findings and must say what is resolved, report genuinely new problems, and not raise fresh objections to previously-accepted unchanged code (no whack-a-mole).
Tuning rules with veto stats
Judgment rules have empirical precision: the only way to know a rule is
noisy or dead is to measure it. veto stats folds the retained run history
(the last 10 heads — older runs are pruned) into a per-rule health table:
rule fired suppressed error warning info last seen
no-cross-layer 12 5 8 4 0 a94f3c2
keep-domain-pure 1 0 0 1 0 77b01ceThe tuning loop: a rule that fires constantly and gets mostly suppressed is
noisy — sharpen its wording or delete it; a rule that never fires may be
dead weight. --format json emits the same data machine-readable. The runs
dir is anchored the same way as a review (next to the configs; .veto/ by
default).
Escape hatches
When the reviewer is wrong:
Suppress a finding permanently: copy its fingerprint into
.veto/ignore(committed;#comments allowed):a94f3c21e0b7 # architect: false positive about repository patternEmergency bypass:
git commit --no-verify.
Outputs
Everything lands under .veto/runs/ (self-gitignored — the engine
writes a .gitignore containing * on first run):
.veto/runs/
latest.json most recent findings, machine-readable
latest.md same content, human-readable
<headSha>/<reviewer>/
baseline.json findings carried between commit attempts
record.json diff/config hashes, attempt, timings
attempt-N.events.jsonl the full agent event stream (observability)latest.json is the contract for coding agents: add one line to your
CLAUDE.md —
If a commit is blocked by the reviewer, read
.veto/runs/latest.jsonand fix the findings, then commit again.
Sandboxing
Reviewers are restricted in code, not by prompt: read-only tools
(Read/Grep/Glob), a turn ceiling and a 90 s per-reviewer timeout, and
a per-call policy that denies any path outside the repo root or inside
.veto/runs/. Denials are logged as ToolCallDenied events.
Development
npm run check # lint + typecheck + tests with coverage + type coverage
npm run build # tsup → dist/cli.jsSee docs/SPEC.md for the full specification and docs/PLAN.md for the implementation plan.
