Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pclr — Pre-Commit LLM Review

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:

  1. 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.
  2. 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.


Why

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.

How it talks to Claude

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 (claude on PATH). See Requirements.
  • In CI, where the CLI may not be available, the semantic gate can be configured to skip (see Configuration → semantic.on_unavailable).

Requirements

  • Python ≥ 3.10 (the pclr CLI 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). pclr only runs a tool if it's enabled and present.
  • For the Python gate's defaults, uv (the commands are wrapped in uv run). For TypeScript, the linters installed in the package's node_modules. Adjust the commands in .pclr.toml if your projects use a different runner.

Install

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 pclr

To 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 pclr isn't found in a fresh shell after installing, run uv tool update-shell once 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 hook

pclr install writes (or safely augments) .git/hooks/pre-commit so that every git commit invokes pclr run. To remove it later: pclr uninstall.

pclr installs a native git hook and does not require the pre-commit framework. If you already use that framework, you can still invoke pclr run from it as a system/local hook.

Usage

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 range

Scanning the whole project

By 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".

Monorepos

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

What a run looks like

$ 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)

Configuration

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__/**",
]

Severity & exit codes

Exit code Meaning
0 All gates passed (or findings below threshold).
1 A gate blocked the commit.
2 Configuration or invocation error.

How the semantic gate works

  1. Collect staged files: git diff --cached --name-only --diff-filter=ACMR, filtered by [files] globs and max_diff_lines.
  2. Build the staged diff plus light surrounding context.
  3. 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 }.
  4. Filter findings by min_confidence, render a report, and set the exit code from fail_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.

Project status

Early scaffolding. This README and CLAUDE.md define the intended design; the CLI is being built out command by command (initinstall/uninstallcheckreviewrun).

License

GPL-3.0. See LICENSE.

About

Deterministic pre-commit checks + Claude-powered judgment review.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages