Ambitious · 150 minutes

A code reviewer that never leaves your laptop

A command-line reviewer you run before you commit. It reads your staged diff, sends it to your local model with the surrounding file for context, and prints what it thinks you got wrong. There is a pleasing circularity to this one: you will almost certainly build it using Claude Code or Codex, which means using a coding agent to build a coding tool, and comparing what the two produce is half the lesson.

150 minutes 6 steps 16 GB RAM Not started yet

  • Python
  • openai
  • git
  • Pip text server

Before you start

  • Running in Pip: a text server.
  • 16 GB of memory or more. Less than that and the model you need will not load.
  • Installed already: Python 3.10+, git, Claude Code or Codex.

Worth reading first: What each one is genuinely good at, A decision rule you can actually use, Context: the model's short memory.

Be honest about what this will and will not do

Start here, because a reviewer you over-trust is worse than no reviewer. A model you can run on a laptop is not as good at code review as the largest cloud models, and it is not close. It will miss subtle bugs. It will occasionally object to code that is correct.

What it is genuinely good at is the tedious half of review: a variable used before assignment, an error swallowed silently, a function that grew a fourth responsibility, a test that asserts nothing. And it has three properties the better reviewer does not — it costs nothing per run, it works on an aeroplane, and it will look at code you are contractually forbidden from uploading. That combination is the reason to build it.

Aim it at the tedious half deliberately. A reviewer asked for everything gives you a wall of text you stop reading by Thursday.

How you know it worked

A clear idea of what you want it to catch. Write down three real bugs from your own history and ask whether a careful but unfamiliar reader could have caught them from the diff alone. Those are the ones to aim at.

Get the diff, and the context around it

A diff alone is a poor thing to review. It shows the lines that changed and nothing about what they changed. A reviewer who cannot see the function a line sits in will comment on style, because style is all that is visible.

So gather two things: the diff, and enough of each changed file to make it meaningful. The trade-off is context budget — the more surrounding code you send, the less room there is for the model to think, and on a local model that budget is tight.

gather.py
import subprocess
from dataclasses import dataclass


@dataclass
class Change:
    path: str
    diff: str


def run(args: list[str]) -> str:
    result = subprocess.run(args, capture_output=True, text=True)
    if result.returncode != 0:
        raise RuntimeError(result.stderr.strip() or " ".join(args))
    return result.stdout


def staged_changes() -> list[Change]:
    """One entry per changed file. Reviewing file by file rather than all at
    once keeps each request inside the context window and stops a large
    change from crowding out a small important one."""
    names = run(["git", "diff", "--cached", "--name-only"]).split()
    changes = []
    for path in names:
        diff = run(["git", "diff", "--cached", "--unified=8", "--", path])
        if diff.strip():
            changes.append(Change(path=path, diff=diff))
    return changes

How you know it worked

git add -p && python -c "from gather import staged_changes; [print(c.path, len(c.diff)) for c in staged_changes()]"

One line per staged file with the size of its diff. If you get nothing, you have not staged anything — this reads the index, not the working tree.

Ask for the tedious half, specifically

The instruction is where this project is won or lost. Ask a model to "review this code" and you get a summary of what the code does, two style opinions, and a compliment. None of those help.

Ask for specific categories of defect, demand a file and line for each, and give it explicit permission to find nothing. That last part matters more than it sounds: a model that cannot say "this looks fine" will manufacture an objection on every clean diff, and a reviewer that cries wolf on every commit gets turned off within a week.

review.py
from openai import OpenAI

from gather import Change

client = OpenAI(base_url="http://127.0.0.1:8081/v1", api_key="not-needed")
MODEL = client.models.list().data[0].id

JOB = """You review a single file's staged diff before it is committed.

Look for these, in this order:
1. Bugs: wrong logic, off-by-one, a variable used before it is set, a case
   the change does not handle, an error caught and silently discarded.
2. Things left behind: debugging output, commented-out code, a TODO written
   in this diff, a hard-coded value that should not be hard-coded.
3. Tests: a behaviour changed with no test touched, or a test that asserts
   nothing meaningful.

Ignore formatting, naming and style entirely. A formatter handles those.

For each finding write one line:
  path:line  what is wrong, in one sentence

If the diff has none of these problems, reply with exactly: LOOKS FINE
Do not invent a finding to seem useful. A clean diff is a normal outcome."""


def review(change: Change) -> str:
    reply = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": JOB},
            {"role": "user", "content": f"File: {change.path}\n\n{change.diff}"},
        ],
        temperature=0.1,
    )
    return reply.choices[0].message.content.strip()

How you know it worked

python -c "from gather import staged_changes; from review import review; c = staged_changes()[0]; print(review(c))"

Either findings with line references, or LOOKS FINE. Now the test that matters: stage a file with a deliberate bug — an off-by-one in a loop, or an exception caught and ignored — and confirm it is found. Then stage a trivially clean change and confirm you get LOOKS FINE rather than an invented objection.

Make it feel fast

A local model reviewing a large diff takes a while, and a silent terminal for forty seconds feels broken even when it is working. Streaming does not make it faster; it makes the waiting legible, and that is the difference between a tool you use and one you abandon.

This is also the cheapest possible lesson in why local feels different. The first token arrives quickly; it is the last one that takes time. Showing the first changes the experience entirely.

review.py
def review_streaming(change: Change) -> str:
    stream = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": JOB},
            {"role": "user", "content": f"File: {change.path}\n\n{change.diff}"},
        ],
        temperature=0.1,
        stream=True,
    )
    collected = []
    for piece in stream:
        token = piece.choices[0].delta.content or ""
        print(token, end="", flush=True)
        collected.append(token)
    print()
    return "".join(collected).strip()

How you know it worked

Text appearing a few words at a time rather than all at once at the end. If nothing appears until the end, flush=True is missing or the output is being buffered by whatever you piped it into.

Wire it into the way you actually work

The finished tool: every staged file reviewed, findings printed under a heading, a non-zero exit code when something was found so it can sit in a pre-commit hook if you want it to.

Make the exit code a choice rather than a default. A reviewer that blocks your commit on a false positive is a reviewer you will disable, and you want this one advisory until you trust it.

cli.py
import argparse
import sys

import httpx

from gather import staged_changes
from review import review, review_streaming


def main() -> int:
    parser = argparse.ArgumentParser(description="Review staged changes locally.")
    parser.add_argument("--strict", action="store_true",
                        help="exit non-zero when anything is found")
    parser.add_argument("--quiet", action="store_true",
                        help="do not stream; print each review when it is done")
    args = parser.parse_args()

    try:
        changes = staged_changes()
    except RuntimeError as err:
        print(f"git: {err}", file=sys.stderr)
        return 2

    if not changes:
        print("nothing staged")
        return 0

    findings = 0
    for change in changes:
        print(f"\n=== {change.path} " + "=" * max(0, 60 - len(change.path)))
        try:
            result = review(change) if args.quiet else review_streaming(change)
        except (httpx.ConnectError, httpx.ReadTimeout):
            print("Could not reach a model at http://127.0.0.1:8081/v1 — "
                  "open Pip and start a text server.", file=sys.stderr)
            return 2
        if args.quiet:
            print(result)
        if "LOOKS FINE" not in result.upper():
            findings += 1

    print(f"\n{findings} of {len(changes)} files had findings")
    return 1 if (args.strict and findings) else 0


if __name__ == "__main__":
    sys.exit(main())

How you know it worked

python cli.py && echo "exit $?"

A section per staged file and a closing count. Run it with --strict on a diff you know has a problem and confirm the exit code is 1; run it on a clean diff and confirm 0.

Now build it again, with the other agent

This is the step that justifies the project sitting at the end of the course. You have built this by hand, or with one agent. Now give the same instruction to the other one — Codex if you used Claude Code, Claude Code if you used Codex — in an empty folder, and read both results side by side.

What you are looking for is not which is better. It is what each one assumed when you did not tell it. One will stream and the other will not. One will review the whole diff at once and blow the context window on a large change; the other will go file by file. One will add a pre-commit hook you did not ask for. Those assumptions are exactly what you need to learn to spot, because they are what you will be reviewing for the rest of your working life.

  • Did it review file by file, or send everything in one request?
  • Did it handle the local server being down, or does it throw?
  • Did it give the model permission to find nothing?
  • Did it hard-code a model name instead of asking /v1/models?
  • Did it reach for a cloud API anywhere, despite the instruction?
  • Which one would you rather maintain in six months?

How you know it worked

Two working implementations and a written note of six differences. If both agents made the same omission, that omission is probably in your instruction rather than in the agents — which is the most useful thing this whole project can teach you.

Building it with an agent

I want a command-line tool that reviews my staged git diff against a local model and prints findings with file and line references. It should look for bugs and leftovers rather than style, stream its output, cope with the local server being down, and exit non-zero only when I ask it to.

With Claude Code

Claude Code can run git and read your actual repository, which makes it well suited here — it can test against a real diff instead of an imagined one:

  Build a Python CLI that reviews my staged git changes using a
  local OpenAI-compatible model at http://127.0.0.1:8081/v1 via
  the openai package. No cloud API.

  It runs `git diff --cached` per changed file rather than all
  at once, so each request fits the context window. The system
  prompt looks for bugs, leftover debugging code, and missing
  tests — not style — and is explicitly allowed to reply
  LOOKS FINE. Stream the output. Handle the server being down
  with one clear line, not a traceback. Add a --strict flag
  that makes it exit 1 when there are findings.

  Stage a file with a deliberate off-by-one bug and show me it
  catches it before you add anything else.

That last instruction turns the agent's own test into your acceptance test, which is a habit worth keeping well beyond this project.

With Codex

Give Codex the layout and the two behaviours it will otherwise omit — streaming and the clean-diff escape:

  Create a Python project: gather.py, review.py, cli.py,
  requirements.txt, README.md.

  gather.py shells out to git for the staged file list and a
  per-file diff with --unified=8.

  review.py talks to http://127.0.0.1:8081/v1 with the openai
  package, api_key="not-needed", model id from
  client.models.list(), temperature 0.1. The system prompt asks
  for bugs, leftover debug code and missing tests only, one
  finding per line as "path:line description", explicitly
  ignores style, and replies exactly "LOOKS FINE" when there is
  nothing wrong. Provide both a blocking and a streaming
  version.

  cli.py reviews each staged file in turn, streams by default,
  supports --quiet and --strict, prints a summary count, and
  exits 2 with one clear sentence if the local server is
  unreachable.

The "explicitly replies LOOKS FINE" clause is the one to keep. Without it you get a reviewer that finds something wrong with every commit, and you will stop running it.

Read what it gave you

Generated code is a draft. These are the things that go wrong on this particular project, in roughly the order they are worth checking.

  • Does it go file by file? A single request containing a large diff will exceed a local model's context window, and some servers truncate silently rather than erroring — so the reviewer reports on half your change and says nothing about the rest.
  • Is the model allowed to find nothing? Without an explicit escape the reviewer manufactures a finding on every clean diff and becomes noise.
  • Does it read the index or the working tree? `git diff` and `git diff --cached` are different things, and reviewing unstaged changes while you commit staged ones produces confusing results.
  • Does it stream with flush? Buffered streaming looks identical to no streaming.
  • Is the model id fetched rather than hard-coded? A hard-coded name breaks the moment you load a different model.
  • What is the exit code on success? A tool that exits non-zero when it worked will break any hook you put it in.
  • Does it handle a binary file in the diff? A staged image produces a diff git describes rather than shows, and an agent rarely thinks of it.
  • Ask the agent what happens when the diff is larger than the context window. If it has no answer, neither does the tool, and this is the failure that matters most here.

Where to take it next

  • Pass the whole changed file rather than the diff when the file is small enough to fit. Reviewing a change in isolation misses anything that depends on code it did not touch.
  • Add a second opinion from a larger model for files you mark as sensitive, and keep everything else local. This is the two-lane design from the Strategy track, in something you use daily.
  • Feed it your team's conventions as part of the system prompt, so it reviews against your rules rather than its training's.
  • Track which findings you acted on. After a month you will know what your model is actually good at catching, which is worth more than any benchmark.
  • Put it in a pre-commit hook without --strict first. Advisory for a month, blocking only once you trust it.

Where this comes from

  1. git-diff documentation, including --cached and --unified
  2. openai-python: the official client library

Stuck on a step, or found something here that does not work? Email info@redroostertec.com and a person will answer.