#!/usr/bin/env python3 """ Clerk pre-push review — checks D-record consistency, ticket drift, and decision contradictions against the diff that would be pushed. Large pushes are reviewed one commit at a time — commits are the logical units (each /git-commit is one coherent change), so each clerk agent reviews a self-contained change *and its commit message* (enabling the "does this commit's implementation match ticket #NNN?" check). Commits are reviewed by parallel clerk agents and the verdicts aggregated. This avoids the single-agent failure mode where one `claude -p` ran out of turns on a huge combined diff and never emitted a verdict (which defaulted to REJECTED). Outputs exactly one word as the LAST stdout line: APPROVED, REJECTED, or TIMEOUT. Writes verbose findings to .cache/pre-push-review.md. Exit codes: 0 = APPROVED 1 = REJECTED (hard contradiction with an active D-record in >=1 commit) 2 = TIMEOUT (>=1 commit review timed out, none rejected) Env knobs: SR_CLERK_COMMIT_BUDGET per-commit diff char cap (default 120000, truncates) SR_CLERK_WORKERS parallel clerk agents (default 4) SR_CLERK_MAX_TURNS turns per clerk agent (default 6) SR_CLERK_TIMEOUT per-commit timeout seconds (default 150) Usage: tooling/clerk-review run the review, print verdict tooling/clerk-review --plan print the per-commit plan only (no clerk spawned) """ import os import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path REPO_ROOT = Path(subprocess.check_output( ["git", "rev-parse", "--show-toplevel"], text=True ).strip()) CACHE_DIR = REPO_ROOT / ".cache" FINDINGS_FILE = CACHE_DIR / "pre-push-review.md" COMMIT_BUDGET = int(os.environ.get("SR_CLERK_COMMIT_BUDGET", "120000")) WORKERS = int(os.environ.get("SR_CLERK_WORKERS", "4")) MAX_TURNS = os.environ.get("SR_CLERK_MAX_TURNS", "6") TIMEOUT_SECONDS = int(os.environ.get("SR_CLERK_TIMEOUT", "150")) CLERK_PROMPT = """You are the CLERK, the institutional guardrail for The Settled Reach. You are reviewing ONE COMMIT ({label}) from a larger pre-push for D-record consistency, ticket drift, and decision contradictions. ## decisions/ domain index {index} ## The commit (message + diff) {diff} ## Your task - Does this commit contradict any active D-record? (grep decisions/*.md for relevant keywords) - If the commit's code/text references a D/Q/R-ID, does that ID exist and is it active? - If the commit message references ticket #NNN, does the change match the ticket? Surface any open Q-records relevant to the changed files. ## Output format First write your detailed findings. Then, on the VERY LAST LINE, output exactly one word — APPROVED or REJECTED. REJECTED only for a hard contradiction with an active D-record; drift, open Q-records, and suggestions are findings, not blocks. """ def base_range(): """The commit range that would be pushed (base..HEAD).""" branch = subprocess.check_output( ["git", "branch", "--show-current"], text=True ).strip() for ref in [f"origin/{branch}", "origin/main"]: try: subprocess.check_output( ["git", "rev-parse", "--verify", ref], stderr=subprocess.DEVNULL, text=True, ) return f"{ref}..HEAD" except subprocess.CalledProcessError: continue return "HEAD~1..HEAD" def list_commits(rng): """SHAs in the push range, oldest first.""" out = subprocess.check_output( ["git", "rev-list", "--reverse", rng], text=True ) return [s for s in out.splitlines() if s.strip()] def commit_unit(sha): """Return (subject, full text = message + diff, truncated to budget).""" subject = subprocess.check_output( ["git", "show", "-s", "--format=%h %s", sha], text=True ).strip() text = subprocess.check_output( ["git", "show", "--format=fuller", sha], text=True ) if len(text) > COMMIT_BUDGET: text = text[:COMMIT_BUDGET] + f"\n\n... (commit diff truncated; {len(text)} total chars)\n" return subject, text def run_clerk(label, diff_text, index): """Spawn one clerk agent over a commit; return (verdict, findings).""" prompt = CLERK_PROMPT.format(label=label, index=index, diff=diff_text) try: result = subprocess.run( ["claude", "-p", "--model", "sonnet", "--max-turns", str(MAX_TURNS)], input=prompt, capture_output=True, text=True, timeout=TIMEOUT_SECONDS, cwd=str(REPO_ROOT), ) output = result.stdout.strip() except subprocess.TimeoutExpired: return "TIMEOUT", f"{label}: clerk timed out after {TIMEOUT_SECONDS}s." if not output: return "REJECTED", f"{label}: clerk produced no output." last_line = output.splitlines()[-1].strip().upper() if last_line == "APPROVED": return "APPROVED", output if last_line == "REJECTED": return "REJECTED", output return "REJECTED", output + "\n\n(No clear verdict on last line — defaulting to REJECTED)" def load_index(): readme = REPO_ROOT / "decisions" / "README.md" return readme.read_text()[:8000] if readme.exists() else "(decisions/README.md not found)" def main(): plan_only = "--plan" in sys.argv[1:] CACHE_DIR.mkdir(parents=True, exist_ok=True) rng = base_range() commits = list_commits(rng) if not commits: FINDINGS_FILE.write_text("# Clerk Review\n\nNo commits to review.\n\nVerdict: APPROVED\n") print(" clerk: no commits to review", file=sys.stderr) sys.stderr.flush() print("APPROVED") return 0 units = [commit_unit(sha) for sha in commits] # [(subject, text), ...] if plan_only: print(f"clerk plan: {len(commits)} commit(s) over {rng} " f"(budget {COMMIT_BUDGET} chars, {WORKERS} workers, {MAX_TURNS} turns each)") for i, (subject, text) in enumerate(units): print(f" commit {i + 1}/{len(commits)}: {len(text):>9} chars {subject}") return 0 index = load_index() print(f" clerk: reviewing {len(commits)} commit(s), {WORKERS} parallel...", file=sys.stderr) def task(i, subject, text): label = f"commit {i + 1}/{len(commits)} ({subject})" verdict, findings = run_clerk(label, text, index) return i, label, verdict, findings results = [None] * len(commits) with ThreadPoolExecutor(max_workers=WORKERS) as ex: futures = [ex.submit(task, i, s, t) for i, (s, t) in enumerate(units)] for fut in as_completed(futures): i, label, verdict, findings = fut.result() results[i] = (label, verdict, findings) print(f" clerk: {label} — {verdict}", file=sys.stderr) verdicts = [r[1] for r in results] if "REJECTED" in verdicts: overall = "REJECTED" elif "TIMEOUT" in verdicts: overall = "TIMEOUT" else: overall = "APPROVED" parts = [ f"# Clerk Pre-Push Review\n\n**Overall verdict: {overall}**\n", f"Reviewed {len(commits)} commit(s) over {rng}.\n", ] for label, verdict, findings in results: parts.append(f"\n---\n\n## {label} — {verdict}\n\n{findings}\n") FINDINGS_FILE.write_text("\n".join(parts)) print(f" clerk: overall verdict — {overall} (details: {FINDINGS_FILE})", file=sys.stderr) sys.stderr.flush() print(overall) return {"APPROVED": 0, "REJECTED": 1, "TIMEOUT": 2}[overall] if __name__ == "__main__": sys.exit(main())