Every time Claude Code finishes a response and there
are uncommitted changes, a hook automatically triggers
a background Codex code review — without blocking your
workflow. You keep working while the review runs. When
it finishes, Claude reads the output and presents the
findings.
Claude Code Stop event → redline check (fast, <1s) → git diff --stat HEAD (any uncommitted changes?) → hash diff stat, compare to .git/redline-last-diff → if changes exist AND diff has changed since last check: save hash to .git/redline-last-diff output { "decision": "block", "reason": "..." } reason includes diff stat summary Claude decides if changes warrant a review Claude spawns `redline review` as background task → codex exec review streams output in real-time → user can monitor, kill, or keep working → when done, Claude reads output, presents findings → if no changes OR same diff as last check: exit 0 silently → Claude proceeds normally
No background processes, no daemons, no filesystem
protocols. The hook is the trigger, and Claude Code’s
own background task system handles async execution.
The base URL is https://openrouter.ai/api — no
/v1 suffix. This is OpenRouter’s Anthropic Skin,
which speaks the native Anthropic protocol. Using
/v1 causes model-not-found errors.
ANTHROPIC_API_KEY must be explicitly empty to
prevent Claude Code from authenticating directly
with Anthropic.
Claude Code has a hook system configured in
settings.json. The key hook for this use case is
Stop, which fires every time Claude finishes a
response cycle.
If the JSON contains "decision": "block" with a
"reason" string, Claude Code:
Does not stop — it continues the conversation
The reason text is injected into Claude’s
context as new information
Claude processes it and acts on it (e.g.,
spawning a background task as instructed)
If the command exits 0 with no JSON output, Claude
proceeds normally (no blocking)
If the command exits non-zero, it’s treated as a
non-blocking error
This is the key mechanism: the check command uses
decision: "block" to inject a diff stat summary
and review instructions into Claude’s context. Claude
sees the summary, decides whether the changes warrant
a review, and if so spawns the review command via its
Bash tool. The background task appears in Claude’s
task list — visible, monitorable, and killable.
A simpler synchronous approach — running the full
review inside the Stop hook — has three problems:
Blocking.codex exec review can take minutes.
A synchronous Stop hook blocks Claude Code the
entire time — no progress visibility, no way to
cancel.
No filtering. The hook fires after every
response, even when Claude just answered a question
and made no code changes.
Duplicate reviews. Nothing prevents the hook
from triggering a second review while one is
already running.
The async approach solves all three: the check is
fast (<1s), only fires when changes exist, hashes
the diff stat to skip when nothing has changed since
the last check, and the reason text tells Claude to
skip if a review is already running. The actual
review runs as a Claude Code background task that the
user can monitor and kill.
The check command runs on every Stop event and must
complete in under a second.
import { execSync } from "child_process";import { existsSync, readFileSync, writeFileSync,} from "fs";import { join } from "path";function getDiffStat(): string { // Prefer diff --stat for a concise summary const diff = execSync("git diff --stat HEAD", { encoding: "utf-8", }).trim(); if (diff) return diff; // Fall back to status for untracked files return execSync("git status --porcelain", { encoding: "utf-8", }).trim();}function hash(s: string): string { let h = 0; for (let i = 0; i < s.length; i++) { h = ((h << 5) - h + s.charCodeAt(i)) | 0; } return h.toString(36);}function check(model?: string): void { const diffStat = getDiffStat(); if (!diffStat) { process.exit(0); } // Deduplicate: skip if diff unchanged since last check const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf-8", }).trim(); const hashFile = join(gitDir, "redline-last-diff"); const currentHash = hash(diffStat); if (existsSync(hashFile)) { const lastHash = readFileSync( hashFile, "utf-8", ).trim(); if (lastHash === currentHash) { process.exit(0); } } writeFileSync(hashFile, currentHash); const cmd = model ? `redline review --model ${model}` : "redline review"; const hookOutput = { decision: "block", reason: [ "Redline: Here is a summary of uncommitted", "changes since the last review:", "", diffStat, "", "If these changes are substantial enough to", "warrant a code review (e.g., new logic, bug", "fixes, refactors — not just formatting or", "comments), run the following command as a", "background task:", "", ` ${cmd}`, "", "If the changes are trivial, or a review is", "already running, skip it. When a review", "completes, assess the findings and inform", "the user of any issues.", ].join("\n"), }; console.log(JSON.stringify(hookOutput));}
The check uses git diff --stat HEAD for a concise
summary of what changed, falling back to
git status --porcelain for untracked files. It
hashes the diff stat and stores it in
.git/redline-last-diff — if the diff hasn’t changed
since the last check, the hook exits silently. This
prevents the same diff from repeatedly firing the
hook. The diff stat is included in the reason text so
Claude can decide whether the changes warrant a
review.
The review command is spawned by Claude as a
background task. It streams Codex output in real-time
for progress visibility, then prints a final summary.
import { spawn } from "child_process";import { readFileSync, unlinkSync } from "fs";import { join } from "path";import { tmpdir } from "os";async function review(model?: string): Promise<void> { const outputFile = join( tmpdir(), `redline-review-${Date.now()}.txt`, ); const args = [ "exec", "review", "-c", 'model_provider="openrouter"', "--uncommitted", "-o", outputFile, ]; if (model) { args.push("-c", `model="${model}"`); } // Stream output in real-time so background task // shows progress const exitCode = await new Promise<number>( (resolve) => { const proc = spawn("codex", args, { cwd: process.cwd(), env: process.env, stdio: ["ignore", "inherit", "inherit"], }); proc.on("close", (code) => resolve(code ?? 1)); }, ); // Read the final review from the -o output file let review = ""; try { review = readFileSync(outputFile, "utf-8").trim(); unlinkSync(outputFile); } catch { // No output file — output was already streamed } if (exitCode !== 0 && !review) { console.error(`Codex review failed (exit ${exitCode}).`); process.exit(1); } if (review) { console.log("\n--- Review Summary ---\n"); console.log(review); }}
Key details:
codex exec review --uncommitted — reviews all
staged, unstaged, and untracked changes
stdio: "inherit" — streams Codex output in
real-time so the background task shows progress
-o <file> — writes the last agent message to a
file for reliable output capture
-c 'model_provider="openrouter"' — routes
through OpenRouter
Optional: -c 'model="openai/gpt-5.4-pro"' for
model override
Exit code is checked — if Codex fails and produced
no output, the tool exits with an error
The check command’s reason field includes the diff
stat and lets Claude decide whether to review:
Redline: Here is a summary of uncommitted changessince the last review: src/commands/check.ts | 25 +++++++++++++++------ src/commands/review.ts | 12 +++++----- 2 files changed, 22 insertions(+), 15 deletions(-)If these changes are substantial enough to warranta code review (e.g., new logic, bug fixes, refactors— not just formatting or comments), run thefollowing command as a background task: redline reviewIf the changes are trivial, or a review is alreadyrunning, skip it. When a review completes, assessthe findings and inform the user of any issues.
Claude sees the summary, judges whether the changes
are substantive, and either spawns the review as a
background task or skips it. When the review
completes, Claude reads the streamed output and
presents the findings.
Read .claude/settings.local.json, deep-merge the
Stop hook entry, and write back. Create the .claude/
directory if needed. Be idempotent — if the hook
already exists with the same config, skip. If it
exists with a different model, update it. Identify
your hooks by command prefix (commands starting with
"redline").The resulting file:
# Install the hookredline install# Install with a specific review modelredline install --model openai/gpt-5.4-pro# Remove the hookredline off# Run a review manually (prints to stdout)redline review# Fast gate check (called by the Stop hook)redline check
This pattern relies on Claude Code’s
decision: "block" hook output, which injects
instructions directly into the agent’s conversation.
Codex CLI’s hook system (as of early 2026) is more
limited — its Stop hook is fire-and-forget and
cannot feed structured output back into the model’s
context. Native hooks ([[hooks]] in config.toml)
support several events, but only SessionStart can
feed stdout into the model. There is no equivalent of
decision: "block" + reason for injecting
mid-session instructions. When Codex adds full
structured hook output support, this pattern can be
extended.
The review covers everything uncommitted — all
staged, unstaged, and untracked changes. It does not
distinguish between changes Claude just made and
pre-existing uncommitted work. Future versions could
use smarter change detection (e.g., diffing against a
baseline snapshot taken before Claude’s response).