fable-ish의 후크들은 매번 따로 실행돼 서로 기억을 못 하므로, “이번 작업이 어떤 모드이고 무엇을 고쳤고 검증했는지”를 세션별 JSON 공책 하나에 계속 적어두는 단기 기억 장치가 증거 원장(ledger)이다.
왜 배우나: 종료 게이트가 “검증했나?”를 판단할 때 읽는 유일한 사실 출처라서, 이걸 모르면 fable-ish가 왜 멈추고 왜 통과시키는지 설명할 수 없다.
그림
flowchart TD
A["사용자 프롬프트 도착<br/>UserPromptSubmit"] -->|classify_prompt| B["원장 리셋·초기화<br/>모드·위험·목표 기록"]
B -->|이번 작업 규칙 주입| M((Claude 모델))
M -->|"Bash/Edit/Write 실행"| C["도구 실행 직후<br/>PostToolUse"]
C -->|parse_tool_result| D["원장에 증거 누적<br/>변경경로·검증결과·실패"]
D -->|실패 감지 시 경고 주입| M
M -->|턴 종료 시도| E["종료 게이트<br/>Stop gate"]
E -->|"load_ledger + should_block_stop"| F{"검증 충분?"}
F -- 아니오 --> G["종료 차단 + 사유 안내<br/>stop_blocks++ 저장"]
G --> M
F -- 예 / 2회 초과 --> H[종료 허용]
한 가지만 기억하세요
원장은 세 시점(프롬프트 수신 → 도구 실행 후 → 종료 시도)에서 읽고-고치고-다시 저장되는 작은 JSON 파일 하나입니다. 화살표는 “기록 → 누적 → 판단”의 한 사이클입니다.
쉽게 풀기
비유 — 교대 근무하는 경비원들의 공동 일지
후크 세 명은 손님이 들어올 때(프롬프트), 작업이 일어날 때(도구 실행), 문을 닫으려 할 때(종료) 각각 근무합니다. 문제는 셋이 서로 만나지 못한다는 점입니다 — 별개의 파이썬 프로세스라 머릿속 기억을 공유할 수 없습니다. 그래서 데스크에 공동 일지(원장) 한 권을 둡니다. 다음 경비원이 일지만 읽고 상황을 이어받고, 마지막 경비원(종료 게이트)은 “점검 기록이 없네 → 아직 문 닫으면 안 돼”라고 판단합니다.
flowchart LR
P1["경비원1<br/>프롬프트 수신"] -->|기록| L["(공동 일지<br/>원장 JSON)"]
P2["경비원2<br/>도구 실행"] -->|누적| L
L -->|읽고 판단| P3["경비원3<br/>종료 게이트"]
P3 -.점검 없으면 차단.-> P2
단계별 핵심
공책 위치 — 세션×작업폴더마다 다른 공책. 둘을 이어 붙여 SHA-256 해시를 만들고 앞 24자를 파일 이름으로 삼는다. 같은 채팅이라도 cwd가 다르면 일지가 분리된다.
적는 내용 — 작업 모드(quick/normal/deep/blocked), 목표 한 줄, 위험 태그, 고친 파일, 검증 명령과 결과, 실패 기록.
비밀 가리기 — API 키·비밀번호는 적기 전에 [REDACTED]로 마스킹하고 한 줄로 평탄화.
안전 저장 — 임시 페이지에 다 쓴 뒤 한 번에 갈아끼운다(원자적 교체). 일지가 깨졌으면 멈추지 말고 새 빈 공책으로 시작(fail-open).
길이 자르기 — 목록·기록에 상한을 둬 오래된 것 또는 머리를 잘라낸다.
핵심: 원장은 “AI가 정말 검증했는지”를 증명하는 누적 증거 공책이고, 후크들은 이 공책에만 사실을 적고 이 공책만 보고 판단한다.
핵심 정리
원장이 사는 곳과 이름 규칙:
요소
값
데이터 루트
$CLAUDE_PLUGIN_DATA/$PLUGIN_DATA, 없으면 /tmp/fable-ish (.resolve()로 절대화)
파일 경로
<루트>/ledgers/<ledger_key>.json (세션×cwd당 1개)
ledger_key
`sha256(“session_id
격리 키의 핵심
session_id 와 cwd 를 |로 이어 SHA-256 → 앞 24자. 같은 세션이라도 작업 디렉터리가 다르면 원장이 자동으로 분리됩니다.
원장의 주요 필드(전부 필수, 분류기·후크가 채움). 연관도(coverage_relation) 등급은 none < uncertain < generic < direct(오른쪽일수록 “이 검증이 바로 그 변경을 확인했다”에 가까움):
# /home/seunghyeong/harness-work/fable-ish/scripts/ledger.pySECRET_PATTERNS = [ re.compile(r"(?i)(api[_-]?key|token|secret|password)\s*[:=]\s*['\"]?[^'\"\s]+"), re.compile(r"sk-[A-Za-z0-9_-]{12,}"), # OpenAI류 키 re.compile(r"gh[pousr]_[A-Za-z0-9_]{12,}"), # GitHub 토큰 re.compile(r"xox[baprs]-[A-Za-z0-9-]{12,}"), # Slack 토큰]def redact(text: Any, limit: int = 500) -> str: value = "" if text is None else str(text) value = value.replace("\r", " ").replace("\n", " ").strip() # 한 줄로 평탄화 for pattern in SECRET_PATTERNS: value = pattern.sub("[REDACTED]", value) if len(value) > limit: return value[: limit - 3] + "..." return value
펼쳐보기: 손상 시 fail-open + 트림 상한
# /home/seunghyeong/harness-work/fable-ish/scripts/ledger.pydef load_ledger(input_data: dict[str, Any]) -> dict[str, Any]: path = ledger_path(input_data) if not path.exists(): return default_ledger() try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): data = default_ledger() # 깨지면 새 원장으로 시작(fail-open) data["failures"].append({ "kind": "ledger", "summary": "Ledger could not be read; continuing with a fresh ledger.", "baseline": "uncertain", }) return data ledger = default_ledger() if isinstance(data, dict): # 알려진 키만 흡수(스키마 강제) ledger.update({k: data.get(k, v) for k, v in ledger.items()}) for key in ("risk_flags", "changed_paths", "change_kinds", "verification_commands", "verification_results", "failures"): if not isinstance(ledger.get(key), list): # 타입 방어 ledger[key] = [] if ledger.get("coverage_relation") not in {"direct", "generic", "uncertain", "none"}: ledger["coverage_relation"] = "none" return ledgerdef trim_ledger(ledger: dict[str, Any]) -> None: for key in ("risk_flags", "changed_paths", "change_kinds"): # 고유화 후 머리 자름 values = [] for value in ledger.get(key, []): if value not in values: values.append(value) ledger[key] = values[:40 if key == "changed_paths" else 20] for key in ("verification_commands", "verification_results", "failures"): ledger[key] = ledger.get(key, [])[-40:] # 최근 40개만 보존
펼쳐보기: 직접 만들 때 — 재구현 최소 골격(ledger_min.py)
# ledger_min.py — 재구현 최소 골격import copy, hashlib, json, os, re, tempfilefrom datetime import datetime, timezonefrom pathlib import PathDEFAULT = {"task_mode": "quick", "changed_paths": [], "verification_results": [], "coverage_relation": "none", "failures": [], "stop_blocks": 0, "last_updated": ""}SECRETS = [re.compile(r"(?i)(api[_-]?key|token|secret|password)\s*[:=]\s*['\"]?[^'\"\s]+"), re.compile(r"sk-[A-Za-z0-9_-]{12,}")]def redact(t, limit=500): v = ("" if t is None else str(t)).replace("\n", " ").strip() for p in SECRETS: v = p.sub("[REDACTED]", v) return v if len(v) <= limit else v[:limit-3] + "..."def key(inp): raw = f'{inp.get("session_id") or "no-session"}|{inp.get("cwd") or os.getcwd()}' return hashlib.sha256(raw.encode("utf-8", "replace")).hexdigest()[:24]def path(inp): base = Path(os.environ.get("PLUGIN_DATA") or Path(tempfile.gettempdir()) / "myapp").resolve() return base / "ledgers" / f"{key(inp)}.json"def load(inp): p = path(inp) if not p.exists(): return copy.deepcopy(DEFAULT) try: data = json.loads(p.read_text("utf-8")) except (OSError, json.JSONDecodeError): d = copy.deepcopy(DEFAULT); d["failures"].append({"kind": "ledger", "summary": "fresh"}); return d d = copy.deepcopy(DEFAULT) if isinstance(data, dict): d.update({k: data.get(k, v) for k, v in d.items()}) return ddef save(inp, ledger): p = path(inp); p.parent.mkdir(parents=True, exist_ok=True) ledger["last_updated"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat() tmp = p.with_suffix(".tmp") tmp.write_text(json.dumps(ledger, indent=2, sort_keys=True), "utf-8") tmp.replace(p) # 원자적 교체
펼쳐보기: 직접 만들 때 체크리스트(8항목)
격리 키 = sha256(session_id|cwd)[:24], 누락 시 "no-session"/os.getcwd() 폴백.
저장 루트는 환경변수 우선(CLAUDE_PLUGIN_DATA/PLUGIN_DATA), 없으면 OS 임시폴더 하위. .resolve()로 절대화.
UserPromptSubmit (user_prompt_submit.py) — classify_prompt로 모드·위험·목표 산출 → 원장을 리셋 후 새 작업으로 초기화(changed/verification/failures/stop_blocks 비움) → context_for_mode를 additionalContext로 주입. 단, 프롬프트가 fable-ish: run/add/resolve로 시작하는 종료게이트 재투입이면 리셋하지 않고 현재 모드만 재안내.
PostToolUse (post_tool_use.py, 매처 ^(Bash|Edit|Write|MultiEdit|NotebookEdit)$) — 도구 입출력을 parse_tool_result.py로 파싱 → 변경 경로·종류·검증 레코드·실패 추출 → update_ledger로 누적(append + coverage_relation 최고값 갱신). 도구 실패 시 “완료 보고 말고 고쳐라” 주입.
Stop (stop_gate.py) — 종료 시도 시 load_ledger로 읽어 should_block_stop 판정. 미검증/위험 미해결이면 {"decision":"block","reason":...}로 종료를 막고stop_blocks++(최대 2회). “말만 하고 안 한”(stated_but_unstarted) 응답도 transcript 역방향 스캔으로 차단.
펼쳐보기: 종료 차단 판정 로직(verify_state.py)
quick 모드 또는 문서만 변경(docs_only)이면 절대 막지 않음.
blocked 모드면 무조건 막음(위험 경계 확인 요구).
deep + 성공 검증 없음 → 변경 있으면 “가장 좁은 검증 명령 실행”, 검증이 아예 없으면 “관찰 가능한 증거 1개 추가 or 검증 불가 사유 기록”.
normal + 변경 있음 + 성공 검증 없음 → “관련 검증 명령 1개 실행 or 불가 사유 기술”.
stop_blocks >= 2면 더 막지 않고 통과(루프 방지), 대신 검증 누락 보고 경고.
요약 & 셀프체크
3줄 요약:
증거 원장은 세션×cwd 해시를 파일명으로 한 단일 JSON 공책으로, 후크들이 공유하는 단기 기억이다.
쓰기는 항상 redact → trim → tmp.replace 원자 저장, 읽기 실패는 빈 원장으로 fail-open한다.
종료 게이트가 이 원장을 읽어 “검증 충분?”을 판단하므로, 원장은 검증 게이트의 유일한 사실 출처다.
스스로 답해보기:
같은 채팅 세션인데 작업 폴더(cwd)를 옮기면 원장은 같은 파일을 쓸까, 다른 파일을 쓸까? 그 이유는?
일지(원장) 파일이 깨져 있으면 fable-ish는 멈출까, 계속 갈까? 그 동작을 뭐라 부르나?
verification_results와 verification_commands는 왜 머리가 아니라 꼬리에서 잘릴까?