The per-commit clerk had three flaws, exposed by a 20-commit push where 6 of 7
rejections were false (incl. a CHANGELOG-only commit):
1. No-verdict / max-turns / timeout defaulted to REJECTED — an unfinished review
read as 'hard contradiction found'. Now a third outcome, INCOMPLETE, which is
non-blocking (the push proceeds with a warning); only a real REJECTED blocks.
2. Turn/time budget too tight (6 turns / 150s) for decision-heavy commits. Raised
defaults to 15 turns / 300s, and the prompt now biases to APPROVED when no
concrete contradiction is found ('unsure' means APPROVED, never REJECTED).
3. The skip valve matched its own feature commit because it scanned for the token
anywhere in the message. Moved to a trailer-line match so prose/subject mentions
no longer trip it.
Pre-push hook updated to treat INCOMPLETE as a non-blocking warning. The git-commit
skill documents the trailer form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
254 lines
9.7 KiB
Python
Executable File
254 lines
9.7 KiB
Python
Executable File
#!/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())
|