fable-ish · 증거 원장(ledger) 상태 영속성
fable-ish · 증거 원장(ledger) 상태 영속성
한 줄 요약
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[종료 허용]
[!note] 한 가지만 기억하세요 원장은 세 시점(프롬프트 수신 → 도구 실행 후 → 종료 시도)에서 읽고-고치고-다시 저장되는 작은 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 |
[!note] 격리 키의 핵심 session_id 와 cwd 를
|로 이어 SHA-256 → 앞 24자. 같은 세션이라도 작업 디렉터리가 다르면 원장이 자동으로 분리됩니다.
원장의 주요 필드(전부 필수, 분류기·후크가 채움). 연관도(coverage_relation) 등급은 none < uncertain < generic < direct(오른쪽일수록 “이 검증이 바로 그 변경을 확인했다”에 가까움):
| 필드 | 뜻 | 기본값 |
|---|---|---|
task_mode | quick/normal/deep/blocked | "quick" |
goal | 목표 한 줄(180자, redact) | "" |
risk_flags | 위험 태그 | [] |
changed_paths | 변경 경로(고유, 상한 40) | [] |
verification_results | 검증 레코드 배열(최근 40) | [] |
coverage_relation | 검증↔변경 연관 최고값 | "none" |
failures | 실패 레코드(최근 40) | [] |
stop_blocks | 종료 막은 횟수(최대 2) | 0 |
[!note]- 펼쳐보기: 전체 필드표 · 배열 원소 모양 · 트림 규칙 전체 필드(12개):
필드 뜻 기본값 task_modequick/normal/deep/blocked "quick"goal목표 한 줄(180자, redact) ""risk_flags위험 태그 목록 []changed_files_seen편집 도구 관찰 여부 falsechanged_paths변경 경로(고유, 상한 40) []change_kindscode/docs/config/assets/other []verification_commands검증 명령(최근 40) []verification_results검증 레코드 배열(최근 40) []coverage_relation검증↔변경 연관 최고값 "none"failures실패 레코드(최근 40) []stop_blocks종료 막은 횟수(최대 2) 0last_updated저장 시각 ISO8601 UTC ""배열 원소 모양:
verification_results[]=command(redact, 220자) +success(true/false/null) +summary(redact, 220자) +coverage_relation(post_tool_use에서 변경경로 기준 재계산)failures[]=kind("tool-result"도구실패 /"ledger"원장손상) +summary(redact, 240자) +baseline("uncertain"= 기존결함/신규 미확정)트림 규칙:
changed_paths40 /change_kinds·risk_flags20 → 고유화 후 머리(앞) 자름verification_commands·verification_results·failures각 40 → 최근성 보존, 꼬리(뒤) 자름
실제 예시
핵심 골격은 세 가지뿐: 세션×cwd 해시 파일명 → tmp.replace 원자 교체 → 읽기 실패 시 빈 원장 fail-open. 저장 전 모든 문자열은 redact를 거쳐 (1)개행 제거 (2)비밀 4종 마스킹 (3)길이 절단됩니다.
flowchart LR IN[입력 문자열] -->|redact| R["비밀 마스킹·평탄화·절단"] R -->|update_ledger| T[trim 상한 적용] T -->|tmp.write_text| TMP[.tmp 임시파일] TMP -->|tmp.replace| FIN["최종 .json<br/>원자적 교체"] RD[load_ledger] -->|JSON 깨짐| FO["빈 원장 + failures 기록<br/>fail-open"]
[!note]- 펼쳐보기: 스키마·격리·원자적 쓰기 (실제 소스 ledger.py)
# /home/seunghyeong/harness-work/fable-ish/scripts/ledger.py DEFAULT_LEDGER: dict[str, Any] = { "task_mode": "quick", "goal": "", "risk_flags": [], "changed_files_seen": False, "changed_paths": [], "change_kinds": [], "verification_commands": [], "verification_results": [], "coverage_relation": "none", "failures": [], "stop_blocks": 0, "last_updated": "", } def data_root() -> Path: env_data = os.environ.get("CLAUDE_PLUGIN_DATA") or os.environ.get("PLUGIN_DATA") base = Path(env_data).expanduser() if env_data else Path(tempfile.gettempdir()) / "fable-ish" return base.resolve() def ledger_key(input_data: dict[str, Any]) -> str: cwd = input_data.get("cwd") or os.getcwd() session_id = input_data.get("session_id") or "no-session" raw = f"{session_id}|{cwd}" return hashlib.sha256(raw.encode("utf-8", "replace")).hexdigest()[:24] def ledger_path(input_data: dict[str, Any]) -> Path: return data_root() / "ledgers" / f"{ledger_key(input_data)}.json" def save_ledger(input_data: dict[str, Any], ledger: dict[str, Any]) -> Path: path = ledger_path(input_data) path.parent.mkdir(parents=True, exist_ok=True) ledger["last_updated"] = utc_now() tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(ledger, indent=2, sort_keys=True), encoding="utf-8") tmp.replace(path) # 원자적 교체: tmp -> 최종경로 return path
[!note]- 펼쳐보기: 비밀정보 마스킹(redact) — 4종 정규식
# /home/seunghyeong/harness-work/fable-ish/scripts/ledger.py SECRET_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
[!note]- 펼쳐보기: 손상 시 fail-open + 트림 상한
# /home/seunghyeong/harness-work/fable-ish/scripts/ledger.py def 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 ledger def 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개만 보존
[!note]- 펼쳐보기: 직접 만들 때 — 재구현 최소 골격(ledger_min.py)
# ledger_min.py — 재구현 최소 골격 import copy, hashlib, json, os, re, tempfile from datetime import datetime, timezone from pathlib import Path DEFAULT = {"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 d def 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) # 원자적 교체
[!note]- 펼쳐보기: 직접 만들 때 체크리스트(8항목)
- 격리 키 =
sha256(session_id|cwd)[:24], 누락 시"no-session"/os.getcwd()폴백.- 저장 루트는 환경변수 우선(
CLAUDE_PLUGIN_DATA/PLUGIN_DATA), 없으면 OS 임시폴더 하위..resolve()로 절대화.- 쓰기는 반드시
tmp.write_text → tmp.replace(path)(부분쓰기/경쟁조건 방지).json.dumps(..., indent=2, sort_keys=True)로 결정적 출력.- 읽기 실패(OSError/JSONDecodeError)는 예외 없이 빈 원장 +
failures에kind:"ledger"= fail-open. 후크 전체도 try/except로 감싸failed open만 내고 exit 0.- 로드 시 DEFAULT 위에 알려진 키만 덮어쓰기(스키마 강제) + 리스트/enum 타입 방어.
- 저장 전 모든 사용자/명령/출력 문자열은
redact().- 리스트는 trim 상한(누적/고유: 머리 자르기, 최근성: 꼬리 자르기).
last_updated는 저장 시점 자동 갱신. 쓰기 패턴은update_ledger(inp, lambda l: ...)처럼 load→mutate→trim→save 한 함수로 묶기.
원장이 AI 행동에 들어가는 경로
원장 자체는 LLM 프롬프트에 통째로 주입되지 않습니다. 원장은 후크가 읽고 쓰는 내부 상태이고, 모델은 후크가 원장을 근거로 만든 **짧은 자연어 메시지(additionalContext / block reason)**만 소비합니다.
flowchart TD
U[UserPromptSubmit] -->|classify_prompt| RST["원장 리셋·초기화"]
RST -->|context_for_mode| MSG1["additionalContext<br/>모드·위험·검증규칙"]
PT["PostToolUse<br/>Bash·Edit·Write"] -->|parse_tool_result| ACC[update_ledger 누적]
ACC -->|도구 실패 시| MSG2[고쳐라 경고 주입]
SG[Stop gate] -->|load_ledger| JDG{should_block_stop}
JDG -->|미검증| MSG3["block reason<br/>stop_blocks++"]
MSG1 & MSG2 & MSG3 --> MODEL((Claude 모델))
- 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 역방향 스캔으로 차단.
[!note]- 펼쳐보기: 종료 차단 판정 로직(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는 왜 머리가 아니라 꼬리에서 잘릴까?
근거 파일
[!note]- 펼쳐보기: 근거 파일 전체 목록
/home/seunghyeong/harness-work/fable-ish/scripts/ledger.py(스키마, data_root, ledger_key, load/save/update/trim, redact, classify_path_kind)/home/seunghyeong/harness-work/fable-ish/hooks/post_tool_use.py(증거 누적 쓰기 경로)/home/seunghyeong/harness-work/fable-ish/scripts/parse_tool_result.py(verification_record/failure 레코드 형식)/home/seunghyeong/harness-work/fable-ish/hooks/user_prompt_submit.py(원장 리셋/초기화)/home/seunghyeong/harness-work/fable-ish/scripts/classify_task.py(task_mode/risk_flags 산출)/home/seunghyeong/harness-work/fable-ish/hooks/stop_gate.py+/home/seunghyeong/harness-work/fable-ish/scripts/verify_state.py(원장 소비: 종료 차단 판정, stop_blocks 쓰기)/home/seunghyeong/harness-work/fable-ish/hooks/hooks.json,/home/seunghyeong/harness-work/fable-ish/.claude-plugin/plugin.json(후크 등록/매처),/home/seunghyeong/harness-work/fable-ish/.gitignore(.fable-ish-ledger/무시)
연결
FB_개요 · _분석축_루브릭 · FB_40_task-classification-engine · FB_60_evidence-extraction-from-tools · FB_70_stop-completion-gate