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)이 한다. 감독관은 운전대를 뺏지 않고, 세 순간에만 한마디 거든다.

  1. 출발 전 (UserPromptSubmit) — 시동 직전, 오늘 코스가 동네 한 바퀴(간단)/시내(보통)/고속도로(깊음)/빙판(위험) 중 무엇인지 판단해 귀띔한다. user_prompt_submit.py가 프롬프트를 읽어 난이도를 분류하고 빈 “운행 일지(장부, ledger)“를 펴 둔다.
  2. 운전 중 (PostToolUse) — 차선 변경(파일 수정)·액셀(명령 실행)마다 “무슨 파일이 바뀌고, 테스트·린트를 돌렸고, 실패가 있었는지”를 일지에 적는다. post_tool_use.py 담당. 단 운전 동작 다섯 가지(Bash·Edit·Write·MultiEdit·NotebookEdit) 에만 끼고, 읽기 같은 안전 행동은 지나친다.
  3. 도착 직전 (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/>세션·폴더별 분리"]

실제 예시

1) 세 훅 등록 계약 — hooks.json

PostToolUse만 matcher 정규식 ^(Bash|Edit|Write|MultiEdit|NotebookEdit)$으로 다섯 도구를 거른다. 명령 경로는 하드코딩 대신 ${CLAUDE_PLUGIN_ROOT} 변수로 쓰고, 셋 다 timeout:10·type:"command"다.

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이든 예외 없이 흡수한다. 이 두 함수가 “감독관의 입·귀”의 유일한 통로다.

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은 “변경됐는데 성공 검증 없음”이면 차단.

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)

요약 & 셀프체크

  • fable-ish는 운전자가 아니라 감독관이다. 세 훅은 AI 행동을 직접 바꾸지 않고 “귀띔과 종료 차단”이라는 연성 신호만 준다.
  • 세 훅은 /tmp/fable-ish/ledgers/장부 한 권으로 이어진다 — PostToolUse가 적고 Stop이 읽는다.
  • 모든 훅은 입출력이 JSON이고, 무슨 일이 있어도 길을 막지 않는 fail-open으로 끝난다.

스스로 답해보기:

  1. AI가 파일을 고쳤는데 테스트를 안 돌리고 끝내려 하면, 어느 훅이 장부의 어떤 칸을 근거로 막는가?
  2. 같은 잔소리로 AI를 무한히 붙잡지 않으려는 장치 두 가지는? (힌트: 카운터 하나, 플래그 하나)
  3. 훅에서 예외가 터지면 사용자 작업은 멈출까 계속될까? 그렇게 설계한 이유는?

연결

FB_개요 · _분석축_루브릭 · FB_40_task-classification-engine(훅1의 난이도 분류) · FB_50_evidence-ledger-state(공유 장부의 상세 스키마) · FB_70_stop-completion-gate(훅3 차단 규칙 심화) · FB_30_context-injection-via-additionalContext(귀띔이 모델에 들어가는 방식)