fable-ish 훅 루프, hook event loop, 검증 게이트 훅, UserPromptSubmit-PostToolUse-Stop
13 min read
fable-ish · 훅 3종 이벤트 루프 (실행 진입점)
한 줄 요약
fable-ish는 자기 두뇌 루프 대신, Claude Code가 대화 중 자동으로 만드는 세 순간(프롬프트 받을 때 / 도구 쓴 직후 / 말 끝내려 할 때)에 작은 파이썬 3개를 끼워 “감독관”으로 동작한다.
왜 배우나 — fable-ish가 AI에 어디서·어떻게 개입하는지, 그 실행 진입점의 골격을 한 장으로 잡기 위해서다. 나머지 분해 노트는 전부 이 세 훅의 가지다.
그림
flowchart TD
U[사용자 프롬프트] -->|stdin JSON| H1["훅1 UserPromptSubmit<br/>user_prompt_submit.py"]
H1 -->|난이도 분류| C{"간단/보통/<br/>깊음/위험"}
H1 -->|"장부 초기화 + 귀띔 주입"| M[Claude 모델]
M -->|도구 실행| T["Bash/Edit/Write/<br/>MultiEdit/NotebookEdit"]
T -->|stdin JSON| H2["훅2 PostToolUse<br/>post_tool_use.py"]
H2 -->|"바뀐 파일·검증·실패 기록"| L["(장부 ledger<br/>tmp/.../*.json)"]
H2 -->|실패 시 경고 귀띔| M
M -->|턴 종료 시도| H3["훅3 Stop<br/>stop_gate.py"]
H3 -->|장부 읽기| L
H3 -->|"검증했나?"| D{"검증 미흡<br/>그리고 차단 2회 미만?"}
D -->|"예: 막고 다시 시킴"| M
D -->|"아니오: 통과"| END[턴 종료]
H1 -.오류 나도 길 열어줌 SystemExit 0.-> M
H2 -.오류 나도 길 열어줌 SystemExit 0.-> M
H3 -.오류 나도 길 열어줌 SystemExit 0.-> END
쉽게 풀기
fable-ish는 “운전 학원의 감독관”이다. 운전(코드 작성·실행)은 학생(Claude)이 한다. 감독관은 운전대를 뺏지 않고, 세 순간에만 한마디 거든다.
출발 전 (UserPromptSubmit) — 시동 직전, 오늘 코스가 동네 한 바퀴(간단)/시내(보통)/고속도로(깊음)/빙판(위험) 중 무엇인지 판단해 귀띔한다. user_prompt_submit.py가 프롬프트를 읽어 난이도를 분류하고 빈 “운행 일지(장부, ledger)“를 펴 둔다.
운전 중 (PostToolUse) — 차선 변경(파일 수정)·액셀(명령 실행)마다 “무슨 파일이 바뀌고, 테스트·린트를 돌렸고, 실패가 있었는지”를 일지에 적는다. post_tool_use.py 담당. 단 운전 동작 다섯 가지(Bash·Edit·Write·MultiEdit·NotebookEdit) 에만 끼고, 읽기 같은 안전 행동은 지나친다.
도착 직전 (Stop) — 학생이 끝낸다고 하면 일지를 펴 본다. stop_gate.py가 “차선은 바꿨는데(파일 변경) 봤다는 증거(검증)가 없으면” 종료를 막는다(차단). 단 무한 잔소리는 금지라 최대 2번만 막고 보내준다.
핵심 안전장치: 감독관이 쓰러져도(훅 예외) 주행을 막으면 안 된다. 그래서 세 훅 모두 무슨 오류든 “통과”로 끝낸다(fail-open) — 막는 쪽이 아니라 항상 열어주는 쪽으로 실패한다.
flowchart LR
A[정상 동작] -->|예외 발생| B{어떻게 끝낼까}
B -->|fail-open| C["SystemExit 0<br/>= 통과, 주행 계속"]
B -.금지.-> D["차단 신호<br/>= 작업 멈춤"]
style D stroke-dasharray: 4 4
핵심 정리
훅
발동 순간
하는 일
UserPromptSubmit
프롬프트 받기 직전
난이도 분류 + 장부 초기화 + 귀띔
PostToolUse
도구 실행 직후
바뀐 파일·검증·실패를 장부에 기록
Stop
턴 끝내려 할 때
검증 누락이면 종료 차단(최대 2회)
세 훅이 공유하는 약속과 장부 구조는 이렇게 이어진다.
flowchart LR
H2["PostToolUse<br/>쓴다"] -->|기록| L["(장부 ledger<br/>sha256 키)"]
H3["Stop<br/>읽는다"] -->|판정| L
L --- K["키: session_id+cwd 해시<br/>세션·폴더별 분리"]
PostToolUse만 matcher 정규식 ^(Bash|Edit|Write|MultiEdit|NotebookEdit)$으로 다섯 도구를 거른다. 명령 경로는 하드코딩 대신 ${CLAUDE_PLUGIN_ROOT} 변수로 쓰고, 셋 다 timeout:10·type:"command"다.
규칙 요지: quick·docs-only는 무조건 통과, blocked는 차단, deep/normal은 “변경됐는데 성공 검증 없음”이면 차단.
펼쳐보기: should_block_stop 전문
# scripts/verify_state.pyMAX_STOP_BLOCKS = 2def should_block_stop(ledger: dict[str, Any]) -> tuple[bool, str]: mode = ledger.get("task_mode") or "quick" stop_blocks = int(ledger.get("stop_blocks") or 0) changed = bool(ledger.get("changed_files_seen")) verified = has_successful_verification(ledger) if stop_blocks >= MAX_STOP_BLOCKS: return False, "fable-ish allowed stop after two verification reminders; ..." if mode == "quick": return False, "" if docs_only(ledger): return False, "" if mode == "blocked": return True, "fable-ish: resolve or narrow the blocked risk before final response." if mode == "deep" and not verified: if changed: return True, "fable-ish: run the narrowest verification command ..." if not has_any_verification(ledger): return True, "fable-ish: add one observable proof or ... record why ..." if mode == "normal" and changed and not verified: return True, "fable-ish: run one relevant verification command ..." return False, ""
5) 직접 만들 때 최소 훅 골격 (fail-open 필수)
#!/usr/bin/env python3import sys, jsondef read_stdin_json(): raw = sys.stdin.read() if not raw.strip(): return {} try: data = json.loads(raw) except json.JSONDecodeError: return {"_parse_error": "bad json"} return data if isinstance(data, dict) else {"_input": data}def emit_json(payload): sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")def main() -> int: data = read_stdin_json() # ... 분류/기록/판정 ... emit_json({"hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "my task mode: normal."}}) return 0if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: # fail-open emit_json({"systemMessage": f"hook failed open: {exc}"}) raise SystemExit(0)
펼쳐보기: 직접 만들 때 빠뜨리기 쉬운 항목
세 이벤트(UserPromptSubmit / PostToolUse / Stop)를 hooks.json에 등록
PostToolUse에 matcher 정규식으로 다섯 도구만 거르기
모든 훅에 timeout: 10, type: "command"
명령 경로는 ${CLAUDE_PLUGIN_ROOT} 변수로 (하드코딩 금지)
모든 훅이 try/except → 예외 시 SystemExit(0) (fail-open)
장부 키는 session_id + cwd 해시로 세션별 분리
Stop 차단은 stop_blocks 상한(2회)으로 무한루프 방지
stop_hook_active is True면 즉시 통과(재진입 보호)
저장은 임시파일 write 후 replace로 원자적 교체
redact()로 secret/token/api-key를 기록 전 마스킹
요약 & 셀프체크
fable-ish는 운전자가 아니라 감독관이다. 세 훅은 AI 행동을 직접 바꾸지 않고 “귀띔과 종료 차단”이라는 연성 신호만 준다.
세 훅은 /tmp/fable-ish/ledgers/의 장부 한 권으로 이어진다 — PostToolUse가 적고 Stop이 읽는다.
모든 훅은 입출력이 JSON이고, 무슨 일이 있어도 길을 막지 않는 fail-open으로 끝난다.
스스로 답해보기:
AI가 파일을 고쳤는데 테스트를 안 돌리고 끝내려 하면, 어느 훅이 장부의 어떤 칸을 근거로 막는가?
같은 잔소리로 AI를 무한히 붙잡지 않으려는 장치 두 가지는? (힌트: 카운터 하나, 플래그 하나)
트리거 주체: 세 훅은 Claude Code 호스트가 자체 호출한다(fable-ish가 호출 안 함). UserPromptSubmit(엔터 직후·모델이 받기 직전), PostToolUse(matcher 매칭 도구 실행 직후), Stop(턴 종료 직전).
주입 위치·형태: 훅이 stdout JSON의 additionalContext 문자열을 뱉으면 호스트가 모델 컨텍스트에 끼운다. context_for_mode()가 만드는 문장(예: "fable-ish task mode: deep.", "Never claim verification that was not actually observed.")이 그대로 모델에 보인다. Stop이 decision:"block" + reason을 뱉으면 호스트가 턴을 안 끝내고 reason을 새 지시로 모델을 다시 굴린다.
왜 모델이 소비하나: 훅은 모델 행동을 강제할 권한이 없다(코드 실행은 Claude Code 권한 시스템 몫). “분류 라벨 + 검증 누락 경고 + 종료 차단”의 연성 신호로 위험 비례 검증 규율을 스스로 따르게 유도한다.
데이터 흐름: 트리거 → stdin JSON 파싱(read_stdin_json) → 분류/기록/판정 → ledger 읽기·쓰기 → additionalContext/decision 주입(emit_json) → 모델 재실행 또는 종료.
입력 JSON 주요 필드: prompt(UserPromptSubmit), tool_name·tool_input·tool_response(PostToolUse), transcript_path·stop_hook_active(Stop), session_id·cwd(전부, 장부 키 생성).