#!/usr/bin/env python3 """ Clerk pre-push review — checks D-record consistency, ticket drift, and decision contradictions against the diff that would be pushed. 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*. Commits are reviewed by a bounded pool of parallel clerk agents and the per-commit verdicts aggregated. Three outcomes per commit: APPROVED no contradiction found. REJECTED a concrete contradiction with a named, active D-record. HARD BLOCK. INCOMPLETE the review could not finish — timed out, ran out of turns, or emitted no clear verdict. This is NOT a contradiction; it is non-blocking by default (the push proceeds with a warning). Raise the budget knobs or add a `Clerk-Skip:` trailer to avoid it. Why INCOMPLETE exists: an unfinished review must never read as "hard contradiction found." The previous version defaulted no-verdict / max-turns to REJECTED, which turned every slow review into a false block (e.g. a CHANGELOG-only commit). Safety valve: a commit message with a `Clerk-Skip:` trailer line is auto-approved without spawning an agent — for bulk content commits (e.g. shipping thousands of generated planetary description files) where D-record review is moot. A *trailer* (a line starting with `Clerk-Skip:`) is required, so merely mentioning the token in prose or a subject line does not trip the valve. Outputs exactly one word as the LAST stdout line: APPROVED, REJECTED, or INCOMPLETE. Writes verbose findings to .cache/pre-push-review.md. Exit codes: 0 = APPROVED or INCOMPLETE (non-blocking) 1 = REJECTED (>=1 commit contradicts an active D-record) 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 15) SR_CLERK_TIMEOUT per-commit timeout seconds (default 300) 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 re 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", "15") TIMEOUT_SECONDS = int(os.environ.get("SR_CLERK_TIMEOUT", "300")) # Safety valve: a commit message with a "Clerk-Skip:" trailer line is auto-approved # (no agent). A trailer (line-start) is required so that merely mentioning the token # in prose or a subject line does not trip the valve. SKIP_TRAILER = re.compile(r"(?im)^[ \t]*clerk-skip[ \t]*:") 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 Be efficient — a few targeted greps of decisions/*.md, then conclude. Check: - Does this commit contradict any active D-record? - If the commit's code/text cites a D/Q/R-ID, does that ID exist and is it active? - If the commit message cites ticket #NNN, does the change match the ticket? Note any relevant open Q-records. ## Output format (REQUIRED) Write brief findings, then on the VERY LAST LINE output exactly one word: APPROVED or REJECTED. - REJECTED *only* for a concrete contradiction with a named, active D-record. - Drift, stale references, open Q-records, and suggestions are findings, NOT blocks -> APPROVED. - If you did not find a concrete contradiction, output APPROVED — even if you could not check everything. "Unsure / didn't finish" means APPROVED, never REJECTED. """ 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, text, skip) for one commit. `skip` is True when the commit message has a `Clerk-Skip:` trailer (the safety valve); `text` is the message + diff, truncated to COMMIT_BUDGET. """ subject = subprocess.check_output( ["git", "show", "-s", "--format=%h %s", sha], text=True ).strip() message = subprocess.check_output( ["git", "show", "-s", "--format=%B", sha], text=True ) skip = bool(SKIP_TRAILER.search(message)) 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, skip def run_clerk(label, diff_text, index): """Spawn one clerk agent over a commit; return (verdict, findings). verdict is APPROVED, REJECTED, or INCOMPLETE. INCOMPLETE covers timeout, empty output, and no-clear-verdict — none of which is a contradiction. """ 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 "INCOMPLETE", f"{label}: review timed out after {TIMEOUT_SECONDS}s (not a contradiction)." if not output: return "INCOMPLETE", f"{label}: clerk produced no output (not a contradiction)." last_line = output.splitlines()[-1].strip().upper() if last_line == "APPROVED": return "APPROVED", output if last_line == "REJECTED": return "REJECTED", output return "INCOMPLETE", output + "\n\n(No clear verdict on last line — recorded as INCOMPLETE, not a contradiction.)" 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, skip), ...] 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, skip) in enumerate(units): tag = "SKIP (Clerk-Skip:)" if skip else "review" print(f" commit {i + 1}/{len(commits)}: {len(text):>9} chars [{tag}] {subject}") return 0 index = load_index() reviewable = sum(1 for _, _, skip in units if not skip) print(f" clerk: {len(commits)} commit(s) — {reviewable} to review, " f"{len(commits) - reviewable} auto-approved (Clerk-Skip:); {WORKERS} parallel...", file=sys.stderr) def task(i, subject, text, skip): label = f"commit {i + 1}/{len(commits)} ({subject})" if skip: return i, label, "APPROVED", f"{label}: auto-approved via Clerk-Skip: trailer." 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, skip) for i, (s, t, skip) 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 "INCOMPLETE" in verdicts: overall = "INCOMPLETE" else: overall = "APPROVED" n_rej = verdicts.count("REJECTED") n_inc = verdicts.count("INCOMPLETE") parts = [ f"# Clerk Pre-Push Review\n\n**Overall verdict: {overall}**\n", f"Reviewed {len(commits)} commit(s) over {rng} — " f"{verdicts.count('APPROVED')} approved, {n_rej} rejected, {n_inc} incomplete.\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 1 if overall == "REJECTED" else 0 if __name__ == "__main__": sys.exit(main())