Skip to content
Agent Orchestration
Hook

Hooks

What fablize runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
fablize
8951 skill1 command3 hooks
Install
> /plugin marketplace add fivetaku/fablize
> /plugin install fablize@fablize

Ships with fablize. Installing the plugin gets these hooks.

What fires, and when

UserPromptSubmit

Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.

  • ${CLAUDE_PLUGIN_ROOT}/hooks/router.sh
  • python3 "${CLAUDE_PLUGIN_ROOT}/hooks/gate_prompt.py"

PostToolUse

  • Matches^(Bash|Edit|Write|NotebookEdit|MultiEdit)$python3 "${CLAUDE_PLUGIN_ROOT}/hooks/gate_post_tool.py"

Stop

  • python3 "${CLAUDE_PLUGIN_ROOT}/hooks/gate_stop.py"
Read hooks/hooks.json

Where it lives

  • hooks/finish-the-work.shGitHub
    Read the script
    #!/bin/bash
    # finish-the-work.sh — Stop hook
    # Detects early termination where the agent only PROMISES work without doing it, and re-engages it.
    # Deterministic (regex). Infinite loops are guarded by stop_hook_active.
    # stdin: JSON { transcript_path, stop_hook_active, ... }
    # stdout: {"decision":"block","reason":"..."} to continue, otherwise empty (exit 0)
    
    set -e
    input=$(cat)
    
    # Loop guard: if this hook already forced one continuation, do not block again.
    active=$(printf '%s' "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('stop_hook_active', False))" 2>/dev/null || echo "False")
    if [ "$active" = "True" ]; then exit 0; fi
    
    tpath=$(printf '%s' "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null || echo "")
    if [ -z "$tpath" ] || [ ! -f "$tpath" ]; then exit 0; fi
    # Reject paths outside TMPDIR or HOME to block path-traversal via crafted hook input.
    case "$tpath" in
        "${TMPDIR:-/tmp}"/* | "$HOME"/*) ;;
        *) exit 0 ;;
    esac
    
    # Extract the last assistant message's text and whether it ended with a tool call.
    python3 - "$tpath" <<'PY'
    import sys, json, re
    
    path = sys.argv[1]
    last_text = ""
    last_had_tool = False
    try:
        with open(path) as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    obj = json.loads(line)
                except json.JSONDecodeError:
                    continue
                msg = obj.get("message", obj)
                if obj.get("type") == "assistant" or msg.get("role") == "assistant":
                    content = msg.get("content", [])
                    if isinstance(content, list):
                        texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
                        tools = [b for b in content if isinstance(b, dict) and b.get("type") == "tool_use"]
                        if texts or tools:
                            last_text = "\n".join(texts).strip()
                            last_had_tool = bool(tools)
    except Exception:
        sys.exit(0)
    
    # Ended with a tool call (still working) or with no text -> not an early stop.
    if last_had_tool or not last_text:
        sys.exit(0)
    
    # Inspect only the closing paragraph, not the whole report.
    tail = last_text[-400:]
    
    # Unfulfilled-promise patterns (English + Korean). Future/intent only; past tense is excluded.
    promise = re.search(
        r"\b(I'?ll|I will|let me|next,? I|now I'?ll)\b[^.]{0,60}\b(now|next|then|implement|create|write|add|run|fix|save|build|start|proceed)\b",
        tail, re.IGNORECASE)
    
    # A legitimate stop that ends by asking the user passes through.
    asks_user = re.search(
        r"(\?|shall i|would you like|do you want|let me know|which option)",
        tail, re.IGNORECASE)
    
    if promise and not asks_user:
        out = {
            "decision": "block",
            "reason": "Your previous response ended by stating an intent to do work without actually doing it. "
                      "Do that work now with tool calls. End the turn only when the task is complete or you are "
                      "blocked on input that only the user can provide."
        }
        print(json.dumps(out, ensure_ascii=False))
    sys.exit(0)
    PY
    
  • hooks/gate_post_tool.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """fablize observation gate — PostToolUse.
    
    Records observed evidence after each Bash/Edit/Write tool call: whether files
    changed (and their kind), and whether a verification command ran and observably
    succeeded or failed. Fails open.
    """
    
    from __future__ import annotations
    
    import sys
    from pathlib import Path
    
    sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts" / "gate"))
    
    from ledger import add_unique, emit_json, read_stdin_json, update_ledger
    from parse_tool_result import (
        changed_kinds,
        command_from_input,
        detect_failure,
        repeated_failure,
        verification_record,
    )
    
    
    def main() -> int:
        input_data = read_stdin_json()
        kinds = changed_kinds(input_data)
        failure = detect_failure(input_data)
        verification = verification_record(input_data)
        command = command_from_input(input_data)
    
        def apply(ledger):
            if kinds:
                ledger["changed_files_seen"] = True
                add_unique(ledger, "change_kinds", kinds)
            if verification:
                ledger["verification_results"].append(verification)
                if command:
                    ledger["verification_commands"].append(verification["command"])
            if failure:
                ledger["failures"].append(failure)
    
        ledger = update_ledger(input_data, apply)
    
        # silent-recovery guard: recover quietly from one-offs, but if the SAME class
        # of failure repeats (>=2), disclose it instead of retrying silently.
        repeat = repeated_failure(ledger.get("failures", [])) if failure else None
        if repeat:
            _sig, count = repeat
            emit_json(
                {
                    "hookSpecificOutput": {
                        "hookEventName": "PostToolUse",
                        "additionalContext": (
                            f"fablize: the same class of failure has repeated {count} times. "
                            "Stop retrying silently — report it briefly (what failed / recovery "
                            "already tried / next path)."
                        ),
                    }
                }
            )
        elif failure:
            emit_json(
                {
                    "hookSpecificOutput": {
                        "hookEventName": "PostToolUse",
                        "additionalContext": "fablize gate observed a tool failure. Do not report completion until it is fixed, isolated as a known baseline, or explicitly documented.",
                    }
                }
            )
        else:
            emit_json({})
        return 0
    
    
    if __name__ == "__main__":
        try:
            raise SystemExit(main())
        except Exception as exc:  # noqa: BLE001 — fail open
            emit_json({"systemMessage": f"fablize gate post-tool hook failed open: {exc}"})
            raise SystemExit(0)
    
  • hooks/gate_prompt.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """fablize observation gate — UserPromptSubmit.
    
    Classifies the new prompt's task mode and resets the per-prompt ledger so the
    Stop gate judges only this turn's evidence. Fails open (emits {} on any error).
    """
    
    from __future__ import annotations
    
    import sys
    from pathlib import Path
    
    sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts" / "gate"))
    
    from ledger import add_unique, emit_json, read_stdin_json, update_ledger
    from classify_task import classify_prompt, context_for_mode
    
    
    def main() -> int:
        input_data = read_stdin_json()
        prompt = str(input_data.get("prompt") or input_data.get("user_prompt") or "")
        mode, risks = classify_prompt(prompt)
    
        def apply(ledger):
            ledger["task_mode"] = mode
            ledger["changed_files_seen"] = False
            ledger["change_kinds"] = []
            ledger["risk_flags"] = []
            ledger["verification_commands"] = []
            ledger["verification_results"] = []
            ledger["failures"] = []
            ledger["stop_blocks"] = 0
            add_unique(ledger, "risk_flags", risks)
    
        update_ledger(input_data, apply)
    
        emit_json(
            {
                "hookSpecificOutput": {
                    "hookEventName": "UserPromptSubmit",
                    "additionalContext": context_for_mode(mode, risks),
                }
            }
        )
        return 0
    
    
    if __name__ == "__main__":
        try:
            raise SystemExit(main())
        except Exception as exc:  # noqa: BLE001 — fail open, never block on our own bug
            emit_json({"systemMessage": f"fablize gate prompt hook failed open: {exc}"})
            raise SystemExit(0)
    
  • hooks/gate_stop.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """fablize observation gate — Stop.
    
    Blocks completion when this turn's observed ledger shows a non-quick, non-docs
    task that changed files but has no observed successful verification. Capped at
    MAX_STOP_BLOCKS reminders, then allows with a warning. Fails open.
    
    Runs alongside finish-the-work.sh (promise-no-act guard): the two cover
    different failure modes — claim-without-observation vs intent-without-action.
    """
    
    from __future__ import annotations
    
    import os
    import sys
    from pathlib import Path
    
    _HERE = Path(__file__).resolve().parent
    sys.path.insert(0, str(_HERE.parent / "scripts" / "gate"))
    sys.path.insert(0, str(_HERE.parent / "scripts" / "shadow"))
    
    from ledger import emit_json, load_ledger, read_stdin_json, save_ledger
    from verify_state import should_block_stop, warning_after_max_blocks
    
    
    def _holdout_suppresses(input_data: dict) -> bool:
        """M3 holdout: env-gated, opt-in. When FABLIZE_HOLDOUT=1, sessions in the
        'off' arm skip the gate (pure baseline for measuring whether the gate
        helps). Default OFF -> gate behaviour is identical, so the 16/16 regression
        (which tests should_block_stop directly) is unaffected. The arm is computed
        out-of-band and is never shown to the model."""
        if os.environ.get("FABLIZE_HOLDOUT") != "1":
            return False
        try:
            from shadow_logger import holdout_arm
            return holdout_arm(input_data.get("session_id") or "") == "off"
        except Exception:
            return False
    
    
    def _log_holdout(input_data: dict, reason: str) -> None:
        """Record the holdout suppression out-of-band (events.jsonl). Never emits to
        the model and never raises into the gate path."""
        try:
            from shadow_logger import append_event, make_event
            append_event(make_event(input_data.get("session_id") or "", "holdout_suppress",
                                    {"would_block_reason": reason}))
        except Exception:
            pass
    
    
    def main() -> int:
        input_data = read_stdin_json()
        # Respect the loop guard so we never block twice in a row on the same stop.
        if input_data.get("stop_hook_active") is True:
            emit_json({})
            return 0
    
        ledger = load_ledger(input_data)
        block, reason = should_block_stop(ledger)
        if block:
            # M3 holdout (env-gated, default off): 'off' arm sessions skip the gate
            # so we can measure the gate's effect against a pure baseline.
            if _holdout_suppresses(input_data):
                _log_holdout(input_data, reason)
                emit_json({})
                return 0
            ledger["stop_blocks"] = int(ledger.get("stop_blocks") or 0) + 1
            save_ledger(input_data, ledger)
            emit_json({"decision": "block", "reason": reason})
            return 0
    
        warning = warning_after_max_blocks(ledger)
        if warning:
            emit_json(
                {
                    "systemMessage": warning,
                    "hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": warning},
                }
            )
        else:
            emit_json({})
        return 0
    
    
    if __name__ == "__main__":
        try:
            raise SystemExit(main())
        except Exception as exc:  # noqa: BLE001 — fail open
            emit_json({"systemMessage": f"fablize gate stop hook failed open: {exc}"})
            raise SystemExit(0)
    
  • hooks/router.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # fablize UserPromptSubmit router — when a task signal is detected, inject the relevant pack discipline as context.
    # Routing: smallest matching pack only / overlap only when genuinely multi-category / mimic observable behavior only.
    # Only verified packs are auto-routed.
    # stdin: JSON {"prompt": "..."}. stdout: extra context (only when a signal matches). Always exits 0.
    set -uo pipefail
    
    # Plugin root: prefer the runtime-injected var, else fall back to this script's location.
    ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
    PACKS="$ROOT/packs"
    
    prompt="$(python3 -c 'import sys,json
    try: print(json.load(sys.stdin).get("prompt",""))
    except Exception: pass' 2>/dev/null || true)"
    [ -z "${prompt:-}" ] && exit 0
    low="$(printf '%s' "$prompt" | tr '[:upper:]' '[:lower:]')"
    
    emit=""
    add() { emit="${emit:+$emit
    }$1"; }
    
    # Debugging / root-cause → investigation-protocol
    case "$low" in
      *debug*|*bug*|*error*|*traceback*|*"stack trace"*|*crash*|*failing*|*"not working"*)
        add "[fablize:investigation] Debugging/root-cause signal — follow $PACKS/investigation-protocol.txt: reproduce first, form 3+ competing hypotheses, gather evidence per hypothesis, trace the full causal chain, verify before/after, and report the hypotheses you rejected." ;;
    esac
    # Render/executable artifacts → verification-grounding
    case "$low" in
      *html*|*svg*|*game*|*canvas*|*chart*|*render*|*website*|*webpage*)
        add "[fablize:grounding] Render/executable artifact signal — follow $PACKS/verification-grounding-pack.txt grounding loop: run it in the real renderer, observe the actual output, fix what the observation reveals, then re-run. A static check is not observation." ;;
    esac
    
    [ -n "$emit" ] && printf '%s\n' "$emit"
    exit 0
    

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withfablize

A Claude Code plugin that makes Opus (or any Claude model) see a task through to the end — with evidence and verification — as procedure, not as luck.

Get the whole plugin
Stats
894
Stars
125
Forks
Maintained
Maintenance
Python
Language
MIT
License
3mo ago
Last commit
4mo ago
Created
5h ago
Added

Repo: fivetaku/fablize