#!/usr/bin/env python3 """Assemble the context packet for a-review. Reads CLAUDE.md, latest session notes, expanded diff, and diff stat from the current git repository. Outputs a structured context document to stdout for piping to the reviewer model. Usage: python scripts/assemble_context.py # diff against HEAD~1 python scripts/assemble_context.py --staged # diff of staged changes python scripts/assemble_context.py --context 15 # 15 lines of context """ from __future__ import annotations import argparse import subprocess import sys from pathlib import Path def _run(cmd: list[str], cwd: Path | None = None) -> str: """Run a command and return stdout, or empty string on failure.""" try: result = subprocess.run( cmd, capture_output=True, text=True, cwd=cwd, check=False, ) return result.stdout.strip() except FileNotFoundError: return "" def _find_project_root() -> Path: """Find the git repository root.""" root = _run(["git", "rev-parse", "--show-toplevel"]) if root: return Path(root) return Path.cwd() def _read_file_if_exists(path: Path) -> str: """Read a file if it exists, return empty string otherwise.""" if path.exists(): return path.read_text() return "" def _find_latest_session_entry(project_root: Path) -> str: """Find and return the latest session notes entry.""" sessions_dir = project_root / "sessions" if not sessions_dir.exists(): return "" # Find session note files session_files = sorted(sessions_dir.glob("*.md"), key=lambda f: f.stat().st_mtime, reverse=True) if not session_files: return "" content = session_files[0].read_text() # Extract the last ## Session entry sections = content.split("\n## Session") if len(sections) < 2: return content # Return full file if no session headers last_section = "## Session" + sections[-1] return last_section.strip() def assemble(staged: bool = False, context_lines: int = 10) -> str: """Assemble the full context packet.""" root = _find_project_root() parts: list[str] = [] # Header project_name = root.name parts.append(f"# Code Review Context: {project_name}") parts.append("") # CLAUDE.md claude_md = _read_file_if_exists(root / "CLAUDE.md") if claude_md: parts.append("## Project Documentation (CLAUDE.md)") parts.append("") parts.append(claude_md) parts.append("") else: parts.append("## Project Documentation") parts.append("No CLAUDE.md found in project root.") parts.append("") # Session notes session = _find_latest_session_entry(root) if session: parts.append("## Latest Session Notes") parts.append("") parts.append(session) parts.append("") else: parts.append("## Session Notes") parts.append("No session notes found.") parts.append("") # Diff stat diff_ref = ["--cached"] if staged else ["HEAD~1"] stat = _run(["git", "diff", "--stat", *diff_ref], cwd=root) if stat: parts.append("## Diff Summary") parts.append("```") parts.append(stat) parts.append("```") parts.append("") # Expanded diff (with surrounding context) expanded = _run( ["git", "diff", f"-U{context_lines}", *diff_ref], cwd=root, ) if expanded: parts.append(f"## Expanded Diff ({context_lines} lines context)") parts.append("```diff") parts.append(expanded) parts.append("```") parts.append("") # Standard diff diff = _run(["git", "diff", *diff_ref], cwd=root) if diff: parts.append("## Diff") parts.append("```diff") parts.append(diff) parts.append("```") elif not expanded: parts.append("## Diff") parts.append("No changes found.") return "\n".join(parts) def main() -> None: parser = argparse.ArgumentParser(description="Assemble a-review context packet") parser.add_argument("--staged", action="store_true", help="Review staged changes instead of HEAD~1") parser.add_argument("--context", type=int, default=10, help="Lines of surrounding context (default: 10)") args = parser.parse_args() output = assemble(staged=args.staged, context_lines=args.context) sys.stdout.write(output) if __name__ == "__main__": main()