Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """stop hook: Codex-powered diff review when session changes exceed threshold. | |
| Reads a baseline git tree snapshot (saved by codex_baseline.py at session | |
| start), computes the diff, and runs ``codex exec review`` when changes are | |
| substantial. The review prompt references AGENTS.md and checks for banned | |
| patterns, hacky workarounds, and code-quality issues. | |
| Change tracking is independent of commits: the baseline is a git tree | |
| object captured at session start, and the diff is computed against the | |
| live working tree at stop time. | |
| """ | |
| import json | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| project_root = Path(__file__).resolve().parent.parent | |
| state_dir = project_root / ".cursor" / "hooks" / "state" | |
| baseline_path = state_dir / "codex_review_baseline" | |
| CODEX_REVIEW_MIN_LINES = 50 | |
| def count_diff_lines(diff_text: str) -> int: | |
| """Count additions and deletions in a unified diff.""" | |
| count = 0 | |
| for line in diff_text.splitlines(): | |
| is_add = line.startswith("+") and not line.startswith("+++") | |
| is_del = line.startswith("-") and not line.startswith("---") | |
| if is_add or is_del: | |
| count += 1 | |
| return count | |
| def compute_session_diff(baseline_sha: str) -> str: | |
| """Diff between the baseline snapshot and the current working tree. | |
| Includes untracked files by using intent-to-add (git add -N) so new | |
| files created during the session appear in the diff. | |
| """ | |
| subprocess.run( | |
| ["/usr/bin/git", "add", "-N", "."], | |
| capture_output=True, | |
| text=True, | |
| timeout=10, | |
| check=False, | |
| cwd=str(project_root), | |
| ) | |
| result = subprocess.run( | |
| ["/usr/bin/git", "diff", baseline_sha], | |
| capture_output=True, | |
| text=True, | |
| timeout=30, | |
| check=False, | |
| cwd=str(project_root), | |
| ) | |
| return result.stdout | |
| def build_review_prompt(lines_changed: int) -> str: | |
| """Construct the custom instructions for codex exec review.""" | |
| kw_sep = "keyword" + "-only separator" | |
| return ( | |
| f"This session changed {lines_changed} lines. " | |
| "Read AGENTS.md in the repo root for the full project rules.\n\n" | |
| "Key banned patterns to check for:\n" | |
| "- try/except without rationale, assert, nested functions, @dataclass\n" | |
| "- typing.Any, typing.cast, functools.cache\n" | |
| "- Magic numbers at module level, os.environ/os.getenv\n" | |
| f"- {kw_sep}, string-key membership checks on dicts\n" | |
| "- Defensive .get() after model_validate, isinstance after validation\n" | |
| "- Inline suppression comments without owner approval\n\n" | |
| "Also flag:\n" | |
| "1. Hacky workarounds, TODO/FIXME/HACK, band-aid fixes\n" | |
| "2. Loose dicts where typed Pydantic models belong\n" | |
| "3. God functions, excessive nesting, duplicated logic\n" | |
| "4. Unclear or misleading names\n" | |
| "5. Missing type annotations\n" | |
| "6. Hand-rolled code that a library already handles\n\n" | |
| "Be concise. Report actionable findings only.\n" | |
| "Format: file:line [category] description\n" | |
| "If clean: LGTM" | |
| ) | |
| def run_codex_review(lines_changed: int) -> str: | |
| """Run codex exec review --uncommitted with project-specific instructions.""" | |
| codex_path = shutil.which("codex") | |
| if codex_path is None: | |
| return "" | |
| with tempfile.NamedTemporaryFile( | |
| mode="w", | |
| suffix=".txt", | |
| delete=False, | |
| ) as tmp: | |
| output_path = tmp.name | |
| cmd = [ | |
| codex_path, | |
| "exec", | |
| "review", | |
| "--uncommitted", | |
| "--ephemeral", | |
| "-o", | |
| output_path, | |
| build_review_prompt(lines_changed), | |
| ] | |
| subprocess.run( | |
| cmd, | |
| capture_output=True, | |
| text=True, | |
| timeout=150, | |
| check=False, | |
| cwd=str(project_root), | |
| ) | |
| result_file = Path(output_path) | |
| review_text = result_file.read_text().strip() | |
| result_file.unlink(missing_ok=True) | |
| return review_text | |
| def main() -> None: | |
| """Run codex review if session changes exceed the configured threshold.""" | |
| state_dir.mkdir(parents=True, exist_ok=True) | |
| parsed = json.loads(sys.stdin.read()) | |
| status = parsed.get("status", "") | |
| loop_count = parsed.get("loop_count", 0) | |
| if status != "completed": | |
| baseline_path.unlink(missing_ok=True) | |
| print("{}") | |
| return | |
| if loop_count > 0: | |
| print("{}") | |
| return | |
| if not baseline_path.exists(): | |
| print("{}") | |
| return | |
| baseline_sha = baseline_path.read_text().strip() | |
| baseline_path.unlink(missing_ok=True) | |
| if not baseline_sha: | |
| print("{}") | |
| return | |
| diff = compute_session_diff(baseline_sha) | |
| lines_changed = count_diff_lines(diff) | |
| if lines_changed < CODEX_REVIEW_MIN_LINES: | |
| print("{}") | |
| return | |
| review = run_codex_review(lines_changed) | |
| if not review: | |
| print("{}") | |
| return | |
| upper_review = review.strip().upper() | |
| if upper_review.startswith("LGTM"): | |
| print("{}") | |
| return | |
| msg = ( | |
| f"Codex Review ({lines_changed} lines changed):\n\n" | |
| f"{review}\n\n" | |
| "Address these findings or explain why they are acceptable." | |
| ) | |
| print(json.dumps({"followup_message": msg})) | |
| if __name__ == "__main__": | |
| main() | |