Skip to content
Development
Hook

Hooks

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

From plugin
fable-baton
271 skill4 agents3 hooks
Install
> /plugin marketplace add realgarit/fable-baton
> /plugin install fable-baton@fable-baton

Ships with fable-baton. Installing the plugin gets these hooks.

What fires, and when

SessionStart

Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.

  • "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"

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/prompt-nudge.sh"

PostToolUse

  • Matches*"${CLAUDE_PLUGIN_ROOT}/hooks/inline-counter.sh"
Read hooks/hooks.json

Where it lives

  • hooks/inline-counter.shRunsGitHub
    Read the script
    #!/bin/bash
    # fable-baton PostToolUse hook: deterministically count consecutive inline tool
    # calls (Bash/Read/Grep/Glob/Edit/Write/NotebookEdit) and inject a delegation
    # notice once the count crosses FABLE_BATON_TRIPWIRE (default 4), then every
    # 6 calls after that. Agent/Task calls reset the counter to 0. Any parse
    # failure or unrecognized tool is a silent no-op - this must never break a
    # session.
    python3 -c '
    import json
    import os
    import re
    import sys
    import tempfile
    
    def main():
        try:
            payload = json.load(sys.stdin)
        except Exception:
            return
    
        tool_name = payload.get("tool_name")
        session_id = payload.get("session_id") or "default"
    
        inline_tools = {"Bash", "Read", "Grep", "Glob", "Edit", "Write", "NotebookEdit"}
        reset_tools = {"Agent", "Task"}
    
        if tool_name not in inline_tools and tool_name not in reset_tools:
            return
    
        safe_session = re.sub(r"[^A-Za-z0-9-]", "", str(session_id)) or "default"
        state_file = os.path.join(tempfile.gettempdir(), "fable-baton-count-" + safe_session)
    
        tier = "fable"
        try:
            with open(os.path.join(tempfile.gettempdir(), "fable-baton-tier-" + safe_session)) as f:
                value = f.read().strip()
            if value in ("fable", "opus", "sonnet", "haiku"):
                tier = value
        except Exception:
            pass
    
        if tool_name in reset_tools:
            count = 0
        else:
            try:
                with open(state_file, "r") as f:
                    count = int(f.read().strip())
            except Exception:
                count = 0
            count += 1
    
        try:
            with open(state_file, "w") as f:
                f.write(str(count))
        except Exception:
            pass
    
        if tool_name in reset_tools:
            return
    
        try:
            threshold = int(os.environ.get("FABLE_BATON_TRIPWIRE", "4"))
        except Exception:
            threshold = 4
    
        if count >= threshold and (count - threshold) % 6 == 0:
            if tier == "sonnet":
                message = (
                    "[fable-baton] " + str(count) + " consecutive inline tool calls. "
                    "Sonnet session: inline edits are fine at your tier, but if this "
                    "streak is discovery or bulk reading, hand it to scout to keep "
                    "your context lean. Subagents executing a delegated task: ignore "
                    "this notice."
                )
            elif tier == "opus":
                message = (
                    "[fable-baton] " + str(count) + " consecutive inline tool calls "
                    "without delegating. Opus session: this block belongs to a cheaper "
                    "agent (scout for discovery, executor for edits); architect is your "
                    "own tier, so it saves nothing here. Subagents executing a delegated "
                    "task: ignore this notice."
                )
            elif tier == "haiku":
                message = (
                    "[fable-baton] " + str(count) + " consecutive inline tool calls "
                    "without delegating. Haiku session: if this work is nontrivial, "
                    "route it up (executor for implementation, architect for hard "
                    "problems). Subagents executing a delegated task: ignore this "
                    "notice."
                )
            else:
                message = (
                    "[fable-baton] " + str(count) + " consecutive inline tool calls without "
                    "delegating. Main session: this block belongs to an agent (scout for "
                    "discovery, executor for edits) - delegate the remainder now. Subagents "
                    "executing a delegated task: ignore this notice."
                )
            output = {
                "hookSpecificOutput": {
                    "hookEventName": "PostToolUse",
                    "additionalContext": message,
                }
            }
            sys.stdout.write(json.dumps(output, separators=(",", ":")) + "\n")
    
    main()
    '
    exit 0
    
  • hooks/prompt-nudge.shRunsGitHub
    Read the script
    #!/bin/bash
    # fable-baton UserPromptSubmit hook: re-assert the orchestration policy on every turn.
    # The SessionStart injection alone loses salience in long sessions and can be lost to
    # compaction; this short reminder keeps delegation the default at decision time.
    # The reminder is tier-aware: the SessionStart hook persists the session's model tier
    # to a state file, and the text adapts so long sessions on Sonnet or Haiku are not
    # nudged toward routing that no longer saves anything. Any failure to read the tier
    # falls back to the base (Fable) reminder.
    
    detection="$(python3 -c '
    import json
    import os
    import re
    import sys
    import tempfile
    
    
    def tier_from_model(model):
        if "fable" in model or "mythos" in model:
            return "fable"
        if "opus" in model:
            return "opus"
        if "sonnet" in model:
            return "sonnet"
        if "haiku" in model:
            return "haiku"
        return None
    
    
    def tier_from_transcript(transcript_path):
        try:
            if not transcript_path or not os.path.isfile(transcript_path):
                return None
            size = os.path.getsize(transcript_path)
            with open(transcript_path, "rb") as f:
                f.seek(max(0, size - 262144))
                data = f.read()
            lines = data.decode("utf-8", errors="replace").splitlines()
            for line in reversed(lines):
                try:
                    entry = json.loads(line)
                except Exception:
                    continue
                if entry.get("isSidechain"):
                    continue
                message = entry.get("message")
                if not isinstance(message, dict):
                    continue
                m = message.get("model")
                if not isinstance(m, str):
                    continue
                found = tier_from_model(m.lower())
                if found:
                    return found
        except Exception:
            return None
        return None
    
    
    session = "default"
    transcript_path = None
    try:
        payload = json.load(sys.stdin)
        session = str(payload.get("session_id") or "default")
        transcript_path = payload.get("transcript_path")
    except Exception:
        pass
    
    safe_session = re.sub(r"[^A-Za-z0-9-]", "", session) or "default"
    tier_file = os.path.join(tempfile.gettempdir(), "fable-baton-tier-" + safe_session)
    
    tier = "fable"
    try:
        with open(tier_file) as f:
            value = f.read().strip()
        if value in ("fable", "opus", "sonnet", "haiku"):
            tier = value
    except Exception:
        pass
    
    detected = tier_from_transcript(transcript_path)
    if detected and detected != tier:
        tier = detected
        try:
            with open(tier_file, "w") as f:
                f.write(tier)
        except Exception:
            pass
    
    announce = "quiet"
    if tier in ("opus", "sonnet", "haiku"):
        marker_file = os.path.join(tempfile.gettempdir(), "fable-baton-adapted-" + safe_session)
        marker_value = None
        try:
            with open(marker_file) as f:
                marker_value = f.read().strip()
        except Exception:
            marker_value = None
        if marker_value != tier:
            announce = "announce"
            try:
                with open(marker_file, "w") as f:
                    f.write(tier)
            except Exception:
                pass
    
    print(tier)
    print(announce)
    ' 2>/dev/null)"
    
    tier="$(echo "$detection" | sed -n "1p")"
    announce="$(echo "$detection" | sed -n "2p")"
    if [ -z "$tier" ]; then
      tier="fable"
    fi
    
    case "$tier" in
      sonnet)
        cat <<'EOF'
    [fable-baton] Delegation check for this turn (Sonnet session): discovery and bulk reading go to scout, verification to verifier; implementation you do inline at your own tier, using executor only for context isolation or parallel edit streams. architect (Opus) costs more than you now: only for problems you attempted and could not solve, or high-risk review. Skills define what to do, not who does it.
    EOF
        ;;
      opus)
        cat <<'EOF'
    [fable-baton] Delegation check for this turn (Opus session): searching, reading files, editing, testing, and verifying go to the cheaper agents (scout, executor, verifier via the Agent tool); architect is your own tier, so use it only for context isolation or a second opinion. Tripwire: reaching for a 3rd consecutive inline Bash/Read/Grep/Edit call means that block belongs to an agent. Skills define what to do, not who does it.
    EOF
        ;;
      haiku)
        cat <<'EOF'
    [fable-baton] Delegation check for this turn (Haiku session): anything beyond simple lookups and mechanical edits goes UP - executor for implementation, architect for design, debugging, and high-risk work. Correctness beats cost at this tier.
    EOF
        ;;
      *)
        cat <<'EOF'
    [fable-baton] Delegation check for this turn: searching, reading files, editing, testing, and verifying go to agents (scout, executor, architect, verifier via the Agent tool); you keep judgment, decisions, and the final answer. Tripwire: reaching for a 3rd consecutive inline Bash/Read/Grep/Edit call means that block belongs to an agent. Skills define what to do, not who does it: delegate their mechanical steps too. Exempt: conversational turns, one quick lookup, a one-line edit. No exemptions in security-context sessions.
    EOF
        ;;
    esac
    
    if [ "$announce" = "announce" ]; then
      echo
      cat "${CLAUDE_PLUGIN_ROOT}/policy/adapt-${tier}.md"
    fi
    
  • hooks/session-start.shRunsGitHub
    Read the script
    #!/bin/bash
    # fable-baton SessionStart hook: inject the orchestration policy as session context.
    # Stdout from a SessionStart hook is added to the model's context.
    # The hook input JSON may carry the session's model. When the main model is not
    # Fable, append a tier adaptation so the cost logic stays correct, and persist
    # the detected tier to a state file so the other hooks can read it. If neither
    # the payload nor the transcript yields a tier (a fresh startup session has no
    # model field and an empty transcript), append a self-apply fallback that lists
    # every tier's override and asks the model to apply its own; the persisted tier
    # file still stores "fable" so the other hooks keep working, and no adapted
    # marker is written so the turn-2 announce in prompt-nudge.sh can still fire
    # once transcript detection succeeds. Any parse failure falls back to the base
    # (Fable) policy - this must never break a session.
    
    tier="$(python3 -c '
    import json
    import os
    import re
    import sys
    import tempfile
    
    
    def tier_from_model(model):
        if "fable" in model or "mythos" in model:
            return "fable"
        if "opus" in model:
            return "opus"
        if "sonnet" in model:
            return "sonnet"
        if "haiku" in model:
            return "haiku"
        return None
    
    
    def tier_from_transcript(transcript_path):
        try:
            if not transcript_path or not os.path.isfile(transcript_path):
                return None
            size = os.path.getsize(transcript_path)
            with open(transcript_path, "rb") as f:
                f.seek(max(0, size - 262144))
                data = f.read()
            lines = data.decode("utf-8", errors="replace").splitlines()
            for line in reversed(lines):
                try:
                    entry = json.loads(line)
                except Exception:
                    continue
                if entry.get("isSidechain"):
                    continue
                message = entry.get("message")
                if not isinstance(message, dict):
                    continue
                m = message.get("model")
                if not isinstance(m, str):
                    continue
                found = tier_from_model(m.lower())
                if found:
                    return found
        except Exception:
            return None
        return None
    
    
    tier = None
    session = "default"
    try:
        payload = json.load(sys.stdin)
        session = str(payload.get("session_id") or "default")
        model = str(payload.get("model") or "").lower()
        found = tier_from_model(model)
        if found:
            tier = found
        else:
            found = tier_from_transcript(payload.get("transcript_path"))
            if found:
                tier = found
    except Exception:
        pass
    
    detected = tier if tier else "unknown"
    stored_tier = tier if tier else "fable"
    
    safe_session = re.sub(r"[^A-Za-z0-9-]", "", session) or "default"
    try:
        with open(os.path.join(tempfile.gettempdir(), "fable-baton-tier-" + safe_session), "w") as f:
            f.write(stored_tier)
    except Exception:
        pass
    
    # The adaptation for this tier is injected below; mark it announced so the
    # per-prompt nudge does not repeat the full text on the first turn. Unknown
    # (undetected) sessions get no marker, so the turn-2 announce can still fire
    # once transcript detection succeeds.
    if detected in ("opus", "sonnet", "haiku"):
        try:
            with open(os.path.join(tempfile.gettempdir(), "fable-baton-adapted-" + safe_session), "w") as f:
                f.write(detected)
        except Exception:
            pass
    
    print(detected)
    ' 2>/dev/null)"
    
    cat "${CLAUDE_PLUGIN_ROOT}/policy/orchestration.md"
    
    case "$tier" in
      opus|sonnet|haiku)
        echo
        cat "${CLAUDE_PLUGIN_ROOT}/policy/adapt-${tier}.md"
        ;;
      unknown)
        echo
        cat "${CLAUDE_PLUGIN_ROOT}/policy/adapt-unknown.md"
        ;;
    esac
    

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 withfable-baton

Fable 5 holds the baton. The orchestra plays. A Claude Code plugin that makes Fable 5 the orchestrator. Fable keeps the judgment, tiered subagents on Opus, Sonnet and Haiku do the labor. Install once and every new session in every repo starts this way.

Get the whole plugin
Stats
27
Stars
1
Forks
Active
Maintenance
HTML
Language
MIT
License
6d ago
Last commit
2mo ago
Created

Repo: realgarit/fable-baton