AI가 “다 했습니다” 하고 멈추려는 순간, 고친 건 있는데 검증한 증거가 없으면 멈춤을 거부하고 “검증 명령 하나 돌리고 와”라며 다시 일하게 만드는 마지막 검문소다.
왜 배우나 — “거짓 완료 선언”을 막는 안전벨트이자, 무한 잔소리에 빠지지 않는 절제된 게이트 설계의 표준 예시다.
그림
flowchart TD
A["UserPromptSubmit 훅<br/>작업 등급 정하고 장부 리셋"] --> B["모델 작업 + 도구 호출"]
B --> C["PostToolUse 훅<br/>변경/검증 증거를 장부에 기록"]
C --> B
B --> D{"Stop 이벤트<br/>멈추려는 순간"}
D --> E{"게이트가 나 때문에<br/>다시 불렸나?"}
E -- 예 --> P1["통과: 무한 핑퐁 차단"]
E -- 아니오 --> F{"말만 하고<br/>손은 안 댔나?"}
F -- 예 --> BL1["차단: 지금 도구로 실행하라"]
F -- 아니오 --> G[장부 펴서 모드별 판정]
G -- 차단 --> BL2["차단 횟수 +1 저장<br/>모드별 지시문 전달"]
G -- 통과 --> H{"두 번 막았는데<br/>아직 미검증?"}
H -- 예 --> P2["통과 + 솔직히 보고하라 경고"]
H -- 아니오 --> P3["통과: 빈 객체"]
BL1 --> D
BL2 --> D
쉽게 풀기
공항 출국 검색대를 떠올리면 쉽다. 승객(AI)이 “수속 끝, 나갑니다” 하고 나가려 할 때 검색대(게이트)가 마지막으로 한 번 더 들여다본다.
장부 기록 — 일하는 동안 다른 훅들이 작은 장부(ledger)에 “파일 고쳤나?”, “테스트를 실제로 돌려 통과를 봤나?”를 적어 둔다. 게이트는 장부만 펴 볼 뿐 직접 코드를 분석하지 않는다(영수증만 모아 두는 가계부).
멈출 때 검사 — 턴을 끝내려 하면 게이트가 자동으로 켜져 단 하나를 묻는다: “변경은 했는데 검증 증거가 없는가?” 그렇다면 강제로 다시 깨운다. AI는 새 지시를 받은 것처럼 느껴 자연히 검증을 실행한다.
함정 회피 — 같은 잔소리로 영원히 붙잡지 않도록 최대 2번만 막고(MAX_STOP_BLOCKS), 게이트가 자기 자신 때문에 다시 호출된 경우(stop_hook_active)는 즉시 통과시켜 게이트↔모델 무한 핑퐁을 끊는다.
“말만 하고 손 안 댐” — “이제 구현하겠습니다”라고 선언만 하고 도구를 한 번도 안 쓴 턴은 정규식으로 잡아 다시 일하게 한다. 단 “이 방식으로 할까요?”처럼 사용자에게 결정을 넘기는 질문으로 끝났으면 멈춰도 봐준다.
flowchart LR
U["UserPromptSubmit<br/>등급 결정·장부 리셋"] -->|채움| L["(장부 ledger)"]
P["PostToolUse<br/>변경·검증 증거"] -->|채움| L
L -->|읽기 전용| S["Stop 게이트<br/>판정만"]
비유 한 줄
장부 = 영수증 모음, 게이트 = 출국 검색대, MAX_STOP_BLOCKS = “두 번까지만 검사하고 보낸다”, stop_hook_active = “검색대 자기 줄에 또 선 사람”을 그냥 통과시키는 예외.
핵심 정리
게이트의 멈춤 차단 규칙은 위에서부터 순서대로 평가된다. 13번(통과 우선)이 47번(모드별 차단)보다 먼저 걸러지는 게 핵심이다.
flowchart TD
A{"1~3 통과 우선<br/>2회초과·quick·docs_only"} -- 해당 --> PASS[통과]
A -- 아님 --> B{"4 blocked 모드?"}
B -- 예 --> BLK[차단]
B -- 아니오 --> C{"5~7 변경했는데<br/>미검증?"}
C -- 예 --> BLK
C -- 아니오 --> PASS
순서
조건
결과
1
이미 2번 막음 (stop_blocks >= 2)
통과
2
가벼운 작업 (mode == "quick")
통과
3
문서만 고침 (docs_only)
통과
4
위험 경계 작업 (mode == "blocked")
항상 차단
5
deep, 변경했는데 미검증
차단
6
deep, 변경 없고 검증기록 전무
차단
7
normal, 변경했는데 미검증
차단
8
그 외 전부
통과
판정의 두 함정
“검증됨” 기준: verification_results 안에 success is True인 원소가 하나라도 있어야 인정(부분 통과 허용). 명령 실행만으로는 부족.
우선순위: quick / docs_only / 2회 초과는 어떤 모드 규칙보다 먼저 평가되어 통과한다.
펼쳐보기: 게이트가 읽는 장부 필드 + 출력 계약
읽는 장부 필드
task_mode — 작업 등급(quick/normal/deep/blocked). 없으면 "quick" 폴백
stop_blocks — 누적 차단 횟수. 2 도달 시 통과
changed_files_seen — 파일 변경 관측 여부(bool)
change_kinds — 변경 분류 집합(docs/code/config/assets/other). {"docs"} 뿐이면 docs_only
verification_results — 검증 실행 기록. success is True가 있으면 “검증됨”
verification_commands — 실행된 검증 명령 목록(has_any_verification 판정용)
coverage_relation / failures — 보고용 참고값(판정엔 직접 미사용)
출력 계약 (stdout 1줄 JSON)
필드
언제
설명
decision: "block"
차단할 때만
있으면 Claude Code가 멈춤을 취소하고 턴 재개
reason
block과 동반
모델에게 다시 보일 지시문(모드별 상이)
systemMessage
통과·재진입·실패 시
차단 없이 보여줄 안내
hookSpecificOutput
통과/재진입 시
hookEventName: "Stop" 및 additionalContext
실제 예시
핵심은 세 조각이다 — 재진입 가드 → “말만함” 검사 → 모드 게이트 순서로 흐른다.
flowchart TD
IN[stdin JSON] --> R{"stop_hook_active?"}
R -- 예 --> OUT1["통과: 핑퐁 방지"]
R -- 아니오 --> ST{"말만 하고 끝?"}
ST -- 예 --> OUT2["block: 지금 실행하라"]
ST -- 아니오 --> MG["should_block_stop<br/>모드별 판정"]
MG --> OUT3[block 또는 통과]
펼쳐보기: ① 모드별 차단 규칙 should_block_stop (게이트의 두뇌)
# /home/seunghyeong/harness-work/fable-ish/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; report any missing verification clearly." 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 for the changed behavior before final response." if not has_any_verification(ledger): return True, "fable-ish: add one observable proof or explicitly record why this deep task has no runnable verifier." if mode == "normal" and changed and not verified: return True, "fable-ish: run one relevant verification command for the changed files, or state why no verifier applies." return False, ""
펼쳐보기: ② Stop 훅 엔트리포인트 stop_gate.py
# /home/seunghyeong/harness-work/fable-ish/hooks/stop_gate.pydef main() -> int: input_data = read_stdin_json() if input_data.get("stop_hook_active") is True: emit_json({ "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."}, }) return 0 if stated_but_unstarted(str(input_data.get("transcript_path") or "")): emit_json({ "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.", }) return 0 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 ...
펼쳐보기: ③ "말만 하고 안 함" 탐지 정규식 stated_but_unstarted
# /home/seunghyeong/harness-work/fable-ish/scripts/verify_state.py# 다음 행동을 *선언*하는 마무리 문장 — 한국어 먼저, 영어 다음._NEXT_ACTION_RE = re.compile( r"(?:이제|다음으로|곧|바로|먼저)\s*[^.!?]*?(?:구현|작성|추가|수정|진행|생성|실행|시작|반영|정리)\s*(?:하겠|할게|하려|예정)" r"|(?:구현|작성|추가|수정|진행|생성|실행|시작|반영|정리)\s*(?:하겠습니다|하겠어요|할게요|할\s*게)" r"|(?:i'?ll|i will|i'?m going to|let me|next,?\s*i)\s+(?:\w+\s+){0,6}?" r"(?:implement|build|add|write|create|fix|run|update|set\s*up|wire|refactor)", re.IGNORECASE,)# 결정을 사용자에게 넘기는 마무리 문장 — 그러면 멈춰도 됨._USER_DECISION_RE = re.compile( r"(?:할까요|하시겠|드릴까요|선택해|어느\s*쪽|원하시면)" r"|(?:shall\s*i|want me to|would you like|which\s*(?:option|one)|let me know)|\?\s*$", re.IGNORECASE,)def stated_but_unstarted(transcript_path: str) -> bool: if not transcript_path: return False blocks = _final_assistant_blocks(transcript_path) if not blocks: return False if any(isinstance(b, dict) and b.get("type") == "tool_use" for b in blocks): return False # tool_use가 하나라도 있으면 일을 한 것 → 통과 spoken = " ".join( b.get("text", "") for b in blocks if isinstance(b, dict) and b.get("type") == "text" ).strip() closing = _closing_line(spoken) # 마지막 문장만 추출 if not closing: return False return bool(_NEXT_ACTION_RE.search(closing) and not _USER_DECISION_RE.search(closing))