Hooks
What fable-ish runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add chrisryugj/fable-ish > /plugin install fable-ish@fable-ish
Ships with fable-ish. Installing the plugin gets these hooks.
What fires, and when
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
python3 "${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_submit.py"
PreToolUse
- Matches
^(Bash|PowerShell|Edit|Write|MultiEdit|NotebookEdit)$python3 "${CLAUDE_PLUGIN_ROOT}/hooks/pre_tool_use.py"
PermissionRequest
- Matches
^(Bash|PowerShell|Edit|Write|MultiEdit|NotebookEdit)$python3 "${CLAUDE_PLUGIN_ROOT}/hooks/permission_request.py"
PostToolUse
- Matches
^(Bash|PowerShell|Edit|Write|MultiEdit|NotebookEdit)$python3 "${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py"
PostToolUseFailure
- Matches
^(Bash|PowerShell|Edit|Write|MultiEdit|NotebookEdit)$python3 "${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py"
Stop
python3 "${CLAUDE_PLUGIN_ROOT}/hooks/stop_gate.py"
Where it lives
- hooks/permission_request.pyRunsGitHub
Read the script
#!/usr/bin/env python3 """Approval-time guardrails for fable-ish.""" from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) from classify_task import classify_tool_risk from ledger import add_unique, emit_json, read_stdin_json, update_ledger def main() -> int: input_data = read_stdin_json() blocked, flags, reason = classify_tool_risk(input_data) if flags: update_ledger(input_data, lambda ledger: add_unique(ledger, "risk_flags", flags)) if blocked: emit_json( { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "deny", "message": f"fable-ish denied approval request: {reason}", }, } } ) else: emit_json({}) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: emit_json({"systemMessage": f"fable-ish permission hook failed open: {exc}"}) raise SystemExit(0) - hooks/post_tool_use.pyRunsGitHub
Read the script
#!/usr/bin/env python3 """Record fable-ish tool evidence after supported tool calls.""" from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) from ledger import add_unique, emit_json, read_stdin_json, redact, update_ledger from parse_tool_result import ( changed_kinds, changed_paths, command_from_input, detect_failure, verification_coverage, verification_record, ) def main() -> int: input_data = read_stdin_json() event = str(input_data.get("hook_event_name") or "PostToolUse") failure_event = event == "PostToolUseFailure" kinds = changed_kinds(input_data) paths = changed_paths(input_data) failure = detect_failure(input_data) verification = verification_record(input_data) command = command_from_input(input_data) # An explicit failure event means the tool did not succeed: never let it be # recorded as a passing verification, and record a failure even if the # heuristic could not parse one from the (schema-uncertain) failure payload. if failure_event: if verification and verification.get("success") is not False: verification["success"] = False if not failure: failure = { "kind": "tool-failure", "summary": redact(command, 240) or "tool reported a failure", "baseline": "uncertain", } def apply(ledger): if kinds: ledger["changed_files_seen"] = True add_unique(ledger, "change_kinds", kinds) add_unique(ledger, "changed_paths", [path.strip() for path in paths if path]) if verification: verification["coverage_relation"] = verification_coverage(command, ledger.get("changed_paths", [])) ledger["verification_results"].append(verification) if command: ledger["verification_commands"].append(verification["command"]) coverage_order = {"none": 0, "uncertain": 1, "generic": 2, "direct": 3} current = ledger.get("coverage_relation") or "none" observed = verification.get("coverage_relation") or "uncertain" if coverage_order.get(observed, 0) > coverage_order.get(current, 0): ledger["coverage_relation"] = observed if failure: ledger["failures"].append(failure) update_ledger(input_data, apply) if failure: emit_json( { "hookSpecificOutput": { "hookEventName": event, "additionalContext": "fable-ish observed a tool failure. Do not report completion until it is fixed, isolated as baseline, or explicitly documented.", } } ) else: emit_json({}) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: emit_json({"systemMessage": f"fable-ish post-tool hook failed open: {exc}"}) raise SystemExit(0) - hooks/pre_tool_use.pyRunsGitHub
Read the script
#!/usr/bin/env python3 """Pre-tool guardrails for fable-ish.""" from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) from classify_task import classify_tool_risk from ledger import add_unique, emit_json, read_stdin_json, update_ledger def deny(reason: str) -> dict: return { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason, }, "systemMessage": reason, } def main() -> int: input_data = read_stdin_json() blocked, flags, reason = classify_tool_risk(input_data) if flags: update_ledger(input_data, lambda ledger: add_unique(ledger, "risk_flags", flags)) if blocked: emit_json(deny(f"fable-ish blocked tool use: {reason}")) else: emit_json({}) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: emit_json({"systemMessage": f"fable-ish pre-tool hook failed open: {exc}"}) raise SystemExit(0) - hooks/stop_gate.pyRunsGitHub
Read the script
#!/usr/bin/env python3 """Stop-time completion gate for fable-ish.""" from __future__ import annotations import contextlib import io import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) from ledger import emit_json, load_ledger, read_stdin_json, save_ledger from verify_state import should_block_stop, stated_but_unstarted, warning_after_max_blocks FAIL_OPEN_PREFIX = ( "fable-ish plugin bookkeeping/output issue; failed open. " "This is a plugin issue, not evidence that your verification failed:" ) def failure_payload(exc: Exception) -> dict[str, object]: return {"systemMessage": f"{FAIL_OPEN_PREFIX} {exc}"} def main() -> dict[str, object]: input_data = read_stdin_json() if input_data.get("stop_hook_active") is True: return { "systemMessage": "fable-ish stop hook is already active; allowing stop to avoid a continuation loop.", "hookSpecificOutput": { "hookEventName": "Stop", "additionalContext": "fable-ish: stop hook was already active, so no additional block was issued.", }, } if stated_but_unstarted(str(input_data.get("transcript_path") or "")): return { "decision": "block", "reason": "fable-ish: the previous response only stated an intent to do work without doing it. " "Carry it out now with tool calls; end the turn only when the task is complete or you need input " "that only the user can provide.", } ledger = load_ledger(input_data) block, reason = should_block_stop(ledger) if block: ledger["stop_blocks"] = int(ledger.get("stop_blocks") or 0) + 1 save_ledger(input_data, ledger) return {"decision": "block", "reason": reason} warning = warning_after_max_blocks(ledger) if warning: return { "systemMessage": warning, "hookSpecificOutput": { "hookEventName": "Stop", "additionalContext": warning, }, } return {} def run() -> int: captured_stdout = io.StringIO() try: with contextlib.redirect_stdout(captured_stdout): payload = main() except Exception as exc: payload = failure_payload(exc) emit_json(payload) return 0 if __name__ == "__main__": raise SystemExit(run()) - hooks/user_prompt_submit.pyRunsGitHub
Read the script
#!/usr/bin/env python3 """Classify incoming Claude Code prompts for fable-ish.""" from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) from classify_task import classify_prompt, context_for_mode from ledger import add_unique, emit_json, load_ledger, read_stdin_json, update_ledger CONTINUATION_PREFIXES = ( "fable-ish: run ", "fable-ish: add ", "fable-ish: resolve ", ) def main() -> int: input_data = read_stdin_json() prompt = str(input_data.get("prompt") or "") normalized_prompt = prompt.lstrip().lower() if normalized_prompt.startswith(CONTINUATION_PREFIXES): ledger = load_ledger(input_data) mode = str(ledger.get("task_mode") or "normal") risks = list(ledger.get("risk_flags") or []) emit_json( { "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": context_for_mode(mode, risks), } } ) return 0 mode, risks, goal = classify_prompt(prompt) def apply(ledger): ledger["task_mode"] = mode ledger["goal"] = goal ledger["changed_files_seen"] = False ledger["changed_paths"] = [] ledger["change_kinds"] = [] ledger["risk_flags"] = [] ledger["verification_commands"] = [] ledger["verification_results"] = [] ledger["coverage_relation"] = "none" ledger["failures"] = [] ledger["stop_blocks"] = 0 add_unique(ledger, "risk_flags", risks) update_ledger(input_data, apply) if mode == "blocked": emit_json( { "decision": "block", "reason": "fable-ish blocked this prompt because it appears to request destructive or secret-exposing action. Narrow the request or provide explicit safe scope.", } ) return 0 emit_json( { "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": context_for_mode(mode, risks), } } ) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: emit_json({"systemMessage": f"fable-ish prompt hook failed open: {exc}"}) raise SystemExit(0)
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
"fable-ish" = "Fable처럼(-ish)". 관찰 가능한 검증으로 통과하기 전에는, 완료를 인정하지 않는다. fable-ish는 Anthropic이 최근 선보인 코딩 모델 Fable이 보여준 검증 규율을 Claude Code 훅으로 재현한 플러그인입니다.
Repo: chrisryugj/fable-ish

