Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

spar

Occasionally, spar shows you something that isn't there: a synthetic, subtly wrong version of a diff you're reviewing (spar review), or a plausible-but-wrong claim slipped into an AI assistant's own reply (live mode). You engage with it like you would the real thing. Then it tells you whether you caught it.

The premise: review against AI-generated output tends to drift into rubber-stamping — the failure mode any large-volume review task eventually hits once pattern-matching quietly replaces reading. spar is a standing way to check whether you're still reading, without waiting for a real bug to prove it the hard way.

How it works

  1. spar review, run inside a git repo with uncommitted changes, captures the real diff (staged first, else unstaged).
  2. It coin-flips, at a configurable rate, whether this trial injects a bug.
  3. If injecting: it picks one eligible changed file, skipping binary, deleted, or renamed files and anything over a size cap (1,500 lines or 60KB). It asks Claude to produce a subtly mutated version of that file's content, scoped to the lines you actually touched, in a category spar's own random-number generator picked — a code taxonomy (off-by-one, wrong operator, dropped nil check, and so on) for source files, a parallel prose taxonomy (flipped recommendation, misattributed tradeoff, unsupported claim, and so on) for markdown and plaintext, so reviewing an ADR gets a plausible ADR-shaped error, not a code bug wearing prose. It then regenerates a real, valid diff from that locally — the model never hand-writes diff syntax; spar always re-derives it via git diff --no-index, so a malformed diff is never a tell.
  4. You review it exactly like a normal diff — Enter to approve, f to flag a concern.
  5. spar reveals the ground truth immediately: injected or clean, and if injected, what and why.
  6. The trial is logged to ~/.claude/spar/log.jsonl — the only disk write spar makes. Your working tree and git state never change, regardless of outcome.

spar stats reads the log and reports your catch rate.

Install

git clone https://github.com/justinstimatze/spar
cd spar
make install

Needs Go 1.25+ and, optionally, delta on PATH for syntax-highlighted diff rendering — spar falls back to plain text without it. make install runs go install, so the binary lands at $GOBIN or $GOPATH/bin (typically ~/go/bin) — make sure that's on your PATH.

Injection needs ANTHROPIC_API_KEY (see Configuration below) and costs one API call per trial that attempts injection, sending that one file's real content — see SECURITY.md for exactly what does and doesn't leave your machine. Without a key, spar still runs fine; every trial just falls back to showing the real diff.

Usage

spar doctor               # run this first — key, git, delta, log dir
spar review              # rate defaults to 0.4, or SPAR_INJECT_RATE
spar review --rate 0.6
spar stats
spar stats --since 30d --project myrepo   # --project optional, auto-detected
spar stats --trend         # catch rate by week and by category

Live mode adds spar live-hook, spar live-hook-commit, spar live-reveal, spar live-induce, and spar live-fixup — see "Live mode" below. The first three are invoked by Claude Code's hook system, not something you run by hand; the last two are manual, run-when-you-want-them commands.

Configuration

ANTHROPIC_API_KEY — required for injection, resolved from, in order: the shell environment, .env walked up from the current directory, ~/.config/spar/.env. Without it, every attempted trial falls back to showing the real diff, logged with no api key as the reason — see SECURITY.md for exactly what does and doesn't leave your machine.

SPAR_INJECT_RATE — default injection probability (0–1), overridden per-invocation by --rate. Default 0.4.

SPAR_LIVE_ENABLED, SPAR_LIVE_COOLDOWN, SPAR_LIVE_PENDING_TTL, SPAR_LIVE_REVEAL_MODE, SPAR_LIVE_STATS_INTERVAL, SPAR_LIVE_COMMIT_MODE — live mode's own env vars; see "Live mode" below for what each does and their defaults.

Safety invariant

spar reads a diff as text and, at most, shows you a locally-generated variant of that text. It never touches your working tree, your index, or any git ref, under any outcome — a run against real, uncommitted work never risks a synthetic bug landing anywhere but your terminal.

This covers git state only, not the network: a trial that attempts injection sends one file's content to the Anthropic API first — see SECURITY.md for exactly what that does and doesn't include. The mutated candidate is diffed against the original via two temp files (mode 0600) under $TMPDIR, removed as soon as the diff is regenerated.

Live mode

spar review only helps if you actually read diffs. If you don't — if what you actually read is Claude's own chat replies and plan text — then review mode is testing a muscle you don't use. Live mode plants the same kind of error directly into a real end-of-turn reply instead: a conceptual, architectural, or procedural claim that's subtly wrong, with no warning, in the text you were going to read anyway.

This is a materially different safety posture than review mode — read this before enabling it. Review mode never writes to your repo regardless of outcome; nothing it shows you can be acted on by mistake, because it's a read-only render of a diff. Live mode has no such sandbox: a planted claim lives in a real conversation and could inform a real decision before you catch it. The category taxonomy (internal/livetaxonomy) is written to stay out of that territory — no fabricated test/verification/security claims, no invented numbers, nothing you'd act on directly — but there's no independent check enforcing that boundary the way spar review's diff validation does. The instruction text is the whole safety mechanism.

That claim also becomes a permanent part of your actual Claude Code transcript, not just something shown on screen — it's the model's own real reply, and Claude Code persists every reply the same way. spar live-hook never writes to that file itself; it only reads it, once, for the reveal-time corroboration check below. Because nothing automatic ever edits that file after the fact, the reveal always restates the correct fact plainly rather than only naming that something was wrong — and if you want the transcript itself corrected too, spar live-fixup is a separate, manually-invoked command for exactly that, described below.

How it works: a spar live-hook UserPromptSubmit hook — Claude Code's mechanism for running a command before each prompt reaches the model — on a cooldown (SPAR_LIVE_COOLDOWN, default 45m), tells the model to weave one such claim into an otherwise fully correct reply, without flagging it. By default, the reveal never asks you anything: asking hands you a fresh, explicit invitation to go re-examine that reply, which is a different signal than whether you noticed it during ordinary reading. Instead, on your very next turn, spar tells the model to classify what you actually did — did your message show you noticed or corrected the claim, did it show you proceeded as if the claim were true, or does nothing about it bear on the claim either way — log that classification via spar live-reveal, then disclose what was planted, and the correct fact in its place, as a plain statement, not a question, before addressing whatever you actually asked for.

Prefer being asked directly instead? Set SPAR_LIVE_REVEAL_MODE=ask for the earlier two-turn flow: your next turn gets only the question — nothing about what was planted, nothing about logging — and only the turn after that, once your actual answer exists, does spar disclose and log. That split exists so the live-reveal Bash call, whose arguments name what was planted, can never be composed before a real answer is on the table.

An unrevealed plant (session ended, /clear, whatever) expires after SPAR_LIVE_PENDING_TTL (default 8h) and gets logged as unrevealed rather than silently dropped.

Off by default, and not something make install turns on. Enable it per project by adding a hook entry to that project's own .claude/settings.local.json (never the shared settings.json — this should never turn on for someone who just clones a repo) and setting SPAR_LIVE_ENABLED=1 in your shell:

{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [
        { "type": "command", "command": "spar live-hook", "timeout": 10 }
      ] }
    ]
  }
}

Merge that into your existing settings.local.json rather than replacing it. SPAR_LIVE_COOLDOWN and SPAR_LIVE_PENDING_TTL accept Go duration strings (45m, 8h) and default as noted above.

Waiting out the cooldown to see it fire is slow. spar live-induce forces the very next prompt to plant, cooldown ignored, for testing — no effect if a plant is already pending reveal for the session that prompts next.

A commit-time trigger: spar live-hook-commit

spar review needs you to remember to run it. spar live-hook-commit fires on a git commit instead of a chat turn, and behaves one of three ways depending on SPAR_LIVE_COMMIT_MODE (narrate | notify | gate, default narrate). Enable it alongside spar live-hook with a second block in the same settings.local.json:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [
        { "type": "command", "command": "spar live-hook-commit", "timeout": 15, "if": "Bash(git commit *)" }
      ] }
    ]
  }
}

The if filter is best-effort text matching, not a real shell parse: git -C ../other commit won't match (a known, accepted gap — not fixed, since a broader pattern risks false positives), and a Bash command containing $(), a backtick, or a $VAR reference can match even when it isn't a commit at all — a documented Claude Code behavior, not a bug in spar. narrate and notify both guard against that themselves before planting anything, and the model is separately instructed to check the tool result and plant nothing if the commit failed or the matched command wasn't really a commit; gate mode has no such escape hatch (see below).

narrate (default)

When Claude is about to commit, it plants one subtly wrong fact into how it narrates that commit afterward — never touching the commit's actual content, only the description of it. This is the original commit trigger, unchanged. It shares spar live-hook's cooldown and pending-plant slot — one plant in flight per session, regardless of which trigger caused it — and reveals through the exact same spar live-reveal flow. spar live-induce only forces the chat trigger; it has no effect on this one, or on notify below, deliberately — forcing a plant on your very next commit would test something you didn't ask it to.

notify

The same idea, but with a real mutation instead of a fabricated claim: spar runs its own internal/inject pipeline headlessly against the staged diff — the same mechanism spar review uses by hand — and, if it injects, tells the model to narrate the commit from that mutated diff instead of the real one. Everything else matches narrate exactly: same cooldown and pending-plant slot, same silent reveal, same spar live-reveal logging, and the commit itself is never touched — only the narration is ever wrong.

The one real difference is latency: spar review's own injection pipeline can take up to several minutes in the worst case (retries, backoff), fine for a human waiting at a terminal but not for an automatic hook. notify mode uses a bounded configuration instead — one HTTP attempt, an 8s timeout, no validation retry — so the network portion of a trial is bounded to that one call. That doesn't cover the git subprocess calls the same trial also makes (reading file content, regenerating a diff) — those have no timeout of their own, same as spar review's identical calls today. In the ordinary case they're fast, so the realistic total stays comfortably under 10s, but a genuinely stuck git (index lock contention, say) isn't covered — and if git were that stuck, the git commit this hook fires ahead of would already be stalled for the same reason, independent of anything spar does. That's why the timeout in the JSON above is 15, not the 10 a narrate-only setup needs — notify's network bound and the hook's configured timeout have to move together; raising one without the other reopens the hang this bound exists to prevent for the part it actually covers.

Two accepted gaps, shared with gate below since both capture the diff the same way: neither is scoped to a specific pathspec, so git commit -- fileA.go while fileB.go is also staged still shows/mutates from the full staged diff, not just fileA.go — the same best-effort-matching spirit as the if-filter gap above, not worth a real pathspec parse of the Bash command for. And the diff is captured before the real commit runs, so a native git hook that reformats staged content as part of the commit itself (a lint-staged/husky-style setup) can make what actually lands diverge from what spar captured and hashed — an inherent property of firing before the tool runs, not something either mode can see ahead of time. One notify-specific cost: cooldown clears a session to plant before the ~8s API call, not after (MarkFired only stamps once it returns) — two git commits for the same session within that window can both pass the gate and both make a real, paid call; only the winner of the underlying O_CREATE|O_EXCL write keeps its plant, and the loser's completed call is silently discarded. Rare, and the identical race already exists for narrate (where the loser wastes nothing, since narrate never touches the network) — worth naming since notify raises the cost of losing it, not worth a structural fix for how uncommon two same-session commits within 8s of each other actually is.

gate

A pure friction checkpoint, and deliberately the odd one out: no narration, no mutation, no scoring, and — unlike every other spar mechanism — no internal/livestate involvement at all. No pending file, no cooldown marker, nothing logged to ~/.claude/spar/log.jsonl. It can fire on the same commit as an unrelated, separately-pending narrate or notify plant without conflict, because it never touches the state those two share.

gate does not require SPAR_LIVE_ENABLED, unlike everything else in this section — it never deceives, so it isn't part of live mode's on/off switch at all. If the hook is registered with SPAR_LIVE_COMMIT_MODE=gate in your settings.local.json, it fires on every matching commit whether or not SPAR_LIVE_ENABLED is set. narrate and notify both still need it, exactly as before.

Concretely: spar shows you the real, unmutated diff you're about to commit and asks you to approve or deny before the commit lands, via Claude Code's own permission prompt (permissionDecision: "ask"). Approving commits it exactly as staged; denying stops it.

gate never injects a mutated diff, on purpose. permissionDecision: "ask" is a one-shot dialog — the hook process exits before you answer, so there's no way to show a diff blind and reveal ground truth after the fact, the way spar review and live mode's reveal both do. Every other spar mechanism always eventually discloses what it did; an injected but never-revealed gate diff would be the one place that permanently deceives without disclosure, even though the real commit stays safe regardless. So gate trades that away: it's a forced look, not a vigilance test, and it's exempt from your catch rate entirely.

Two accepted gaps, same spirit as the if-filter caveat above: the diff gate shows comes from the same staged-first-else-unstaged capture spar review uses, which doesn't perfectly represent git commit -a's on-the-fly staging of tracked files; and gate can't reuse narrate/ notify's "check the tool result first" escape hatch, since its decision is final before the commit ever runs.

Patching the transcript itself: spar live-fixup

The reveal restates the correct fact in a later turn, but it doesn't touch the earlier turn where the false claim actually lives — spar live-hook never writes to your transcript, only reads it. If you want the historical record itself corrected, not just a later turn that explains it, run spar live-fixup by hand, whenever you like:

spar live-fixup                      # lists sessions with something to fix
spar live-fixup --session ID         # dry run: shows what would change
spar live-fixup --session ID --apply # writes, after a backup

This is a manual maintenance step, not something live mode ever triggers on its own — run it between sessions, never during one. Each --apply run: refuses if the transcript was modified in the last 5 minutes (a soft guard against an accidentally-still-open session; --force bypasses it), backs up the whole file first (<path>.spar-fixup-backup-<timestamp>, restore with mv if you ever need to undo a patch), then finds the plant's exact wording — verified byte-for-byte against the real transcript at reveal time, never trusted on the model's memory alone — and replaces it with the corrected fact, and only that one exact spot. If the wording doesn't appear exactly once, it skips that trial rather than guessing — including the common case where a clear disclosure quotes the original wording back to you, which makes the match ambiguous on purpose. --session is required to patch anything; there's no "sweep every session" mode.

At most once every SPAR_LIVE_STATS_INTERVAL (default 24h, global across every project — not per session), spar mentions your current catch rate as a brief aside and points to spar stats --trend for the full breakdown, rather than reproducing it inline — a nudge toward the CLI, not a scoreboard in the chat. It never fires on the same turn as a reveal. Set SPAR_LIVE_STATS_INTERVAL=0 to disable it.

What v1 doesn't do

No scoring, streaks, or rewards — just a catch-rate log. Adding a score tied to catch rate risks training reviewers to exploit how mechanically easy a small, localized mutation is to patch, rather than rewarding genuine close reading (a 2026 student study on injected-vs-natural GenAI bugs is the citation behind this concern — see docs/DESIGN.md for exactly what it does and doesn't show). Worth building once there's real baseline data to design against, not before. Live mode doesn't change this — it widens which surface gets an injected error, not the incentive model.

No live-embedded hook in review mode — spar review is still a manual command run against git diff, invoked by hand each time, never intercepting a real commit or watching a session as it happens. Live mode (above) is the one exception, opt-in per project — narrate and notify plant into a chat reply (a chat turn's, or, via spar live-hook-commit, a commit's narration); gate doesn't plant anything at all, and unlike the other two it genuinely can block a commit via Claude Code's own approve/deny prompt — a real exception to "observe and comment on, never gate." None of the three ever alters what lands in your repo: narrate and notify only ever shape what gets said about a commit, and gate's approve/deny only ever decides whether the real, untouched commit proceeds.

Prior art

spar is a personal-scale instance of a known technique: seed a known error into real work, keep the ground truth hidden, measure whether it gets caught. Weinberg's "bebugging" (1970) proposed exactly this for keeping programmers — and, per Weinberg, radar operators before them — from going complacent. Buçinca, Malaya & Gajos (CSCW 2021) ran the closest mechanical ancestor: a simulated AI with a controlled, known error rate, built as a one-off experimental control, never packaged as a standing tool. The UK's PERFORMS breast-screening self-assessment scheme is the closest real "standing tool" match, running since 1991 — in radiology, not code review. See docs/DESIGN.md for the fuller lineage and the reasoning behind what v1 does and doesn't do.

Why "spar"

Double meaning: a lustrous mineralogy term (as in "feldspar"), and to practice-fight — the sparring-partner framing the project started from.

"If you are desirous to learn, always play with a strong player, rather than with an inferior one. A single game with a really good player, at such odds as he thinks proper, will be worth much more to you than the winning and beating a dozen bad players." — Samuel Standidge Boden, A Popular Introduction to the Study and Practice of Chess, 1851

License

MIT — see LICENSE.

About

A sparring partner for code review — plants a synthetic bug in your diff (or your AI's chat reply) and tells you whether you caught it.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages