{
  "schema": "https://ai-atoms.com/schemas/hook-v1.json",
  "type": "hook",
  "id": "hook/agentic-review",
  "version": "1.0.0",
  "name": "Agentic Governance Review",
  "description": "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.",
  "event": "Stop",
  "language": "python",
  "trigger": {
    "type": "always"
  },
  "blocking": false,
  "side_effects": [
    "calls an external LLM API (Anthropic / OpenAI / Google) — incurs inference cost",
    "emits GOVERNANCE-VIOLATION sentinel lines to stderr"
  ],
  "authored_by": "convergent-systems-key",
  "tags": [
    "governance",
    "agentic",
    "code-quality",
    "claude-code"
  ],
  "lifecycle": "stable",
  "platforms": [
    "linux",
    "macos",
    "windows"
  ],
  "platform_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.",
  "script": "#!/usr/bin/env python3\n\"\"\"hooks/agentic-review.py — LLM-based governance review of the latest commit diff.\n\nFires on Stop. Collects `git diff HEAD~1 HEAD` (the just-committed work),\nsends it to the cheapest available model, and emits GOVERNANCE-VIOLATION\nsentinel lines for any findings. Non-blocking.\n\nProvider auto-detection order:\n  1. ANTHROPIC_API_KEY  → claude-haiku-4-5-20251001\n  2. OPENAI_API_KEY     → gpt-4o-mini\n  3. AZURE_OPENAI_API_KEY → gpt-4o-mini (requires AZURE_OPENAI_ENDPOINT)\n  4. GOOGLE_API_KEY     → gemini-2.0-flash\n\nConfig override at ~/.config/ai/agentic-review.json:\n  {\n    \"provider\": \"anthropic\",\n    \"model\": \"claude-haiku-4-5-20251001\",\n    \"max_diff_lines\": 300,\n    \"enabled\": true\n  }\n\nSelf-check:\n  --self-check  exits 0 (validates config load only; does not call API).\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport subprocess\nimport sys\nimport urllib.error\nimport urllib.request\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).resolve().parent))\nimport _lib  # noqa: E402\n\n\n# ---------------------------------------------------------------------------\n# Config\n# ---------------------------------------------------------------------------\n\n_CONFIG_PATH = Path.home() / \".config\" / \"ai\" / \"agentic-review.json\"\n\n_PROVIDER_DEFAULTS: dict[str, str] = {\n    \"anthropic\": \"claude-haiku-4-5-20251001\",\n    \"openai\": \"gpt-4o-mini\",\n    \"azure-openai\": \"gpt-4o-mini\",\n    \"google\": \"gemini-2.0-flash\",\n}\n\n\ndef _load_config() -> dict:\n    defaults = {\"enabled\": True, \"max_diff_lines\": 300}\n    try:\n        raw = json.loads(_CONFIG_PATH.read_text(encoding=\"utf-8\"))\n        if isinstance(raw, dict):\n            defaults.update(raw)\n    except (OSError, json.JSONDecodeError):\n        pass\n    return defaults\n\n\ndef _detect_provider(cfg: dict) -> tuple[str | None, str | None, str | None]:\n    \"\"\"Return (provider, model, api_key). Provider/model may come from config.\"\"\"\n    explicit_provider = cfg.get(\"provider\", \"\").lower()\n    explicit_model = cfg.get(\"model\", \"\")\n\n    candidates = [\n        (\"anthropic\", os.environ.get(\"ANTHROPIC_API_KEY\", \"\")),\n        (\"openai\", os.environ.get(\"OPENAI_API_KEY\", \"\")),\n        (\"azure-openai\", os.environ.get(\"AZURE_OPENAI_API_KEY\", \"\")),\n        (\"google\", os.environ.get(\"GOOGLE_API_KEY\", \"\")),\n    ]\n\n    if explicit_provider:\n        for name, key in candidates:\n            if name == explicit_provider and key:\n                model = explicit_model or _PROVIDER_DEFAULTS.get(name, \"\")\n                return name, model, key\n        return None, None, None  # configured provider but no key\n\n    for name, key in candidates:\n        if key:\n            model = explicit_model or _PROVIDER_DEFAULTS[name]\n            return name, model, key\n\n    return None, None, None\n\n\n# ---------------------------------------------------------------------------\n# Diff collection\n# ---------------------------------------------------------------------------\n\n_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.\n\nRules:\n- §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).\n- §4.1.3: Comments must explain WHY, not WHAT. Flag comments that describe what the code does rather than why a decision was made.\n- §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.\n- §4.1.7: Functions should isolate side effects. Flag: functions that mix pure business logic with I/O, DB calls, or global mutations.\n- §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.\n- §4.1.9: Magic values must be named constants. Flag: unnamed numeric literals (other than -1, 0, 1, 2) and unexplained magic strings.\n- §4.1.10: Public functions must have type signatures. Flag: exported/public functions without type annotations in Python, TypeScript, Go, Java, or Kotlin.\n- §4.3.3: Test names must describe behavior, not implementation. Flag: names like test_calls_save(), test_method_1(), test_function_works().\n- §4.5.1: No bare print() in non-test production code. Flag: print() / console.log() / fmt.Println() outside test files.\n- §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.\n\nReturn ONLY valid JSON — no markdown, no explanation:\n{\"violations\": [{\"rule\": \"§X.X.X\", \"file\": \"path/file.py\", \"line\": 42, \"detail\": \"one sentence\"}]}\nIf no violations: {\"violations\": []}\n\nDiff:\n\"\"\"\n\n\ndef _get_diff(cwd: str, max_lines: int) -> str | None:\n    \"\"\"Return the latest commit diff, capped at max_lines. None if unavailable.\"\"\"\n    for cmd in (\n        [\"git\", \"diff\", \"HEAD~1\", \"HEAD\"],\n        [\"git\", \"diff\", \"HEAD\"],\n    ):\n        try:\n            r = subprocess.run(\n                cmd, capture_output=True, text=True, check=False, cwd=cwd\n            )\n            if r.returncode == 0 and r.stdout.strip():\n                lines = r.stdout.splitlines()\n                if len(lines) > max_lines:\n                    lines = lines[:max_lines]\n                    lines.append(f\"... (truncated at {max_lines} lines)\")\n                return \"\\n\".join(lines)\n        except FileNotFoundError:\n            return None\n    return None\n\n\n# ---------------------------------------------------------------------------\n# API callers (stdlib urllib only — no pip deps)\n# ---------------------------------------------------------------------------\n\ndef _call_anthropic(model: str, key: str, prompt: str) -> str:\n    payload = json.dumps({\n        \"model\": model,\n        \"max_tokens\": 1024,\n        \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n    }).encode()\n    req = urllib.request.Request(\n        \"https://api.anthropic.com/v1/messages\",\n        data=payload,\n        headers={\n            \"x-api-key\": key,\n            \"anthropic-version\": \"2023-06-01\",\n            \"content-type\": \"application/json\",\n        },\n    )\n    with urllib.request.urlopen(req, timeout=30) as resp:\n        return json.loads(resp.read())[\"content\"][0][\"text\"]\n\n\ndef _call_openai(model: str, key: str, prompt: str, base_url: str = \"https://api.openai.com/v1\") -> str:\n    payload = json.dumps({\n        \"model\": model,\n        \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n        \"max_tokens\": 1024,\n    }).encode()\n    req = urllib.request.Request(\n        f\"{base_url}/chat/completions\",\n        data=payload,\n        headers={\"Authorization\": f\"Bearer {key}\", \"content-type\": \"application/json\"},\n    )\n    with urllib.request.urlopen(req, timeout=30) as resp:\n        return json.loads(resp.read())[\"choices\"][0][\"message\"][\"content\"]\n\n\ndef _call_google(model: str, key: str, prompt: str) -> str:\n    payload = json.dumps({\n        \"contents\": [{\"parts\": [{\"text\": prompt}]}],\n        \"generationConfig\": {\"maxOutputTokens\": 1024},\n    }).encode()\n    url = f\"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}\"\n    req = urllib.request.Request(\n        url, data=payload, headers={\"content-type\": \"application/json\"}\n    )\n    with urllib.request.urlopen(req, timeout=30) as resp:\n        return json.loads(resp.read())[\"candidates\"][0][\"content\"][\"parts\"][0][\"text\"]\n\n\ndef _call_api(provider: str, model: str, key: str, prompt: str) -> str:\n    if provider == \"anthropic\":\n        return _call_anthropic(model, key, prompt)\n    if provider == \"openai\":\n        return _call_openai(model, key, prompt)\n    if provider == \"azure-openai\":\n        endpoint = os.environ.get(\"AZURE_OPENAI_ENDPOINT\", \"\").rstrip(\"/\")\n        base = f\"{endpoint}/openai/deployments/{model}\"\n        return _call_openai(model, key, prompt, base_url=base)\n    if provider == \"google\":\n        return _call_google(model, key, prompt)\n    raise ValueError(f\"unknown provider: {provider}\")\n\n\n# ---------------------------------------------------------------------------\n# Response parsing\n# ---------------------------------------------------------------------------\n\ndef _parse_violations(raw: str) -> list[dict]:\n    \"\"\"Extract the violations array from the model response.\"\"\"\n    # Strip markdown fences if the model wrapped the JSON\n    text = raw.strip()\n    if text.startswith(\"```\"):\n        lines = text.splitlines()\n        text = \"\\n\".join(l for l in lines if not l.startswith(\"```\"))\n    try:\n        data = json.loads(text)\n        return data.get(\"violations\", []) if isinstance(data, dict) else []\n    except json.JSONDecodeError:\n        return []\n\n\n# ---------------------------------------------------------------------------\n# Entry point\n# ---------------------------------------------------------------------------\n\ndef main() -> None:\n    if \"--self-check\" in sys.argv:\n        cfg = _load_config()\n        _lib.log(f\"self-check OK — config loaded, enabled={cfg.get('enabled', True)}\")\n        sys.exit(0)\n\n    cfg = _load_config()\n    if not cfg.get(\"enabled\", True):\n        sys.exit(0)\n\n    try:\n        event = json.load(sys.stdin)\n    except (json.JSONDecodeError, EOFError):\n        event = {}\n\n    cwd = event.get(\"cwd\") or event.get(\"workingDirectory\") or os.getcwd()\n\n    diff = _get_diff(cwd, int(cfg.get(\"max_diff_lines\", 300)))\n    if not diff:\n        sys.exit(0)\n\n    provider, model, api_key = _detect_provider(cfg)\n    if not provider:\n        sys.exit(0)  # no API key available — skip silently\n\n    prompt = _REVIEW_PROMPT + diff\n\n    try:\n        raw = _call_api(provider, model, api_key, prompt)\n    except (urllib.error.URLError, OSError, KeyError, IndexError) as e:\n        _lib.log(f\"agentic-review: API call failed ({provider}/{model}): {e}\")\n        sys.exit(0)\n\n    violations = _parse_violations(raw)\n    if not violations:\n        _lib.log(f\"agentic-review: no violations found ({provider}/{model})\")\n        sys.exit(0)\n\n    for v in violations:\n        rule = v.get(\"rule\", \"?\")\n        file_ = v.get(\"file\", \"?\")\n        line = v.get(\"line\", \"?\")\n        detail = v.get(\"detail\", \"?\")\n        _lib.log(f\"GOVERNANCE-VIOLATION: {rule} — {detail} [{file_}:{line}]\")\n\n\nif __name__ == \"__main__\":\n    main()\n",
  "depends_on": [
    "hook/lib"
  ],
  "category": "governance"
}