Sprint-based workflow (38 sprints) replaced by kanban + milestones. Milestones are many-to-many with tickets and can block each other. New: /whats-next skill (dependency-driven batch selection with Si refinement review), /pr-process skill (renamed from pr-push, adds review comment pickup), clerk agent + pre-push hook for D-record consistency checks. Deleted: sprint CLI, sprint-start/sprint-plan/sprint-status skills, team-scoped file restrictions. Si rewritten as refinement manager. All 19 agent briefings updated from stale PROJECT_STATE.md reference to live ticket milestone queries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
151 lines
4.4 KiB
Python
Executable File
151 lines
4.4 KiB
Python
Executable File
#!/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.
|
|
|
|
Outputs exactly one word to stdout: APPROVED or REJECTED.
|
|
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)
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
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"
|
|
TIMEOUT_SECONDS = 120
|
|
|
|
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.
|
|
|
|
## The 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.
|
|
|
|
## 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.
|
|
"""
|
|
|
|
|
|
def get_diff():
|
|
"""Get the diff that would be pushed."""
|
|
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
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
continue
|
|
|
|
return subprocess.check_output(["git", "diff", "HEAD~1"], text=True)
|
|
|
|
|
|
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)"
|
|
|
|
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
|
|
|
|
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),
|
|
)
|
|
output = result.stdout.strip()
|
|
except subprocess.TimeoutExpired:
|
|
return "TIMEOUT", "Clerk agent timed out after {} seconds.".format(TIMEOUT_SECONDS)
|
|
finally:
|
|
os.unlink(prompt_file)
|
|
|
|
if not output:
|
|
return "REJECTED", "Clerk agent produced no output."
|
|
|
|
lines = output.strip().split("\n")
|
|
last_line = lines[-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 verdict, output
|
|
|
|
|
|
def main():
|
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
diff_text = get_diff()
|
|
if not diff_text.strip():
|
|
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)
|
|
|
|
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":
|
|
return 0
|
|
elif verdict == "TIMEOUT":
|
|
return 2
|
|
else:
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|