fable-ish · 도구 결과 파싱과 증거 추출

한 줄 요약

fable-ish는 도구를 만들지 않고, AI가 이미 실행한 도구의 입출력을 엿보고 “사실”로 받아쓰는 속기사다 — 왜 배우나: AI가 “테스트 통과했어요”라고 말로만 하는 걸 막고, 진짜 물증이 있어야 일을 끝낼 수 있게 만드는 검증의 출발점이기 때문이다.

그림

flowchart TD
  A["모델이 Bash·Edit·Write 실행"] --> B["PostToolUse 훅 발화<br/>hooks.json matcher"]
  B --> C["post_tool_use.py<br/>stdin JSON 수신"]
  C --> D["무엇이 바뀌었나<br/>changed_paths · changed_kinds"]
  C --> E["검증 명령인가 + 성공인가<br/>verification_record"]
  C --> F["실패 사실인가<br/>detect_failure"]
  D & E & F --> G["원장에 기록<br/>임시파일→원자적 교체"]
  E --> H["커버리지 단조 상승<br/>none<uncertain<generic<direct"]
  F -->|실패 발견| I["즉시 주입<br/>'다 됐다고 보고하지 마'"]
  G --> J["(JSON 원장<br/>세션+작업폴더 해시 키)"]
  J --> K["종료 시도 시 Stop 훅<br/>stop_gate.py"]
  K --> L["검증 미달이면 종료 차단<br/>decision=block"]

쉽게 풀기

공장을 떠올려 보자. 도구 시스템(Bash·Edit·Write 등)은 물건을 찍어내는 기계다. fable-ish는 기계를 만들지도, 직접 돌리지도 않는다. 대신 기계 옆에 서서 작업일지를 손으로 받아쓰는 속기사 역할을 한다. “방금 테스트를 돌렸고 통과했다”, “이 파일을 고쳤다”, “이 명령은 실패했다” 같은 일들을 작은 공책(원장, ledger)에 또박또박 적는다.

속기사가 매번 던지는 질문은 딱 네 가지다.

  1. 이게 검증하는 명령인가?pytest, lint, tsc, build, curl 같은 단어가 명령에 있으면 “아, 이건 점검하는 작업이구나” 하고 인정한다.
  2. 성공했나 실패했나? — 가장 믿을 만한 신호는 종료 코드(exit_code)다. 0이면 성공이다. 종료 코드가 없으면 출력 텍스트에서 “passed”, “traceback” 같은 단서로 추측한다.
  3. 무엇이 바뀌었나? — 고쳐진 파일 경로를 보고 문서인지(docs), 코드인지(code), 설정인지(config)를 분류한다.
  4. 그 검증이 바뀐 파일을 진짜 건드렸나? — 이게 가장 영리한 부분이다. “테스트를 돌리긴 했는데, 방금 고친 파일을 실제로 검사한 게 맞아?”를 4단계로 따진다.

비유: 시험을 봤다 vs 그 과목 시험을 봤다

학생이 “시험 봤어요”라고 말해도, 정작 오늘 배운 단원을 시험 본 게 아니면 의미가 약하다. fable-ish의 커버리지 4단계가 바로 이걸 구분한다. direct는 “고친 파일 이름이 명령에 그대로 들어있다(=그 단원을 콕 집어 시험봤다)”, generic은 “검증성 명령이긴 한데 고친 파일과 연결됐는지는 모름”, uncertain은 “검증으로 인정은 됐지만 그마저도 애매”, none은 “검증 자체가 없음”이다.

이렇게 모은 사실들은 평소엔 조용히 공책에만 쌓인다. 그러다 두 순간에 힘을 발휘한다. (1) 실패를 발견한 그 순간 곧바로 “고치기 전엔 완료 보고 금지”를 AI에게 속삭이거나(즉시 피드백), (2) AI가 일을 끝내려 할 때 공책을 펴서 “아직 검증이 부족한데?” 하며 종료를 막는다(지연 게이트).

핵심 정리

판단무엇을 보나결과
검증 명령?VERIFY_RE 정규식 매칭인정 / 무시
성공/실패?exit_code 우선, 텍스트 폴백True / False / None
무엇이 바뀜?파일 경로 분류docs·code·config·assets·other
검증 커버리지?변경 경로 ↔ 명령 매칭direct > generic > uncertain > none

성공/실패 판정의 우선순위 (반드시 이 순서)

  • 1순위 — success/ok 불리언이 있으면 그대로 채택
  • 2순위 — exit_code/exitCode/returncode/status 정수, 0이면 성공
  • 3순위 — 출력 텍스트에 실패 단서(FAILURE_RE)가 있으면 False
  • 4순위 — 성공 단서(SUCCESS_RE)가 있으면 True
  • 그래도 모르면 None(판정 불가)

입력으로 받는 핵심 필드 (Claude Code PostToolUse 페이로드)

  • tool_name — 도구 이름. ^(Bash|Edit|Write|MultiEdit|NotebookEdit)$ matcher로만 들어옴
  • tool_input.command — Bash 명령 원문. 검증/변이 판정의 1차 소스
  • tool_input.file_path — Edit/Write 대상 경로 → 변경 분류 입력
  • tool_response — 실행 결과(dict·list·str). 중첩된 stdout/stderr가 여기 들어있음
  • tool_response.exit_code(또는 exitCode/returncode/status) — 성공/실패의 최우선 신호
  • session_id + cwd — 원장 키 산출(sha256(session_id|cwd)[:24])

원장(ledger)에 쌓이는 사실 (DEFAULT_LEDGER 기준)

  • changed_paths — 변경된 파일 경로(최대 40, 중복 제거)
  • change_kinds — docs/code/config/assets/other 정렬·중복제거(최대 20)
  • verification_commands — 검증으로 인정된 명령 원문(최근 40)
  • verification_results — 검증 레코드 배열(최근 40): {command, success, summary, coverage_relation}
  • coverage_relationnone<uncertain<generic<direct세션 최고치만 단조 상승
  • failures — 실패 사실(최근 40): {kind, summary, baseline}

실제 예시

검증 명령을 식별하는 정규식 — 어떤 명령을 “점검 작업”으로 인정할지 결정한다.

# /home/seunghyeong/harness-work/fable-ish/scripts/parse_tool_result.py
VERIFY_RE = re.compile(
    r"(?i)\b("
    r"pytest|unittest|go\s+test|cargo\s+test|npm\s+test|pnpm\s+test|yarn\s+test|bun\s+test|"
    r"mvn\s+test|gradle\s+test|rspec|vitest|jest|playwright|cypress|"
    r"lint|eslint|ruff|flake8|mypy|pyright|tsc|typecheck|"
    r"build|check|validate|verify|json\.tool|py_compile|curl"
    r")\b"
)
FAILURE_RE = re.compile(
    r"(?i)(command not found|no such file or directory|traceback|syntaxerror|failed|failure|"
    r"\berror:|\b[1-9][0-9]*\s+errors?\b|exit code [1-9]|exited with code [1-9]|"
    r"tests? failed|build failed|lint failed)"
)
SUCCESS_RE = re.compile(r"(?i)\b(passed|success|succeeded|0 failed|build completed|done|valid)\b")

성공/실패 판정 — exit_code를 먼저 믿고, 없을 때만 텍스트로 추측한다.

# /home/seunghyeong/harness-work/fable-ish/scripts/parse_tool_result.py
def exit_success(input_data: dict[str, Any], text: str) -> bool | None:
    candidates = [input_data, input_data.get("tool_response")]
    for candidate in candidates:
        if isinstance(candidate, dict):
            for key in ("success", "ok"):                       # 1순위: 불리언
                if isinstance(candidate.get(key), bool):
                    return bool(candidate[key])
            for key in ("exit_code", "exitCode", "returncode", "status"):  # 2순위: 종료코드
                value = candidate.get(key)
                if isinstance(value, int):
                    return value == 0
                if isinstance(value, str) and value.isdigit():
                    return int(value) == 0
    if FAILURE_RE.search(text):   # 3순위: 출력 텍스트 정규식
        return False
    if SUCCESS_RE.search(text):
        return True
    return None                   # 판정 불가

커버리지 4단계 — “그 검증이 진짜 바뀐 파일을 건드렸나”를 등급으로 매긴다.

# /home/seunghyeong/harness-work/fable-ish/scripts/parse_tool_result.py
def verification_coverage(command: str, changed: list[str]) -> str:
    if not command:
        return "none"
    clean_paths = [path.strip() for path in changed if path and path not in {"patch", "edit"}]
    if clean_paths and any(path in command for path in clean_paths):
        return "direct"   # 변경된 실제 파일 경로가 명령 문자열 안에 그대로 들어있음
    if DIRECT_TEST_RE.search(command) and re.search(r"(?i)(test|spec|__tests__)", command):
        return "direct"   # 진짜 테스트 러너 + test/spec 토큰 → 직접 검증으로 간주
    if re.search(r"(?i)\b(test|lint|typecheck|tsc|build|check|validate|verify)\b", command):
        return "generic"  # 검증성 명령이긴 한데 변경 파일과의 연결은 불명
    return "uncertain"    # 검증 명령으로 인정됐으나 위 어느 것도 아님

중첩된 출력 파헤치기 — stdout/stderr가 깊이 묻혀 있어도 알려진 키부터 찾아 끌어낸다.

# /home/seunghyeong/harness-work/fable-ish/scripts/parse_tool_result.py
def response_text(value: Any, limit: int = 4000) -> str:
    parts: list[str] = []
    def walk(item: Any) -> None:
        if len(" ".join(parts)) > limit:
            return
        if isinstance(item, str):
            parts.append(item)
        elif isinstance(item, dict):
            for key in ("stdout", "stderr", "output", "message", "text", "content", "error", "summary"):
                if key in item:
                    walk(item[key])
            if not parts:                       # 알려진 키가 하나도 없을 때만
                for child in item.values():     # 모든 값으로 폴백 재귀
                    walk(child)
        elif isinstance(item, list):
            for child in item[:20]:             # 리스트는 앞 20개만
                walk(child)
    walk(value)
    return redact(" ".join(parts), limit)

직접 만들 때 — 최소 PostToolUse 훅 골격 (파싱 → 원장 한 줄 추가):

#!/usr/bin/env python3
import json, sys, re
 
VERIFY_RE = re.compile(r"(?i)\b(pytest|jest|tsc|eslint|ruff|build|curl)\b")
FAIL_RE   = re.compile(r"(?i)(traceback|failed|\berror:|exit code [1-9])")
 
def response_text(v, parts=None):
    parts = parts if parts is not None else []
    if isinstance(v, str): parts.append(v)
    elif isinstance(v, dict):
        for k in ("stdout","stderr","output","message","text","error"):
            if k in v: response_text(v[k], parts)
    elif isinstance(v, list):
        for c in v[:20]: response_text(c, parts)
    return " ".join(parts)
 
data = json.load(sys.stdin)
cmd  = (data.get("tool_input") or {}).get("command", "") if isinstance(data.get("tool_input"), dict) else ""
text = response_text(data.get("tool_response", data))
 
# exit_code 우선, 텍스트 폴백
ec = (data.get("tool_response") or {}).get("exit_code")
success = (ec == 0) if isinstance(ec, int) else (False if FAIL_RE.search(text) else None)
 
if cmd and VERIFY_RE.search(cmd):
    # append {"command":cmd, "success":success} to your ledger here
    pass
 
if success is False:
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PostToolUse",
        "additionalContext": "A tool failure was observed. Do not claim completion until fixed."}}))
else:
    print(json.dumps({}))

만들 때 빠뜨리기 쉬운 체크리스트

  • hooks.json의 PostToolUse matcher를 변이 도구(Bash|Edit|Write|...)로 한정했나
  • 성공 판정 순서를 bool → exit_code → 텍스트 → None으로 지켰나(텍스트는 마지막 폴백)
  • response_text가 알려진 키 우선·없을 때만 전체 폴백·리스트 앞 20개·길이 절단인가
  • 커버리지 4단계로 분리하고, 원장에는 최고치만 단조 상승시키나
  • 비밀값 redact + 줄바꿈 제거 + 길이 절단을 모든 요약에 적용했나
  • 원장 쓰기를 임시파일→replace원자적으로, 키는 session+cwd 해시인가
  • 훅은 예외 시 fail-open(에러 나도 exit 0)인가 — 작업 흐름을 막지 않도록
  • Stop 게이트에 MAX_STOP_BLOCKS 같은 무한루프 차단 장치가 있나

요약 & 셀프체크

  • fable-ish는 도구를 만들지 않고, AI가 실행한 도구의 입출력을 엿봐 검증명령·성공실패·변경·커버리지 4종 사실로 원장에 적는 속기사다.
  • 성공 판정은 exit_code를 최우선으로 믿고 텍스트는 마지막 폴백이며, 커버리지는 none<uncertain<generic<direct 4단계로 “검증이 바뀐 파일을 진짜 건드렸나”를 따진다.
  • 모은 사실은 즉시 피드백(실패 시 “완료 보고 금지” 주입)과 지연 게이트(Stop 훅이 검증 미달이면 종료 차단)로 쓰인다.

셀프체크:

  1. AI가 pytest를 돌렸는데 방금 고친 파일 이름이 명령에 안 들어 있다면, 커버리지는 어떤 등급이 될까? (힌트: direct가 되려면 두 조건 중 하나)
  2. exit_code가 없고 출력에 “passed”도 “traceback”도 없으면 성공 판정은 무엇으로 끝날까?
  3. 실패가 발견된 순간과 AI가 일을 끝내려는 순간, 사실이 쓰이는 방식이 어떻게 다른가?

연결