Deterministic checks first, then a semantic review by Claude — so bugs that linters can't see get caught before they land in a commit.
pclr is a reusable Git pre-commit hook that runs in two gates:
- Deterministic gate — fast, stack-aware tooling (ruff, mypy/pyright, eslint, tsc, gofmt/go vet/golangci-lint, cargo fmt/clippy …). If formatting or types are broken, we fail here and never spend tokens.
- Semantic gate — the staged diff is handed to the Claude Code CLI, which reviews it for
correctness at the meaning level. It is told to pay special attention to:
- Transactional guarantees — atomicity, commit/rollback correctness, partial-failure handling, operations that span multiple systems without a transaction boundary.
- Idempotency — retried or replayed operations that double-apply side effects, missing idempotency keys, non-idempotent handlers behind at-least-once delivery.
- Race conditions & concurrency — check-then-act / TOCTOU, missing locks, shared mutable state, unsafe async ordering, double-spend windows.
A commit is blocked when the deterministic gate fails, or when the semantic gate reports a finding at or above your configured severity/confidence threshold.
Linters and type checkers enforce syntax and shape. They will happily approve code that:
- commits a row to the database and then calls a payment API (no rollback if the call fails),
- processes the same webhook twice because the handler isn't idempotent,
- reads a balance, decides, and writes it back without a lock.
These are the bugs that cause production incidents, and they're exactly the bugs a careful reviewer
catches by reading the change. pclr puts that reviewer in your pre-commit loop.
pclr shells out to the Claude Code CLI (claude -p … in non-interactive print mode). This
means:
- No API key to distribute. It reuses the developer's existing Claude Code authentication.
- The CLI must be installed and authenticated (
claudeonPATH). See Requirements. - In CI, where the CLI may not be available, the semantic gate can be configured to skip (see
Configuration →
semantic.on_unavailable).
- Python ≥ 3.10 (the
pclrCLI itself). - Git ≥ 2.30.
- The Claude Code CLI installed and authenticated, for the semantic gate.
- Per-stack tools you want to run in the deterministic gate (e.g.
ruff,mypy,eslint,tsc,golangci-lint,cargo).pclronly runs a tool if it's enabled and present. - For the Python gate's defaults,
uv(the commands are wrapped inuv run). For TypeScript, the linters installed in the package'snode_modules. Adjust thecommandsin.pclr.tomlif your projects use a different runner.
pclr needs to be on your PATH globally, because the git hook invokes a bare pclr command in a
plain shell. uv handles this with uv tool install, which puts the
command on your PATH in its own isolated environment:
# From a local checkout (current distribution method):
uv tool install /path/to/precommit-llm-review
# Once published to PyPI:
uv tool install pclrTo upgrade after pulling changes: uv tool install --force /path/to/precommit-llm-review
(or add -e for an editable global install so local edits are picked up without reinstalling).
If
pclrisn't found in a fresh shell after installing, runuv tool update-shellonce to add uv's tool directory (~/.local/bin) to your PATH.
Then, inside a repo:
pclr init # scaffold .pclr.toml (detects your stacks)
pclr install # install the native .git/hooks/pre-commit hookpclr install writes (or safely augments) .git/hooks/pre-commit so that every git commit
invokes pclr run. To remove it later: pclr uninstall.
pclrinstalls a native git hook and does not require the pre-commit framework. If you already use that framework, you can still invokepclr runfrom it as asystem/localhook.
The hook runs automatically on git commit. You can also run the gates manually:
pclr run # full pipeline on staged files (what the hook calls)
pclr check # deterministic gate only
pclr review # semantic (LLM) gate only
pclr review --staged # review staged changes
pclr review --range main...HEAD # review an arbitrary diff rangeBy default every command operates on the staged changes. Pass --all to operate on the entire
project (all git-tracked files) instead — no staging required. This is useful for an initial audit
of an existing codebase or for running pclr in CI over the full tree:
pclr check --all # run the deterministic gate over all tracked files
pclr review --all # semantic review of the whole project
pclr run --all # both gates over the whole project (full-repo audit)Under the hood, --all reviews the project as a diff against git's empty tree, so every tracked
file is presented to the semantic reviewer as added code. Because that diff can be large, the
general.max_diff_lines guard still applies — raise it in .pclr.toml (or scope with --range)
if a full-repo review is being skipped as "diff too large".
The deterministic gate runs per package, inside each package's own directory. Each changed
file is assigned to its nearest enclosing package — the closest ancestor directory containing a
stack marker (pyproject.toml, package.json, go.mod, Cargo.toml) — and that package's
commands run with the working directory set there. So a commit touching services/api/*.py and
web/*.ts runs Python tooling in services/api and TS tooling in web, each picking up its own
config and dependencies. This works the same for staged changes and for --all.
Because each package runs in its own directory, tools resolve to the package's local environment:
| Stack | How commands run |
|---|---|
| Python | wrapped in uv run, so they use the package's uv-managed virtual environment |
| TS / JS | node_modules/.bin of the package (then the workspace root, for hoisted installs) is on PATH, so eslint/tsc resolve to the locally installed versions |
| Go | run inside the module directory (go.mod); go uses the toolchain pinned there |
| Rust | run inside the crate directory (Cargo.toml); cargo uses the pinned toolchain |
If a wrapped tool isn't available (e.g. uv not installed, or eslint not in node_modules),
that command is skipped with an informational note rather than blocking the commit.
Bypass for a single commit (use sparingly):
git commit --no-verify # skips ALL hooks
PCLR_SKIP=semantic git commit # skip only the LLM gate
PCLR_SKIP=all git commit # skip pclr entirely, keep other hooks$ git commit -m "add refund endpoint"
pclr ▸ deterministic gate
python ruff .................. ok
python mypy .................. ok
pclr ▸ semantic gate (claude)
reviewing 2 files / 84 added lines …
✖ ERROR payments/refund.py:41-58 [idempotency]
Refund is issued before the idempotency key is persisted. A retried request
(the client times out and retries) will issue a second refund. Persist/lookup
the key inside the same transaction as the refund, before calling the gateway.
⚠ WARN payments/refund.py:33 [transactional-guarantees]
DB row is committed before the external gateway call; a gateway failure leaves
an orphaned "refunded" record. Wrap in a transaction or use an outbox.
commit blocked: 1 error, 1 warning (threshold: error)
pclr reads .pclr.toml from the repo root. pclr init generates a starting point. All keys are
optional; defaults are shown.
[general]
# Block the commit when a finding at this severity or higher is reported.
fail_on = "error" # one of: info | warning | error
# Ignore findings below this model-reported confidence (0.0–1.0).
min_confidence = 0.6
# Skip the whole run if the staged diff is larger than this many lines.
max_diff_lines = 1500
[deterministic]
enabled = true
# Stop at the first failing package/stack instead of running them all.
fail_fast = true
# Each stack is auto-detected, but can be forced on/off and customized. Commands run inside
# each package directory (see "Monorepos" below) using that package's local environment.
[deterministic.python]
enabled = "auto" # auto | true | false (auto = detect pyproject.toml / *.py)
commands = [
"uv run ruff check {files}", # wrapped in `uv run` -> the package's uv environment
"uv run ruff format --check {files}",
"uv run mypy {files}", # swap for "uv run pyright {files}" if you prefer
]
[deterministic.typescript]
enabled = "auto" # detect package.json / tsconfig.json
commands = [
"eslint {files}", # resolved from the package's node_modules/.bin (then workspace root)
"tsc --noEmit",
]
[deterministic.go]
enabled = "auto" # detect go.mod
commands = [
"gofmt -l {files}",
"go vet ./...",
"golangci-lint run",
]
[deterministic.rust]
enabled = "auto" # detect Cargo.toml
commands = [
"cargo fmt --check",
"cargo clippy -- -D warnings",
]
[semantic]
enabled = true
# How to reach Claude. Currently: "claude-cli".
backend = "claude-cli"
model = "claude-opus-4-8" # passed to `claude --model`
# What to do when the claude CLI is missing/unauthenticated (e.g. in CI).
on_unavailable = "skip" # skip | fail
timeout_seconds = 120
# Extra natural-language guidance appended to the review instructions.
extra_focus = []
# Focus areas. These are emphasized in the review prompt. Toggle as needed.
[semantic.focus]
transactional_guarantees = true
idempotency = true
race_conditions = true
correctness = true # general semantic correctness beyond the three above
[files]
# Globs evaluated against staged paths. Excludes win over includes.
include = ["**/*"]
exclude = [
"**/*.lock", "**/*.min.js", "**/vendor/**",
"**/migrations/**", "**/__snapshots__/**",
]| Exit code | Meaning |
|---|---|
0 |
All gates passed (or findings below threshold). |
1 |
A gate blocked the commit. |
2 |
Configuration or invocation error. |
- Collect staged files:
git diff --cached --name-only --diff-filter=ACMR, filtered by[files]globs andmax_diff_lines. - Build the staged diff plus light surrounding context.
- Invoke the Claude Code CLI non-interactively, instructing it to return JSON only: a list of
findings
{ file, line_range, category, severity, confidence, title, explanation, suggestion }. - Filter findings by
min_confidence, render a report, and set the exit code fromfail_on.
The review prompt is correctness-focused on purpose — style, formatting, and typing are the deterministic gate's job, so the model is told not to re-report them.
Early scaffolding. This README and CLAUDE.md define the intended design; the CLI is being built
out command by command (init → install/uninstall → check → review → run).
GPL-3.0. See LICENSE.