지식위키

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

단계별 핵심

  1. 공책 위치 — 세션×작업폴더마다 다른 공책. 둘을 이어 붙여 SHA-256 해시를 만들고 앞 24자를 파일 이름으로 삼는다. 같은 채팅이라도 cwd가 다르면 일지가 분리된다.
  2. 적는 내용 — 작업 모드(quick/normal/deep/blocked), 목표 한 줄, 위험 태그, 고친 파일, 검증 명령과 결과, 실패 기록.
  3. 비밀 가리기 — API 키·비밀번호는 적기 전에 [REDACTED]로 마스킹하고 한 줄로 평탄화.
  4. 안전 저장 — 임시 페이지에 다 쓴 뒤 한 번에 갈아끼운다(원자적 교체). 일지가 깨졌으면 멈추지 말고 새 빈 공책으로 시작(fail-open).
  5. 길이 자르기 — 목록·기록에 상한을 둬 오래된 것 또는 머리를 잘라낸다.

핵심: 원장은 “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_modequick/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편집 도구 관찰 여부false
changed_paths변경 경로(고유, 상한 40)[]
change_kindscode/docs/config/assets/other[]
verification_commands검증 명령(최근 40)[]
verification_results검증 레코드 배열(최근 40)[]
coverage_relation검증↔변경 연관 최고값"none"
failures실패 레코드(최근 40)[]
stop_blocks종료 막은 횟수(최대 2)0
last_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_paths 40 / change_kinds·risk_flags 20 → 고유화 후 머리(앞) 자름
  • 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)는 예외 없이 빈 원장 + failureskind:"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 모델))
  1. UserPromptSubmit (user_prompt_submit.py) — classify_prompt로 모드·위험·목표 산출 → 원장을 리셋 후 새 작업으로 초기화(changed/verification/failures/stop_blocks 비움) → context_for_modeadditionalContext로 주입. 단, 프롬프트가 fable-ish: run/add/resolve로 시작하는 종료게이트 재투입이면 리셋하지 않고 현재 모드만 재안내.
  2. PostToolUse (post_tool_use.py, 매처 ^(Bash|Edit|Write|MultiEdit|NotebookEdit)$) — 도구 입출력을 parse_tool_result.py로 파싱 → 변경 경로·종류·검증 레코드·실패 추출 → update_ledger로 누적(append + coverage_relation 최고값 갱신). 도구 실패 시 “완료 보고 말고 고쳐라” 주입.
  3. 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줄 요약:

  1. 증거 원장은 세션×cwd 해시를 파일명으로 한 단일 JSON 공책으로, 후크들이 공유하는 단기 기억이다.
  2. 쓰기는 항상 redact → trim → tmp.replace 원자 저장, 읽기 실패는 빈 원장으로 fail-open한다.
  3. 종료 게이트가 이 원장을 읽어 “검증 충분?”을 판단하므로, 원장은 검증 게이트의 유일한 사실 출처다.

스스로 답해보기:

  • 같은 채팅 세션인데 작업 폴더(cwd)를 옮기면 원장은 같은 파일을 쓸까, 다른 파일을 쓸까? 그 이유는?
  • 일지(원장) 파일이 깨져 있으면 fable-ish는 멈출까, 계속 갈까? 그 동작을 뭐라 부르나?
  • verification_resultsverification_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