fable-ish · 완료 검증 게이트 (Stop 차단 메커니즘)
fable-ish · 완료 검증 게이트 (Stop 차단 메커니즘)
한 줄 요약
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/>판정만"]
[!note] 비유 한 줄 장부 = 영수증 모음, 게이트 = 출국 검색대, 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 | 그 외 전부 | 통과 |
[!note] 판정의 두 함정
- “검증됨” 기준:
verification_results안에success is True인 원소가 하나라도 있어야 인정(부분 통과 허용). 명령 실행만으로는 부족.- 우선순위: quick / docs_only / 2회 초과는 어떤 모드 규칙보다 먼저 평가되어 통과한다.
[!note]- 펼쳐보기: 게이트가 읽는 장부 필드 + 출력 계약 읽는 장부 필드
task_mode— 작업 등급(quick/normal/deep/blocked). 없으면"quick"폴백stop_blocks— 누적 차단 횟수. 2 도달 시 통과changed_files_seen— 파일 변경 관측 여부(bool)change_kinds— 변경 분류 집합(docs/code/config/assets/other).{"docs"}뿐이면 docs_onlyverification_results— 검증 실행 기록.success is True가 있으면 “검증됨”verification_commands— 실행된 검증 명령 목록(has_any_verification판정용)coverage_relation/failures— 보고용 참고값(판정엔 직접 미사용)출력 계약 (stdout 1줄 JSON)
필드 언제 설명 decision: "block"차단할 때만 있으면 Claude Code가 멈춤을 취소하고 턴 재개 reasonblock과 동반 모델에게 다시 보일 지시문(모드별 상이) 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 또는 통과]
[!note]- 펼쳐보기: ① 모드별 차단 규칙
should_block_stop(게이트의 두뇌)# /home/seunghyeong/harness-work/fable-ish/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; 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, ""
[!note]- 펼쳐보기: ② Stop 훅 엔트리포인트
stop_gate.py# /home/seunghyeong/harness-work/fable-ish/hooks/stop_gate.py def 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 ...
[!note]- 펼쳐보기: ③ “말만 하고 안 함” 탐지 정규식
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))
[!note]- 펼쳐보기: ④ 직접 만들 때 — 최소 Stop 훅 등록
hooks.json{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/stop_gate.py\"", "timeout": 10, "statusMessage": "Reviewing completion" } ] } ] } }
[!note]- 펼쳐보기: ⑤ 직접 만들 때 — 최소 게이트 본문(복붙용 골격)
#!/usr/bin/env python3 import json, sys MAX_STOP_BLOCKS = 2 def main(): data = json.loads(sys.stdin.read() or "{}") # 1) 재진입 가드 — 반드시 가장 먼저 if data.get("stop_hook_active") is True: print(json.dumps({"systemMessage": "already active; allow stop"})) return 0 ledger = load_ledger(data) # 형제 훅이 채워둔 상태 stop_blocks = int(ledger.get("stop_blocks") or 0) # 2) 무한루프 방지 — 임계 도달이면 무조건 통과 if stop_blocks >= MAX_STOP_BLOCKS: print(json.dumps({"systemMessage": "verification gap remains; report it"})) return 0 # 3) 모드별 차단 판정 mode = ledger.get("task_mode") or "quick" changed = bool(ledger.get("changed_files_seen")) verified = any(r.get("success") is True for r in ledger.get("verification_results", [])) block, reason = False, "" if mode == "blocked": block, reason = True, "resolve the blocked risk first." elif mode in ("normal", "deep") and changed and not verified: block, reason = True, "run one verification command before finishing." # 4) 출력 계약 — decision:block + reason 이어야 재개됨 if block: ledger["stop_blocks"] = stop_blocks + 1 save_ledger(data, ledger) print(json.dumps({"decision": "block", "reason": reason}, ensure_ascii=True)) else: print(json.dumps({})) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: # 5) fail-open print(json.dumps({"systemMessage": f"gate failed open: {exc}"})) raise SystemExit(0)
[!note]- 펼쳐보기: ⑥ 구현 체크리스트(전체)
Stop훅으로 등록하고timeout을 짧게(10초) 둘 것.- 재진입 가드(
stop_hook_active)를 판정 맨 앞에 — 빠지면 무한 차단 위험.- 차단할 때마다 카운터를 저장(persist),
MAX_STOP_BLOCKS도달 시 무조건 통과.- 통과·우선 규칙(quick / docs_only / 카운터초과)을 모드 규칙보다 먼저 평가.
verified는 “성공(success is True)이 하나라도” 기준 — 명령 실행만으론 불충분.- 출력은 stdout 1줄 JSON, 차단은 정확히
{"decision":"block","reason":...}형태.- 최상위에서 예외를 삼키고 exit 0(fail-open) — 모델을 가두지 말 것.
- (선택) “말만 하고 끝낸 턴” 탐지: 마지막 assistant 턴 역순 스캔 →
tool_use있으면 통과, 없고 다음행동 선언이며 사용자에게 결정을 안 넘겼으면 차단.- 정규식은 한/영 둘 다, 마지막 문장만 검사(오탐 축소), 질문(
?$)·“~할까요/shall I”는 면제.
[!note] AI에 어떻게 연결되나
- 트리거:
Stop이벤트 훅으로 등록, 턴 마치려는 순간 자동 실행(timeout: 10초). 입력은 stdin JSON 한 덩어리(stop_hook_active,transcript_path,session_id,cwd).- 소비: stdout에
{"decision":"block","reason":...}를 내면 멈춤을 취소하고reason을 모델에게 다시 보여 턴을 재개. 아니면 빈{}나systemMessage만 내 멈춤 허용.- 장부 출처: 게이트는 코드를 직접 분석하지 않는다.
UserPromptSubmit훅이task_mode를 정하고 장부를 리셋(stop_blocks=0),PostToolUse훅이 변경/검증 증거를 채운다.- fail-open: 모든 훅의
__main__은 예외를 잡아systemMessage만 내고SystemExit(0)로 종료 — 게이트가 깨져도 모델을 영구히 가두지 않는다.
요약 & 셀프체크
3줄 요약
- 멈추려는 순간 장부를 펴서 “변경했는데 미검증”이면 차단하고, 모델을 다시 깨워 검증 명령을 돌리게 한다.
- 무한 잔소리를 막으려 최대 2번만 막고(
MAX_STOP_BLOCKS), 자기 재호출은stop_hook_active로 즉시 통과, 깨져도 fail-open으로 멈춤 허용. - “말만 하고 손 안 댄” 턴은 정규식으로 잡되, 사용자에게 결정을 넘기는 질문으로 끝나면 봐준다.
스스로 답해 보기
- Q1. quick 모드에서 코드를 고치고 검증을 안 했다면 게이트는 막을까? (힌트: 규칙 평가 순서)
- Q2. 검증 명령을 5번 실행했지만 전부
success: False였다면 “검증됨”으로 인정될까? - Q3. 재진입 가드(
stop_hook_active)를 빼면 어떤 사고가 나는가?
[!tip] Codex 교차검증 (보존)
- “검증됨”은
verification_results내success is True원소가 하나라도 있으면 참 — 부분 통과 허용(전건 통과 요구 아님).- 통과 우선 규칙(
stop_blocks>=2/ quick / docs_only)이 모드별 차단보다 먼저 평가되는 순서가 동작의 핵심이며, 순서가 바뀌면 quick 작업도 붙잡히는 회귀가 난다.- fail-open(
SystemExit(0))과 재진입 가드는 “게이트가 모델을 영구히 가두지 않는다”는 불변식을 떠받치는 두 축이다.
연결
FB_개요 · _분석축_루브릭 · FB_50_evidence-ledger-state · FB_60_evidence-extraction-from-tools · FB_40_task-classification-engine · FB_20_hook-event-loop