diff --git a/tooling/clerk-review b/tooling/clerk-review index 19dd224d9..28673794d 100755 --- a/tooling/clerk-review +++ b/tooling/clerk-review @@ -1,21 +1,39 @@ #!/usr/bin/env python3 """ -Clerk pre-push review — spawns the clerk agent to check D-record consistency, -ticket drift, and decision contradictions against the current diff. +Clerk pre-push review — checks D-record consistency, ticket drift, and decision +contradictions against the diff that would be pushed. -Outputs exactly one word to stdout: APPROVED or REJECTED. +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 found) - 2 = TIMEOUT (clerk didn't respond in time — treated as REJECTED) + 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 -import tempfile +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path REPO_ROOT = Path(subprocess.check_output( @@ -24,126 +42,166 @@ REPO_ROOT = Path(subprocess.check_output( CACHE_DIR = REPO_ROOT / ".cache" FINDINGS_FILE = CACHE_DIR / "pre-push-review.md" -TIMEOUT_SECONDS = 120 + +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 a pre-push diff for D-record consistency, ticket drift, and decision contradictions. +You are reviewing ONE COMMIT ({label}) from a larger pre-push for D-record +consistency, ticket drift, and decision contradictions. -## The diff +## decisions/ domain index + +{index} + +## The commit (message + diff) {diff} ## Your task -1. Read `decisions/README.md` for the domain index. -2. For each changed file in the diff, check: - - Does the change contradict any active D-record? (grep decisions/*.md for relevant keywords) - - If code references a D/Q/R-ID, does that ID exist and is it active? - - If commits reference ticket #NNN, does the implementation match the ticket description? -3. Surface any open Q-records relevant to changed files. +- 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 of your response, -output exactly one word — either APPROVED or REJECTED. - -REJECTED only for hard contradictions with active D-records. Everything else -(drift, open Q-records, suggestions) is a finding but not a block. +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 get_diff(): - """Get the diff that would be pushed.""" +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 subprocess.check_output( - ["git", "diff", f"{ref}...HEAD"], text=True + stderr=subprocess.DEVNULL, text=True, ) + return f"{ref}..HEAD" except subprocess.CalledProcessError: continue - - return subprocess.check_output(["git", "diff", "HEAD~1"], text=True) + return "HEAD~1..HEAD" -def run_clerk(diff_text): - """Spawn clerk via claude --print, return (verdict, findings).""" - max_diff = 50000 - if len(diff_text) > max_diff: - diff_text = diff_text[:max_diff] + f"\n\n... (truncated, {len(diff_text)} total chars)" +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()] - prompt = CLERK_PROMPT.format(diff=diff_text) - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - f.write(prompt) - prompt_file = f.name +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", "3", - ], - input=prompt, - capture_output=True, - text=True, - timeout=TIMEOUT_SECONDS, - cwd=str(REPO_ROOT), + ["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", "Clerk agent timed out after {} seconds.".format(TIMEOUT_SECONDS) - finally: - os.unlink(prompt_file) + return "TIMEOUT", f"{label}: clerk timed out after {TIMEOUT_SECONDS}s." if not output: - return "REJECTED", "Clerk agent produced no output." - - lines = output.strip().split("\n") - last_line = lines[-1].strip().upper() + return "REJECTED", f"{label}: clerk produced no output." + last_line = output.splitlines()[-1].strip().upper() if last_line == "APPROVED": - verdict = "APPROVED" - elif last_line == "REJECTED": - verdict = "REJECTED" - else: - verdict = "REJECTED" - output += "\n\n(No clear verdict on last line — defaulting to REJECTED)" + return "APPROVED", output + if last_line == "REJECTED": + return "REJECTED", output + return "REJECTED", output + "\n\n(No clear verdict on last line — defaulting to REJECTED)" - return verdict, output + +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) - diff_text = get_diff() - if not diff_text.strip(): + 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") - FINDINGS_FILE.write_text("# Clerk Review\n\nNo diff to review.\n\nVerdict: APPROVED\n") return 0 - print(" clerk: reviewing diff...", file=sys.stderr) - verdict, findings = run_clerk(diff_text) + units = [commit_unit(sha) for sha in commits] # [(subject, text), ...] - FINDINGS_FILE.write_text(f"# Clerk Pre-Push Review\n\n{findings}\n") - print(f" clerk: verdict — {verdict} (details: {FINDINGS_FILE})", file=sys.stderr) - print(verdict) - - if verdict == "APPROVED": + 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 - elif verdict == "TIMEOUT": - return 2 + + 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: - return 1 + 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__":