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
Install
$ npx -y skills add cozytab/fable5-mode --agent claude-code

Ships with fable5-mode. Installing the plugin gets these hooks.

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.

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.

Get the whole plugin
Stats
106
Stars
9
Forks
Maintained
Maintenance
Python
Language
MIT
License
2mo ago
Last commit
2mo ago
Created

Repo: cozytab/fable5-mode