{
  "schema": "https://ai-atoms.com/schemas/hook-v1.json",
  "type": "hook",
  "id": "hook/audit-logger",
  "version": "1.0.0",
  "name": "Interaction Audit Logger",
  "description": "Appends a JSONL record to ~/.ai/audit/interactions/<YYYY-MM>.jsonl for every Claude Code hook event: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SessionEnd, SubagentStop, PreCompact. Non-blocking.",
  "event": "PreToolUse",
  "events": [
    "PreToolUse",
    "SessionStart",
    "UserPromptSubmit",
    "PostToolUse",
    "Stop",
    "SessionEnd",
    "SubagentStop",
    "PreCompact"
  ],
  "language": "python",
  "trigger": {
    "type": "always"
  },
  "blocking": false,
  "side_effects": [
    "writes to ~/.ai/audit/interactions/<YYYY-MM>.jsonl"
  ],
  "authored_by": "convergent-systems-key",
  "tags": [
    "audit",
    "logging",
    "governance",
    "claude-code"
  ],
  "lifecycle": "stable",
  "platforms": [
    "linux",
    "macos",
    "windows"
  ],
  "platform_notes": "Logic is cross-platform. Wiring: use 'ai hooks run audit-logger' in settings.json — the ai binary discovers Python on each OS. Writes to ~/.ai/audit/ — path resolves correctly cross-platform via Python pathlib.",
  "script": "#!/usr/bin/env python3\n\"\"\"hooks/audit.py — interaction audit logger.\n\nAppends one JSONL line per event to\n~/.ai/audit/interactions/<YYYY-MM>.jsonl using the\ndomain-specific vocabulary defined in ~/.ai/Common.md §5.2 (chronon,\ntrace, cwd, actor, kind, engine, stimulus, probe, probe_payload,\nemission_marker).\n\nWired into Claude Code via every event (SessionStart, UserPromptSubmit,\nPreToolUse, PostToolUse, Stop, SessionEnd, SubagentStop, PreCompact, and\nadditional event types listed in KIND_MAP below).\n\nReads the event payload from stdin as JSON. Always exits 0 (audit\nmust never block).\n\nVocabulary mapping (deliberate non-mirror of Claude/Copilot terms):\n\n    Common term     ->  This system\n    ─────────────────────────────────\n    session         ->  trace\n    turn            ->  exchange\n    tool call       ->  invocation / probe\n    user message    ->  request / stimulus\n    assistant turn  ->  emission\n    timestamp       ->  chronon\n    model           ->  engine\n\nSelf-check:\n  --self-check    Verify the audit directory is writable.\n\"\"\"\nfrom __future__ import annotations\n\nimport datetime as dt\nimport json\nimport os\nimport sys\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).resolve().parent))\nimport _lib  # noqa: E402\n\n# Mapping from Claude Code event names to our vocabulary.\n# Vocabulary deliberately does NOT mirror Claude or Copilot terms per Common.md §5.2.\nKIND_MAP = {\n    # Core Claude Code events (PascalCase)\n    \"SessionStart\": \"trace-open\",\n    \"SessionEnd\": \"trace-close\",\n    \"UserPromptSubmit\": \"request\",\n    \"PreToolUse\": \"invocation-attempt\",\n    \"PostToolUse\": \"invocation-result\",\n    \"PostToolUseFailure\": \"invocation-failure\",\n    \"Stop\": \"emission\",\n    \"SubagentStart\": \"subagent-trace-open\",\n    \"SubagentStop\": \"subagent-emission\",\n    \"Notification\": \"signal\",\n    \"PreCompact\": \"compaction-attempt\",\n    \"ErrorOccurred\": \"fault\",\n    \"PermissionRequest\": \"permission-prompt\",\n    # Copilot CLI camelCase event names\n    \"userPromptSubmitted\": \"request\",\n    \"agentStop\": \"emission\",\n}\n\n\ndef audit_dir() -> Path:\n    \"\"\"Path to ~/.ai/audit/interactions/. AI_ROOT overrides $HOME/.ai.\"\"\"\n    root = os.environ.get(\"AI_ROOT\", str(Path.home() / \".ai\"))\n    return Path(root) / \"audit\" / \"interactions\"\n\n\ndef month_file() -> Path:\n    \"\"\"The audit JSONL for the current UTC month.\"\"\"\n    now = dt.datetime.now(dt.timezone.utc)\n    return audit_dir() / f\"{now.strftime('%Y-%m')}.jsonl\"\n\n\ndef chronon() -> str:\n    \"\"\"ISO-8601 with millisecond precision and 'Z' suffix.\"\"\"\n    now = dt.datetime.now(dt.timezone.utc)\n    return now.strftime(\"%Y-%m-%dT%H:%M:%S.\") + f\"{now.microsecond // 1000:03d}Z\"\n\n\ndef _truncate(s: str, limit: int) -> str:\n    if len(s) <= limit:\n        return s\n    return s[:limit] + \"...[truncated]\"\n\n\ndef _actor_for(kind: str) -> str:\n    if kind == \"request\":\n        return \"human\"\n    if kind in (\"emission\", \"subagent-emission\", \"signal\", \"subagent-trace-open\"):\n        return \"assistant\"\n    if \"invocation\" in kind or kind == \"permission-prompt\":\n        return \"tool\"\n    return \"system\"\n\n\ndef normalize_event(raw: dict) -> dict:\n    \"\"\"Map a raw Claude/Copilot event payload to the canonical schema.\n\n    Truncates stimulus to 2000 chars and probe_payload to 1000\n    chars per Common.md §5.2. Redacts secrets via _lib.redact().\"\"\"\n    # Primary key is hookEventName (Claude Code PascalCase); fall back to\n    # hook_event_name, then event.\n    claude_kind = (\n        raw.get(\"hookEventName\")\n        or raw.get(\"hook_event_name\")\n        or raw.get(\"event\")\n        or \"\"\n    )\n    kind = KIND_MAP.get(claude_kind, claude_kind.lower() if claude_kind else \"signal\")\n\n    event = {\n        \"chronon\": chronon(),\n        \"trace\": (\n            raw.get(\"session_id\")\n            or raw.get(\"sessionId\")\n            or raw.get(\"trace\")\n            or \"\"\n        ),\n        \"cwd\": raw.get(\"cwd\") or raw.get(\"workingDirectory\") or os.getcwd(),\n        \"actor\": raw.get(\"actor\") or _actor_for(kind),\n        \"kind\": kind,\n        \"engine\": raw.get(\"engine\") or raw.get(\"model\") or raw.get(\"modelName\") or \"\",\n    }\n\n    # Per-kind payload fields — only include fields relevant to the event type.\n    if kind == \"request\":\n        stimulus = raw.get(\"prompt\") or raw.get(\"stimulus\") or \"\"\n        event[\"stimulus\"] = _lib.redact(_truncate(str(stimulus), 2000))\n\n    elif kind in (\"invocation-attempt\", \"invocation-result\", \"invocation-failure\"):\n        event[\"probe\"] = (\n            raw.get(\"tool_name\")\n            or raw.get(\"toolName\")\n            or raw.get(\"probe\")\n            or \"\"\n        )\n        payload = (\n            raw.get(\"tool_input\")\n            or raw.get(\"toolInput\")\n            or raw.get(\"toolArgs\")\n            or raw.get(\"probe_payload\")\n            or {}\n        )\n        if isinstance(payload, (dict, list)):\n            payload = json.dumps(payload)\n        event[\"probe_payload\"] = _lib.redact(_truncate(str(payload), 1000))\n        # Record that a result is present (don't log content — can be large)\n        if kind == \"invocation-result\":\n            result_val = (\n                raw.get(\"tool_response\")\n                or raw.get(\"toolResponse\")\n                or raw.get(\"toolResult\")\n                or raw.get(\"tool_result\")\n            )\n            if result_val is not None:\n                event[\"probe_result_marker\"] = \"present\"\n\n    elif kind in (\"emission\", \"subagent-emission\"):\n        event[\"emission_marker\"] = (\n            raw.get(\"stopReason\")\n            or raw.get(\"stop_reason\")\n            or raw.get(\"emission_marker\")\n            or \"stop\"\n        )\n        if raw.get(\"transcriptPath\") or raw.get(\"transcript_path\"):\n            event[\"transcript_marker\"] = \"present\"\n\n    elif kind == \"compaction-attempt\":\n        event[\"compaction_trigger\"] = raw.get(\"trigger\") or \"\"\n\n    elif kind == \"fault\":\n        err = raw.get(\"error\") or {}\n        if isinstance(err, dict):\n            event[\"fault_name\"] = err.get(\"name\", \"\")\n            event[\"fault_message\"] = _truncate(err.get(\"message\", \"\"), 500)\n        else:\n            event[\"fault_name\"] = \"\"\n            event[\"fault_message\"] = \"\"\n        event[\"fault_context\"] = (\n            raw.get(\"errorContext\") or raw.get(\"error_context\") or \"\"\n        )\n        event[\"fault_recoverable\"] = bool(raw.get(\"recoverable\", False))\n\n    elif kind == \"permission-prompt\":\n        event[\"probe\"] = raw.get(\"tool_name\") or raw.get(\"toolName\") or \"\"\n        event[\"permission_reason\"] = _truncate(str(raw.get(\"reason\", \"\")), 500)\n\n    elif kind == \"signal\":\n        event[\"signal_type\"] = (\n            raw.get(\"notification_type\")\n            or raw.get(\"notificationType\")\n            or \"\"\n        )\n        event[\"signal_message\"] = _truncate(str(raw.get(\"message\", \"\")), 500)\n\n    return event\n\n\ndef main(argv: list) -> int:\n    if \"--self-check\" in argv:\n        try:\n            audit_dir().mkdir(parents=True, exist_ok=True)\n            test = month_file().with_suffix(\".jsonl.self-check\")\n            test.write_text(\"\", encoding=\"utf-8\")\n            test.unlink(missing_ok=True)\n        except Exception as e:\n            _lib.log(\"self-check FAIL:\", e)\n            return 1\n        _lib.log(\"self-check OK\")\n        return 0\n\n    raw_input = sys.stdin.read()\n    if not raw_input.strip():\n        return 0\n\n    try:\n        raw = json.loads(raw_input)\n    except json.JSONDecodeError:\n        raw = {\"raw\": raw_input}\n\n    try:\n        event = normalize_event(raw)\n        # Redaction pass: scrub the structured fields in-place, but\n        # ONLY when the per-kind field is present. Per Common.md §4.5\n        # the redaction itself is non-optional; per §5.2 the per-kind\n        # fields are kind-scoped (stimulus only for `request`,\n        # probe/probe_payload only for invocation-*, emission_marker\n        # only for emission/subagent-emission). Materializing empty\n        # strings for unrelated kinds pollutes downstream greps.\n        if \"stimulus\" in event:\n            event[\"stimulus\"] = _lib.redact(event[\"stimulus\"])\n        if \"probe_payload\" in event:\n            event[\"probe_payload\"] = _lib.redact(event[\"probe_payload\"])\n        audit_dir().mkdir(parents=True, exist_ok=True)\n        with open(month_file(), \"a\", encoding=\"utf-8\") as f:\n            f.write(json.dumps(event, ensure_ascii=False, separators=(\",\", \":\")) + \"\\n\")\n    except Exception as e:\n        _lib.log(\"audit append failed:\", e)\n        # Audit failures must NEVER block — exit 0 anyway.\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main(sys.argv[1:]))\n",
  "depends_on": [
    "hook/lib"
  ],
  "category": "governance"
}