Two ways to eat abalone A cross-section of the Jeju coast. On the cliff, a small restaurant with a table set outside. Out at sea, an orange float bobs on the surface while a diver, tethered below it, reaches for abalone on the basalt seabed.
All posts

The Haenyeo Guide to AI: When to Order, and When to Dive

Cloud APIs are the restaurant. Local models are the ocean. How to tell which one a job needs, and what it really takes to build your first local agent properly.

There are two ways to eat abalone on Jeju Island.

You can sit at a restaurant on the cliffs, point at the menu, and have it on your plate in twenty minutes. Or you can do what Jeju's haenyeo have done for generations: pull on a wetsuit, swim out to an orange float, take one breath, and go down to pry it off the basalt yourself.

The haenyeo, the island's women divers, work without oxygen tanks. A dive lasts about as long as a breath, roughly a minute. Then they surface with a sharp, whistling exhale called the sumbisori, recover, and go again, for four or five hours a day. In 2016, UNESCO recognized their culture as an intangible heritage of humanity.

Working with AI offers the same two options. A cloud API is the restaurant: a prompt goes in, a good answer comes out, and you never see the kitchen. Running open-weight models on your own hardware is the dive: more effort, more risk, and total control over what you bring up.

Neither is better. Haenyeo eat at restaurants too. The difference is that they know what the restaurant is doing to their abalone, what it costs, and when it's worth going down themselves. That judgment is what this post is about.

The first half is how to make the call, with real numbers. The second half is the dive. We'll build a code reviewer that runs on your laptop every time you git push, and open every layer as we go: sizing the model, the agent loop, the guardrails, and how to tell whether it's any good.

Reading the weather

Haenyeo don't decide whether to go out based on how they feel that morning. The most senior divers, the sanggun, read the wind and the swell and make the call for the group. Choosing between a cloud API and a local model deserves the same discipline, and it comes down to three questions.

Who is allowed to see this data?

Cloud providers offer serious protections: zero-retention options, enterprise agreements and regional hosting. For a lot of work, that's enough. But "enough" is a decision for your security and legal teams, not for whoever happens to be typing the prompt.

Some things you'd rather not send anywhere. Unreleased source code. Customer records. And the case that frames the rest of this post: credentials that slipped into a diff. A tool whose job is to catch leaked secrets shouldn't have to upload them to do it.

How often will you call it?

You'll hear that cloud AI bills "compound exponentially." They don't. They grow in a straight line. The trouble is that the line has no ceiling, and nobody watches the slope until finance does.

Here are two example bills. The prices are illustrative, so plug in your own. The code review assumes a mid-tier frontier model at $3 per million input tokens and $15 per million output tokens. The tagging job assumes a small model at $0.50 per million tokens.

Pre-push code review Nightly product tagging
Volume 40 engineers × 5 pushes a day ≈ 4,200 reviews a month 2 million records a night
Tokens per unit ~40K in, ~2K out (an agent re-sends the whole conversation every round) ~600
Rough monthly bill ~$630 ~$18,000 (batch pricing can roughly halve it)

The review bill is real money, but it isn't why you'd run that workload locally. The tagging bill is where a GPU in your own rack starts paying for itself, as long as someone owns keeping it running. Local isn't free either. Hardware, power, and an engineer's time all get paid for somewhere; it just isn't on an invoice with a model's name on it.

How hard is the thinking?

Frontier cloud models are still ahead on open-ended reasoning: a novel architecture, an ambiguous contract, a bug nobody can reproduce. Local models are strongest on narrow, well-specified jobs where they can use tools to check their own work. Give a mid-sized open model a tight task and a way to verify its claims, and it holds its own. Give it a vague task, and you'll find out why frontier models cost what they do.

Reading the weather: when to order and when to dive A two-by-two map. Horizontal axis: how often you call the model, from rarely to constantly. Vertical axis: how sensitive the data is, from public to must-not-leave. Top right is labelled dive, bottom left order, top left order from a kitchen you trust, bottom right do the math. Example workloads are plotted; pre-push code review sits top right and is highlighted. Order from a kitchen you trust Enterprise terms, private endpoints Dive Run it on hardware you control Order Cloud APIs, pay per call Do the math Batch pricing vs. your own GPUs How often you call the model Rarely Constantly How sensitive the data is Public Can't leave the building Pre-push code review Nightly tagging of 2M product records Reviewing a customer contract Architecture review, new system Prototype for Friday's demo Classifying public forum posts Drafting a blog post Narrow task the model can check with tools Open-ended, benefits from frontier reasoning
Plot your workload on two axes, then ask how hard the thinking is. Filled points are narrow tasks a model can verify with tools. Rings are open-ended tasks that benefit from frontier reasoning.

Pre-push review lands firmly in the top right. The code shouldn't leave the building, the review runs constantly, and the task is narrow. So that's where we'll dive. First, though, it's worth being clear about what you're taking down with you.

The catch is not the meal

Pull an open-weight model onto your laptop and you have the equivalent of a freshly pried abalone: excellent raw material, but not yet dinner. On its own, a model does exactly one thing. Given a conversation, it predicts the next message. It can't open a file, run a query, or remember what happened five minutes ago.

Everything that makes it feel like an agent is code you write around it. That code is the harness. The most useful thing to understand about a harness is this: the model never does anything. It asks.

When a model "calls a tool", it emits a structured request:

{"role": "assistant",
 "tool_calls": [{"function": {"name": "search_repo",
                              "arguments": {"text": "customers_scd"}}}]}

Your code reads that request, checks that it is allowed, runs it, and appends the result to the conversation:

{"role": "tool", "tool_name": "search_repo",
 "content": "jobs/customer_ltv.py:5:    customers = (spark.read.table(\"sales.customers_scd\")"}

Then it calls the model again with the longer conversation, and repeats until the model stops asking. That's the whole trick. Everything else is about doing it safely.

Anatomy of a dive: what a harness does A diver's dive mapped onto an agent harness. The orange float at the surface is the harness state. The whistle on surfacing is the structured report. A partner at the surface stands for guardrails. A breath gauge is the step and context budget. The diver's hook is the tools. The basalt seabed is the read-only repository. call a tool read the result The float holds the state 테왁 · tewak The model forgets everything between calls. The conversation and budget live in your code. No one dives alone Timeouts, output caps and redaction. Checks you trust fail closed; the model fails open. The whistle is the report 숨비소리 · sumbisori Every run ends in JSON that validates against a schema, so a program can act on it. The breath is a budget A cap on tool rounds and on context. When either runs out, surface with what you have. MAX_ROUNDS = 6 The tools are a short, sharp kit Two read-only tools. Errors come back as text, so the model can correct itself and retry. read_file · search_repo The seabed is your repository Look, don't touch. No shell, no writes, only the commit you're about to push.
A harness has the same parts as a dive.

The float holds the state. A haenyeo's catch goes into a net hanging from her tewak, the orange float at the surface, not into her hands. A model is the same: it remembers nothing between calls, so the conversation, the budget and the findings all live in your code. When people talk about "persistent memory" for agents, they mean this state written somewhere durable, such as SQLite, a vector store or a file. A pre-push review finishes in one sitting, so it doesn't need any.

The breath is a budget. Every dive ends, whether or not the abalone cooperated. Your loop needs the same hard stop: a maximum number of tool rounds, and a ceiling on how much of the context window the conversation can fill. Without one, a confused model will happily re-read the same file until your push times out.

The tools are a short, sharp kit. Haenyeo carry only a few tools, each for one job. Smaller models get noticeably worse at choosing tools as the list grows, and every tool description is more prompt text for the model to read. Two good tools beat eight vague ones.

No one dives alone. The partner at the surface stands for your guardrails: tools that can only read, outputs that are capped and redacted, timeouts on everything, and a clear rule for what happens when something fails.

The whistle is the report. The sumbisori tells everyone at the surface that a diver is up. A run should end the same way, with a structured report that validates against a schema, so the next program in line can act on it without anyone reading prose.

Frameworks like LangGraph and Pydantic AI give you this loop with persistence, streaming and retries built in, and you should reach for them in production. But build the loop by hand once. It's about fifty lines, and when a framework does something surprising, you'll know which layer to look at.

Friday, 6:40 p.m.

You've spent the afternoon on a revenue job that keeps assigning orders to the wrong region. Finally you find the cause: the old customers table doesn't have the new region column, but customers_scd does. You swap it in. To test against the real bucket, you paste an AWS key into the file, just for a minute. You add .limit(1000) so the run finishes before the weekend does. It works.

git commit -am "fix region mapping". git push. Laptop closed.

On Monday, the revenue dashboard is wrong in two directions at once. The limit quietly dropped most of Friday's orders. And customers_scd is a type 2 slowly changing dimension, with one row for every version of every customer. Anyone who moved house last year now matches twice, and so do their orders. Meanwhile, the key has been sitting in the remote since Friday evening, and git history doesn't forget.

Here's the diff that went out:

 from pyspark.sql import SparkSession, functions as F

+AWS_ACCESS_KEY_ID = "AKIAZ7Q4R2MXEXAMPLE1"   # TODO remove before merge
+

 def run(spark: SparkSession, run_date: str) -> None:
-    orders = spark.read.table("sales.orders").where(F.col("order_date") == run_date)
-    customers = spark.read.table("sales.customers")
+    orders = (spark.read.table("sales.orders")
+              .where(F.col("order_date") == run_date)
+              .limit(1000))
+    customers = spark.read.table("sales.customers_scd")   # has the new region column

     enriched = orders.join(customers, on="customer_id", how="left")

None of these are hard bugs. Any reviewer would catch all three in half a minute; the problem is that nobody reviews anything at 6:40 on a Friday. Notice, too, that the worst of the three is invisible in the diff. The join line didn't change. You only see the problem if you know that every other job reading customers_scd filters on is_current. That means looking beyond the diff, which is exactly what tools are for.

So let's build a reviewer that lives on your laptop and whistles before your code surfaces. I've called it sumbisori.

It fits the three questions from earlier. The code, and that key, never leave your machine. It runs on every push by every engineer, so per-call pricing would add up for no good reason. And the job is narrow: read a diff, check a few facts, report back. It doesn't replace secret scanning in CI or human review of the pull request. It's the partner at the surface who catches the obvious before anyone else has to.

What happens when you run git push git push triggers the pre-push hook, which takes the diff of the commits being pushed. A secret scan runs first and blocks on any hit. The redacted diff goes into the agent loop, where a local model on Ollama calls two read-only tools for up to six rounds. The loop ends in a JSON report validated by Pydantic. Any blocking finding stops the push; otherwise it continues to the remote. Everything except the final push stays on your machine. Your machine: nothing leaves until exit 0 $ git push you, 6:40 p.m. pre-push hook diff of the commits being pushed Secret scan regex: instant, and redacts The harness: agent loop Local model qwen3-coder:30b served by Ollama on localhost:11434 Read-only tools read_file search_repo at the pushed commit tool call result Stops after 6 rounds, or at 75% of a 32K context, whichever comes first. If Ollama is down or slow, the review is skipped with a warning. redacted diff Report JSON, validated by Pydantic block | warn Exit code 1 push stopped 0 push continues --no-verify to bypass secret found: block origin the remote, where history is forever
Everything happens on your machine. The push only continues if the hook exits 0.

The dive, layer by layer

Everything below is a package called sumbisori, in my blog-tools repository on GitHub. The README there covers installing it, a dry-run command for trying it on a branch before you wire it into git, and the evals from step 6.

git clone https://github.com/Kunal-Vyas/blog-tools.git

1. Size the model to your machine, not the leaderboard

Two numbers decide whether a model is usable locally, and they're not the same number. Total parameters decide whether it fits. Active parameters decide how fast it talks.

Will it fit? Weights take roughly parameters × bits ÷ 8 bytes. Ollama's default 4-bit builds (Q4_K_M) average a little under 5 bits per weight. So qwen3-coder:30b, with 30.5 billion parameters, is about a 19 GB download.

Then there's the part people forget: the KV cache, which stores attention state for every token in the context. Its size per token is:

2 (keys and values) × layers × KV heads × head dimension × bytes per value
= 2 × 48 × 4 × 128 × 2 bytes ≈ 96 KB per token

At a 32K context, that's about 3 GB, so weights, cache and runtime buffers land just under 24 GB. Ask for 128K and the cache alone grows to about 12 GB. The model no longer fits on the card, Ollama spills part of it into system RAM, and every token gets much slower.

Memory budget for qwen3-coder:30b on a 24 GB GPU Two stacked bars against a 24 GB line. At 32K context: about 19 GB of weights, 3 GB of KV cache and about 1 GB of runtime buffers, which fits. At 128K context the KV cache grows to about 12 GB and the total passes 24 GB, spilling into system memory. 0 8 GB 16 GB 32 GB 32K context fits Weights, Q4_K_M ≈19 GB KV 3 GB 128K context spills over Weights, Q4_K_M ≈19 GB KV cache KV cache ≈12 GB: past the card, into system RAM, and much slower 24 GB card runtime buffers, roughly 1 GB (varies by runtime and batch size)
Context length is a memory decision. ollama ps shows whether a loaded model is running entirely on the GPU.

Will it be fast enough? Generating each token means reading the active weights out of memory. That puts a ceiling on decode speed of roughly memory bandwidth ÷ bytes read per token.

A dense 27B model at 4 bits reads about 17 GB per token. On a high-end GPU with around 1 TB/s of bandwidth, that caps out near 55 tokens a second. On a laptop chip with about 270 GB/s, it's nearer 16.

qwen3-coder:30b is a mixture-of-experts model that activates only 3.3 billion parameters per token, about 2 GB of reads. Its ceiling is several times higher on the same hardware.

For something that sits in your push path, speed matters more than the last few points of benchmark quality. That's why the reviewer defaults to qwen3-coder:30b. On a 16 GB machine, try gpt-oss:20b. If you move the review into CI on a proper GPU, a dense model like qwen3.8:27b trades speed for deeper reasoning. The model is one environment variable, so try all three.

One more trap. Unless you say otherwise, Ollama picks the context length from your VRAM: 4K tokens under 24 GB, 32K between 24 and 48 GB, and 256K above that. On a 16 GB laptop, 4K won't hold a system prompt, a medium-sized diff and one file read. Instead of failing loudly, the oldest parts of the conversation get dropped. Set it explicitly:

ollama pull qwen3-coder:30b

# Match the context the harness requests (a mismatch makes Ollama reload
# the model), and keep it warm so each push doesn't pay for a cold load.
OLLAMA_CONTEXT_LENGTH=32768 OLLAMA_KEEP_ALIVE=30m ollama serve

If memory is tight, OLLAMA_KV_CACHE_TYPE=q8_0 roughly halves the cache at a small cost in quality.

2. Run the checks that can't be wrong first

Finding an AWS key is a pattern-matching problem, not a reasoning problem. A regex is instant, free and never hallucinates, so it runs before the model and is allowed to block the push on its own. It also redacts what it finds. Even a local model shouldn't hold your secrets in its context, where they can end up echoed into terminal output or logs.

# sumbisori/scan.py
"""Deterministic checks that run before any model sees the diff."""
import re
from dataclasses import dataclass

# Patterns with near-zero false positives. For production, reach for
# gitleaks or detect-secrets; this is the 40-line version of the idea.
PATTERNS = {
    "AWS access key ID": re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
    "GitHub token": re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"),
    "Slack token": re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"),
    "Private key": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
    "Hardcoded credential": re.compile(
        r"""(?i)\b\w*(?:password|passwd|secret|token|api_?key)\w*\s*[:=]\s*['"]([^'"\s]{8,})['"]"""
    ),
}
HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")


def redact(text: str) -> str:
    for kind, pattern in PATTERNS.items():
text = pattern.sub(f"[REDACTED {kind}]", text)
    return text


@dataclass
class SecretHit:
    path: str
    line: int
    kind: str


def scan_diff(diff: str) -> tuple[list[SecretHit], str]:
    """Find secrets on added lines. Return the hits and a redacted copy of the diff."""
    hits, out = [], []
    path, line_no = "?", 0
    for raw in diff.splitlines():
line = raw
if raw.startswith("+++ "):
    path = raw[6:] if raw.startswith("+++ b/") else raw[4:]
elif m := HUNK.match(raw):
    line_no = int(m.group(1))
elif raw.startswith("+"):
    hits += [SecretHit(path, line_no, kind) for kind, p in PATTERNS.items() if p.search(raw)]
    line = redact(raw)
    line_no += 1
elif raw.startswith(" "):
    line_no += 1
out.append(line)
    return hits, "\n".join(out)

In production, use gitleaks or detect-secrets, which recognize hundreds of formats. The principle is what matters: let deterministic code handle what it can, and save the model for what needs judgment.

3. Give the model a short, sharp kit

The reviewer gets exactly two tools. The interesting decisions are about what they can't do.

# sumbisori/tools.py
"""The only things the model is allowed to do: read the commit being pushed."""
import subprocess

from .scan import redact

MAX_LINES = 200       # per read_file call
MAX_MATCHES = 40      # per search_repo call
MAX_CHARS = 8_000     # hard cap on anything we put back into the context

TOOL_SCHEMAS = [
    {
"type": "function",
"function": {
    "name": "read_file",
    "description": "Read lines from a file as it exists in the commit being pushed. "
                   "Use it to see code around a change.",
    "parameters": {
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "Repo-relative path, e.g. 'jobs/daily_revenue.py'"},
            "start_line": {"type": "integer", "description": "First line, 1-based. Default 1."},
            "end_line": {"type": "integer", "description": f"Last line, inclusive. At most {MAX_LINES} lines per call."},
        },
        "required": ["path"],
    },
},
    },
    {
"type": "function",
"function": {
    "name": "search_repo",
    "description": "Find every line in the repository containing an exact string. "
                   "Use it to check how a table, column or function is used elsewhere.",
    "parameters": {
        "type": "object",
        "properties": {
            "text": {"type": "string", "description": "Exact text to search for, e.g. 'customers_scd'"},
        },
        "required": ["text"],
    },
},
    },
]


def _git(root: str, *args: str) -> subprocess.CompletedProcess:
    # argv list, never shell=True: nothing the model says is ever interpreted by a shell.
    return subprocess.run(["git", "-C", root, *args], capture_output=True, text=True, timeout=10)


def _cap(text: str) -> str:
    text = redact(text)     # tool output is context too: don't hand back the key we just redacted
    return text if len(text) <= MAX_CHARS else text[:MAX_CHARS] + "\n[...truncated]"


class RepoTools:
    """Reads from git's object database at a fixed commit, not from the filesystem.
    There is no path to escape to, and uncommitted edits can't leak into the review."""

    def __init__(self, root: str, commit: str):
self.root, self.commit = root, commit

    def read_file(self, path: str, start_line: int = 1, end_line: int | None = None) -> str:
path = path.strip().lstrip("/")
if path.startswith("-") or ".." in path.split("/"):
    return f"Error: '{path}' is not a valid repo-relative path."
proc = _git(self.root, "show", f"{self.commit}:{path}")
if proc.returncode != 0:
    return f"Error: '{path}' does not exist in the commit being pushed."
lines = proc.stdout.splitlines()
start = max(1, int(start_line))
end = min(len(lines), int(end_line) if end_line else start + MAX_LINES - 1, start + MAX_LINES - 1)
numbered = [f"{n:>5}  {lines[n - 1]}" for n in range(start, end + 1)]
return _cap(f"{path} (lines {start}-{end} of {len(lines)})\n" + "\n".join(numbered))

    def search_repo(self, text: str) -> str:
if not text.strip():
    return "Error: search text is empty."
proc = _git(self.root, "grep", "-n", "-I", "-F", "-e", text, self.commit, "--")
if proc.returncode == 1:
    return f"No matches for '{text}'."
if proc.returncode != 0:
    return f"Error: search failed: {proc.stderr.strip()[:200]}"
prefix = f"{self.commit}:"
matches = [m.removeprefix(prefix) for m in proc.stdout.splitlines()]
shown = matches[:MAX_MATCHES]
more = f"\n[{len(matches) - len(shown)} more matches not shown]" if len(matches) > len(shown) else ""
return _cap("\n".join(shown) + more)

    def call(self, name: str, arguments: dict) -> str:
        """Dispatch a tool call. Mistakes come back as text so the model can correct itself."""
handler = {"read_file": self.read_file, "search_repo": self.search_repo}.get(name)
if handler is None:
    return f"Error: unknown tool '{name}'. Available tools: read_file, search_repo."
try:
    return handler(**arguments)
except (TypeError, ValueError) as err:
    return f"Error: bad arguments for {name}: {err}"
except subprocess.TimeoutExpired:
    return f"Error: {name} timed out."

A few of these choices are worth spelling out:

  • The tools read from git, not the filesystem. git show <commit>:<path> can only return files in the commit being pushed. There's no ../../ to escape to, and uncommitted edits can't leak into the review.
  • There's no shell, ever. Commands are built as argument lists, so nothing the model writes is interpreted by a shell. There's no write tool either; a reviewer doesn't need hands.
  • Every output is capped and redacted. Capping protects the context budget. Redaction closes a gap that's easy to miss: without it, read_file would hand the model the exact key we just redacted from the diff.
  • Mistakes come back as text. If the model misspells an argument or invents a tool, it gets an error message and a chance to try again. That's all a "self-correction loop" really is.
  • Descriptions are prompts. "Use it to check how a table, column or function is used elsewhere" nudges the model toward exactly the check that catches the slowly-changing-dimension bug.

The schemas are written by hand for a reason. The Ollama Python client can build them from a function's docstring, but at the time of writing (ollama-python 0.6.2) it marks parameters that have default values as required.

4. The loop: one breath at a time

# sumbisori/agent.py
"""The harness: a bounded loop around a local model."""
import os
from typing import Literal

import ollama
from pydantic import BaseModel, Field, ValidationError

from .tools import TOOL_SCHEMAS, RepoTools

MODEL = os.environ.get("SUMBI_MODEL", "qwen3-coder:30b")
NUM_CTX = int(os.environ.get("SUMBI_NUM_CTX", "32768"))
MAX_ROUNDS = 6          # the breath: tool rounds before we force the model to surface
CONTEXT_BUDGET = 0.75   # ...or sooner, if the conversation fills 75% of the window

SYSTEM = """You review code changes to a data engineering repository before they are pushed.

Look for problems that would hurt production:
- debugging leftovers: .limit(), .sample(), .show(), hardcoded dates, local file paths
- joins or filters that silently change row counts
- destructive SQL: DROP, TRUNCATE, DELETE or UPDATE without a WHERE clause
- errors that are swallowed, and writes that are not idempotent on re-run

Rules:
- Only report problems introduced by lines this diff adds or changes.
- Verify before you claim. If a finding depends on how a table, column or function is
  used elsewhere, check with search_repo or read_file first.
- Secrets are already handled and appear as [REDACTED ...]. Do not report them.
- Skip style and naming. If nothing is wrong, say so."""


class Finding(BaseModel):
    severity: Literal["block", "warn"] = Field(description="block = would break production or corrupt data")
    path: str
    line: int | None = None
    title: str = Field(description="One line, under 80 characters")
    evidence: str = Field(description="What you checked that confirms the problem")


class Review(BaseModel):
    findings: list[Finding]


def _approx_tokens(messages: list) -> int:
    # ~3 characters per token is a deliberately pessimistic estimate for code.
    return sum(len(str(m["content"] or "")) + len(str(m.get("tool_calls") or "")) for m in messages) // 3


def review(diff: str, tools: RepoTools, client: ollama.Client) -> Review:
    messages: list = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"Review this diff:\n\n{diff}"},
    ]
    options = {"num_ctx": NUM_CTX, "temperature": 0.1}

    for _ in range(MAX_ROUNDS):
response = client.chat(model=MODEL, messages=messages, tools=TOOL_SCHEMAS,
                       options=options, keep_alive="30m")
messages.append(response.message)
if not response.message.tool_calls:
    break                                   # the model has seen enough
for call in response.message.tool_calls:
    result = tools.call(call.function.name, call.function.arguments)
    messages.append({"role": "tool", "tool_name": call.function.name, "content": result})
if _approx_tokens(messages) > CONTEXT_BUDGET * NUM_CTX:
    break                                   # running out of breath: surface now

    return _surface(messages, client, options)


def _surface(messages: list, client: ollama.Client, options: dict) -> Review:
    """Turn the investigation into a validated, machine-readable report."""
    messages = messages + [{
"role": "user",
"content": "Stop investigating. Report only the problems you verified, as JSON.",
    }]
    for _ in range(2):
response = client.chat(model=MODEL, messages=messages, format=Review.model_json_schema(),
                       options={**options, "temperature": 0}, keep_alive="30m")
try:
    return Review.model_validate_json(response.message.content)
except ValidationError as err:
    messages += [response.message,
                 {"role": "user", "content": f"That JSON was invalid:\n{err}\nReturn corrected JSON only."}]
    raise RuntimeError("the model could not produce a valid report")

The loop runs in two phases, and the split is deliberate.

Investigation. The model gets its tools and does the work: search, read, reason. The harness enforces the budget; it isn't just a request in the prompt. The loop stops after six rounds or at 75% of the context, whichever comes first. The token count is a deliberately pessimistic estimate from character counts. The moment you run out of context is not the moment to discover your estimate was optimistic.

Surfacing. The tools are taken away and the model is asked for a report. Ollama's format parameter constrains the output to the Review JSON schema, and Pydantic validates it. If validation fails, the error goes back to the model for one more attempt. Asking a model to call tools and produce strict JSON in the same call makes the two jobs fight each other. Separating them gives each call a single job.

Two details in the schema do more work than they appear to. severity is a Literal, so the hook never has to guess what "critical-ish" means. And evidence makes the model say what it checked, which makes unverified claims easy to spot.

On the Friday diff, the path you want to see goes like this:

  1. The model notices the table swap.
  2. It searches for customers_scd and finds customer_ltv.py filtering on is_current.
  3. It reads that file to confirm.
  4. It reports a blocking finding, with that filter as evidence.

Whether it actually takes that path, and how often, is exactly what step 6 measures.

5. Wire it into git

# sumbisori/hook.py
"""git pre-push hook: review what is about to leave your machine."""
import os
import subprocess
import sys

import httpx
import ollama

from .agent import MODEL, review
from .scan import scan_diff
from .tools import RepoTools

BASE_BRANCH = os.environ.get("SUMBI_BASE", "origin/main")
MAX_DIFF_CHARS = 40_000
TIMEOUT_SECONDS = 120


def git(*args: str) -> str:
    return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout.strip()


def is_zero(sha: str) -> bool:
    return set(sha) == {"0"}        # works for SHA-1 and SHA-256 repositories


def ranges(stdin_lines):
    """git feeds pre-push one line per ref: <local ref> <local sha> <remote ref> <remote sha>"""
    for line in stdin_lines:
if not line.strip():
    continue
_, local_sha, _, remote_sha = line.split()
if is_zero(local_sha):
    continue                                        # deleting a remote branch
known = not is_zero(remote_sha) and subprocess.run(
    ["git", "cat-file", "-e", remote_sha], capture_output=True).returncode == 0
if known:
    yield remote_sha, local_sha
    continue
try:                                                # new branch, or remote moved
    yield git("merge-base", BASE_BRANCH, local_sha), local_sha
except subprocess.CalledProcessError:
    print(f"sumbisori: can't find a base for {local_sha[:8]}; skipping.", file=sys.stderr)


def check(root: str, base: str, head: str, client: ollama.Client) -> bool:
    """Review one range. Returns True if the push should be stopped."""
    diff = git("diff", "--no-color", "--unified=5", f"{base}..{head}")
    if not diff:
return False

    hits, redacted = scan_diff(diff)
    for hit in hits:
print(f"  BLOCK  {hit.path}:{hit.line}  {hit.kind}")
    blocked = bool(hits)                                    # deterministic checks fail closed

    if len(redacted) > MAX_DIFF_CHARS:
print(f"  skip   diff is {len(redacted):,} chars, too large for a local review")
return blocked
    try:
report = review(redacted, RepoTools(root, head), client)
    except (ConnectionError, httpx.TimeoutException, ollama.ResponseError, RuntimeError) as err:
print(f"  skip   model review unavailable ({MODEL}): {err}")       # the model fails open
return blocked

    for f in report.findings:
where = f"{f.path}:{f.line}" if f.line else f.path
print(f"  {f.severity.upper():<5}  {where}  {f.title}\n         {f.evidence}")
    if not hits and not report.findings:
print("  ok     nothing to flag")
    return blocked or any(f.severity == "block" for f in report.findings)


def main() -> int:
    root = git("rev-parse", "--show-toplevel")
    client = ollama.Client(timeout=TIMEOUT_SECONDS)          # the default timeout is None: forever
    blocked = False

    for base, head in ranges(sys.stdin):
blocked |= check(root, base, head, client)

    if blocked:
print("\nPush stopped. Fix the issues above, or push anyway with: git push --no-verify")
    return 1 if blocked else 0


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

Package it so git can call it:

# pyproject.toml
[project]
name = "sumbisori"
version = "0.1.0"
description = "A local pre-push code reviewer built on Ollama"
requires-python = ">=3.11"
dependencies = ["ollama>=0.6", "pydantic>=2.7", "httpx>=0.27"]

[project.scripts]
sumbisori-pre-push = "sumbisori.hook:main"

[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"

[tool.setuptools]
packages = ["sumbisori"]

Then install it with pip install -e . and add the hook:

#!/bin/sh
# .git/hooks/pre-push  (chmod +x)
# Review the commits being pushed. Bypass with: git push --no-verify
exec sumbisori-pre-push "$@"

The details that matter:

  • git tells the hook what's being pushed. Each line on stdin carries the local and remote commit for one branch. For a new branch, the remote commit is all zeros, so we review from where the branch forked off origin/main. Checking for "all zeros", rather than exactly forty of them, keeps it working in SHA-256 repositories.
  • Set a timeout. The Ollama Python client's default timeout is None, so a hung server would hang your push forever.
  • Decide how each check fails. The secret scan is deterministic, so it fails closed: a hit blocks the push. The model is probabilistic, and its server might be off, so it fails open: if Ollama is unreachable, you get a warning and the push continues. A reviewer that stops people working whenever the GPU is busy gets uninstalled by Tuesday.
  • Keep an escape hatch. git push --no-verify skips the hook. Guardrails nobody can bypass don't stay installed for long.
  • Cap the diff. A huge diff won't fit in the budget, so the model review is skipped and the hook says so. That's the breath limit again.

The same check() runs from python -m sumbisori.dryrun <branch>, which prints what the hook would print without pushing anything. That's the way to try it before wiring it into git.

When it fires, the push stops with something like this:

  BLOCK  jobs/daily_revenue.py:3  AWS access key ID
  BLOCK  jobs/daily_revenue.py:10  Join to customers_scd without an is_current filter
 customer_ltv.py filters customers_scd on is_current; this join matches every customer version
  WARN   jobs/daily_revenue.py:9  .limit(1000) left in a production read
 The limit truncates the day's orders before aggregation

Push stopped. Fix the issues above, or push anyway with: git push --no-verify

6. Find out whether it's any good

A reviewer that cries wolf gets --no-verify'd into irrelevance within a week. One that misses things is worse than none, because people start trusting it. So before you roll it out to a team, measure three things: how many real problems it catches, how often it blocks a clean change, and how long it makes people wait.

Build a fixture repository with one branch per known problem, plus clean branches that must pass, and replay them:

# evals/run_evals.py
"""Replay branches with known problems through the reviewer and score it.

Each case runs several times: local calls are free, and models are not deterministic.
Usage: python evals/run_evals.py /path/to/fixture-repo
"""
import statistics
import subprocess
import sys
import time

import ollama

from sumbisori.agent import MODEL, review
from sumbisori.scan import scan_diff
from sumbisori.tools import RepoTools

REPO = sys.argv[1] if len(sys.argv) > 1 else "."
RUNS = 3
CASES = {                                   # branch -> file that must be flagged (None: must not block)
    "case/scd2-join": "jobs/daily_revenue.py",
    "case/delete-without-where": "sql/cleanup.sql",
    "case/limit-left-in": "jobs/daily_revenue.py",
    "case/clean-refactor": None,
}


def git(*args: str) -> str:
    return subprocess.check_output(["git", "-C", REPO, *args], text=True).strip()


client = ollama.Client(timeout=600)
caught = missed = false_blocks = 0
latencies = []

for branch, expected in CASES.items():
    head = git("rev-parse", branch)
    _, diff = scan_diff(git("diff", f"main...{branch}"))
    for _ in range(RUNS):
started = time.perf_counter()
findings = review(diff, RepoTools(REPO, head), client).findings
latencies.append(time.perf_counter() - started)
if expected is None:
    false_blocks += any(f.severity == "block" for f in findings)
elif any(f.path == expected for f in findings):
    caught += 1
else:
    missed += 1
    print(f"missed  {branch}")

clean_runs = RUNS * sum(e is None for e in CASES.values())
print(f"model         {MODEL}")
print(f"caught        {caught}/{caught + missed}")
print(f"false blocks  {false_blocks}/{clean_runs}")
print(f"latency       p50 {statistics.median(latencies):.1f}s   "
      f"p95 {statistics.quantiles(latencies, n=20)[-1]:.1f}s")

Each case runs three times, because local calls cost nothing and models aren't perfectly consistent. A problem caught one time in three isn't caught. Watch p95 latency rather than the average, since the slowest push is the one people remember. And every time the reviewer misses something in real life, add it as a case.

Then swap SUMBI_MODEL, tweak the prompt, change the budget, and run it again. This is the part the restaurant never lets you do: change the recipe and taste the difference.

If you would rather start from the working version than from the listings above, clone blog-tools and follow the README.

Knowing when to surface

Here's the honest ledger. Going local bought us a reviewer that runs on every push, for every engineer, at no extra cost per call. Neither the code nor the key ever leaves the laptop. And every layer, from the model to the prompt to the budget, is ours to measure and change. It cost an afternoon of plumbing, a 19 GB download, a model that's less capable than the frontier, and ownership of the evals that tell us whether it works.

That trade is right for this job, and wrong for plenty of others. When the problem is rare and hard, like an architecture review, an ambiguous contract or a production incident nobody understands, order from the best kitchen you have access to, under terms your security team has signed off on. And once you've built a harness, switching kitchens is a change of client, not a rewrite.

The haenyeo don't dive every day. The season is about ninety days a year. The rest of the time they farm, mend their gear and, yes, eat at restaurants. What makes them haenyeo isn't refusing the menu. It's that when the water is right, they know exactly how deep they can go and how long they can stay down, and they always come back up with something in the net.