#!/usr/bin/env python3 """autoheal-aggregate.py - deterministic friction signature aggregator. Counts recurring tool failures with plain code (no model, no network) so the drafting step only sees signatures that earned attention (#1099 Phase 2.1). Input, under the autoheal data dir ($CCGM_AUTOHEAL_DIR, default ~/.claude/autoheal): events/{date}.jsonl tool_failure and user_interrupt rows counts/{date}.json per-tool call counters ({"Bash": 123, ...}) proposals.jsonl the proposal ledger (lib/ledger.py); rows carrying `signature_id` cover that signature unless their state is "dropped" A dropped row covers its signature for a cooldown instead (item `excluded`: "cooldown", with `cooldown_until`), so a draft that cannot pass validation is not paid for again every night: - content drop (personal_data, module_tests, apply_conflict, rule_budget, anchor_missing, path_not_candidate, insert_too_long, ...): `redraft_cooldown_days` (default 14) from the row's drop date. Each further content drop doubles it, capped at 90 days. - validation_unavailable (infrastructure, not the proposal's fault): 1 day. Three such drops in a row start the 14-day cooldown. A model `skip` row is not dropped: it covers its signature with no expiry. A `rejected` row covers its signature until its `suppressed_until` (90 days after /autoheal-review rejected it); a `snoozed` row covers it while the row exists. Signature: (tool_name, cmd_head, error_class), from tool_failure rows only. A row's class is recomputed from its stored `error` text with the current lib/error_classes.json (shared with failure-logger.py through lib/error_classes.py), so class improvements apply to old rows. A class with "group_by_cmd_head": false (hook denials, harness refusals, shell quirks) has cmd_head "" in its signature; "other" and "exit_code" keep the split by command. Rows written before PR #1112 have no error text or class; they become class "unknown" and never qualify. Days: `min_days` counts distinct dates in the machine's local timezone, taken from each row's `timestamp` (the event files are named by UTC date, which rolls over at 8pm in New York, so file dates would count one evening as two days). The window itself stays keyed on event-file dates. A row with no usable timestamp counts under its file date. user_interrupt rows are tallied per tool in a separate `interrupts` list. Output: signatures/{date}.json, ranked by count x sessions. Bar and window come from config.json (CCGM_AUTOHEAL_CONFIG or /config.json), key "aggregation": {"window_days": 14, "min_occurrences": 5, "min_sessions": 2, "min_days": 2, "redraft_cooldown_days": 14} Outcome measurement (#1099 Phase 4.1). Each run also measures every `applied` rule_insert row whose 14-day post-merge window has ended (the window is the 14 days after the merge day, so a row is due on merge day + 15). post_rate is signature_rate() over that window; it is compared with the row's baseline_rate (the 14 days before the merge, written by /autoheal-review): effective post_rate <= 50% of baseline_rate ineffective above 50% and at most 100% harmful above baseline_rate, or a new signature (same tool and non-empty cmd_head, another error_class, at least NEW_SIGNATURE_MIN_OCCURRENCES failures in the post window and none in the baseline window) appeared unmeasurable no baseline to compare against When either window has no counted calls, failure counts over the two equal windows are compared instead of rates. The row becomes `measured` with the outcome. Reverting a harmful fix is the auto-apply step's job, not this one's: the aggregator makes no git, gh or network call. Usage: autoheal-aggregate.py [--date YYYY-MM-DD] (default: today, UTC) """ from __future__ import annotations import argparse import datetime as dt import hashlib import json import os import sys DEFAULTS = {"window_days": 14, "min_occurrences": 5, "min_sessions": 2, "min_days": 2, "redraft_cooldown_days": 14} OUTCOME_WINDOW_DAYS = 14 EFFECTIVE_MAX_RATIO = 0.5 NEW_SIGNATURE_MIN_OCCURRENCES = 2 MAX_COOLDOWN_DAYS = 90 INFRA_DROP_REASONS = ("validation_unavailable",) INFRA_STREAK_FOR_COOLDOWN = 3 MAX_SAMPLES = 3 MAX_SAMPLE_LEN = 300 UNKNOWN = "unknown" KINDS = ("tool_failure", "user_interrupt") def autoheal_dir() -> str: return os.environ.get("CCGM_AUTOHEAL_DIR") or os.path.expanduser("~/.claude/autoheal") def _redactor(): here = os.path.dirname(os.path.abspath(__file__)) for path in ( os.path.join(here, "..", "..", "hooks", "lib"), os.path.expanduser("~/.claude/lib"), ): if os.path.isfile(os.path.join(path, "hook_utils.py")): sys.path.insert(0, path) try: from hook_utils import redact_secrets return redact_secrets except Exception: pass finally: sys.path.remove(path) return lambda text: text def load_config(data_dir: str) -> dict: path = os.environ.get("CCGM_AUTOHEAL_CONFIG") or os.path.join(data_dir, "config.json") cfg = dict(DEFAULTS) try: with open(path, "r", encoding="utf-8") as fh: raw = json.load(fh) except (OSError, ValueError): return cfg agg = raw.get("aggregation") if isinstance(raw, dict) else None if isinstance(agg, dict): for key in DEFAULTS: val = agg.get(key) if isinstance(val, int) and not isinstance(val, bool) and val > 0: cfg[key] = val return cfg def signature_id(sig: tuple) -> str: return hashlib.sha256("\x1f".join(sig).encode("utf-8")).hexdigest()[:12] def _dates(start: dt.date, end: dt.date): for n in range((end - start).days + 1): yield start + dt.timedelta(days=n) def _read_rows(data_dir: str, date: dt.date): path = os.path.join(data_dir, "events", date.isoformat() + ".jsonl") try: fh = open(path, "r", encoding="utf-8") except OSError: return with fh: for line in fh: try: row = json.loads(line) except ValueError: continue if isinstance(row, dict): yield row def _read_counts(data_dir: str, date: dt.date) -> dict: try: with open(os.path.join(data_dir, "counts", date.isoformat() + ".json"), "r", encoding="utf-8") as fh: val = json.load(fh) except (OSError, ValueError): return {} return val if isinstance(val, dict) else {} def _calls(data_dir: str, tool: str, start: dt.date, end: dt.date) -> int: total = 0 for d in _dates(start, end): n = _read_counts(data_dir, d).get(tool, 0) if isinstance(n, int) and not isinstance(n, bool): total += n return total def _rate(occurrences: int, calls: int): return occurrences / calls * 100 if calls > 0 else None def _classes(): """lib/error_classes.py, the classifier failure-logger.py also uses.""" import importlib.util here = os.path.dirname(os.path.abspath(__file__)) for path in (os.path.join(here, "..", "lib", "error_classes.py"), os.path.expanduser("~/.claude/lib/error_classes.py")): if os.path.isfile(path): spec = importlib.util.spec_from_file_location("autoheal_error_classes", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod raise ImportError("autoheal-aggregate: lib/error_classes.py not found") _ERROR_CLASSES = _classes() def row_signature(row: dict) -> tuple: """(tool_name, cmd_head, error_class). A row with error text is classified again from that text with the current error_classes.json, so class changes apply to stored rows. A row with no text keeps its stored class; a row with neither (pre-#1112) is unknown. A class with group_by_cmd_head false drops cmd_head: its cause does not depend on the command. """ tool = str(row.get("tool_name") or "") err = row.get("error") if isinstance(err, str) and err: cls = _ERROR_CLASSES.classify(err) else: cls = row.get("error_class") if not isinstance(cls, str) or not cls or cls == UNKNOWN: return (tool, "", UNKNOWN) if not _ERROR_CLASSES.groups_by_cmd_head(cls): return (tool, "", cls) head = row.get("cmd_head") return (tool, head if isinstance(head, str) else "", cls) def local_day(row: dict, file_date: dt.date) -> str: """The row's date in the machine's local timezone (ISO). Falls back to the event-file date, which is UTC, when the row has no usable timestamp.""" try: ts = dt.datetime.fromisoformat(str(row.get("timestamp")).replace("Z", "+00:00")) except ValueError: return file_date.isoformat() if ts.tzinfo is None: ts = ts.replace(tzinfo=dt.timezone.utc) return ts.astimezone().date().isoformat() def signature_rate(data_dir: str, sig: tuple, start: dt.date, end: dt.date) -> dict: """Failure rate of one signature over [start, end] (inclusive). Returns {"occurrences", "calls", "rate_per_100_calls"}; the rate is None when counts/ holds no calls of the tool in the window. """ sig = tuple(sig) occurrences = 0 for d in _dates(start, end): for row in _read_rows(data_dir, d): if row.get("kind") == "tool_failure" and row_signature(row) == sig: occurrences += 1 calls = _calls(data_dir, sig[0], start, end) return {"occurrences": occurrences, "calls": calls, "rate_per_100_calls": _rate(occurrences, calls)} def _repo(cwd) -> str: """Repo name from a cwd: the dir under code/, minus -repos/-workspaces.""" if not isinstance(cwd, str) or not cwd: return "" parts = [p for p in cwd.split("/") if p] if "code" in parts and parts.index("code") + 1 < len(parts): name = parts[parts.index("code") + 1] else: name = parts[-1] if parts else "" for suffix in ("-repos", "-workspaces"): if name.endswith(suffix): name = name[: -len(suffix)] return name def _ledger_rows(data_dir: str): """Every row of the proposal ledger that names a signature.""" try: fh = open(os.path.join(data_dir, "proposals.jsonl"), "r", encoding="utf-8") except OSError: return with fh: for line in fh: try: row = json.loads(line) except ValueError: continue if isinstance(row, dict) and isinstance(row.get("signature_id"), str): yield row def _suppression_over(row: dict, as_of: dt.date) -> bool: """True for a rejected row whose `suppressed_until` (set by /autoheal-review) has passed.""" if row.get("state") != "rejected": return False try: until = dt.datetime.fromisoformat(str(row.get("suppressed_until")).replace("Z", "+00:00")) except ValueError: return False # a rejection with no end date stays in force return as_of > until.date() def _covered_ids(data_dir: str, as_of: dt.date) -> set: """Signatures a ledger row still covers. A measured row the user asked to redraft (`/autoheal-review redraft`) stops covering its signature, so the next run drafts it again from the newer samples.""" return {row["signature_id"] for row in _ledger_rows(data_dir) if row.get("state") != "dropped" and not _suppression_over(row, as_of) and not (row.get("state") == "measured" and row.get("redraft_requested_at"))} def drop_history(data_dir: str) -> dict: """{signature_id: [(drop date, drop_reason), ...]} oldest first, dropped rows only.""" hist: dict = {} for row in _ledger_rows(data_dir): if row.get("state") != "dropped": continue when = None for key in ("generated_at", "source_day"): try: when = dt.datetime.fromisoformat(str(row.get(key))).date() break except ValueError: continue if when is None: continue hist.setdefault(row["signature_id"], []).append((when, str(row.get("drop_reason") or ""))) for rows in hist.values(): rows.sort() return hist def unavailable_streak(history: list) -> int: """Length of the run of validation_unavailable drops at the end of the history.""" n = 0 for _, reason in reversed(history): if reason not in INFRA_DROP_REASONS: break n += 1 return n def cooldown_until(history: list, base_days: int): """First date a dropped signature may be drafted again, or None with no history.""" if not history: return None last_day, last_reason = history[-1] if last_reason in INFRA_DROP_REASONS: days = base_days if unavailable_streak(history) >= INFRA_STREAK_FOR_COOLDOWN else 1 else: content_drops = sum(1 for _, r in history if r not in INFRA_DROP_REASONS) days = min(MAX_COOLDOWN_DAYS, base_days * 2 ** (content_drops - 1)) return last_day + dt.timedelta(days=days) def aggregate(data_dir: str, end: dt.date, cfg: dict) -> dict: redact = _redactor() start = end - dt.timedelta(days=cfg["window_days"] - 1) stats: dict = {} interrupts: dict = {} for d in _dates(start, end): iso = d.isoformat() for row in _read_rows(data_dir, d): kind = row.get("kind") if kind not in KINDS: continue sess = row.get("session_id") or "" if kind == "user_interrupt": rec = interrupts.setdefault(str(row.get("tool_name") or ""), {"count": 0, "sessions": set(), "days": set()}) rec["count"] += 1 rec["sessions"].add(sess) rec["days"].add(local_day(row, d)) continue sig = row_signature(row) rec = stats.get(sig) if rec is None: rec = stats[sig] = {"count": 0, "sessions": set(), "repos": set(), "days": set(), "first": iso, "last": iso, "samples": {}} rec["count"] += 1 rec["sessions"].add(sess) repo = _repo(row.get("cwd")) if repo: rec["repos"].add(repo) rec["days"].add(local_day(row, d)) rec["last"] = iso # days iterate in order err = row.get("error") if isinstance(err, str) and err: text = redact(err)[:MAX_SAMPLE_LEN] rec["samples"].pop(text, None) # keep the latest occurrence last rec["samples"][text] = True covered = _covered_ids(data_dir, end) drops = drop_history(data_dir) call_totals: dict = {} out = [] for sig, rec in stats.items(): sid = signature_id(sig) tool = sig[0] if tool not in call_totals: call_totals[tool] = _calls(data_dir, tool, start, end) calls = call_totals[tool] excluded = None if sig[2] == UNKNOWN: excluded = "unknown" elif sid in covered: excluded = "covered" until = cooldown_until(drops.get(sid, []), cfg["redraft_cooldown_days"]) if excluded is None and until is not None and end < until: excluded = "cooldown" meets = (rec["count"] >= cfg["min_occurrences"] and len(rec["sessions"]) >= cfg["min_sessions"] and len(rec["days"]) >= cfg["min_days"]) item = { "signature_id": sid, "tool_name": sig[0], "cmd_head": sig[1], "error_class": sig[2], "count": rec["count"], "sessions": len(rec["sessions"]), "repos": len(rec["repos"]), "days": len(rec["days"]), "first_seen": rec["first"], "last_seen": rec["last"], "calls": calls, "rate_per_100_calls": _rate(rec["count"], calls), "samples": list(rec["samples"])[-MAX_SAMPLES:], "qualifies": meets and excluded is None, } if excluded: item["excluded"] = excluded if excluded == "cooldown": item["cooldown_until"] = until.isoformat() out.append(item) out.sort(key=lambda s: (-s["count"] * s["sessions"], s["signature_id"])) ints = [{"tool_name": t, "count": r["count"], "sessions": len(r["sessions"]), "days": len(r["days"])} for t, r in sorted(interrupts.items())] return { "date": end.isoformat(), "window_start": start.isoformat(), "window_days": cfg["window_days"], "bar": {k: cfg[k] for k in ("min_occurrences", "min_sessions", "min_days")}, "signatures": out, "interrupts": ints, } def _ledger_lib(): import importlib.util here = os.path.dirname(os.path.abspath(__file__)) spec = importlib.util.spec_from_file_location("autoheal_ledger", os.path.join(here, "..", "lib", "ledger.py")) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def _signatures_in(data_dir: str, tool: str, head: str, start: dt.date, end: dt.date) -> dict: """{signature: count} of tool_failure rows with this tool and cmd_head.""" counts: dict = {} for d in _dates(start, end): for row in _read_rows(data_dir, d): if row.get("kind") != "tool_failure": continue sig = row_signature(row) if sig[0] == tool and sig[1] == head and sig[2] != UNKNOWN: counts[sig] = counts.get(sig, 0) + 1 return counts def classify(baseline, post) -> str: """effective | ineffective | harmful | unmeasurable, from two comparable numbers.""" if isinstance(baseline, bool) or not isinstance(baseline, (int, float)) or baseline <= 0 or post is None: return "unmeasurable" if post <= baseline * EFFECTIVE_MAX_RATIO: return "effective" if post <= baseline: return "ineffective" return "harmful" def _merge_day(row: dict): try: return dt.datetime.fromisoformat(str(row.get("merged_at")).replace("Z", "+00:00")).date() except ValueError: return None def measure_row(data_dir: str, row: dict, merged: dt.date) -> dict: """The outcome fields for one due applied row. Reads events and counts only.""" start, end = merged + dt.timedelta(days=1), merged + dt.timedelta(days=OUTCOME_WINDOW_DAYS) sig = (str(row.get("tool_name") or ""), str(row.get("cmd_head") or ""), str(row.get("error_class") or "")) post = signature_rate(data_dir, sig, start, end) baseline_rate = row.get("baseline_rate") if isinstance(baseline_rate, (int, float)) and post["rate_per_100_calls"] is not None: outcome, basis = classify(baseline_rate, post["rate_per_100_calls"]), "rate" else: outcome, basis = classify(row.get("baseline_occurrences"), post["occurrences"]), "occurrences" new_sigs = [] if sig[1]: before = _signatures_in(data_dir, sig[0], sig[1], merged - dt.timedelta(days=OUTCOME_WINDOW_DAYS), merged) after = _signatures_in(data_dir, sig[0], sig[1], start, end) new_sigs = sorted("|".join(s) for s, n in after.items() if s != sig and s not in before and n >= NEW_SIGNATURE_MIN_OCCURRENCES) if new_sigs and outcome != "unmeasurable": outcome = "harmful" return {"outcome": outcome, "outcome_basis": basis, "post_rate": post["rate_per_100_calls"], "post_occurrences": post["occurrences"], "post_calls": post["calls"], "post_window": [start.isoformat(), end.isoformat()], "new_signatures": new_sigs} def measure_outcomes(data_dir: str, today: dt.date) -> list: """Move every due applied rule_insert row to `measured`; returns [(id, outcome)].""" ledger = _ledger_lib() path = os.path.join(data_dir, "proposals.jsonl") done = [] for row in ledger.read_rows(path): if row.get("state") != "applied" or row.get("kind") != "rule_insert": continue merged = _merge_day(row) if merged is None or today < merged + dt.timedelta(days=OUTCOME_WINDOW_DAYS + 1): continue fields = measure_row(data_dir, row, merged) if ledger.set_state(row["id"], "measured", path=path, from_states=("applied",), measured_at=today.isoformat(), **fields): done.append((row["id"], fields["outcome"])) return done def main(argv=None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("--date", help="window end, YYYY-MM-DD (default: today UTC)") args = ap.parse_args(argv) try: end = (dt.date.fromisoformat(args.date) if args.date else dt.datetime.now(dt.timezone.utc).date()) except ValueError: print("autoheal-aggregate: --date must be YYYY-MM-DD", file=sys.stderr) return 2 data_dir = autoheal_dir() result = aggregate(data_dir, end, load_config(data_dir)) out_dir = os.path.join(data_dir, "signatures") os.makedirs(out_dir, exist_ok=True) out_path = os.path.join(out_dir, end.isoformat() + ".json") tmp = out_path + ".tmp" with open(tmp, "w", encoding="utf-8") as fh: json.dump(result, fh, indent=2) fh.write("\n") os.replace(tmp, out_path) qualifying = sum(1 for s in result["signatures"] if s["qualifies"]) print(f"autoheal-aggregate: {len(result['signatures'])} signatures, " f"{qualifying} qualify -> {out_path}") measured = measure_outcomes(data_dir, end) if measured: detail = ", ".join(f"{pid} {outcome}" for pid, outcome in measured) print(f"autoheal-aggregate: measured {len(measured)} applied fix(es): {detail}") return 0 if __name__ == "__main__": sys.exit(main())