chore(meta): clerk-review reviews per-commit in parallel
The clerk pre-push review spawned a single claude -p over the entire combined diff (truncated at 50k chars) with a 3-turn budget. On a large push (e.g. 754 files / 2.8M chars) it ran out of turns before emitting a verdict, which the wrapper defaulted to REJECTED. Rewrite to review one commit at a time — commits are the logical units, so each clerk agent sees a self-contained change plus its commit message (enabling the 'does this match ticket #NNN?' check). Commits are reviewed by a bounded pool of parallel clerk agents and the verdicts aggregated (REJECTED if any commit contradicts an active D-record). Oversized commit diffs truncate to a budget. Adds --plan (print the per-commit plan, no agents spawned) and env knobs: SR_CLERK_COMMIT_BUDGET / SR_CLERK_WORKERS / SR_CLERK_MAX_TURNS / SR_CLERK_TIMEOUT. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+133
-75
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user