지식위키

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")항상 차단
5deep, 변경했는데 미검증차단
6deep, 변경 없고 검증기록 전무차단
7normal, 변경했는데 미검증차단
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_only
  • verification_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줄 요약

  1. 멈추려는 순간 장부를 펴서 “변경했는데 미검증”이면 차단하고, 모델을 다시 깨워 검증 명령을 돌리게 한다.
  2. 무한 잔소리를 막으려 최대 2번만 막고(MAX_STOP_BLOCKS), 자기 재호출은 stop_hook_active로 즉시 통과, 깨져도 fail-open으로 멈춤 허용.
  3. “말만 하고 손 안 댄” 턴은 정규식으로 잡되, 사용자에게 결정을 넘기는 질문으로 끝나면 봐준다.

스스로 답해 보기

  • Q1. quick 모드에서 코드를 고치고 검증을 안 했다면 게이트는 막을까? (힌트: 규칙 평가 순서)
  • Q2. 검증 명령을 5번 실행했지만 전부 success: False였다면 “검증됨”으로 인정될까?
  • Q3. 재진입 가드(stop_hook_active)를 빼면 어떤 사고가 나는가?

[!tip] Codex 교차검증 (보존)

  • “검증됨”은 verification_resultssuccess 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