← Files get-fableARCHIVED FILE

hooks/fable_fail_streak.py

4.12 KB · Oct 2, 2026 · 00:30 UTC

↓ Download file

#!/usr/bin/env python3
"""get-fable command-result attribution hook.

Claude Code reports command failures with PostToolUseFailure. Codex reports
Bash completion through PostToolUse even when the command exits non-zero.
Gemini and Antigravity expose shell completion through AfterTool. This handler
normalizes all three result styles and retains legacy payload compatibility.

Two consecutive command failures move the durable workflow to `recovering` and
select `fable-recover`. The hook remains advisory and fail-open.
"""
import json
import os
import re
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _fable_common import (  # noqa: E402
    read_hook_input,
    start_dir,
    find_fable_dir,
    ledger_path,
    parse_ledger,
    load_fail_streak,
    save_fail_streak,
    record_command_result,
)

_EXIT_CODE_RE = re.compile(r"[Ee]xit code[: ]+([0-9]+)")

RECOVERY_CONTEXT = (
    "[get-fable] Repeated command failure moved durable state to fable-recover. "
    "Change the diagnosis before another code edit. Attribution order: "
    "(1) HARNESS: prove the command, test driver, fixture, expectation, permissions, and environment; "
    "(2) EXECUTION PATH: prove the changed code is actually running, including branch, worktree, build output, generated files, cache, and runtime selection; "
    "(3) PRODUCT LOGIC: debug implementation after the first two are supported by evidence; "
    "(4) INVARIANT: state the general rule that would prevent this class of failure. "
    "Record the revised hypothesis before retrying."
)


def command_failed(tool_response):
    """Best-effort failure detection; uncertain results fail open as success."""
    response = tool_response
    if isinstance(response, str):
        match = _EXIT_CODE_RE.search(response)
        return bool(match and match.group(1) != "0")
    if not isinstance(response, dict):
        return False

    for key in ("exitCode", "exit_code", "code", "returncode", "statusCode"):
        value = response.get(key)
        if isinstance(value, int):
            return value != 0
    for key in ("is_error", "isError"):
        if response.get(key) is True:
            return True
    if response.get("success") is False or response.get("ok") is False:
        return True
    if response.get("error") not in (None, False, "", {}):
        return True

    text = " ".join(
        str(response.get(key, ""))
        for key in ("stdout", "stderr", "output", "error", "returnDisplay", "llmContent")
    )
    match = _EXIT_CODE_RE.search(text)
    return bool(match and match.group(1) != "0")


def event_failed(data):
    """Classify native host events while preserving response-based detection."""
    event_name = data.get("hook_event_name")
    if event_name == "PostToolUseFailure":
        return True
    return command_failed(data.get("tool_response"))


def main():
    data = read_hook_input()
    fable_dir = find_fable_dir(start_dir(data))
    if not fable_dir:
        return 0

    _open, _has_any, paused = parse_ledger(ledger_path(fable_dir))
    if paused:
        return 0

    failed = event_failed(data)
    durable = record_command_result(fable_dir, failed)

    session_id = data.get("session_id")
    if session_id:
        if failed:
            save_fail_streak(session_id, load_fail_streak(session_id) + 1)
        elif load_fail_streak(session_id):
            save_fail_streak(session_id, 0)

    if not failed:
        return 0

    streak = int(durable.get("failureStreak", 0)) if isinstance(durable, dict) else load_fail_streak(session_id)
    if streak >= 2:
        event_name = data.get("hook_event_name")
        if not isinstance(event_name, str) or not event_name.strip():
            event_name = "PostToolUseFailure"
        print(json.dumps({
            "hookSpecificOutput": {
                "hookEventName": event_name,
                "additionalContext": RECOVERY_CONTEXT + " failureStreak=%d." % streak,
            }
        }, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as exc:
        sys.stderr.write("[get-fable] fail-streak error (ignored): %r\n" % exc)
        sys.exit(0)

SHA-256: 0713c8e337b43a68878ad53fc5dae98b7d03283a26613cecfc1984bf33c1899b