ai-incidents 1.0.0
test / test (macos-latest, 3.13) (push) Canceled after 0s
test / test (ubuntu-latest, 3.11) (push) Successful in 17s
test / test (ubuntu-latest, 3.12) (push) Successful in 18s
test / test (ubuntu-latest, 3.13) (push) Successful in 15s

This commit is contained in:
Holden Salomon
2026-09-21 17:48:40 +00:00
commit a9e8287065
41 changed files with 4546 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""ai-incidents: an unattended incident recorder for AI coding-agent sessions."""
__version__ = "1.0.0"
+5
View File
@@ -0,0 +1,5 @@
import sys
from .cli import main
sys.exit(main())
+144
View File
@@ -0,0 +1,144 @@
"""Command line: ``ai-incidents run | scan | init | reindex | config``."""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from . import __version__, config, gitops, pipeline
from .config import ConfigError
from .envelope import EnvelopeError, WriteGuard
from .state import LockedError, State
def _say(msg: str) -> None:
try:
print(msg, flush=True)
except BrokenPipeError:
# The reader went away (`ai-incidents scan --show | head`). Stop printing, keep working:
# a run must not be abandoned halfway because nobody is reading its progress.
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
def cmd_run(args, cfg) -> int:
o = pipeline.run(
cfg,
dry_run=args.dry_run,
with_judge=args.with_judge,
backfill=args.backfill,
notify_cmd=args.notify_cmd,
use_git=False if args.no_git else None,
push=args.push,
verbose=args.verbose,
say=_say,
)
return o.code
def cmd_scan(args, cfg) -> int:
"""Show what the pre-filter finds. Reads transcripts and state; writes nothing."""
state = State(cfg.state.file, cfg.state.seen_list)
res = pipeline.scan(cfg, state, backfill=args.backfill)
for line in res.summary():
_say(line.replace("**", ""))
if args.show:
for c in res.candidates:
_say("\n" + c.render())
return pipeline.EXIT_OK
def cmd_init(args, cfg_path: str) -> int:
ledger_dir = os.path.abspath(os.path.expanduser(args.ledger))
if os.path.exists(cfg_path) and not args.force:
_say(f"{cfg_path} already exists (use --force to overwrite)")
return pipeline.EXIT_USAGE
guard = WriteGuard(dirs=[ledger_dir], files=[cfg_path])
guard.write_text(cfg_path, config.EXAMPLE.format(ledger=ledger_dir, state=config.default_state_path()))
_say(f"wrote {cfg_path}")
guard.makedirs(ledger_dir)
if args.git and not gitops.is_repo(ledger_dir):
subprocess.run(["git", "init", "--quiet", ledger_dir], check=True)
_say(f"initialised a git repository in {ledger_dir}")
_say("next: `ai-incidents run --dry-run` to see what would be judged")
return pipeline.EXIT_OK
def cmd_config(args, cfg) -> int:
_say(f"config: {cfg.path}")
for s in cfg.sources:
_say(f"source: {s.name} ({s.type}) {s.path}")
j = cfg.judge
_say(f"judge: {pipeline._judge_label(cfg)}, timeout {j.timeout}s")
_say(f"ledger: {cfg.ledger.dir or '(not set)'}"
+ (" [git]" if cfg.ledger.dir and gitops.is_repo(cfg.ledger.dir) and cfg.git.enabled else ""))
_say(f"state: {cfg.state.file}")
if cfg.state.seen_list:
_say(f"seen: {cfg.state.seen_list}")
_say(f"writes: {cfg.ledger.dir}/ and {cfg.state.file}" + (f" and {cfg.state.seen_list}" if cfg.state.seen_list else ""))
return pipeline.EXIT_OK
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="ai-incidents",
description="Find the moments your AI coding agents caused real damage, and keep a ledger of the lessons.",
)
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
p.add_argument("-c", "--config", help="config file (default: $AI_INCIDENTS_CONFIG or ~/.config/ai-incidents/config.toml)")
sub = p.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("run", help="scan, judge, and update the ledger")
r.add_argument("--dry-run", action="store_true",
help="scan and show what would be judged; call no model and write nothing")
r.add_argument("--with-judge", action="store_true",
help="with --dry-run: do call the judge and print the report, but still write nothing")
r.add_argument("--backfill", action="store_true",
help="judge existing sessions on a first run instead of taking a baseline")
r.add_argument("--notify-cmd", help="command to run when incidents are filed (overrides hooks.notify_cmd)")
r.add_argument("--no-git", action="store_true", help="write the ledger but do not commit")
push = r.add_mutually_exclusive_group()
push.add_argument("--push", dest="push", action="store_true", default=None, help="push after committing")
push.add_argument("--no-push", dest="push", action="store_false", help="do not push")
r.add_argument("-v", "--verbose", action="store_true", help="with --dry-run: print the full judge prompt")
s = sub.add_parser("scan", help="show what the pre-filter finds; read-only")
s.add_argument("--show", action="store_true", help="print the candidates")
s.add_argument("--backfill", action="store_true", help="include sessions a first run would baseline")
i = sub.add_parser("init", help="write a starter config and create the ledger directory")
i.add_argument("--ledger", required=True, help="ledger directory to create")
i.add_argument("--git", action="store_true", help="also `git init` the ledger directory")
i.add_argument("--force", action="store_true", help="overwrite an existing config")
sub.add_parser("reindex", help="re-rank incidents.md and rebuild index.json after hand edits")
sub.add_parser("config", help="print the effective configuration")
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
cfg_path = os.path.abspath(os.path.expanduser(args.config)) if args.config else config.default_config_path()
try:
if args.cmd == "init":
return cmd_init(args, cfg_path)
cfg = config.load(cfg_path, required=args.cmd in ("run", "reindex"))
if args.cmd == "run":
return cmd_run(args, cfg)
if args.cmd == "scan":
return cmd_scan(args, cfg)
if args.cmd == "reindex":
return pipeline.reindex(cfg, say=_say)
if args.cmd == "config":
return cmd_config(args, cfg)
except ConfigError as e:
print(f"ai-incidents: config: {e}", file=sys.stderr)
return pipeline.EXIT_USAGE
except LockedError as e:
print(f"ai-incidents: {e}; not starting a second run", file=sys.stderr)
return pipeline.EXIT_LOCKED
except (EnvelopeError, ValueError, OSError) as e:
print(f"ai-incidents: {e}", file=sys.stderr)
return pipeline.EXIT_FAILED
return pipeline.EXIT_USAGE
+244
View File
@@ -0,0 +1,244 @@
"""One TOML file configures everything. Unknown keys are an error, so a typo cannot silently turn a
setting off."""
from __future__ import annotations
import os
import tomllib
from dataclasses import dataclass, field, fields
from .judge import BACKENDS, JudgeConfig
from .sources import SOURCE_TYPES
class ConfigError(ValueError):
pass
def xdg(var: str, fallback: str) -> str:
return os.environ.get(var) or os.path.expanduser(fallback)
def default_config_path() -> str:
return os.environ.get("AI_INCIDENTS_CONFIG") or os.path.join(
xdg("XDG_CONFIG_HOME", "~/.config"), "ai-incidents", "config.toml"
)
def default_state_path() -> str:
return os.path.join(xdg("XDG_STATE_HOME", "~/.local/state"), "ai-incidents", "state.json")
def default_source_path(kind: str) -> str:
if kind == "claude-code":
base = os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude"
return os.path.join(base, "projects")
if kind == "opencode":
return os.path.join(xdg("XDG_DATA_HOME", "~/.local/share"), "opencode", "opencode*.db")
return ""
@dataclass
class SourceConfig:
type: str = ""
path: str = ""
name: str = ""
@dataclass
class ScanConfig:
max_candidates: int = 50
max_per_session: int = 12
first_run: str = "baseline" # "baseline" | "backfill"
exclude: list[str] = field(default_factory=list)
extra_destructive: list[str] = field(default_factory=list)
extra_benign: list[str] = field(default_factory=list)
extra_alarm: list[str] = field(default_factory=list)
@dataclass
class LedgerConfig:
dir: str = ""
order: str = "severity" # "severity" | "newest"
title: str = "AI incidents"
@dataclass
class GitConfig:
enabled: bool = True
push: bool = False
remote: str = "origin"
branch: str = ""
expected_remote_url: str = ""
author_name: str = ""
author_email: str = ""
@dataclass
class StateConfig:
file: str = ""
seen_list: str = ""
@dataclass
class PrivacyConfig:
redact: bool = True
extra_redact_patterns: list[str] = field(default_factory=list)
@dataclass
class HooksConfig:
notify_cmd: str = ""
on_success_cmd: str = ""
on_failure_cmd: str = ""
timeout: int = 60
@dataclass
class Config:
sources: list[SourceConfig] = field(default_factory=list)
scan: ScanConfig = field(default_factory=ScanConfig)
judge: JudgeConfig = field(default_factory=JudgeConfig)
ledger: LedgerConfig = field(default_factory=LedgerConfig)
git: GitConfig = field(default_factory=GitConfig)
state: StateConfig = field(default_factory=StateConfig)
privacy: PrivacyConfig = field(default_factory=PrivacyConfig)
hooks: HooksConfig = field(default_factory=HooksConfig)
path: str = ""
def _section(cls, raw, where: str):
if raw is None:
return cls()
if not isinstance(raw, dict):
raise ConfigError(f"[{where}] must be a table")
known = {f.name: f for f in fields(cls)}
unknown = sorted(set(raw) - set(known))
if unknown:
raise ConfigError(f"[{where}] unknown key(s): {', '.join(unknown)}")
defaults = cls()
for k, v in raw.items():
want = type(getattr(defaults, k))
if want is int and isinstance(v, bool) or not isinstance(v, want):
raise ConfigError(f"[{where}] {k} must be {want.__name__}, got {type(v).__name__}")
if want is list and not all(isinstance(x, str) for x in v):
raise ConfigError(f"[{where}] {k} must be a list of strings")
return cls(**raw)
def _expand(p: str) -> str:
return os.path.abspath(os.path.expanduser(os.path.expandvars(p))) if p else p
def load(path: str | None = None, required: bool = True) -> Config:
path = path or default_config_path()
raw: dict = {}
if os.path.exists(path):
try:
with open(path, "rb") as f:
raw = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise ConfigError(f"{path}: {e}") from e
elif required:
raise ConfigError(f"no config at {path} (create one with `ai-incidents init`)")
return from_dict(raw, path)
def from_dict(raw: dict, path: str = "") -> Config:
top = {"source", "scan", "judge", "ledger", "git", "state", "privacy", "hooks"}
unknown = sorted(set(raw) - top)
if unknown:
raise ConfigError(f"unknown top-level key(s): {', '.join(unknown)}")
cfg = Config(path=path)
srcs = raw.get("source")
if srcs is None:
srcs = [{"type": "claude-code"}, {"type": "opencode"}]
if not isinstance(srcs, list):
raise ConfigError("[[source]] must be an array of tables")
names = set()
for i, s in enumerate(srcs):
sc = _section(SourceConfig, s, f"source #{i + 1}")
if sc.type not in SOURCE_TYPES:
raise ConfigError(f"[[source]] #{i + 1}: type must be one of {sorted(SOURCE_TYPES)}")
sc.name = sc.name or sc.type
if sc.name in names:
raise ConfigError(f"[[source]] name {sc.name!r} is used twice; give one a distinct `name`")
names.add(sc.name)
sc.path = _expand(sc.path or default_source_path(sc.type))
cfg.sources.append(sc)
cfg.scan = _section(ScanConfig, raw.get("scan"), "scan")
cfg.judge = _section(JudgeConfig, raw.get("judge"), "judge")
cfg.ledger = _section(LedgerConfig, raw.get("ledger"), "ledger")
cfg.git = _section(GitConfig, raw.get("git"), "git")
cfg.state = _section(StateConfig, raw.get("state"), "state")
cfg.privacy = _section(PrivacyConfig, raw.get("privacy"), "privacy")
cfg.hooks = _section(HooksConfig, raw.get("hooks"), "hooks")
if cfg.scan.first_run not in ("baseline", "backfill"):
raise ConfigError("[scan] first_run must be 'baseline' or 'backfill'")
if cfg.scan.max_candidates < 2:
raise ConfigError("[scan] max_candidates must be at least 2")
if not 2 <= cfg.scan.max_per_session <= cfg.scan.max_candidates:
raise ConfigError("[scan] max_per_session must be between 2 and max_candidates")
if cfg.ledger.order not in ("severity", "newest"):
raise ConfigError("[ledger] order must be 'severity' or 'newest'")
if cfg.judge.backend not in BACKENDS:
raise ConfigError(f"[judge] backend must be one of {sorted(BACKENDS)}")
if cfg.judge.backend == "command" and not cfg.judge.command:
raise ConfigError("[judge] backend = 'command' needs `command = [...]`")
if cfg.judge.timeout <= 0:
raise ConfigError("[judge] timeout must be positive")
cfg.ledger.dir = _expand(cfg.ledger.dir)
cfg.state.file = _expand(cfg.state.file) or default_state_path()
cfg.state.seen_list = _expand(cfg.state.seen_list)
cfg.judge.prompt_file = _expand(cfg.judge.prompt_file)
return cfg
EXAMPLE = '''\
# ai-incidents configuration. Every key is optional except ledger.dir.
# Reference: https://github.com/sudolulo/ai-incidents#configuration
# Where the transcripts are. Read-only, always. Omit [[source]] entirely to read both defaults.
[[source]]
type = "claude-code"
path = "~/.claude/projects"
[[source]]
type = "opencode"
path = "~/.local/share/opencode/opencode*.db"
[scan]
max_candidates = 50 # most candidates shown to the judge in one run
max_per_session = 12 # most candidates from any one session
first_run = "baseline" # "baseline": mark existing sessions judged; "backfill": judge them
[judge]
backend = "claude" # "claude" | "openai" | "command"
model = "sonnet"
timeout = 900
# For a local model, use an OpenAI-compatible server instead (llama.cpp, Ollama, vLLM):
# backend = "openai"
# base_url = "http://localhost:11434/v1"
# model = "qwen3:32b"
[ledger]
dir = "{ledger}"
order = "severity" # "severity" (most severe first) | "newest"
[git]
enabled = true # commit the ledger if ledger.dir is a git repository
push = false
[state]
file = "{state}"
[privacy]
redact = true
[hooks]
# notify_cmd = "curl -s -H 'Title: ai-incidents' --data-binary @- https://ntfy.sh/your-topic"
'''
+60
View File
@@ -0,0 +1,60 @@
"""The write side of the permission envelope.
Every file this tool writes goes through a ``WriteGuard``. The guard is built once per run from the
configuration and knows exactly which places may be written: the ledger directory, the state file
(and its lock), and the optional seen-list export. Anything else raises ``EnvelopeError`` before a
byte is written, so a bug, a hostile path in the config, or a judge that tries to smuggle a file
name into its verdict cannot turn this tool into a general-purpose writer.
The read side is simpler: transcripts are opened with ``open(..., "r")`` and SQLite ``mode=ro``,
and the judge is given text on stdin and has no tools at all. See the README's "Permission
envelope" section.
"""
from __future__ import annotations
import os
import tempfile
class EnvelopeError(PermissionError):
pass
def _real(path: str) -> str:
return os.path.realpath(os.path.abspath(os.path.expanduser(path)))
class WriteGuard:
def __init__(self, dirs: list[str] = (), files: list[str] = ()):
self.dirs = [_real(d) for d in dirs if d]
self.files = {_real(f) for f in files if f}
def check(self, path: str) -> str:
real = _real(path)
if real in self.files:
return real
for d in self.dirs:
if real == d or real.startswith(d + os.sep):
return real
raise EnvelopeError(f"refusing to write outside the envelope: {path}")
def makedirs(self, path: str) -> None:
os.makedirs(self.check(path), exist_ok=True)
def write_text(self, path: str, content: str) -> None:
"""Atomic write: a reader never sees a half-written file, and a crash leaves the old one."""
real = self.check(path)
parent = os.path.dirname(real)
os.makedirs(parent, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
os.replace(tmp, real)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
+93
View File
@@ -0,0 +1,93 @@
"""Commit (and optionally push) the ledger. The only git operations this tool performs.
* Only the files this run wrote are staged and committed, by explicit path, with
``git commit -- <paths>``. Anything else in the ledger repository, staged or not, is left alone.
``git add -A`` is not a review step.
* Push is off by default. When on, it pushes the current branch to the configured remote under its
own name: no refspec rewriting, no force, no tags. A detached HEAD is refused.
* ``expected_remote_url`` pins the remote: if the checkout has been repointed, the push is refused
instead of publishing the ledger somewhere else.
* Git never prompts: an unattended run must fail, not hang waiting for a password.
"""
from __future__ import annotations
import os
import subprocess
from .config import GitConfig
class GitError(RuntimeError):
pass
def _git(repo: str, *args: str, check: bool = True, cfg: GitConfig | None = None) -> subprocess.CompletedProcess:
pre = []
if cfg and cfg.author_name:
pre += ["-c", f"user.name={cfg.author_name}"]
if cfg and cfg.author_email:
pre += ["-c", f"user.email={cfg.author_email}"]
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "", "SSH_ASKPASS": ""}
p = subprocess.run(["git", *pre, "-C", repo, *args], capture_output=True, text=True, env=env)
if check and p.returncode != 0:
raise GitError(f"git {' '.join(args)}: {(p.stderr or p.stdout).strip()[-400:]}")
return p
def is_repo(path: str) -> bool:
if not os.path.isdir(path):
return False
p = _git(path, "rev-parse", "--show-toplevel", check=False)
return p.returncode == 0 and os.path.realpath(p.stdout.strip()) == os.path.realpath(path)
def current_branch(repo: str) -> str:
return _git(repo, "branch", "--show-current").stdout.strip()
def _check_remote(repo: str, cfg: GitConfig) -> None:
if not cfg.expected_remote_url:
return
url = _git(repo, "remote", "get-url", cfg.remote, check=False).stdout.strip()
if url.rstrip("/").removesuffix(".git") != cfg.expected_remote_url.rstrip("/").removesuffix(".git"):
raise GitError(f"remote {cfg.remote} is {url!r}, not the expected {cfg.expected_remote_url!r}; refusing")
def _branch(repo: str, cfg: GitConfig) -> str:
branch = current_branch(repo)
if not branch:
raise GitError("detached HEAD; refusing")
if cfg.branch and branch != cfg.branch:
raise GitError(f"ledger repo is on {branch!r}, configured branch is {cfg.branch!r}; refusing")
return branch
def pull(repo: str, cfg: GitConfig) -> str | None:
"""Fast-forward from the remote before writing. Returns a warning, or None."""
_check_remote(repo, cfg)
branch = _branch(repo, cfg)
p = _git(repo, "pull", "--ff-only", "--quiet", cfg.remote, branch, check=False)
if p.returncode != 0:
return f"git pull --ff-only failed: {(p.stderr or p.stdout).strip()[-200:]}"
return None
def commit(repo: str, paths: list[str], message: str, cfg: GitConfig) -> str | None:
"""Commit exactly ``paths``. Returns the short hash, or None when nothing changed."""
_branch(repo, cfg)
rel = [os.path.relpath(p, repo) for p in paths]
for r in rel:
if r.startswith(".."):
raise GitError(f"refusing to commit a path outside the ledger repo: {r}")
_git(repo, "add", "--", *rel)
if _git(repo, "diff", "--cached", "--quiet", "--", *rel, check=False).returncode == 0:
return None
_git(repo, "commit", "--quiet", "-m", message, "--", *rel, cfg=cfg)
return _git(repo, "rev-parse", "--short", "HEAD").stdout.strip()
def push(repo: str, cfg: GitConfig) -> None:
_check_remote(repo, cfg)
branch = _branch(repo, cfg)
_git(repo, "push", "--quiet", "--no-follow-tags", cfg.remote, f"refs/heads/{branch}:refs/heads/{branch}")
+42
View File
@@ -0,0 +1,42 @@
"""Optional hooks: tell something else how the run went.
Hooks are commands you configure, so they run outside the permission envelope by definition. They
receive the run summary on stdin and as ``AI_INCIDENTS_*`` environment variables; nothing is ever
interpolated into the command line. A hook that fails is logged and ignored: a broken notifier must
not turn a successful run into a failed one, or the other way round.
* ``notify_cmd``: after a run that filed at least one incident. A quiet run is silent.
* ``on_success_cmd``: after every successful run, quiet or not. Use it as a dead-man's-switch
heartbeat: a skipped judge is a successful run, and a monitor must be told so.
* ``on_failure_cmd``: after a run that failed (judge error, git error, bad state).
"""
from __future__ import annotations
import os
import shlex
import subprocess
import sys
def run_hook(cmd: str, summary: str, env: dict[str, str], timeout: int = 60, log=None) -> bool:
if not cmd:
return True
log = log or (lambda m: print(m, file=sys.stderr))
try:
argv = shlex.split(cmd)
except ValueError as e:
log(f"hook {cmd!r}: cannot parse ({e})")
return False
try:
p = subprocess.run(
argv, input=summary, text=True, capture_output=True, timeout=timeout,
env={**os.environ, **{k: str(v) for k, v in env.items()}},
)
except (OSError, subprocess.TimeoutExpired) as e:
log(f"hook {argv[0]}: {e}")
return False
if p.returncode != 0:
log(f"hook {argv[0]} exited {p.returncode}: {(p.stderr or p.stdout).strip()[-200:]}")
return False
return True
+300
View File
@@ -0,0 +1,300 @@
"""The judge: one model call, text in, JSON out, no tools.
The judge never touches the filesystem. It receives the rubric as a system prompt and the
candidates on stdin (never in argv, where other local users could read them with ``ps``), and it
answers with a JSON verdict. The verdict is validated here before anything is written: an exit code
of 0 is not evidence of an answer. ``claude -p`` exits 0 while printing "You've hit your spend
limit", and a local model can return prose. Either is a failed run, and a failed run records
nothing, so the next run shows the same candidates again.
Backends:
* ``claude``: the Claude Code CLI in print mode, with every built-in tool and every MCP server
disabled and session persistence off (so the judge's own session is not swept next time).
* ``openai``: any OpenAI-compatible ``/chat/completions`` endpoint. This is the local-model path:
llama.cpp's server, Ollama, vLLM, LM Studio.
* ``command``: any program that reads a prompt on stdin and prints the answer on stdout.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import tempfile
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from importlib import resources
from .ledger import ISO_DATE, SEVERITIES, Entry, one_line
from .prefilter import Candidate
CATEGORIES = (
"data-loss",
"outage",
"credential-leak",
"destructive-action",
"debug-spiral",
"unmetered-cost",
"misattribution",
"other",
)
class JudgeError(RuntimeError):
pass
def default_rubric() -> str:
return resources.files("ai_incidents").joinpath("prompts/judge.md").read_text(encoding="utf-8")
def build_message(candidates: list[Candidate], existing: list[Entry], today: str) -> str:
lines = [f"Today is {today}.", "", f"## Candidates ({len(candidates)})", ""]
lines += [c.render() + "\n" for c in candidates]
lines += ["", f"## Already in the ledger ({len(existing)})", ""]
if not existing:
lines.append("(the ledger is empty)")
for i, e in enumerate(existing, 1):
what = e.what()
lines.append(f"- [E{i:02d}] {e.title} · {e.date} · {e.severity}" + (f" -- {what[:200]}" if what else ""))
lines += ["", "Answer with the JSON object only."]
return "\n".join(lines)
# --- verdict ---------------------------------------------------------------------------------
@dataclass
class Incident:
title: str
date: str
severity: str
category: str
what: str
cost: str
lesson: str
candidates: list[str]
why: str
def entry(self) -> Entry:
return Entry.new(self.title, self.date, self.severity, self.what, self.cost, self.lesson)
@dataclass
class Exclusion:
candidates: list[str]
reason: str
duplicate_of: str = ""
@dataclass
class Verdict:
incidents: list[Incident] = field(default_factory=list)
excluded: list[Exclusion] = field(default_factory=list)
patterns: list[str] = field(default_factory=list)
rejected: list[str] = field(default_factory=list) # malformed incidents, with the reason
unaddressed: list[str] = field(default_factory=list) # candidate ids the judge never mentioned
usage: dict = field(default_factory=dict)
def extract_json(text: str) -> dict:
"""The first JSON object in ``text``. Tolerates a code fence or a sentence around it, because
models add them however firmly they are told not to."""
text = text.strip()
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
if fence:
text = fence.group(1)
start = text.find("{")
if start < 0:
raise JudgeError(f"judge answer contains no JSON object: {text[:200]!r}")
try:
obj, _ = json.JSONDecoder().raw_decode(text[start:])
except ValueError as e:
raise JudgeError(f"judge answer is not valid JSON ({e}): {text[start:start + 200]!r}") from e
if not isinstance(obj, dict):
raise JudgeError("judge answer is not a JSON object")
return obj
def _ids(v) -> list[str]:
if isinstance(v, str):
v = [v]
if not isinstance(v, list):
return []
return [str(x).strip() for x in v if str(x).strip()]
def parse_verdict(text: str, candidates: list[Candidate], today: str, redact=lambda s: s) -> Verdict:
obj = extract_json(text)
if not isinstance(obj.get("incidents", []), list) or not isinstance(obj.get("excluded", []), list):
raise JudgeError("judge answer: 'incidents' and 'excluded' must be lists")
if "incidents" not in obj and "excluded" not in obj:
raise JudgeError("judge answer has neither 'incidents' nor 'excluded'")
by_id = {c.id: c for c in candidates}
v = Verdict()
for i, raw in enumerate(obj.get("incidents") or []):
if not isinstance(raw, dict):
v.rejected.append(f"incident #{i + 1}: not an object")
continue
def get(k, raw=raw):
return one_line(redact(str(raw.get(k) or "")))
sev = get("severity").upper()
missing = [k for k in ("title", "what", "cost", "lesson") if not get(k)]
if missing or sev not in SEVERITIES:
why = f"missing {', '.join(missing)}" if missing else f"severity {sev!r}"
v.rejected.append(f"incident #{i + 1} ({get('title') or 'untitled'}): {why}")
continue
ids = [x for x in _ids(raw.get("candidates")) if x in by_id]
date = get("date")
if not ISO_DATE.match(date):
dated = [by_id[x].date for x in ids if by_id[x].date]
date = dated[0] if dated else today
cat = get("category").lower()
v.incidents.append(Incident(
title=get("title"), date=date, severity=sev,
category=cat if cat in CATEGORIES else "other",
what=get("what"), cost=get("cost"), lesson=get("lesson"),
candidates=ids, why=get("why"),
))
for raw in obj.get("excluded") or []:
if not isinstance(raw, dict):
continue
ids = [x for x in _ids(raw.get("candidates") or raw.get("candidate")) if x in by_id]
v.excluded.append(Exclusion(ids, one_line(redact(str(raw.get("reason") or ""))) or "(no reason given)",
one_line(str(raw.get("duplicate_of") or ""), 40)))
pats = obj.get("patterns") or []
if isinstance(pats, list):
v.patterns = [one_line(redact(str(p)), 500) for p in pats if str(p).strip()]
mentioned = {x for i in v.incidents for x in i.candidates} | {x for e in v.excluded for x in e.candidates}
v.unaddressed = [c.id for c in candidates if c.id not in mentioned]
return v
# --- backends --------------------------------------------------------------------------------
@dataclass
class JudgeConfig:
backend: str = "claude"
model: str = "sonnet"
timeout: int = 900
binary: str = "claude"
extra_args: list[str] = field(default_factory=list)
base_url: str = "http://localhost:8080/v1"
api_key_env: str = ""
json_mode: bool = True
max_tokens: int = 16000
command: list[str] = field(default_factory=list)
prompt_file: str = ""
def claude_argv(cfg: JudgeConfig, system_prompt: str) -> list[str]:
argv = [
cfg.binary, "-p",
"--output-format", "json",
"--tools", "", # no built-in tools: no Read, no Bash, no Write, no web
"--strict-mcp-config", # and no MCP servers, since none are passed in
"--no-session-persistence", # the judge's own session is not written to ~/.claude/projects
"--system-prompt", system_prompt,
]
if cfg.model:
argv += ["--model", cfg.model]
return argv + list(cfg.extra_args)
def _run(argv: list[str], stdin: str, timeout: int) -> str:
# An empty working directory: no project CLAUDE.md, .mcp.json or settings get picked up, and
# nothing the judge's CLI might write lands anywhere that matters.
with tempfile.TemporaryDirectory(prefix="ai-incidents-judge-") as cwd:
try:
p = subprocess.run(
argv, input=stdin, capture_output=True, text=True, timeout=timeout, cwd=cwd,
env={**os.environ, "NO_COLOR": "1"},
)
except FileNotFoundError as e:
raise JudgeError(f"judge command not found: {argv[0]}") from e
except subprocess.TimeoutExpired as e:
raise JudgeError(f"judge timed out after {timeout}s") from e
if p.returncode != 0:
tail = (p.stderr or p.stdout or "").strip()[-500:]
raise JudgeError(f"judge exited {p.returncode}: {tail}")
return p.stdout
def _claude(cfg: JudgeConfig, system_prompt: str, message: str) -> tuple[str, dict]:
out = _run(claude_argv(cfg, system_prompt), message, cfg.timeout)
try:
env = json.loads(out)
except ValueError as e:
raise JudgeError(f"claude did not return its JSON envelope: {out.strip()[:300]!r}") from e
if not isinstance(env, dict):
raise JudgeError("claude returned an unexpected envelope")
result = str(env.get("result") or "")
if env.get("is_error") or env.get("subtype") not in (None, "success"):
raise JudgeError(f"claude reported an error: {result[:300] or env.get('subtype')}")
usage = {k: env[k] for k in ("total_cost_usd", "duration_ms", "num_turns") if k in env}
if isinstance(env.get("usage"), dict):
u = env["usage"]
usage["input_tokens"] = sum(int(u.get(k) or 0) for k in (
"input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"))
usage["output_tokens"] = int(u.get("output_tokens") or 0)
return result, usage
def _openai(cfg: JudgeConfig, system_prompt: str, message: str) -> tuple[str, dict]:
body = {
"model": cfg.model,
"messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": message}],
"temperature": 0,
"max_tokens": cfg.max_tokens,
}
if cfg.json_mode:
body["response_format"] = {"type": "json_object"}
headers = {"Content-Type": "application/json"}
if cfg.api_key_env:
key = os.environ.get(cfg.api_key_env, "")
if not key:
raise JudgeError(f"environment variable {cfg.api_key_env} is not set")
headers["Authorization"] = f"Bearer {key}"
req = urllib.request.Request(
cfg.base_url.rstrip("/") + "/chat/completions", data=json.dumps(body).encode(), headers=headers
)
try:
with urllib.request.urlopen(req, timeout=cfg.timeout) as r:
data = json.loads(r.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as e:
raise JudgeError(f"judge endpoint returned HTTP {e.code}: {e.read()[:300]!r}") from e
except (urllib.error.URLError, TimeoutError, OSError) as e:
raise JudgeError(f"judge endpoint unreachable: {e}") from e
except ValueError as e:
raise JudgeError(f"judge endpoint returned non-JSON: {e}") from e
try:
text = data["choices"][0]["message"]["content"] or ""
except (KeyError, IndexError, TypeError) as e:
raise JudgeError(f"unexpected response shape from judge endpoint: {str(data)[:300]}") from e
usage = {}
if isinstance(data.get("usage"), dict):
usage = {"input_tokens": data["usage"].get("prompt_tokens"), "output_tokens": data["usage"].get("completion_tokens")}
return text, usage
def _command(cfg: JudgeConfig, system_prompt: str, message: str) -> tuple[str, dict]:
if not cfg.command:
raise JudgeError("judge.backend = 'command' needs judge.command")
return _run(list(cfg.command), system_prompt + "\n\n---\n\n" + message, cfg.timeout), {}
BACKENDS = {"claude": _claude, "openai": _openai, "command": _command}
def call(cfg: JudgeConfig, system_prompt: str, message: str) -> tuple[str, dict]:
try:
fn = BACKENDS[cfg.backend]
except KeyError:
raise JudgeError(f"unknown judge backend {cfg.backend!r}; expected one of {sorted(BACKENDS)}") from None
return fn(cfg, system_prompt, message)
+233
View File
@@ -0,0 +1,233 @@
"""The ledger: a markdown file of incidents, ranked by what they cost.
``incidents.md`` is the source of truth and stays hand-editable. Each entry is
## <short title> · <YYYY-MM-DD> · <HIGH|MEDIUM|LOW>
- **What:** one sentence: what the agent did
- **Cost:** one sentence: what it actually cost
- **Lesson:** one actionable sentence: how not to repeat it
Everything above the first entry is the header and everything from the first non-entry ``##``
section after the entries (for example ``## Recurring patterns``) is the footer; both are kept
verbatim. Entry bodies are kept verbatim too, so a hand-written entry with extra lines survives a
run untouched. Only the order changes: most severe first, newest first within a severity.
``index.json`` is derived from ``incidents.md`` on every write and is never authored, so a confused
run can garble prose but cannot corrupt the index.
"""
from __future__ import annotations
import datetime as _dt
import hashlib
import json
import re
from dataclasses import dataclass, field
SEVERITIES = ("HIGH", "MEDIUM", "LOW")
SEV_RANK = {s: i for i, s in enumerate(SEVERITIES)}
SEP = " · "
HEAD = re.compile(
r"^##\s+(?P<title>.+?)\s+·\s+(?P<date>[^·]+?)\s+·\s+(?P<sev>HIGH|MEDIUM|LOW)\s*$"
)
ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_RULE = re.compile(r"^\s*(-{3,}|\*{3,}|_{3,})\s*$")
DEFAULT_HEADER = """# {title}
Times AI-generated work caused a real problem: what it did, what it cost, and the lesson.
Most severe first; newest first within a severity. Written by `ai-incidents`; hand edits and
hand-written entries are kept.
"""
def fingerprint(title: str) -> str:
"""Stable identity for an incident: its title, normalised. Severity gets re-rated and dates get
corrected, but a retitled incident is a different incident."""
norm = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
return hashlib.sha1(norm.encode()).hexdigest()[:12]
def one_line(s: str, limit: int = 1000) -> str:
"""Collapse whitespace. A newline in model output must never become a new heading."""
s = re.sub(r"\s+", " ", str(s or "")).strip()
return s[:limit]
def clean_title(s: str) -> str:
# The separator is structural; a title containing it would parse as a different entry.
return one_line(s, 160).replace("·", "-").lstrip("#").strip()
@dataclass
class Entry:
title: str
date: str
severity: str
body: list[str] = field(default_factory=list)
@property
def fp(self) -> str:
return fingerprint(self.title)
def heading(self) -> str:
return f"## {self.title}{SEP}{self.date}{SEP}{self.severity}"
def render(self) -> str:
return "\n".join([self.heading(), *self.body])
def sort_date(self) -> str:
m = re.match(r"\d{4}-\d{2}-\d{2}", self.date)
return m.group(0) if m else ""
def what(self) -> str:
for ln in self.body:
m = re.match(r"^\s*-\s*\*\*What:\*\*\s*(.*)$", ln)
if m:
return m.group(1).strip()
return ""
@classmethod
def new(cls, title, date, severity, what, cost, lesson) -> "Entry":
return cls(
clean_title(title),
date,
severity,
[f"- **What:** {one_line(what)}", f"- **Cost:** {one_line(cost)}", f"- **Lesson:** {one_line(lesson)}"],
)
@dataclass
class Ledger:
header: str = ""
entries: list[Entry] = field(default_factory=list)
footer: str = ""
def fps(self) -> set[str]:
return {e.fp for e in self.entries}
def _trim(lines: list[str]) -> list[str]:
"""Drop trailing blank lines and horizontal rules: they are separators, not content."""
while lines and (not lines[-1].strip() or _RULE.match(lines[-1])):
lines = lines[:-1]
return lines
def parse(text: str) -> Ledger:
lines = text.splitlines()
header: list[str] = []
entries: list[Entry] = []
footer: list[str] = []
cur: Entry | None = None
in_footer = False
for ln in lines:
m = HEAD.match(ln)
if in_footer:
if m:
# An entry after the footer started: treat it as an entry, keep the footer intact.
in_footer = False
else:
footer.append(ln)
continue
if m:
if cur:
cur.body = _trim(cur.body)
entries.append(cur)
cur = Entry(m.group("title").strip(), m.group("date").strip(), m.group("sev"))
elif ln.startswith("## ") and entries + ([cur] if cur else []):
if cur:
cur.body = _trim(cur.body)
entries.append(cur)
cur = None
in_footer = True
footer.append(ln)
elif cur is not None:
cur.body.append(ln)
else:
header.append(ln)
if cur:
cur.body = _trim(cur.body)
entries.append(cur)
return Ledger("\n".join(_trim(header)), entries, "\n".join(footer).rstrip())
def ordered(entries: list[Entry], order: str = "severity") -> list[Entry]:
# sorted() is stable, so entries that tie keep their existing relative order.
newest = sorted(entries, key=lambda e: e.sort_date(), reverse=True)
if order == "newest":
return newest
return sorted(newest, key=lambda e: SEV_RANK.get(e.severity, len(SEVERITIES)))
def render(ledger: Ledger, order: str = "severity") -> str:
parts = [ledger.header.rstrip()] if ledger.header.strip() else []
parts += [e.render() for e in ordered(ledger.entries, order)]
if ledger.footer.strip():
parts.append(ledger.footer.rstrip())
return "\n\n".join(parts) + "\n"
def add_entries(ledger: Ledger, new: list[Entry]) -> tuple[list[Entry], list[Entry]]:
"""Add entries whose fingerprint is not already present. Returns (added, duplicates)."""
have = ledger.fps()
added, dupes = [], []
for e in new:
if e.fp in have:
dupes.append(e)
continue
have.add(e.fp)
ledger.entries.append(e)
added.append(e)
return added, dupes
PATTERNS_HEADING = "## Recurring patterns"
def add_patterns(ledger: Ledger, patterns: list[str]) -> list[str]:
"""Append bullets to the Recurring patterns section, creating it if needed."""
pats = [one_line(p, 500) for p in patterns if one_line(p)]
existing = set(re.findall(r"^\s*-\s+(.*)$", ledger.footer, flags=re.M))
pats = [p for p in dict.fromkeys(pats) if p not in existing]
if not pats:
return []
bullets = "\n".join(f"- {p}" for p in pats)
footer = ledger.footer
if PATTERNS_HEADING in footer:
lines = footer.splitlines()
start = next(i for i, ln in enumerate(lines) if ln.strip() == PATTERNS_HEADING)
end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("## ") or _RULE.match(lines[i])), len(lines))
while end > start + 1 and not lines[end - 1].strip():
end -= 1
lines[end:end] = bullets.splitlines()
ledger.footer = "\n".join(lines)
else:
ledger.footer = (PATTERNS_HEADING + "\n\n" + bullets + ("\n\n" + footer if footer.strip() else "")).rstrip()
return pats
def index(ledger: Ledger, prior: dict | None, today: str) -> dict:
prior_by_fp = {i.get("fp"): i for i in (prior or {}).get("incidents", []) if isinstance(i, dict)}
items = []
for e in ordered(ledger.entries):
items.append({
"fp": e.fp,
"title": e.title,
"date": e.date,
"severity": e.severity,
"first_seen": prior_by_fp.get(e.fp, {}).get("first_seen", today),
})
counts = {"total": len(items)}
for s in SEVERITIES:
counts[s.lower()] = sum(1 for i in items if i["severity"] == s)
return {"generated": today, "counts": counts, "incidents": items}
def dump_index(data: dict) -> str:
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def today() -> str:
return _dt.date.today().isoformat()
+407
View File
@@ -0,0 +1,407 @@
"""One run: scan, gate, judge, write, commit, record.
The order is what makes it safe to run unattended:
1. **Scan** every configured source. Read-only. Clean sessions are recorded as judged straight
away (they carry nothing a judge could file).
2. **Gate.** No candidates means no model call at all. A quiet night costs nothing.
3. **Judge** the candidates in one call. The answer must parse and validate, or the run fails.
4. **Write** the ledger, the run report, ``latest.md`` and ``index.json``, and nothing else.
5. **Commit** exactly those files, and push if configured.
6. **Record** the judged sessions as seen. Only now: if any earlier step failed, the next run sees
the same candidates again.
"""
from __future__ import annotations
import fnmatch
import json
import os
import sqlite3
from dataclasses import dataclass, field
from . import gitops, judge, ledger, report
from .config import Config, ConfigError
from .envelope import WriteGuard
from .hooks import run_hook
from .prefilter import Candidate, Patterns, extract
from .redact import Redactor
from .sources import SourceError, iter_source
from .state import RunLock, State
EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_LOCKED = 0, 1, 2, 3
@dataclass
class SourceStats:
total: int = 0
new: int = 0
clean: int = 0
with_candidates: int = 0
deferred: int = 0
excluded: int = 0
baseline: int = 0
unreadable: int = 0
missing: bool = False
failed: bool = False
@dataclass
class ScanResult:
candidates: list[Candidate] = field(default_factory=list)
pending: dict[str, set[str]] = field(default_factory=dict) # judged only after a successful write
done: dict[str, set[str]] = field(default_factory=dict) # clean, excluded, or baseline: seen now
stats: dict[str, SourceStats] = field(default_factory=dict)
errors: list[str] = field(default_factory=list)
def summary(self) -> list[str]:
lines = []
for name, s in self.stats.items():
if s.missing:
lines.append(f"**{name}:** no transcripts found at the configured path")
continue
if s.baseline:
lines.append(f"**{name}:** first run, {s.baseline} existing session(s) marked as judged without judging")
continue
bits = [f"{s.new} new session(s)", f"{s.clean} clean", f"{s.with_candidates} with candidates"]
if s.deferred:
bits.append(f"{s.deferred} deferred to the next run (candidate budget spent)")
if s.excluded:
bits.append(f"{s.excluded} excluded by config")
if s.unreadable:
bits.append(f"{s.unreadable} unreadable")
lines.append(f"**{name}:** {', '.join(bits)}")
lines.append(f"**Candidates:** {len(self.candidates)}")
lines += [f"**Error:** {e}" for e in self.errors]
return lines
def patterns_for(cfg: Config) -> Patterns:
return Patterns.with_extras(cfg.scan.extra_destructive, cfg.scan.extra_benign, cfg.scan.extra_alarm)
def scan(cfg: Config, state: State, backfill: bool = False) -> ScanResult:
"""Read every source and collect candidates. Never writes anything; the caller decides."""
res = ScanResult()
pats = patterns_for(cfg)
budget = cfg.scan.max_candidates
for src in cfg.sources:
st = res.stats[src.name] = SourceStats()
seen = state.seen(src.name)
pending = res.pending.setdefault(src.name, set())
done = res.done.setdefault(src.name, set())
baseline = not state.initialized(src.name) and not backfill and cfg.scan.first_run == "baseline"
try:
for ref in iter_source(src.type, src.name, src.path):
st.total += 1
if ref.key in seen:
continue
if baseline:
st.baseline += 1
done.add(ref.key)
continue
st.new += 1
if any(fnmatch.fnmatch(ref.path, pat) for pat in cfg.scan.exclude):
st.excluded += 1
done.add(ref.key)
continue
try:
sess = ref.load()
except (OSError, sqlite3.Error, ValueError) as e:
# Left unrecorded, so it is tried again next run.
st.unreadable += 1
res.errors.append(f"{ref.path}: {e}")
continue
ex = extract(sess, src.name, ref.short_id, pats, cfg.scan.max_per_session)
if ex.clean:
st.clean += 1
done.add(ref.key)
continue
if len(ex.candidates) > budget:
# Not shown, not recorded: the next run starts with it. Keep scanning, so clean
# sessions further on are still recorded.
st.deferred += 1
continue
budget -= len(ex.candidates)
st.with_candidates += 1
res.candidates += ex.candidates
pending.add(ref.key)
except SourceError as e:
res.errors.append(f"{src.name}: {e}")
st.failed = True
st.missing = st.total == 0 and not st.failed
for n, c in enumerate(res.candidates, 1):
c.id = f"C{n:02d}"
return res
def _rubric(cfg: Config) -> str:
if cfg.judge.prompt_file:
with open(cfg.judge.prompt_file, encoding="utf-8") as f:
return f.read()
return judge.default_rubric()
def _judge_label(cfg: Config) -> str:
j = cfg.judge
if j.backend == "claude":
return f"claude CLI, model {j.model or 'default'}"
if j.backend == "openai":
return f"{j.model} at {j.base_url}"
return f"command `{os.path.basename(j.command[0])}`" if j.command else "command"
def _read(path: str) -> str | None:
try:
with open(path, encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return None
def _report_path(ledger_dir: str, date: str) -> str:
base = os.path.join(ledger_dir, "reports", date)
path, n = base + ".md", 2
while os.path.exists(path):
path, n = f"{base}-{n}.md", n + 1
return path
@dataclass
class Outcome:
code: int = EXIT_OK
headline: str = ""
filed: list = field(default_factory=list)
excluded: int = 0
report_path: str = ""
def summary(self) -> str:
out = [self.headline]
out += [f"{i.severity:<6} {i.title}" for i in self.filed]
if self.report_path:
out.append(f"report: {self.report_path}")
return "\n".join(out)
def env(self, status: str) -> dict[str, str]:
sev = lambda s: sum(1 for i in self.filed if i.severity == s) # noqa: E731
return {
"AI_INCIDENTS_STATUS": status,
"AI_INCIDENTS_HEADLINE": self.headline,
"AI_INCIDENTS_FILED": str(len(self.filed)),
"AI_INCIDENTS_HIGH": str(sev("HIGH")),
"AI_INCIDENTS_MEDIUM": str(sev("MEDIUM")),
"AI_INCIDENTS_LOW": str(sev("LOW")),
"AI_INCIDENTS_EXCLUDED": str(self.excluded),
"AI_INCIDENTS_REPORT": self.report_path,
}
def headline(date: str, filed: list, excluded: int) -> str:
if not filed:
return f"ai-incidents {date}: no new incidents" + (f", {excluded} candidate(s) excluded" if excluded else "")
high = sum(1 for i in filed if i.severity == "HIGH")
return f"ai-incidents {date}: {len(filed)} filed" + (f" ({high} high)" if high else "") + f", {excluded} candidate(s) excluded"
def run(
cfg: Config,
*,
dry_run: bool = False,
with_judge: bool = False,
backfill: bool = False,
notify_cmd: str | None = None,
use_git: bool | None = None,
push: bool | None = None,
verbose: bool = False,
say=print,
) -> Outcome:
if not cfg.ledger.dir:
raise ConfigError("[ledger] dir is not set")
ledger_dir = os.path.realpath(cfg.ledger.dir)
guard = WriteGuard(
dirs=[ledger_dir],
files=[cfg.state.file, cfg.state.file + ".lock", cfg.state.seen_list],
)
notify = cfg.hooks.notify_cmd if notify_cmd is None else notify_cmd
git_on = cfg.git.enabled if use_git is None else use_git
git_on = git_on and gitops.is_repo(ledger_dir)
do_push = git_on and (cfg.git.push if push is None else push)
date = ledger.today()
redact = Redactor(cfg.privacy.extra_redact_patterns, enabled=cfg.privacy.redact)
def fail(msg: str, outcome: Outcome | None = None) -> Outcome:
o = outcome or Outcome()
o.code = EXIT_FAILED
# An error can quote the judge's answer, which can quote a transcript: mask it like the rest.
o.headline = f"ai-incidents {date}: FAILED: {redact(msg)}"
say(o.headline)
if not dry_run:
run_hook(cfg.hooks.on_failure_cmd, o.summary(), o.env("failed"), cfg.hooks.timeout)
return o
lock = None
if not dry_run:
lock = RunLock(guard, cfg.state.file)
lock.__enter__() # LockedError propagates to the CLI
try:
state = State(cfg.state.file, cfg.state.seen_list)
warnings = []
if do_push and not dry_run:
try:
w = gitops.pull(ledger_dir, cfg.git)
except gitops.GitError as e:
return fail(str(e))
if w:
warnings.append(w)
res = scan(cfg, state, backfill=backfill)
for line in res.summary():
say(line.replace("**", ""))
for w in warnings:
say(f"warning: {w}")
if not dry_run:
for name, keys in res.done.items():
state.mark_seen(name, keys)
for src in cfg.sources:
# A source that could not be read has not had its baseline taken; the next run must
# still treat it as a first run rather than judging its whole history.
if not res.stats[src.name].failed:
state.mark_initialized(src.name)
if not res.candidates:
o = Outcome(headline=f"ai-incidents {date}: no new candidate incidents; judge not invoked")
say(o.headline)
if not dry_run:
state.record_run({"result": "quiet", "candidates": 0})
state.save(guard)
if do_push:
try:
gitops.push(ledger_dir, cfg.git) # deliver anything an earlier run could not
except gitops.GitError as e:
return fail(str(e), o)
run_hook(cfg.hooks.on_success_cmd, o.summary(), o.env("ok"), cfg.hooks.timeout)
return o
for c in res.candidates:
c.body = redact(c.body)
led_path = os.path.join(ledger_dir, "incidents.md")
text = _read(led_path)
led = ledger.parse(text) if text is not None else ledger.Ledger(ledger.DEFAULT_HEADER.format(title=cfg.ledger.title))
message = judge.build_message(res.candidates, ledger.ordered(led.entries), date)
if not dry_run:
state.save(guard) # clean and baseline sessions are durable even if the judge fails
if dry_run and not with_judge:
say(f"dry run: would send {len(res.candidates)} candidate(s), {len(message):,} characters, "
f"to the judge ({_judge_label(cfg)}). Nothing was sent and nothing was written.")
if verbose:
say("\n" + message)
return Outcome(headline=f"ai-incidents {date}: dry run, {len(res.candidates)} candidate(s)")
try:
answer, usage = judge.call(cfg.judge, _rubric(cfg), message)
verdict = judge.parse_verdict(answer, res.candidates, date, redact)
except (judge.JudgeError, OSError) as e:
return fail(f"judge: {e}")
verdict.usage = usage
entries = [(i, i.entry()) for i in verdict.incidents]
added, _ = ledger.add_entries(led, [e for _, e in entries])
added_ids = {id(e) for e in added}
filed = [i for i, e in entries if id(e) in added_ids]
already = [i for i, e in entries if id(e) not in added_ids]
added_patterns = ledger.add_patterns(led, verdict.patterns)
verdict.patterns = added_patterns
excluded = sum(len(e.candidates) or 1 for e in verdict.excluded)
o = Outcome(filed=filed, excluded=excluded)
o.headline = headline(date, filed, excluded)
rep_path = _report_path(ledger_dir, date)
rep = report.render(date, res.summary(), res.candidates, verdict, filed, already, _judge_label(cfg))
idx_path = os.path.join(ledger_dir, "index.json")
prior = None
if (raw := _read(idx_path)) is not None:
try:
prior = json.loads(raw)
except ValueError:
prior = None
new_ledger = ledger.render(led, cfg.ledger.order)
new_index = ledger.dump_index(ledger.index(led, prior, date))
if dry_run:
say(o.headline + " (dry run: nothing written)")
say("\n" + rep)
return o
latest = os.path.join(ledger_dir, "latest.md")
guard.makedirs(os.path.join(ledger_dir, "reports"))
guard.write_text(led_path, new_ledger)
guard.write_text(rep_path, rep)
guard.write_text(latest, rep)
guard.write_text(idx_path, new_index)
o.report_path = rep_path
if not git_on and cfg.git.enabled and use_git is None:
say(f"note: {ledger_dir} is not a git repository; the ledger was written but not committed")
if git_on:
try:
sha = gitops.commit(ledger_dir, [led_path, rep_path, latest, idx_path], o.headline, cfg.git)
if sha:
say(f"committed {sha}")
except gitops.GitError as e:
return fail(str(e), o)
# The verdict is on disk (and committed): these sessions are now safely judged.
for name, keys in res.pending.items():
state.mark_seen(name, keys)
state.record_run({"result": "judged", "candidates": len(res.candidates),
"filed": len(filed), "excluded": excluded})
state.save(guard)
if do_push:
try:
gitops.push(ledger_dir, cfg.git)
say(f"pushed to {cfg.git.remote}")
except gitops.GitError as e:
# The ledger is committed locally and the sessions are recorded; the next run pushes.
return fail(f"push: {e}", o)
say(o.summary())
if filed:
run_hook(notify, o.summary(), o.env("ok"), cfg.hooks.timeout)
run_hook(cfg.hooks.on_success_cmd, o.summary(), o.env("ok"), cfg.hooks.timeout)
return o
except (OSError, ValueError) as e:
# Includes an EnvelopeError (a write outside the envelope) and a corrupt state file.
return fail(f"{type(e).__name__}: {e}")
finally:
if lock:
lock.__exit__(None, None, None)
def reindex(cfg: Config, say=print) -> int:
"""Re-rank ``incidents.md`` and regenerate ``index.json`` after hand edits. Commits nothing."""
if not cfg.ledger.dir:
raise ConfigError("[ledger] dir is not set")
ledger_dir = os.path.realpath(cfg.ledger.dir)
guard = WriteGuard(dirs=[ledger_dir])
led_path = os.path.join(ledger_dir, "incidents.md")
text = _read(led_path)
if text is None:
say(f"no ledger at {led_path}")
return EXIT_FAILED
led = ledger.parse(text)
idx_path = os.path.join(ledger_dir, "index.json")
prior = None
if (raw := _read(idx_path)) is not None:
try:
prior = json.loads(raw)
except ValueError:
pass
guard.write_text(led_path, ledger.render(led, cfg.ledger.order))
guard.write_text(idx_path, ledger.dump_index(ledger.index(led, prior, ledger.today())))
say(f"reindexed {len(led.entries)} incident(s)")
return EXIT_OK
+313
View File
@@ -0,0 +1,313 @@
"""Deterministic pre-filter: find the moments in a session worth showing to the judge.
Nothing here calls a model. The pre-filter is tuned for recall, and it is cheap, so it can look at
every session; the judge is tuned for precision, and it is expensive, so it only sees what survives
this step.
A session produces candidates in three shapes:
* SNIPPET: somebody *said* something. The user corrected or blamed the agent, or the agent admitted
a mistake, raised an alarm, or blamed somebody else's code. The surrounding turns come with it.
* EVIDENCE: what a session that produced snippets actually *did*: its destructive commands and its
notable failures, attached to those snippets.
* DIGEST: nobody said anything, but the session ran something destructive or something notably
broke. The task, the commands and the failures are shown instead.
Recall cannot depend on the culprit choosing the right words. The worst incidents are often the
ones nobody narrates, so tool calls and tool output are read as well as conversation text.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from .sources import Session
# High-precision signals: agent self-admissions ...
AI_ADMIT = re.compile(
r"\b(i made a mistake|my (fault|mistake|error)|i broke|i accidentally|"
r"i shouldn'?t have|i should not have|credential exposure|real credential|"
r"leaked|data loss|i deleted|i overwrote|i wiped|i blinded|i clobbered|"
r"i corrupted|i destroyed|i lost|i caused|i introduced|i polluted|i swept in|"
r"premature (exclusion|delete|deletion)|unplanned reboot|that was wrong of me|"
r"that was my (error|mistake|fault)|(that|the) (outage|breakage|damage) was mine|"
r"i wrongly|hard to undo|blast radius|was mine\b|apolog(y|ise|ize)|"
r"i had to (kill|revert|undo|roll back)|undo(ing)? (my|the damage)|"
r"i (just )?(reverted|rolled back)|no damage|left (it|things) (broken|dirty)|"
r"went blind|stopped being (monitored|watched)|silently (failed|stopped|broke))\b",
re.I,
)
# ... and direct user blame or correction.
USER_SIG = re.compile(
r"\b(you (broke|deleted|overwrote|wiped|lost|removed|nuked|corrupted|clobbered)|"
r"why did you|that'?s (wrong|not what|broken)|what merge|something you did|"
r"revert (that|it|the)|undo (that|it|the)|roll ?back|stop,? (you|that)|"
r"you shouldn'?t|you weren'?t supposed|don'?t do that|that broke|"
r"put (it|that) back|restore (it|that))\b",
re.I,
)
# "User" turns injected by the agent harness. They are not a person complaining.
SYNTHETIC = re.compile(
r"^\s*(<task-notification|<command-|<local-command-|<bash-(input|stdout|stderr)>|"
r"<user-prompt-submit-hook|\[SYSTEM NOTIFICATION|"
r"<system-reminder|This session is being continued|Caveat: The messages)",
re.I,
)
# What damage and remediation look like as commands, whatever anybody said about them.
DESTRUCTIVE = re.compile(
r"(git\s+(reset\s+--hard|revert|checkout\s+--|clean\s+-[a-z]*f|push\s+(-f|--force))"
r"|rm\s+-[a-z]*[rf]|shred\b|truncate\b|>\s*/dev/(sd|nvme)"
r"|DROP\s+(TABLE|DATABASE)|DELETE\s+FROM|TRUNCATE\s+TABLE"
r"|pkill|kill\s+-9|kill\s+-KILL|killall"
r"|systemctl[^\n]*\b(stop|disable|mask)\b"
r"|docker[^\n]*\b(rm|kill|down|prune)\b|zfs\s+destroy|--restore\b"
r"|chown\s+-R|chmod\s+-R|mv\s+[^\n]*\.(bak|old|orig)\b)",
re.I,
)
# Monitoring and billing alarms. If a session trips them, that is the incident, confessed or not.
ALARM = re.compile(
r"(scrape_error\s+1|node_textfile_scrape_error"
r"|spend limit|silently (failed|stopped|broke)|stopped being (monitored|watched)"
r"|went blind|data ?loss|corrupt(ed|ion)?)\b",
re.I,
)
# Misattribution: the agent deciding somebody else's code is at fault. It hides itself (no
# confession, no complaint, no destructive command), so it needs its own signal: the language of
# blame. High recall on purpose; most hits are correct diagnoses and the judge says so in one line.
BLAME = re.compile(
r"((this|that|it)('?s| is) an? (known )?(bug|issue|limitation|quirk|restriction) (in|with)"
r"|(is|are) (being |just )?(strict|picky|fussy|weird|broken|buggy) (about|here|with)"
r"|(bug|issue|regression|limitation) (in|with) (the )?(upstream|library|package|module|tool|api|sdk|"
r"framework|kernel|driver|compiler|runtime)"
r"|upstream (bug|issue|problem|regression)|known (bug|issue) (in|with)"
r"|must be a bug|looks like a bug in|appears to be a bug in|seems to be a bug in"
r"|work ?around (for|the) |workaround for a|monkey.?patch"
r"|pin(ning)? (it |the )?(to|back to) (an? )?(older|previous|earlier)"
r"|(broken|regressed) (in|since) (version|v?\d)"
r"|file (an? )?(bug|issue) (upstream|against)|report(ing)? (it|this) upstream"
r"|not our (bug|fault|problem)|nothing (we|i) can do about)",
re.I,
)
# Commands that do their damage while SUCCEEDING and say so in output nobody reads. A short,
# high-signal list; this is not a place to grep for the word "warning".
WARN_NOTABLE = re.compile(
r"(adding embedded git repository"
r"|would be overwritten by (merge|checkout)"
r"|untracked working tree files would be overwritten"
r"|non-fast-forward|have diverged|detached HEAD"
r"|forced update|\(forced update\))",
re.I,
)
# A failed tool call is not automatically interesting: most are a typo'd path or a grep that
# matched nothing. Only failures that mean something broke.
ERR_NOTABLE = re.compile(
r"(Traceback \(most recent|Segmentation fault|Out of memory|\bOOM\b|\bKilled\b"
r"|spend limit|scrape_error|would be overwritten|non-fast-forward|diverged"
r"|corrupt|permission denied.*(/etc|/var|/mnt)|disk (full|quota))",
re.I,
)
# A traceback from the agent's own inline one-liner (`python -c`, a heredoc on stdin) is routine
# iteration, not something breaking. Only a traceback from real code counts on its own.
ADHOC_TRACEBACK = re.compile(r'File "<(string|stdin)>"')
# Destructive-looking but routine: a session clearing its own scratch space, a worktree being torn
# down, a throwaway container removed. Filtered here, for free, rather than paying a model to say
# "this is fine".
BENIGN = re.compile(
r"(scratchpad|/tmp/claude-|/tmp/wt-"
r"|rm\s+-[a-z]*[rf][a-z]*\s+[\"']?(/tmp/|/var/tmp/|\$?\{?TMP|\$?\{?WT)"
r"|git\s+worktree\s+(remove|prune|add)"
r"|docker\s+(rm|kill)\s+[^\n]*\b(test|tmp|scratch|throwaway)\b"
r"|rm\s+-[a-z]*f?\s+[\"']?/tmp/)",
re.I,
)
# Excerpt sizes. Enough context to judge, small enough that fifty candidates fit in one prompt.
TURN_CHARS = 280
CMD_CHARS = 160
ERR_CHARS = 160
WARN_CHARS = 150
TASK_CHARS = 200
MAX_COMMANDS = 8
MAX_FAILURES = 5
def danger_rank(cmd: str) -> int:
"""Lower is worse. The evidence block is capped, so the cap must keep the WORST commands, not
the first ones: rewriting history or deleting real paths outranks stopping a service, which
outranks anything confined to /tmp."""
c = cmd.lower()
if re.search(r"git\s+(rm|reset\s+--hard|checkout\s+--|push\s+(-f|--force)|commit\s+--amend|clean)", c):
return 0
if re.search(r"(zfs\s+destroy|drop\s+(table|database)|delete\s+from|truncate|shred|>\s*/dev/)", c):
return 0
if re.search(r"rm\s+-[a-z]*[rf]", c) and not re.search(r"/tmp|/var/tmp|scratch", c):
return 1
if re.search(r"(systemctl[^\n]*\b(stop|disable|mask)|docker[^\n]*\b(rm|kill|down|prune))", c):
return 2
if re.search(r"(pkill|kill\s+-9|kill\s+-kill|killall)", c):
return 3
return 4
@dataclass
class Patterns:
"""The regexes in force, plus any the user configured."""
destructive: list[re.Pattern] = field(default_factory=lambda: [DESTRUCTIVE])
benign: list[re.Pattern] = field(default_factory=lambda: [BENIGN])
alarm: list[re.Pattern] = field(default_factory=lambda: [ALARM])
@classmethod
def with_extras(cls, destructive=(), benign=(), alarm=()) -> "Patterns":
p = cls()
p.destructive += [re.compile(x, re.I) for x in destructive]
p.benign += [re.compile(x, re.I) for x in benign]
p.alarm += [re.compile(x, re.I) for x in alarm]
return p
def _any(pats, s) -> bool:
return any(p.search(s) for p in pats)
def flat(s: str, limit: int) -> str:
"""One line, at most ``limit`` characters: an excerpt must not break the block around it."""
return re.sub(r"\s+", " ", s).strip()[:limit]
def notable_failure(text: str) -> bool:
if not ERR_NOTABLE.search(text):
return False
if ADHOC_TRACEBACK.search(text):
return bool(ERR_NOTABLE.search(text.replace("Traceback (most recent", "")))
return True
@dataclass
class Candidate:
kind: str # SNIPPET | EVIDENCE | DIGEST
source: str
session: str
date: str
body: str # indented lines, ready to show the judge
id: str = "" # assigned per run: C01, C02, ...
def render(self) -> str:
note = {
"EVIDENCE": " -- what this session actually did; read with its snippets",
"DIGEST": " -- nobody said anything; the commands did",
}.get(self.kind, "")
head = f"[{self.id}] {self.kind} · {self.source} · {self.session} · {self.date or 'undated'}{note}"
return head + "\n" + self.body
@dataclass
class Extraction:
candidates: list[Candidate]
clean: bool # nothing at all to judge
dropped: int = 0 # snippets cut by the per-session limit
def is_hit(role: str, text: str, pats: Patterns) -> bool:
if role == "user":
return not SYNTHETIC.match(text) and bool(USER_SIG.search(text))
if role == "assistant":
return bool(AI_ADMIT.search(text) or BLAME.search(text) or _any(pats.alarm, text))
return False
def behaviour(sess: Session, pats: Patterns) -> tuple[list[str], list[str]]:
"""Destructive commands run, and notable failures or warnings seen, for one session."""
danger, failures = [], []
for call in sess.calls:
if call.command and _any(pats.destructive, call.command) and not _any(pats.benign, call.command):
danger.append(f"{call.name}: {flat(call.command, CMD_CHARS)}")
for res in sess.results:
if res.is_error:
if notable_failure(res.text):
failures.append(flat(res.text, ERR_CHARS))
else:
# A command that SUCCEEDED can still have announced the damage in its output.
hits = [ln.strip() for ln in res.text.splitlines() if WARN_NOTABLE.search(ln)]
if hits:
failures.append(flat(hits[0], WARN_CHARS) + (f" (x{len(hits)})" if len(hits) > 1 else ""))
return danger, failures
def _behaviour_lines(danger: list[str], failures: list[str]) -> list[str]:
out = []
if danger:
out.append(" [destructive commands run]")
for d in sorted(dict.fromkeys(danger), key=danger_rank)[:MAX_COMMANDS]:
out.append(f" ! {d}")
if failures:
out.append(" [notable failures]")
for e in list(dict.fromkeys(failures))[:MAX_FAILURES]:
out.append(f" x {e}")
return out
def extract(
sess: Session,
source: str,
short_id: str,
pats: Patterns | None = None,
per_session: int = 12,
) -> Extraction:
"""Candidates for one session, at most ``per_session`` of them.
A session with no hit, no destructive command and no notable failure is ``clean``: it has been
examined, deterministically, and found empty. That costs nothing and needs no judge.
"""
if per_session < 2:
raise ValueError("per_session must be at least 2")
pats = pats or Patterns()
turns = sess.turns
danger, failures = behaviour(sess, pats)
hit_idx = [i for i, t in enumerate(turns) if is_hit(t.role, t.text, pats)]
if not hit_idx and not danger and not failures:
return Extraction([], clean=True)
out: list[Candidate] = []
for i in hit_idx:
ctx = "\n".join(
f" [{turns[j].role}] {flat(turns[j].text, TURN_CHARS)}"
for j in range(max(0, i - 1), min(len(turns), i + 2))
)
out.append(Candidate("SNIPPET", source, short_id, turns[i].date, ctx))
date = turns[0].date if turns else ""
if danger or failures:
# A session that talked is still a session that did. Its words must never decide whether
# its actions are examined, so behaviour rides along with snippets as well as standing alone.
if hit_idx:
out.append(Candidate("EVIDENCE", source, short_id, date, "\n".join(_behaviour_lines(danger, failures))))
else:
task = next((t.text for t in turns if t.role == "user" and not SYNTHETIC.match(t.text)), "")
ended = next((t.text for t in reversed(turns) if t.role == "assistant"), "")
lines = [f" [task] {flat(task, TASK_CHARS)}"] + _behaviour_lines(danger, failures)
lines.append(f" [ended] {flat(ended, TASK_CHARS)}")
out.append(Candidate("DIGEST", source, short_id, date, "\n".join(lines)))
dropped = 0
if len(out) > per_session:
# A very talkative session is cut here, once, rather than split across runs. Splitting it
# would re-show the same first snippets on every run and never finish the session. The
# behaviour block is always kept: it is what a chatty transcript would otherwise crowd out.
keep_tail = [out[-1]] if out[-1].kind == "EVIDENCE" else []
head = out[: per_session - len(keep_tail)]
dropped = len(out) - len(head) - len(keep_tail)
out = head + keep_tail
out[-1].body += f"\n ({dropped} more snippet(s) from this session not shown)"
return Extraction(out, clean=False, dropped=dropped)
+110
View File
@@ -0,0 +1,110 @@
You are the judge for an incident ledger. You are reading excerpts from one person's AI
coding-agent sessions. Your job is to decide which of the candidate moments below were **real
incidents caused by AI-generated work**, and to write each confirmed one up so that its lesson is
not lost.
You are this person's memory of their agents' mistakes. Nobody else writes this down. A miss is a
lesson lost; a false positive is noise that makes the ledger worth less. Be strict and be honest,
including about incidents caused by agents much like you.
You have no tools. Everything you may use is in this message. Judge only from the excerpts shown.
Do not invent detail beyond what they show.
## The candidates
A deterministic pre-filter found these moments. Each has an id like `C07` and comes in one of three
shapes:
- **SNIPPET**: somebody *said* something. The user corrected or blamed the agent, or the agent
admitted a mistake, raised an alarm, or blamed somebody else's code. You get the surrounding turns.
- **EVIDENCE**: the destructive commands and notable failures of a session that also produced
snippets. It carries the same session id as those snippets and is part of them: the snippets are
what was said, the evidence is what was done. When evidence shows a destructive command whose
consequence you can see, that IS concrete evidence. Do not hold out for a sentence in which
somebody admits to it.
- **DIGEST**: nobody confessed and nobody complained, but the session did something destructive or
something notably broke. You get the task, the commands, the failures and how the session ended.
The worst incidents are often the ones nobody narrates, so judge a digest on its evidence, not on
the absence of an apology. Equally, a digest is not an accusation: destructive commands are often
correct and intended (clearing scratch files, a deliberate revert, stopping something the user
asked to stop). If the commands were the right thing to do and nothing was harmed, it is not an
incident. Say so in one line.
## What counts
An **incident** is: AI-generated work caused a real problem.
- **data-loss**: data was deleted, overwritten or corrupted.
- **outage**: something that worked broke; a crash, a service down, an unplanned reboot.
- **credential-leak**: a real secret was exposed (printed to a transcript, committed, sent somewhere).
- **destructive-action**: a destructive or hard-to-reverse action on something that was not the
agent's to destroy (pushed to a shared branch, deleted someone else's work, rewrote history).
- **debug-spiral**: an AI decision sent the work into a long, expensive loop of fixing its own fallout;
over-engineering with the wrong tool belongs here.
- **unmetered-cost**: an agent or job burned money, tokens or quota that nobody was measuring.
- **misattribution**: see below.
- **other**: a real cost that fits none of the above.
**Misattribution is an incident in its own right.** Blaming a tool, library, upstream project, API
or "a known bug" for a problem the agent actually caused, and acting on that belief, is a footgun
even when nothing else breaks. It sends the human to debug, report or work around something that was
never broken; the real bug survives because nobody is looking for it any more; and it hides itself,
because a mistake that has been blamed on someone else is a mistake nobody will look for. The tell is
a fix that works around someone else's code instead of correcting the agent's own: a pin to an old
version, a "workaround for <library> bug" comment, a retry loop around a call made wrongly, a
monkey-patch, an issue filed upstream. It is **not** misattribution if the upstream bug was real, and
it is not misattribution to be uncertain out loud. The footgun is confident, acted-upon blame that
turns out to be wrong.
**Not** an incident: routine iteration; the user changing their mind; a preemptive "don't touch X";
a planned or expected revert; the agent catching its own mistake before it cost anything.
The bar is a **cost**: something was actually lost, broken, leaked or wasted. Better one solid
incident than five weak ones.
## Severity is the cost
- **HIGH**: data loss, an outage, or a real credential exposure.
- **MEDIUM**: wasted rework, a bad decision pushed to a shared branch, a debug spiral.
- **LOW**: a self-inflicted mess, cleaned up cheaply.
## Duplicates
The same incident often shows up in several candidates, and may already be in the ledger (listed
below with ids like `E03`). Check by substance, not by wording. Never file an incident that is
already in the ledger; exclude those candidates with `duplicate_of` set to the ledger id.
## Recurring patterns
If, and only if, this run turns up a pattern that now spans three or more incidents (counting the
ledger), add one line describing it to `patterns`. Otherwise leave `patterns` empty.
## Answer format
Answer with a single JSON object and nothing else: no prose before or after it, no code fence.
Every candidate id must appear exactly once, either in an incident's `candidates` or in an
`excluded` entry.
{
"incidents": [
{
"title": "short, specific title (under 100 characters)",
"date": "YYYY-MM-DD: the date of the incident from the candidate, not today",
"severity": "HIGH | MEDIUM | LOW",
"category": "data-loss | outage | credential-leak | destructive-action | debug-spiral | unmetered-cost | misattribution | other",
"what": "one sentence: what the agent did",
"cost": "one sentence: what it actually cost",
"lesson": "one actionable sentence: how not to repeat it",
"candidates": ["C03", "C04"],
"why": "one line: why it cleared the bar"
}
],
"excluded": [
{"candidates": ["C01", "C02"], "reason": "one line: why it did not clear the bar", "duplicate_of": ""}
],
"patterns": []
}
Never copy a secret, password, token or key into your answer, even when an excerpt shows one.
Describe it ("the database password") instead. Refer to people other than the user by role, not
by name.
+71
View File
@@ -0,0 +1,71 @@
"""Best-effort removal of secret-shaped strings.
Applied twice: to transcript excerpts before they leave the machine for the judge, and to the
judge's verdict before anything is written to the ledger. A ledger that records credential leaks
must not become one.
This is pattern matching, not a guarantee. It catches the common token formats and ``key=value``
assignments of secret-named keys; it cannot recognise a bare password with no context around it.
"""
from __future__ import annotations
import re
MASK = "[REDACTED]"
_TOKEN_PATTERNS = [
# PEM private keys, whole block
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z0-9 ]*PRIVATE KEY-----|$)",
r"\bgh[pousr]_[A-Za-z0-9]{30,}\b", # GitHub tokens
r"\bgithub_pat_[A-Za-z0-9_]{20,}\b",
r"\bglpat-[A-Za-z0-9_-]{20,}\b", # GitLab
r"\bsk-[A-Za-z0-9_-]{20,}\b", # OpenAI / Anthropic style API keys
r"\bAKIA[0-9A-Z]{16}\b", # AWS access key id
r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b", # Slack
r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b", # JWT
r"\bAIza[0-9A-Za-z_-]{35}\b", # Google API key
]
# Authorization headers: keep the scheme, drop the credential.
_BEARER = re.compile(r"\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{16,}", re.I)
# key = value / key: value / "key": "value" for secret-named keys.
_ASSIGN = re.compile(
r"(?P<key>[\"']?[A-Za-z0-9_.-]*(?:password|passwd|passphrase|secret|token|api[_-]?key|"
r"access[_-]?key|private[_-]?key|client[_-]?secret)[A-Za-z0-9_.-]*[\"']?)"
r"(?P<sep>\s*[:=]\s*)"
r"(?P<q>[\"']?)(?P<val>[^\s\"',;&]{6,})",
re.I,
)
# scheme://user:password@host
_URL_CREDS = re.compile(r"(?P<pre>[a-z][a-z0-9+.-]*://[^\s:/@]+:)(?P<pw>[^\s@/]+)(?P<at>@)", re.I)
_VALUE_ALLOW = re.compile(
r"^(\[REDACTED\]|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|true|false|null|none|\*+|[0-9][0-9.,_kKmMbB]*)$", re.I
)
class Redactor:
def __init__(self, extra_patterns: list[str] | tuple[str, ...] = (), enabled: bool = True):
self.enabled = enabled
self.tokens = [re.compile(p) for p in _TOKEN_PATTERNS] + [re.compile(p) for p in extra_patterns]
def __call__(self, text: str) -> str:
if not self.enabled or not text:
return text
for pat in self.tokens:
text = pat.sub(MASK, text)
text = _BEARER.sub(lambda m: f"{m.group(1)} {MASK}", text)
text = _URL_CREDS.sub(lambda m: f"{m.group('pre')}{MASK}{m.group('at')}", text)
def assign(m: re.Match) -> str:
val = m.group("val")
# Leave variable references and obvious placeholders alone: `password=$PW` shows how a
# secret was passed, which is often the lesson, and is not itself a secret.
if _VALUE_ALLOW.match(val):
return m.group(0)
return f"{m.group('key')}{m.group('sep')}{m.group('q')}{MASK}"
return _ASSIGN.sub(assign, text)
+86
View File
@@ -0,0 +1,86 @@
"""The per-run report: the judge's reasoning, shown plainly.
Confirmed incidents get one line on why they cleared the bar; every excluded candidate gets one
line on why it did not. The exclusions are the point: they keep the bar visible and arguable, and
they are where a judge drifting lax or strict shows up first.
"""
from __future__ import annotations
from .judge import Incident, Verdict
from .prefilter import Candidate
def _refs(ids: list[str], by_id: dict[str, Candidate]) -> str:
seen, out = set(), []
for i in ids:
c = by_id.get(i)
if not c:
continue
tag = f"{c.source} {c.session}"
if tag not in seen:
seen.add(tag)
out.append(f"`{c.session}` ({c.source}, {c.date or 'undated'})")
return ", ".join(out)
def render(
date: str,
scan_summary: list[str],
candidates: list[Candidate],
verdict: Verdict,
filed: list[Incident],
already: list[Incident],
judge_label: str,
) -> str:
by_id = {c.id: c for c in candidates}
out = [f"# AI-incidents run · {date}", "", "## What the pre-filter found", ""]
out += [f"- {line}" for line in scan_summary]
out += [f"- **Judge:** {judge_label}"]
if verdict.usage:
u = verdict.usage
bits = []
if u.get("input_tokens") is not None:
bits.append(f"{u['input_tokens']:,} input tokens")
if u.get("output_tokens") is not None:
bits.append(f"{u['output_tokens']:,} output tokens")
if u.get("total_cost_usd") is not None:
bits.append(f"${float(u['total_cost_usd']):.2f} at API rates")
if bits:
out.append(f"- **Judge cost:** {', '.join(bits)}")
out += ["", f"## Confirmed incidents ({len(filed)})", ""]
if not filed:
out += ["Nothing cleared the bar. A quiet run is a real result, not a failure.", ""]
for i in filed:
out += [
f"### {i.title} · {i.date} · {i.severity}",
f"**Category:** {i.category} · **Sessions:** {_refs(i.candidates, by_id) or 'not given'}",
"",
f"Clears the bar: {i.why or '(no reason given)'}",
"",
]
if already:
out += [f"## Confirmed but already in the ledger ({len(already)})", ""]
out += [f"- **{i.title}** ({i.severity}): same title as an existing entry; not filed again." for i in already]
out.append("")
out += [f"## Excluded candidates ({sum(len(e.candidates) or 1 for e in verdict.excluded)})", ""]
if not verdict.excluded:
out += ["None.", ""]
for e in verdict.excluded:
ids = ", ".join(e.candidates) or "?"
dup = f" Already filed as {e.duplicate_of}." if e.duplicate_of else ""
out.append(f"- **{ids}** {_refs(e.candidates, by_id)}: {e.reason}{dup}")
out.append("")
if verdict.unaddressed or verdict.rejected:
out += ["## Loose ends", ""]
if verdict.unaddressed:
out.append(f"- The judge did not address: {', '.join(verdict.unaddressed)}.")
for r in verdict.rejected:
out.append(f"- Rejected a malformed incident from the judge: {r}.")
out.append("")
if verdict.patterns:
out += ["## Recurring patterns added", ""] + [f"- {p}" for p in verdict.patterns] + [""]
out += ["## Candidates shown to the judge", ""]
for c in candidates:
out.append(f"- **{c.id}** {c.kind} · {c.source} · `{c.session}` · {c.date or 'undated'}")
return "\n".join(out).rstrip() + "\n"
+283
View File
@@ -0,0 +1,283 @@
"""Read agent session transcripts. Read-only, always.
Every source yields ``SessionRef`` objects cheaply (a stat or one small query), so a run can skip
sessions it has already judged without opening them. ``SessionRef.load()`` then reads one session
into a ``Session`` that the pre-filter understands, whatever tool produced it.
Two formats are supported:
* Claude Code: one JSONL file per session under ``~/.claude/projects/``. Subagent sessions live
alongside their parent and are read too.
* opencode 1.x: the SQLite store under ``~/.local/share/opencode/``. It is opened with
``mode=ro``; nothing here can write to it.
"""
from __future__ import annotations
import datetime as _dt
import glob
import json
import os
import sqlite3
from dataclasses import dataclass, field
from typing import Callable, Iterator
@dataclass
class Turn:
"""One human-readable message: what somebody *said*."""
role: str # "user" | "assistant"
date: str # YYYY-MM-DD (UTC), or "" when unknown
text: str
@dataclass
class ToolCall:
name: str
command: str # the shell command; empty for tools that take no command
@dataclass
class ToolResult:
is_error: bool
text: str
@dataclass
class Session:
"""What a session said (turns) and what it did (tool calls and their results)."""
turns: list[Turn] = field(default_factory=list)
calls: list[ToolCall] = field(default_factory=list)
results: list[ToolResult] = field(default_factory=list)
@dataclass
class SessionRef:
source: str # the configured source name, e.g. "claude-code"
key: str # changes whenever the session's content changes
short_id: str # a short, stable handle for reports
path: str # where it lives, for exclusion rules and error messages
loader: Callable[[], Session]
def load(self) -> Session:
return self.loader()
class SourceError(Exception):
pass
# --- Claude Code ------------------------------------------------------------------------------
def claude_key(path: str) -> str | None:
"""``<file name>:<int mtime>:<size>``.
The shape is deliberate and stable: an external cleanup job can read the exported seen-list and
refuse to delete any transcript whose current key is not in it. A transcript that grows after it
was judged gets a new key, so it is judged again rather than silently skipped.
"""
try:
st = os.stat(path)
except OSError:
return None
return f"{os.path.basename(path)}:{int(st.st_mtime)}:{st.st_size}"
def _cc_text(content) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(
b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"
)
return ""
def _cc_result_text(content) -> str:
if isinstance(content, list):
return " ".join(x.get("text", "") for x in content if isinstance(x, dict))
return str(content or "")
def parse_claude_jsonl(path: str) -> Session:
sess = Session()
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except ValueError:
continue
if not isinstance(obj, dict):
continue
msg = obj.get("message")
if not isinstance(msg, dict):
continue
role = msg.get("role") or obj.get("type", "")
date = str(obj.get("timestamp") or "")[:10]
content = msg.get("content")
text = _cc_text(content).strip()
if text:
sess.turns.append(Turn(role, date, text))
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "tool_use":
inp = block.get("input") or {}
cmd = inp.get("command") if isinstance(inp, dict) else None
sess.calls.append(
ToolCall(block.get("name", "?"), cmd.strip() if isinstance(cmd, str) else "")
)
elif block.get("type") == "tool_result":
sess.results.append(
ToolResult(bool(block.get("is_error")), _cc_result_text(block.get("content")))
)
return sess
def iter_claude_code(name: str, root: str) -> Iterator[SessionRef]:
root = os.path.expanduser(root)
if not os.path.isdir(root):
return
for path in sorted(glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True)):
key = claude_key(path)
if not key:
continue
base = os.path.basename(path)[: -len(".jsonl")]
yield SessionRef(name, key, base[:8], path, lambda p=path: parse_claude_jsonl(p))
# --- opencode -------------------------------------------------------------------------------
def _ms_date(ms) -> str:
try:
return _dt.datetime.fromtimestamp(int(ms) / 1000, _dt.timezone.utc).date().isoformat()
except (TypeError, ValueError, OverflowError, OSError):
return ""
def open_readonly(db_path: str) -> sqlite3.Connection:
"""Open an SQLite database so that this process cannot write to it.
``mode=ro`` is enforced by SQLite itself, and ``query_only`` on top of it. One caveat is
inherent to SQLite's WAL mode, which opencode uses: even a read-only reader coordinates through
the ``-shm`` shared-memory index next to the database, and may create or update it. That file is
lock bookkeeping, not data. It is only a problem when it is created by a *different* user, who
then owns a sidecar the database's real owner can no longer write. So this happens only when
we are the database's owner; for anyone else's database the connection is ``immutable=1``, which
never touches a sidecar file but may not see sessions written since the last checkpoint (they
are picked up on a later run).
"""
path = os.path.abspath(db_path)
try:
foreign = os.stat(path).st_uid != os.geteuid()
except OSError:
foreign = False
uri = "file:" + path + ("?mode=ro&immutable=1" if foreign else "?mode=ro")
con = sqlite3.connect(uri, uri=True, timeout=10)
con.execute("PRAGMA query_only = ON")
return con
def _load_opencode_session(db_path: str, session_id: str) -> Session:
sess = Session()
con = open_readonly(db_path)
try:
roles: dict[str, tuple[str, str]] = {}
order: list[str] = []
for mid, created, data in con.execute(
"SELECT id, time_created, data FROM message WHERE session_id = ? "
"ORDER BY time_created, id",
(session_id,),
):
try:
role = json.loads(data).get("role", "")
except ValueError:
role = ""
roles[mid] = (role, _ms_date(created))
order.append(mid)
parts: dict[str, list[dict]] = {}
for mid, data in con.execute(
"SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id",
(session_id,),
):
try:
parts.setdefault(mid, []).append(json.loads(data))
except ValueError:
continue
finally:
con.close()
for mid in order:
role, date = roles[mid]
texts = []
for p in parts.get(mid, []):
ptype = p.get("type")
if ptype == "text":
# Text opencode injected on the user's behalf (file attachments, reminders) is not
# something the user said.
if not p.get("synthetic") and not p.get("ignored"):
texts.append(str(p.get("text") or ""))
elif ptype == "tool":
state = p.get("state") or {}
inp = state.get("input") or {}
cmd = inp.get("command") if isinstance(inp, dict) else None
sess.calls.append(
ToolCall(str(p.get("tool") or "?"), cmd.strip() if isinstance(cmd, str) else "")
)
status = state.get("status")
if status == "error":
sess.results.append(ToolResult(True, str(state.get("error") or "")))
elif status == "completed":
sess.results.append(ToolResult(False, str(state.get("output") or "")))
text = " ".join(t for t in texts if t).strip()
if text and role in ("user", "assistant"):
sess.turns.append(Turn(role, date, text))
return sess
def iter_opencode(name: str, pattern: str) -> Iterator[SessionRef]:
for db_path in sorted(glob.glob(os.path.expanduser(pattern))):
if not os.path.isfile(db_path):
continue
try:
con = open_readonly(db_path)
except sqlite3.Error as e:
raise SourceError(f"{db_path}: cannot open read-only: {e}") from e
try:
rows = con.execute("SELECT id, time_updated FROM session ORDER BY time_created, id").fetchall()
except sqlite3.Error as e:
raise SourceError(f"{db_path}: not an opencode 1.x database ({e})") from e
finally:
con.close()
for sid, updated in rows:
short = sid[4:12] if sid.startswith("ses_") else sid[:8]
yield SessionRef(
name,
f"{sid}:{updated}",
short,
f"{db_path}#{sid}",
lambda d=db_path, s=sid: _load_opencode_session(d, s),
)
SOURCE_TYPES = {
"claude-code": iter_claude_code,
"opencode": iter_opencode,
}
def iter_source(kind: str, name: str, path: str) -> Iterator[SessionRef]:
try:
fn = SOURCE_TYPES[kind]
except KeyError:
raise SourceError(f"unknown source type {kind!r}; expected one of {sorted(SOURCE_TYPES)}") from None
return fn(name, path)
+118
View File
@@ -0,0 +1,118 @@
"""Which sessions have already been judged.
A session is recorded as *seen* only once its candidates can no longer be lost:
* a session the pre-filter found clean is seen immediately (it has nothing a judge could file);
* a session with candidates is seen only after the judge's verdict has been written to the ledger
(and committed, when git is configured). If the judge fails, times out, hits a quota, or returns
something that is not a valid verdict, nothing is recorded and the next run shows it again.
Keys include size and modification time (or opencode's ``time_updated``), so a session that grows
after it was judged is looked at again.
"""
from __future__ import annotations
import datetime as _dt
import fcntl
import json
import os
from .envelope import WriteGuard
VERSION = 1
class LockedError(RuntimeError):
pass
class RunLock:
"""One run at a time per state file. Non-blocking: a second run exits instead of queueing."""
def __init__(self, guard: WriteGuard, state_path: str):
self.path = guard.check(state_path + ".lock")
self.fh = None
def __enter__(self):
os.makedirs(os.path.dirname(self.path), exist_ok=True)
self.fh = open(self.path, "a")
try:
fcntl.flock(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
self.fh.close()
raise LockedError(f"another run holds {self.path}") from None
return self
def __exit__(self, *exc):
try:
fcntl.flock(self.fh, fcntl.LOCK_UN)
finally:
self.fh.close()
class State:
def __init__(self, path: str, seen_list: str = "", seen_list_source: str = "claude-code"):
self.path = os.path.abspath(os.path.expanduser(path))
self.seen_list = os.path.abspath(os.path.expanduser(seen_list)) if seen_list else ""
self.seen_list_source = seen_list_source
self.data = {"version": VERSION, "sources": {}, "last_run": None}
self._load()
def _load(self) -> None:
try:
with open(self.path, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
data = None
except ValueError as e:
raise ValueError(f"state file {self.path} is not valid JSON: {e}") from e
if data:
if data.get("version") != VERSION:
raise ValueError(f"state file {self.path} has unsupported version {data.get('version')!r}")
self.data = data
for src in self.data["sources"].values():
src["seen"] = set(src.get("seen", []))
# The optional seen-list is read as well as written, so an external cleanup job's view and
# ours can never disagree, and an existing list (from an earlier install) is honoured.
if self.seen_list and os.path.exists(self.seen_list):
with open(self.seen_list, encoding="utf-8") as f:
keys = {ln.strip() for ln in f if ln.strip()}
if keys:
src = self._src(self.seen_list_source)
src["seen"] |= keys
src["initialized"] = True
def _src(self, name: str) -> dict:
return self.data["sources"].setdefault(name, {"initialized": False, "seen": set()})
def initialized(self, name: str) -> bool:
return bool(self._src(name).get("initialized"))
def seen(self, name: str) -> set[str]:
return self._src(name)["seen"]
def mark_seen(self, name: str, keys) -> None:
src = self._src(name)
src["seen"] |= set(keys)
src["initialized"] = True
def mark_initialized(self, name: str) -> None:
self._src(name)["initialized"] = True
def record_run(self, summary: dict) -> None:
self.data["last_run"] = {"at": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"), **summary}
def save(self, guard: WriteGuard) -> None:
out = {
"version": VERSION,
"sources": {
name: {"initialized": bool(s.get("initialized")), "seen": sorted(s["seen"])}
for name, s in sorted(self.data["sources"].items())
},
"last_run": self.data.get("last_run"),
}
guard.write_text(self.path, json.dumps(out, indent=1) + "\n")
if self.seen_list:
keys = sorted(self.seen(self.seen_list_source))
guard.write_text(self.seen_list, "\n".join(keys) + ("\n" if keys else ""))