지식위키

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

  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/>세션·폴더별 분리"]

[!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으로 끝난다.

스스로 답해보기:

  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(귀띔이 모델에 들어가는 방식)

[!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/ 기준).