SkillsHooksPromptsAgentsPersonasModelsPoliciesToolsTemplatesBundlesCategoriesStart here
← Hooks
Hk hookgovernanceadvisorystable

Agentic Governance Review

Fires on Stop. Collects the latest commit diff and sends it to an LLM for governance review — covering rules that require semantic understanding and cannot be enforced by pattern-matching hooks. Auto-detects the cheapest model available for the current provider (Haiku for Anthropic, gpt-4o-mini for OpenAI, Gemini Flash for Google). Config override at ~/.config/ai/agentic-review.json. Non-blocking — emits GOVERNANCE-VIOLATION sentinel lines.

id hook/agentic-reviewv1.0.0by convergent-systems-key
Event
Stop
Trigger
always
Language
python
Side effects
  • calls an external LLM API (Anthropic / OpenAI / Google) — incurs inference cost
  • emits GOVERNANCE-VIOLATION sentinel lines to stderr
Platforms
linuxmacoswindows
Notes
Requires at least one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY, GOOGLE_API_KEY. Uses only Python stdlib for HTTP — no pip dependencies. Config override: ~/.config/ai/agentic-review.json. Silently skips if no API key is found or diff is empty.
Depends on
hook/lib

Script · agentic-review.py

#!/usr/bin/env python3
"""hooks/agentic-review.py — LLM-based governance review of the latest commit diff.

Fires on Stop. Collects `git diff HEAD~1 HEAD` (the just-committed work),
sends it to the cheapest available model, and emits GOVERNANCE-VIOLATION
sentinel lines for any findings. Non-blocking.

Provider auto-detection order:
  1. ANTHROPIC_API_KEY  → claude-haiku-4-5-20251001
  2. OPENAI_API_KEY     → gpt-4o-mini
  3. AZURE_OPENAI_API_KEY → gpt-4o-mini (requires AZURE_OPENAI_ENDPOINT)
  4. GOOGLE_API_KEY     → gemini-2.0-flash

Config override at ~/.config/ai/agentic-review.json:
  {
    "provider": "anthropic",
    "model": "claude-haiku-4-5-20251001",
    "max_diff_lines": 300,
    "enabled": true
  }

Self-check:
  --self-check  exits 0 (validates config load only; does not call API).
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import _lib  # noqa: E402


# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------

_CONFIG_PATH = Path.home() / ".config" / "ai" / "agentic-review.json"

_PROVIDER_DEFAULTS: dict[str, str] = {
    "anthropic": "claude-haiku-4-5-20251001",
    "openai": "gpt-4o-mini",
    "azure-openai": "gpt-4o-mini",
    "google": "gemini-2.0-flash",
}


def _load_config() -> dict:
    defaults = {"enabled": True, "max_diff_lines": 300}
    try:
        raw = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
        if isinstance(raw, dict):
            defaults.update(raw)
    except (OSError, json.JSONDecodeError):
        pass
    return defaults


def _detect_provider(cfg: dict) -> tuple[str | None, str | None, str | None]:
    """Return (provider, model, api_key). Provider/model may come from config."""
    explicit_provider = cfg.get("provider", "").lower()
    explicit_model = cfg.get("model", "")

    candidates = [
        ("anthropic", os.environ.get("ANTHROPIC_API_KEY", "")),
        ("openai", os.environ.get("OPENAI_API_KEY", "")),
        ("azure-openai", os.environ.get("AZURE_OPENAI_API_KEY", "")),
        ("google", os.environ.get("GOOGLE_API_KEY", "")),
    ]

    if explicit_provider:
        for name, key in candidates:
            if name == explicit_provider and key:
                model = explicit_model or _PROVIDER_DEFAULTS.get(name, "")
                return name, model, key
        return None, None, None  # configured provider but no key

    for name, key in candidates:
        if key:
            model = explicit_model or _PROVIDER_DEFAULTS[name]
            return name, model, key

    return None, None, None


# ---------------------------------------------------------------------------
# Diff collection
# ---------------------------------------------------------------------------

_REVIEW_PROMPT = """You are a code governance reviewer. Review the git diff below for violations of these rules. Be precise — only flag clear violations, not style preferences.

Rules:
- §4.1.1: Names must reveal intent. Flag symbols named: util, helper, data, temp, mgr, handler, manager, info, obj, val (unless genuinely the most precise word).
- §4.1.3: Comments must explain WHY, not WHAT. Flag comments that describe what the code does rather than why a decision was made.
- §4.1.6: Every catch/except/recover must contain a deliberate decision. Flag: empty blocks, bare pass/continue, or logging-only handlers with no recovery logic.
- §4.1.7: Functions should isolate side effects. Flag: functions that mix pure business logic with I/O, DB calls, or global mutations.
- §4.1.8: Inputs from external sources must be validated at boundaries. Flag: missing input validation on parameters received from user input, network, or files.
- §4.1.9: Magic values must be named constants. Flag: unnamed numeric literals (other than -1, 0, 1, 2) and unexplained magic strings.
- §4.1.10: Public functions must have type signatures. Flag: exported/public functions without type annotations in Python, TypeScript, Go, Java, or Kotlin.
- §4.3.3: Test names must describe behavior, not implementation. Flag: names like test_calls_save(), test_method_1(), test_function_works().
- §4.5.1: No bare print() in non-test production code. Flag: print() / console.log() / fmt.Println() outside test files.
- §5.1.3: No AI tells in prose or comments. Flag: em-dash overload, 'Let's dive in', 'In today's world', 'It's not just X it's Y', generic summary paragraphs.

Return ONLY valid JSON — no markdown, no explanation:
{"violations": [{"rule": "§X.X.X", "file": "path/file.py", "line": 42, "detail": "one sentence"}]}
If no violations: {"violations": []}

Diff:
"""


def _get_diff(cwd: str, max_lines: int) -> str | None:
    """Return the latest commit diff, capped at max_lines. None if unavailable."""
    for cmd in (
        ["git", "diff", "HEAD~1", "HEAD"],
        ["git", "diff", "HEAD"],
    ):
        try:
            r = subprocess.run(
                cmd, capture_output=True, text=True, check=False, cwd=cwd
            )
            if r.returncode == 0 and r.stdout.strip():
                lines = r.stdout.splitlines()
                if len(lines) > max_lines:
                    lines = lines[:max_lines]
                    lines.append(f"... (truncated at {max_lines} lines)")
                return "\n".join(lines)
        except FileNotFoundError:
            return None
    return None


# ---------------------------------------------------------------------------
# API callers (stdlib urllib only — no pip deps)
# ---------------------------------------------------------------------------

def _call_anthropic(model: str, key: str, prompt: str) -> str:
    payload = json.dumps({
        "model": model,
        "max_tokens": 1024,
        "messages": [{"role": "user", "content": prompt}],
    }).encode()
    req = urllib.request.Request(
        "https://api.anthropic.com/v1/messages",
        data=payload,
        headers={
            "x-api-key": key,
            "anthropic-version": "2023-06-01",
            "content-type": "application/json",
        },
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read())["content"][0]["text"]


def _call_openai(model: str, key: str, prompt: str, base_url: str = "https://api.openai.com/v1") -> str:
    payload = json.dumps({
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": 1024,
    }).encode()
    req = urllib.request.Request(
        f"{base_url}/chat/completions",
        data=payload,
        headers={"Authorization": f"Bearer {key}", "content-type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read())["choices"][0]["message"]["content"]


def _call_google(model: str, key: str, prompt: str) -> str:
    payload = json.dumps({
        "contents": [{"parts": [{"text": prompt}]}],
        "generationConfig": {"maxOutputTokens": 1024},
    }).encode()
    url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
    req = urllib.request.Request(
        url, data=payload, headers={"content-type": "application/json"}
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read())["candidates"][0]["content"]["parts"][0]["text"]


def _call_api(provider: str, model: str, key: str, prompt: str) -> str:
    if provider == "anthropic":
        return _call_anthropic(model, key, prompt)
    if provider == "openai":
        return _call_openai(model, key, prompt)
    if provider == "azure-openai":
        endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", "").rstrip("/")
        base = f"{endpoint}/openai/deployments/{model}"
        return _call_openai(model, key, prompt, base_url=base)
    if provider == "google":
        return _call_google(model, key, prompt)
    raise ValueError(f"unknown provider: {provider}")


# ---------------------------------------------------------------------------
# Response parsing
# ---------------------------------------------------------------------------

def _parse_violations(raw: str) -> list[dict]:
    """Extract the violations array from the model response."""
    # Strip markdown fences if the model wrapped the JSON
    text = raw.strip()
    if text.startswith("```"):
        lines = text.splitlines()
        text = "\n".join(l for l in lines if not l.startswith("```"))
    try:
        data = json.loads(text)
        return data.get("violations", []) if isinstance(data, dict) else []
    except json.JSONDecodeError:
        return []


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main() -> None:
    if "--self-check" in sys.argv:
        cfg = _load_config()
        _lib.log(f"self-check OK — config loaded, enabled={cfg.get('enabled', True)}")
        sys.exit(0)

    cfg = _load_config()
    if not cfg.get("enabled", True):
        sys.exit(0)

    try:
        event = json.load(sys.stdin)
    except (json.JSONDecodeError, EOFError):
        event = {}

    cwd = event.get("cwd") or event.get("workingDirectory") or os.getcwd()

    diff = _get_diff(cwd, int(cfg.get("max_diff_lines", 300)))
    if not diff:
        sys.exit(0)

    provider, model, api_key = _detect_provider(cfg)
    if not provider:
        sys.exit(0)  # no API key available — skip silently

    prompt = _REVIEW_PROMPT + diff

    try:
        raw = _call_api(provider, model, api_key, prompt)
    except (urllib.error.URLError, OSError, KeyError, IndexError) as e:
        _lib.log(f"agentic-review: API call failed ({provider}/{model}): {e}")
        sys.exit(0)

    violations = _parse_violations(raw)
    if not violations:
        _lib.log(f"agentic-review: no violations found ({provider}/{model})")
        sys.exit(0)

    for v in violations:
        rule = v.get("rule", "?")
        file_ = v.get("file", "?")
        line = v.get("line", "?")
        detail = v.get("detail", "?")
        _lib.log(f"GOVERNANCE-VIOLATION: {rule} — {detail} [{file_}:{line}]")


if __name__ == "__main__":
    main()
governanceagenticcode-qualityclaude-code
Author convergent-systems-key. Catalog data license CC-BY-4.0.