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.
> /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"
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.
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.
Repo: realgarit/fable-baton

