Read the script
#!/usr/bin/env python3
"""Bedrock error reporter hook — auto-creates GitHub issues for framework errors.
Stop hook entrypoint. Reads transcript via stdin JSON, scans the last turn
for bedrock framework errors (technical + logical), opens deduplicated issues
on iurykrieger/claude-bedrock via the gh CLI.
Never raises, always exits 0. Failures log to ~/.claude-bedrock-cache/error-reporter.log.
"""
import hashlib
import json
import os
import platform
import re
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
# Keep window small for performance: only the most recent N lines matter,
# since hook fires per turn and older lines are from prior turns.
_TRANSCRIPT_TAIL_LINES = 200
_BEDROCK_INVOCATION_RE = re.compile(r"/bedrock:(\w+)")
_TRACEBACK_RE = re.compile(r"Traceback \(most recent call last\):", re.MULTILINE)
_LAST_FRAME_RE = re.compile(r'File "([^"]+)", line (\d+), in (\w+)\n((?!\s*File ")[^\n]*\n)?([A-Z][\w\.]+(?:Error|Exception):.*)', re.MULTILINE)
# Regex catalog. ID -> compiled regex. Keep small to avoid false positives.
_LOGICAL_ERROR_CATALOG = {
"graphify_invalid": re.compile(r"graphify.{0,40}(returned|gave|produced).{0,20}invalid", re.IGNORECASE),
"vault_corrupt": re.compile(r"vault\.json.{0,30}corrupt", re.IGNORECASE),
"skill_failure": re.compile(r"bedrock\s+\w+\s+(skill\s+)?failed", re.IGNORECASE),
"entity_unwritable": re.compile(r"failed\s+to\s+(write|persist)\s+entity", re.IGNORECASE),
"sync_unauthorized": re.compile(r"(sync.{0,30}unauthorized|auth(?:entication)?\s+failed.{0,30}sync)", re.IGNORECASE),
}
# Redaction regexes — order matters in redact(). See function docstring.
_HOME_PATH_RE = re.compile(r"(?:/Users/[^/\s]+|/home/[^/\s]+|/root)(?:/[^/\s]+)*?(?=/\.claude/plugins/[^/\s]+/[^/\s]+)")
_GENERIC_HOME_PATH_RE = re.compile(r"(?:/Users/[^/\s]+|/home/[^/\s]+|/root)")
_PLUGIN_PREFIX_RE = re.compile(r"\.claude/plugins/[^/\s]+/[^/\s]+/")
_UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
_URL_RE = re.compile(r"https?://[^\s'\"<>)]+", re.IGNORECASE)
_ISO_TIMESTAMP_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:?\d{2})?")
_VAULT_ENTITY_FILE_RE = re.compile(r"\b(?:people|teams|actors|concepts|topics|discussions|projects|fleeting)/[\w-]+\.md\b")
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
_CC_LIKE_RE = re.compile(r"\b\d[\d -]{11,17}\d\b")
_API_KEY_RE = re.compile(r"\b(?:sk_(?:live|test)|ghp|ghs|gho|ghu|ghr|xox[abps]|AKIA|ASIA|GITHUB_TOKEN)[\w_-]{10,}\b", re.IGNORECASE)
_BARE_WIKILINK_RE = re.compile(r"\[\[[\w/-]+\]\]")
def _read_transcript_tail(transcript_path: Path) -> str:
"""Read up to the last _TRANSCRIPT_TAIL_LINES of a JSONL transcript.
Returns empty string if the file does not exist or is unreadable.
"""
try:
with open(transcript_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except (FileNotFoundError, OSError):
return ""
return "".join(lines[-_TRANSCRIPT_TAIL_LINES:])
def contains_bedrock_invocation(transcript_path: Path) -> bool:
"""Fast gate: returns True if '/bedrock:' appears in the recent transcript tail.
Intentionally a substring check, not JSON parsing — optimized for the 99% case
where the answer is no. False positives (e.g., the substring quoted in an
assistant message) only cost an extra slow-path traversal in the next stage;
false negatives would mean lost error reports, which is the worse failure mode.
"""
tail = _read_transcript_tail(Path(transcript_path))
return "/bedrock:" in tail
def is_reporting_enabled(start_dir: Path) -> bool:
"""Walk up from start_dir looking for .bedrock/config.json.
Returns True (default) if no config found, config malformed, or field missing.
Returns False only if config explicitly sets error_reporting to the JSON `false`
boolean — null, "false" strings, 0, etc. all default-on. Opt-out must be
well-formed and intentional.
"""
current = Path(start_dir).resolve()
for candidate in [current, *current.parents]:
cfg_path = candidate / ".bedrock" / "config.json"
if cfg_path.is_file():
try:
with open(cfg_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except (json.JSONDecodeError, OSError):
return True # default-on if config unreadable
val = cfg.get("error_reporting", True)
return val if isinstance(val, bool) else True
return True
def _iter_transcript_lines(transcript_path: Path) -> Iterable[dict]:
"""Yield parsed JSON objects from each non-empty JSONL line. Skips malformed lines."""
try:
with open(transcript_path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
except (FileNotFoundError, OSError):
return
def _iter_last_turn_lines(transcript_path: Path) -> list[dict]:
"""Return parsed JSON entries from the last turn.
A turn boundary is defined by a role=user entry containing a type=text content
block (i.e., a user-typed message, not a tool_result). Returns the last such
entry and everything after it. If no boundary is found, returns all entries.
"""
entries = list(_iter_transcript_lines(transcript_path))
if not entries:
return []
# Walk backwards to find the last user-typed message
last_user_text_idx = None
for idx in range(len(entries) - 1, -1, -1):
entry = entries[idx]
if entry.get("role") != "user":
continue
for block in entry.get("message", {}).get("content", []) or []:
if isinstance(bl