fable-ish · 훅 3종 이벤트 루프 (실행 진입점)
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/>세션·폴더별 분리"]
[!note]- 펼쳐보기: 세 훅의 4가지 공통 약속
- 입력: 전부 stdin으로 JSON 수신 (
read_stdin_json).- 출력: 전부 stdout으로 JSON 송신 (
emit_json).- 타임아웃: 셋 다 10초 안에 응답 못 하면 호스트가 취소.
- fail-open: 어떤 예외든 종료코드 0으로 끝내 작업을 절대 막지 않음.
[!note]- 펼쳐보기: 장부(ledger) 전체 필드
- PostToolUse가 쓰고, Stop이 읽는 공유 메모리.
- 위치:
/tmp/fable-ish/ledgers/<sha256>.json.- 키:
session_id + cwd를 sha256 24자로 → 세션·작업폴더별 분리.- 주요 칸:
task_mode(난이도) ·changed_files_seen(변경 여부) ·verification_results(검증 성공) ·stop_blocks(차단 횟수, 상한 2) ·risk_flags(위험 표식).
[!note]- 펼쳐보기: Stop 게이트 판정 규칙 (평가 순서 그대로)
stop_blocks≥ 2 → 통과 (두 번 알렸으면 보냄)task_mode=quick(간단) → 통과- 변경이 문서(docs)뿐 → 통과
task_mode=blocked(위험) → 항상 차단deep(깊음)인데 변경했고 검증 없음 → 차단normal(보통)인데 변경했고 검증 없음 → 차단- 그 외 전부 → 통과
실제 예시
1) 세 훅 등록 계약 — hooks.json
PostToolUse만 matcher 정규식 ^(Bash|Edit|Write|MultiEdit|NotebookEdit)$으로 다섯 도구를 거른다. 명령 경로는 하드코딩 대신 ${CLAUDE_PLUGIN_ROOT} 변수로 쓰고, 셋 다 timeout:10·type:"command"다.
[!note]- 펼쳐보기: hooks.json 전문 (최소형)
// /home/seunghyeong/harness-work/fable-ish/hooks/hooks.json { "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/user_prompt_submit.py\"", "timeout": 10, "statusMessage": "Classifying task" } ] } ], "PostToolUse": [ { "matcher": "^(Bash|Edit|Write|MultiEdit|NotebookEdit)$", "hooks": [ { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/post_tool_use.py\"", "timeout": 10, "statusMessage": "Recording evidence" } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/stop_gate.py\"", "timeout": 10, "statusMessage": "Reviewing completion" } ] } ] } }
2) 공통 fail-open 종결 패턴
세 훅 모두 같은 try / except / SystemExit(0) 꼬리를 갖는다. 어떤 예외든 종료코드 0 → Claude Code가 차단 신호를 못 받아 작업이 멈추지 않는다.
# hooks/stop_gate.py
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
emit_json({"systemMessage": f"fable-ish stop hook failed open: {exc}"})
raise SystemExit(0)
3) stdin/stdout JSON 계약
빈 입력이든 깨진 JSON이든 예외 없이 흡수한다. 이 두 함수가 “감독관의 입·귀”의 유일한 통로다.
[!note]- 펼쳐보기: read_stdin_json / emit_json 전문
# scripts/ledger.py def read_stdin_json() -> dict[str, Any]: import sys raw = sys.stdin.read() if not raw.strip(): return {} try: data = json.loads(raw) except json.JSONDecodeError: return {"_parse_error": "invalid stdin json"} return data if isinstance(data, dict) else {"_input": data} def emit_json(payload: dict[str, Any]) -> None: import sys sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
4) Stop 게이트의 차단 결정
장부를 읽어 should_block_stop이 참이면 decision:block을 내보내 턴을 못 끝내게 한다. 막을 때마다 stop_blocks를 1씩 올려 저장 → 최대 2회만 막고 그 뒤엔 열어준다(무한 차단루프 방지).
# hooks/stop_gate.py
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)
emit_json({"decision": "block", "reason": reason})
return 0
규칙 요지: quick·docs-only는 무조건 통과, blocked는 차단, deep/normal은 “변경됐는데 성공 검증 없음”이면 차단.
[!note]- 펼쳐보기: should_block_stop 전문
# scripts/verify_state.py MAX_STOP_BLOCKS = 2 def 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 python3
import sys, json
def 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 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc: # fail-open
emit_json({"systemMessage": f"hook failed open: {exc}"})
raise SystemExit(0)
[!note]- 펼쳐보기: 직접 만들 때 빠뜨리기 쉬운 항목
- 세 이벤트(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를 무한히 붙잡지 않으려는 장치 두 가지는? (힌트: 카운터 하나, 플래그 하나)
- 훅에서 예외가 터지면 사용자 작업은 멈출까 계속될까? 그렇게 설계한 이유는?
연결
FB_개요 · _분석축_루브릭 · FB_40_task-classification-engine(훅1의 난이도 분류) · FB_50_evidence-ledger-state(공유 장부의 상세 스키마) · FB_70_stop-completion-gate(훅3 차단 규칙 심화) · FB_30_context-injection-via-additionalContext(귀띔이 모델에 들어가는 방식)
[!tip]- Codex 교차검증 메모 (원본 분석 보존)
- 트리거 주체: 세 훅은 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(전부, 장부 키 생성).- 근거 파일:
hooks/hooks.json,hooks/user_prompt_submit.py,hooks/post_tool_use.py,hooks/stop_gate.py,scripts/ledger.py,scripts/classify_task.py,scripts/verify_state.py,scripts/parse_tool_result.py,.claude-plugin/plugin.json,.claude-plugin/marketplace.json,skills/fable-ish/SKILL.md(모두/home/seunghyeong/harness-work/fable-ish/기준).