Skip to content
Development
Hook

Hooks

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

From plugin
fable5-mode
1061 skill6 hooks

Claude Code

$ npx -y skills add cozytab/fable5-mode --agent claude-code

Where it lives

  • hooks/_fable_common.pyGitHub
    Read the script
    """Shared helpers for fable-mode guard hooks.
    
    Design invariants (keep these true, they are the safety contract):
    - FAIL-OPEN: any unexpected error must let the session proceed (exit 0). A bug
      in a guard must never brick the user's Claude Code session.
    - OPT-IN: a guard only does anything when the project has opted into fable-mode
      enforcement by having a `.fable/` directory somewhere from cwd up to the root.
      No `.fable/` dir  ->  guards are inert.
    """
    import json
    import os
    import re
    import sys
    import tempfile
    import time
    
    
    def read_hook_input():
        """Parse the hook JSON delivered on stdin. Returns {} on any problem."""
        try:
            raw = sys.stdin.read()
            if not raw.strip():
                return {}
            return json.loads(raw)
        except Exception:
            return {}
    
    
    def start_dir(data):
        """Best-effort project dir: hook 'cwd' field, else process cwd."""
        cwd = data.get("cwd")
        if cwd and os.path.isdir(cwd):
            return cwd
        try:
            return os.getcwd()
        except Exception:
            return "."
    
    
    def find_fable_dir(start):
        """Walk up from `start` looking for a `.fable/` directory.
    
        Stops at the filesystem root or at a git repo root (whichever comes first),
        so a stray `.fable` far up the tree can't accidentally arm every project.
        Returns the absolute path to the `.fable` dir, or None.
        """
        try:
            cur = os.path.abspath(start)
        except Exception:
            return None
        while True:
            cand = os.path.join(cur, ".fable")
            if os.path.isdir(cand):
                return cand
            # git root is a natural project boundary; don't cross it.
            if os.path.isdir(os.path.join(cur, ".git")):
                return None
            parent = os.path.dirname(cur)
            if parent == cur:  # filesystem root
                return None
            cur = parent
    
    
    def ledger_path(fable_dir):
        return os.path.join(fable_dir, "LEDGER.md")
    
    
    # --- model-tier ranking & per-session model cache (for the model ceiling) ---
    
    TIER_ORDER = ("haiku", "sonnet", "opus", "fable")
    
    
    def model_tier(model_str):
        """Rank a model string by capability keyword; None if unrecognized."""
        s = (model_str or "").lower()
        tiers = [i for i, k in enumerate(TIER_ORDER) if k in s]
        return max(tiers) if tiers else None
    
    
    def _sessions_dir():
        d = os.path.join(tempfile.gettempdir(), "fable-mode-sessions")
        os.makedirs(d, exist_ok=True)
        return d
    
    
    def _safe_sid(session_id):
        return re.sub(r"[^A-Za-z0-9._-]", "_", str(session_id))[:120]
    
    
    def save_session_model(session_id, model):
        """Cache the session's model at SessionStart so PreToolUse guards (which
        never receive `model`) can enforce the ceiling. Best-effort, fail-open."""
        if not session_id or not model:
            return
        try:
            d = _sessions_dir()
            now = time.time()
            for f in os.listdir(d):  # opportunistic self-cleanup, no SessionEnd hook needed
                p = os.path.join(d, f)
                try:
                    if now - os.path.getmtime(p) > 7 * 86400:
                        os.remove(p)
                except OSError:
                    pass
            with open(os.path.join(d, _safe_sid(session_id) + ".txt"), "w",
                      encoding="utf-8") as fh:
                fh.write(str(model))
        except Exception:
            pass
    
    
    def load_session_model(session_id):
        if not session_id:
            return None
        try:
            with open(os.path.join(_sessions_dir(), _safe_sid(session_id) + ".txt"),
                      encoding="utf-8") as fh:
                return fh.read().strip() or None
        except Exception:
            return None
    
    
    # --- evidence-on-close convention (lever 4: report evidence, not adjectives) ---
    
    EVIDENCE_RE = re.compile(r"(evidence|verified|证据|凭证|验证)\s*[::]", re.IGNORECASE)
    
    
    # --- model-routing profiles (quality / balanced / frugal) ---
    
    ROUTING_PROFILES = ("quality", "balanced", "frugal")
    _ROUTING_RE = re.compile(r"^ROUTING\s*[::]\s*(quality|balanced|frugal)\b",
                             re.IGNORECASE)
    _TIER_RE = re.compile(r"^TIER\s*[::]\s*(throughput|conservative)\b",
                          re.IGNORECASE)
    
    
    def read_tier(path):
        """Per-round concurrency tier from a `TIER: <tier>` ledger line, or None.
    
        Same pattern as ROUTING — the user says a word, the model writes the line,
        the choice persists for the round and stays auditable. Fail-open.
        """
        try:
            with open(path, "r", encoding="utf-8", errors="replace") as fh:
                for line in fh:
                    m = _TIER_RE.match(line.strip())
                    if m:
                        return m.group(1).lower()
        except Exception:
            return None
        return None
    
    
    def read_routing(path):
        """Per-round routing profile from a `ROUTING: <profile>` ledger line.
    
        Returns 'quality'|'balanced'|'frugal', or None when absent/unrecognized
        (callers fall back to the default). Fail-open on any read problem.
        """
        try:
            with open(path, "r", encoding="utf-8", errors="replace") as fh:
                for line in fh:
                    m = _ROUTING_RE.match(line.strip())
                    if m:
                        return m.group(1).lower()
        except Exception:
            return None
        return None
    
    
    MIN_EVIDENCE_CHARS = 6
    
    
    def closed_without_evidence(path):
        """List `- [x]` ledger lines whose evidence marker is missing OR hollow.
    
        Convention: a card may only be checked `- [x]` together with a substantive
        evidence note (`-- evidence: <command output / screenshot / test count>`).
        A marker followed by fewer than MIN_EVIDENCE_CHARS characters ("evidence:
        ok") is treated as missing — adjectives are not evidence.
        Returns [] on any read problem (fail-open).
        """
        bad = []
        try:
            with open(path, "r", encoding="utf-8", errors="replace") as fh:
                for line in fh:
                    s = line.strip()
                    if s[:5].lower() != "- [x]":
                        continue
                    m = EVIDENCE_RE.search(s)
                    if not m or len(s[m.end():]
  • hooks/fable_close_guard.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """fable-mode Close Guard  (Stop hook).
    
    Two duties while a `.fable/LEDGER.md` exists (both loop-safe, both off when
    the ledger is PAUSED):
    
    1. Blocks ending the turn while the ledger still has open `- [ ]` items,
       so fable-mode can't quietly stop mid-plan (the "early-stopping" failure).
       Mark items `- [x]` (done+verified) or `- [~] ... -- deferred: reason`.
    2. Evidence-on-close: blocks ending the turn while any `- [x]` item lacks an
       evidence marker (`-- evidence: ...` / `证据: ...`) — "report evidence, not
       adjectives" as a hard rule, not prose.
    
    Inert unless a `.fable/LEDGER.md` is found. Loop-safe via stop_hook_active.
    Fail-open on any error.
    
    Exit codes: 0 = allow stop; 2 = block stop (stderr shown to Claude).
    """
    import os
    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,
        closed_without_evidence,
    )
    
    MAX_LIST = 12
    
    
    def main():
        data = read_hook_input()
    
        # Prevent an infinite stop/continue loop: if we already blocked once and
        # Claude is stopping again, let it through.
        if data.get("stop_hook_active"):
            return 0
    
        fable_dir = find_fable_dir(start_dir(data))
        if not fable_dir:
            return 0
    
        path = ledger_path(fable_dir)
        if not os.path.isfile(path):
            return 0  # no ledger -> nothing to enforce
    
        open_items, _has_any, paused = parse_ledger(path)
        if paused:
            return 0  # round paused -> enforcement off
        if not open_items:
            # All cards closed -> enforce evidence-on-close before allowing stop.
            bad = closed_without_evidence(path)
            if bad:
                shown = bad[:MAX_LIST]
                lines = "\n".join("    " + it for it in shown)
                if len(bad) > len(shown):
                    lines += "\n    ... and %d more" % (len(bad) - len(shown))
                sys.stderr.write(
                    "[fable-mode] BLOCKED stop: %d checked card(s) in %s carry no "
                    "evidence marker:\n%s\n"
                    "A card is only done when its acceptance actually ran. Append "
                    "`-- evidence: <what proved it>` (a command + its result, a "
                    "test count, a screenshot path) to each `- [x]` line — or "
                    "uncheck the card and verify it now. Adjectives are not "
                    "evidence.\n" % (len(bad), path, lines)
                )
                return 2
            return 0  # all closed, all evidenced -> allow stop
    
        shown = open_items[:MAX_LIST]
        more = len(open_items) - len(shown)
        lines = "\n".join("    " + it for it in shown)
        if more > 0:
            lines += "\n    ... and %d more" % more
        sys.stderr.write(
            "[fable-mode] BLOCKED stop: %d open ledger item(s) in %s\n%s\n"
            "Three legitimate exits: (1) finish each item, verify it (its "
            "acceptance command), mark `- [x] ... -- evidence: <proof>`; "
            "(2) genuinely out of scope -> `- [~] ... -- deferred: reason`; "
            "(3) the user is steering to unrelated work -> add a line "
            "`PAUSED: reason` to the ledger and continue with what they asked. "
            "Do NOT invent completion — pick the exit that matches reality.\n"
            % (len(open_items), path, lines)
        )
        return 2
    
    
    if __name__ == "__main__":
        try:
            sys.exit(main())
        except Exception as e:  # fail-open: never trap the user in a session
            sys.stderr.write("[fable-mode] close guard error (ignored): %r\n" % e)
            sys.exit(0)
    
  • hooks/fable_fail_streak.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """fable-mode Fail-Streak Reminder  (PostToolUse hook on Bash).
    
    Grinding is the failure mode this catches: N consecutive failing commands
    usually means the model is patching the wrong layer. At every 3rd consecutive
    Bash failure it injects the attribution ladder as context:
    
        harness -> deployment -> product
    
    (1) suspect the test/driver itself, (2) prove the new code is actually
    running (cache/build/restart), (3) only then debug the product — and fix the
    class via an invariant, not the instance.
    
    Advisory only — never blocks (exit 0 always). Armed per project by `.fable/`;
    off while the ledger is PAUSED. Streak state lives beside the model cache in
    $TMPDIR/fable-mode-sessions/<sid>.fails and self-resets on the next success.
    Fail-open on any error.
    """
    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,
    )
    
    REMIND_EVERY = 3
    
    _EXIT_CODE_RE = re.compile(r"[Ee]xit code[: ]+([0-9]+)")
    
    LADDER = (
        "[fable-mode] %d consecutive failing commands — before the next fix, walk "
        "the attribution ladder (cheapest layer first): (1) HARNESS: the test/"
        "driver/acceptance script is code too — falsify the test before the "
        "tested; (2) DEPLOYMENT: prove the code you just changed is actually "
        "running (behavior signature, cache-bust, rebuild/restart) — 'fix had no "
        "effect' is often 'fix never ran'; (3) PRODUCT: only now debug, and fix "
        "the class via an invariant, not the one observed symptom. If the same "
        "command keeps failing verbatim, stop retrying it."
    )
    
    
    def command_failed(tool_response):
        """Best-effort failure detection; uncertain -> treated as success."""
        r = tool_response
        if isinstance(r, str):
            return bool(_EXIT_CODE_RE.search(r) and
                        _EXIT_CODE_RE.search(r).group(1) != "0")
        if not isinstance(r, dict):
            return False
        for key in ("exitCode", "exit_code", "code", "returncode"):
            v = r.get(key)
            if isinstance(v, int):
                return v != 0
        for key in ("is_error", "isError"):
            if r.get(key) is True:
                return True
        text = " ".join(str(r.get(k, "")) for k in ("stdout", "stderr", "output"))
        m = _EXIT_CODE_RE.search(text)
        return bool(m and m.group(1) != "0")
    
    
    def main():
        data = read_hook_input()
        sid = data.get("session_id")
        if not sid:
            return 0
    
        fable_dir = find_fable_dir(start_dir(data))
        if not fable_dir:
            return 0  # not opted in -> inert
        _open, _has, paused = parse_ledger(ledger_path(fable_dir))
        if paused:
            return 0
    
        if not command_failed(data.get("tool_response")):
            if load_fail_streak(sid):
                save_fail_streak(sid, 0)
            return 0
    
        streak = load_fail_streak(sid) + 1
        save_fail_streak(sid, streak)
        if streak >= REMIND_EVERY and streak % REMIND_EVERY == 0:
            print(json.dumps({
                "hookSpecificOutput": {
                    "hookEventName": "PostToolUse",
                    "additionalContext": LADDER % streak,
                }
            }, ensure_ascii=False))
        return 0
    
    
    if __name__ == "__main__":
        try:
            sys.exit(main())
        except Exception as e:  # advisory hook: never disturb the session
            sys.stderr.write("[fable-mode] fail-streak error (ignored): %r\n" % e)
            sys.exit(0)
    
  • hooks/fable_lint.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """fable-mode lint — one-shot discipline check for a fable-mode project.
    
    Not a hook: a CLI the model (or CI) runs at wrap-up, and the machine-checkable
    acceptance for the discipline itself:
    
        python3 ~/.claude/skills/fable-mode/hooks/fable_lint.py [project_dir]
    
    Checks (each finding names the file and the rule):
      1. docs/SPEC.md exists next to the `.fable/` project root.
      2. SPEC.md decisions carry source tags — at least one of
         [measured]/[inferred]/[not-shown]/[design-gap] (or the Chinese set
         [实测]/[读数]/[推断]/[未展示]/[设计补全]). Untagged specs hide guesses.
      3. Every open `- [ ]` ledger card hints at its acceptance (mentions a test/
         acceptance/验收, or carries a `backtick command`).
      4. Every closed `- [x]` ledger card carries an evidence marker
         (`-- evidence:` / `证据:`).
      5. Every deferred `- [~]` card carries a reason (`-- deferred: why` /
         `搁置:`/`推迟:`) — deferring everything is the cheap way out of a round;
         reasons keep it auditable.
    
    Exit 0 = clean, exit 1 = findings (printed one per line), exit 0 with a note
    if the directory isn't a fable-mode project at all.
    """
    import os
    import re
    import sys
    
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    from _fable_common import (  # noqa: E402
        find_fable_dir, ledger_path, closed_without_evidence,
    )
    
    SOURCE_TAG_RE = re.compile(
        r"\[(measured|inferred|not-shown|design-gap|实测|读数|推断|未展示|设计补全)\]",
        re.IGNORECASE)
    ACCEPTANCE_HINT_RE = re.compile(r"(accept|验收|test|测试|`[^`]+`)", re.IGNORECASE)
    DEFER_REASON_RE = re.compile(r"(deferred|搁置|推迟)\s*[::]\s*\S{2,}", re.IGNORECASE)
    
    
    def lint(project_dir):
        findings = []
        fable_dir = find_fable_dir(project_dir)
        if not fable_dir:
            print("fable-lint: no .fable/ found from %s — not a fable-mode "
                  "project, nothing to lint" % project_dir)
            return 0
        root = os.path.dirname(fable_dir)
    
        spec = os.path.join(root, "docs", "SPEC.md")
        if not os.path.isfile(spec):
            findings.append("%s: missing (plan gate: requirements + approach + "
                            "task cards live here)" % spec)
        else:
            try:
                with open(spec, encoding="utf-8", errors="replace") as fh:
                    if not SOURCE_TAG_RE.search(fh.read()):
                        findings.append(
                            "%s: no source tags — tag design decisions "
                            "[measured]/[inferred]/[not-shown] so reviewers know "
                            "which foundations are guesses" % spec)
            except Exception:
                pass
    
        lp = ledger_path(fable_dir)
        if os.path.isfile(lp):
            try:
                with open(lp, encoding="utf-8", errors="replace") as fh:
                    for i, line in enumerate(fh, 1):
                        s = line.strip()
                        if s[:5].lower() == "- [ ]" and not ACCEPTANCE_HINT_RE.search(s):
                            findings.append(
                                "%s:%d: open card with no acceptance hint "
                                "(name the test/command that will prove it): %s"
                                % (lp, i, s))
                        elif s[:5].lower() == "- [~]" and not DEFER_REASON_RE.search(s):
                            findings.append(
                                "%s:%d: deferred card without a reason "
                                "(`-- deferred: why` keeps skips auditable): %s"
                                % (lp, i, s))
            except Exception:
                pass
            for s in closed_without_evidence(lp):
                findings.append(
                    "%s: checked card without `-- evidence:` marker: %s" % (lp, s))
        else:
            findings.append("%s: missing (no task cards -> no round structure)" % lp)
    
        for f in findings:
            print("FINDING  " + f)
        print("fable-lint: %d finding(s)" % len(findings))
        return 1 if findings else 0
    
    
    if __name__ == "__main__":
        target = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
        try:
            sys.exit(lint(os.path.abspath(target)))
        except Exception as e:
            print("fable-lint: error (%r)" % e)
            sys.exit(0)
    
  • hooks/fable_profile_inject.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """fable-mode Profile Injector  (SessionStart hook).
    
    When a project has opted into fable-mode (`.fable/` dir present), inject the
    discipline at session start — sized to the ledger state, so a big project's
    small tasks aren't taxed:
    
      starting  (.fable/ but no task cards)  -> full injection (guide the round)
      active    (ledger has open `- [ ]`)    -> full injection + context recovery
      idle      (all cards closed)           -> minimal one-liner; quick work flows
      paused    (a `PAUSED` line in ledger)  -> one-liner; enforcement off except
                                                the model ceiling
    
    Also caches {session_id -> model} so the spawn guard can enforce the model
    ceiling (PreToolUse hooks never receive `model`).
    
    Inert unless `.fable/` is found. Fail-open: any error -> exit 0, no output.
    Tier: model contains "fable" -> throughput, else conservative
    (env FABLE_MODE_PROFILE=auto|conservative|throughput overrides).
    FABLE_ESCALATION=on marks a stronger tier as genuinely available.
    """
    import json
    import os
    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,
        save_session_model, read_routing, ROUTING_PROFILES, read_tier,
    )
    
    # resolved from this file's real location, correct wherever the skill is cloned
    SKILL = os.path.join(
        os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "SKILL.md")
    MAX_LIST = 12
    
    
    def choose_profile(model, lp):
        """Concurrency tier: env FABLE_MODE_PROFILE > ledger `TIER:` > by model."""
        env = os.environ.get("FABLE_MODE_PROFILE", "auto").lower()
        if env in ("conservative", "throughput"):
            return env
        ledger_tier = read_tier(lp)
        if ledger_tier:
            return ledger_tier
        return "throughput" if "fable" in (model or "").lower() else "conservative"
    
    
    def choose_routing(lp):
        """Routing profile: env FABLE_ROUTING > ledger `ROUTING:` line > balanced."""
        env = os.environ.get("FABLE_ROUTING", "").lower()
        if env in ROUTING_PROFILES:
            return env
        return read_routing(lp) or "balanced"
    
    
    def routing_text(routing):
        """Per-profile routing guidance. The two iron rules and the safety net are
        identical in every profile — profiles only tune safe downgrading."""
        if routing == "quality":
            head = (
                "Model routing [QUALITY — user-selected]: every card runs on this "
                "session's model, no downgrades at all; effort still routed "
                "(max = verify/judge, high = implement, low = gather)."
            )
        elif routing == "frugal":
            head = (
                "Model routing [FRUGAL — user-selected]: implementation cards "
                "default ONE tier down (machine-checkable acceptance required; "
                "tricky or vaguely-specified cards stay inherited); mechanical "
                "gather/format/search goes to the cheapest tier at low effort."
            )
        else:
            head = (
                "Model routing [BALANCED]: inherit by default; a tightly-specified "
                "implementation card (machine-checkable acceptance) may drop one "
                "tier; mechanical work goes cheap at low effort."
            )
        return head + (
            " In EVERY profile: task decomposition, orchestration, design, "
            "debugging and all verification stay on this session's model — never "
            "downgraded. Two failed acceptances -> escalate, CAPPED AT this "
            "session's model (top of the ladder is pulling the card back inline — "
            "never spawn above the session model); the verifier must be at least "
            "as strong as the implementer. When unsure, inherit the session model. "
            "Switch profiles only when the user asks (write `ROUTING: <profile>` "
            "into the ledger), never silently."
        )
    
    
    def build_context(profile, model, ledger_state, open_items, routing):
        m = model or "unknown"
    
        if ledger_state == "paused":
            return (
                "[fable-mode] Round PAUSED (.fable/LEDGER.md has a PAUSED line): "
                "enforcement is off except the model ceiling (never spawn above "
                "this session's model). Remove the PAUSED line to resume the round."
            )
    
        if ledger_state == "idle":
            return (
                "[fable-mode] Project armed, ledger idle — quick tasks flow "
                "freely, no process tax. For the next substantial task, follow "
                "%s: SPEC + task cards in .fable/LEDGER.md first (a detailed "
                "fan-out needs a live open card). Always: audit every progress "
                "claim against a tool result; don't end the turn on an actionable "
                "promise; lead with the outcome. Never spawn a subagent above "
                "this session's model." % SKILL
            )
    
        # starting / active -> full injection
        if profile == "throughput":
            tier = (
                "Current model %s -> THROUGHPUT tier: delegate parallel subagents "
                "aggressively, communicate async (don't block on each return); "
                "still enforce ledger-before-delegation and staged fresh-eyes "
                "verification. Cost (~15x tokens / rate limits) is accepted." % m
            )
        else:
            tier = (
                "Current model %s -> CONSERVATIVE tier: cap concurrency at 5. "
                "Quality-critical, tightly-coupled implementation stays inline "
                "(if unsure delegation preserves quality, do it yourself) — but "
                "MULTITASK within the cap: batch independent tool calls in one "
                "message, and run independent side-tasks (searches, verification "
                "runs, bulk mechanical work) as background subagents while you "
                "keep working. Never sit idle waiting for a result you don't "
                "need yet." % m
            )
    
        lines = [
            "[fable-mode] This project has fable-mode enabled (.fable/ detected). "
            "Follow the six levers in %s." % SKILL,
            tier,
          
  • hooks/fable_spawn_guard.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """fable-mode Spawn Guard  (PreToolUse on Agent | Task | Workflow).
    
    Two enforcement duties when the project has opted in (`.fable/` dir present):
    
    1. Design gate: block a *detailed* delegation while the ledger has no OPEN
       task cards (write the SPEC + a live round's cards before fanning out;
       closed cards from a finished round don't unlock new fan-out).
       Small spawns and forks are exempt.
    2. Model ceiling: block any spawn that requests a model STRONGER than the
       session's (haiku < sonnet < opus < fable) — fable-mode exists to get
       Fable-5-grade results without reaching upward. The session model comes from
       the Profile Injector's per-session cache; unknown either side -> fail-open.
       FABLE_ESCALATION=on disables the ceiling.
    
    Inert unless a `.fable/` dir is found. Fail-open on any error.
    
    Exit codes: 0 = allow the tool call; 2 = block (stderr shown to Claude).
    Env:
      FABLE_SPAWN_MIN_CHARS  minimum payload length to be considered "detailed"
                             (default 1500). Below this, the design gate passes.
      FABLE_ESCALATION       "on" allows spawning above the session model.
    """
    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,
        model_tier, load_session_model,
    )
    
    
    def payload_len(tool_name, tool_input):
        if not isinstance(tool_input, dict):
            return 0
        parts = []
        for key in ("prompt", "script", "description"):
            v = tool_input.get(key)
            if isinstance(v, str):
                parts.append(v)
        return len("\n".join(parts))
    
    
    def is_fork(tool_input):
        if not isinstance(tool_input, dict):
            return False
        for key in ("subagent_type", "agentType", "agent_type"):
            v = tool_input.get(key)
            if isinstance(v, str) and "fork" in v.lower():
                return True
        return False
    
    
    # Key-prefixed on purpose: matches model: 'fable' / model="claude-fable-5"
    # inside a Workflow script, but can NOT false-positive on prose like
    # "fable-mode" (no model key in front of it).
    _SCRIPT_MODEL_RE = re.compile(r"""model\s*[:=]\s*['"]([^'"]+)['"]""")
    
    
    def requested_tier(tool_name, tool_input):
        """Strongest model tier explicitly requested by this spawn, or None."""
        if not isinstance(tool_input, dict):
            return None
        tiers = []
        v = tool_input.get("model")
        if isinstance(v, str):
            t = model_tier(v)
            if t is not None:
                tiers.append((t, v))
        if tool_name == "Workflow":
            s = tool_input.get("script")
            if isinstance(s, str):
                for match in _SCRIPT_MODEL_RE.finditer(s):
                    t = model_tier(match.group(1))
                    if t is not None:
                        tiers.append((t, match.group(1)))
        return max(tiers) if tiers else None
    
    
    def main():
        data = read_hook_input()
        tool_name = data.get("tool_name", "")
        tool_input = data.get("tool_input", {}) or {}
    
        fable_dir = find_fable_dir(start_dir(data))
        if not fable_dir:
            return 0  # project not opted in -> inert
    
        # Model ceiling — checked before all exemptions: even a small spawn or a
        # fork must not request a model stronger than the session's.
        if os.environ.get("FABLE_ESCALATION", "auto").lower() != "on":
            sess_model = load_session_model(data.get("session_id"))
            sess_tier = model_tier(sess_model)
            req = requested_tier(tool_name, tool_input)
            if sess_tier is not None and req is not None and req[0] > sess_tier:
                sys.stderr.write(
                    "[fable-mode] BLOCKED: this spawn requests model '%s', which is "
                    "STRONGER than the session model '%s'. fable-mode's purpose is "
                    "Fable-5-grade results WITHOUT reaching upward — the escalation "
                    "ceiling is the session model. Omit the model param to inherit, "
                    "or pull the card back inline. Set FABLE_ESCALATION=on only if "
                    "upward deferral is genuinely intended.\n" % (req[1], sess_model)
                )
                return 2
    
        if is_fork(tool_input):
            return 0  # forks inherit full context; exempt from the spec tax
    
        try:
            threshold = int(os.environ.get("FABLE_SPAWN_MIN_CHARS", "1500"))
        except ValueError:
            threshold = 1500
        if payload_len(tool_name, tool_input) < threshold:
            return 0  # small delegation -> exempt
    
        _open, has_any, paused = parse_ledger(ledger_path(fable_dir))
        if paused:
            return 0  # round paused -> design gate off (ceiling stayed active above)
        if _open:
            return 0  # a live round with open cards -> allowed
        # No OPEN cards: either no ledger at all, or only closed cards from a
        # finished round. Stale closed cards must not unlock new fan-out — a
        # detailed delegation is round work, so it needs a live card first.
        sys.stderr.write(
            "[fable-mode] BLOCKED: this project is in fable-mode (.fable/ present) "
            "but .fable/LEDGER.md has no OPEN task cards for a current round"
            " (closed cards from a finished round don't count).\n"
            "Before fanning out a detailed subagent/Workflow, write the design gate:\n"
            "  1. docs/SPEC.md  -- requirements + approach + task-card list\n"
            "  2. .fable/LEDGER.md  -- one checkbox per card:\n"
            "       - [ ] 1. <card>  (each card needs a machine-checkable acceptance test)\n"
            "Close the load-bearing unknowns with targeted probes first and tag SPEC "
            "decisions [measured]/[inferred]/[not-shown]; cards close only with an "
            "`-- evidence:` note.\n"
            "Starter skeletons: <skill-dir>/templates/ (SPEC/LEDGER/PROGRESS).\n"
            "Then retry the delegation. (Small spawns < %d chars and forks are exempt.)\n"
            % threshold
        )
        return 2
    
    
    if __name__ == "__main__":
        try:
            sys.exit(main())
        except Exception as e:  # fail-open: n

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.

@cozytab
Ships withfable5-mode

Make Opus 4.8 — or any Claude model — work like Claude Fable 5. A Claude Code skill plus guard hooks that give a non-frontier model Fable-5-grade work discipline: plan-gate, self-verification, and sub-agent routing, enforced mechanically.

Stars on GitHub

Momentum

stars in total
106
Star history of cozytab/fable5-mode on GitHub, a chart by star-history.com

More plugins that ship hooks.

superpowers

superpowers

A development methodology for coding agents that makes the agent brainstorm before building, follow plans, run parallel work and finish branches in a disciplined way. You get fewer skipped steps and more consistent results from your coding assistant.

15 skills1 hook311k
@obra@obra
Install Plugin
ponytail

ponytail

A Claude Code plugin with skills for auditing a whole repo, reviewing a change, listing deferred shortcut comments, and showing measured savings and a quick command reference.

6 skills10 hooks188k
@dietrichgebert@dietrichgebert
Install Plugin
agent-skills

agent-skills

Walks a software change from a written spec through design, testing, code review, CI/CD, and release, applying the matching engineering skill at each stage. Useful for developers using Claude Code on full development workflows.

25 skills+3117k
@addyosmani@addyosmani
Install Plugin
planning-with-files

planning-with-files

Persistent file-based planning for AI coding agents on long-running, multi-step tasks. It keeps task_plan.md, findings.md and progress.md on disk, so work can resume after /clear or context compaction.

6 skills+228k
@othmanadi@othmanadi
Install Plugin
gsd-core

gsd-core

A spec-driven development workflow for Claude Code that guides a project through planning, phases, milestone audits and fixes. Includes skills to generate tests from UAT criteria and to audit completed work.

72 skills+311k
@open-gsd@open-gsd
Install Plugin
superpowers-zh

superpowers-zh

Chinese-language skill pack for Claude Code that adds workflow guidance for brainstorming, TDD, debugging, and code review, plus Chinese conventions for commits, documentation, and domestic Git platforms like Gitee and Coding.net.

21 skills1 hook8.5k
@jnmetacode@jnmetacode
Install Plugin
socraticode

socraticode

Gives Claude Code semantic search, dependency graph and architecture context over a codebase, so an AI assistant can explore and understand a project's code. Includes skills for indexing a project and managing its file watching.

2 skills+33.4k
@giancarloerra@giancarloerra
Install Plugin
cocoindex-code

cocoindex-code

A lightweight code search engine that uses AST-based semantic search with local embeddings and incremental indexing. It helps Claude Code users find code faster and more efficiently.

1 skill1 MCP+13k
@cocoindex-io@cocoindex-io
Install Plugin

Questions about the hooks in fable5-mode.

Which coding agents can use the hooks in fable5-mode?
The hooks in fable5-mode install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.