fable-ish · 컨텍스트/프롬프트 주입 (additionalContext 채널)

한 줄 요약

fable-ish는 시스템 프롬프트를 갈아끼우는 대신, 클로드 코드가 제공하는 훅 출력 규약(additionalContext)으로 그때그때 짧은 지침 문장만 모델 귀에 속삭이는 방식으로 행동을 조종한다. 왜 배우나 — “모델을 통제한다”는 게 꼭 거창한 프롬프트 교체가 아니라, 런타임에 한 문장 얹기 + 멈춤 거부 두 채널만으로도 가능하다는 핵심 설계를 익히기 위해.

그림

flowchart TD
    A[사용자 프롬프트 제출] --> B[UserPromptSubmit 훅]
    B --> C[classify_prompt 정규식 분류]
    C --> D["ledger 기록 + context_for_mode"]
    D --> E["additionalContext 주입: 소프트"]
    E --> M[모델 추론]
    M --> T["Bash/Edit/Write 도구 호출"]
    T --> P["PostToolUse 훅: detect_failure"]
    P -->|실패| F["완료보고 금지 문장 주입: 소프트"]
    P -->|정상| G["ledger 누적, 무개입"]
    F --> M
    M --> S[턴 종료 시도]
    S --> ST["Stop 훅: should_block_stop"]
    ST -->|차단| R["decision:block + reason: 하드 게이트"]
    ST -->|통과| Z[종료 허용]
    R --> M

쉽게 풀기

비유로 풀면, fable-ish는 모델 옆에 붙은 작은 비서 세 명이다. 모델의 성격(시스템 프롬프트)을 바꾸지 않고, 상황마다 한마디씩 거든다.

  1. 입구의 분류 비서 (UserPromptSubmit) — 사용자가 부탁을 던지는 순간, 작은 파이썬 분류기가 먼저 읽는다. “이건 가벼운 질문이네 / 위험한 배포 작업이네”를 정규식으로 판정한 뒤, 그 판단을 한두 문장으로 적어 매 프롬프트마다 모델 입력에 슬쩍 끼워 넣는다. 명령이 아니라 조언이라 모델을 멈추지는 않는다.
  2. 현장의 감독 비서 (PostToolUse) — 모델이 Bash/Edit/Write 같은 도구를 쓴 직후, 결과가 실패였는지 본다. 실패면 “고치기 전엔 완료라고 말하지 마”라고 딱 한 문장을 더 속삭인다. 성공이면 아무 말 안 하고(빈 {}) 기록만 남긴다.
  3. 출구의 문지기 비서 (Stop) — 모델이 “다 했습니다” 하고 턴을 끝내려 하면, 일을 진짜 끝냈는지 검사한다. 검증 안 한 변경이 남아 있으면 아예 멈춤을 거부하고(“block”) “이 이유로 더 일해” 하고 되돌려 보낸다. 이건 조언이 아니라 강제다.

핵심 비대칭 — 앞 두 비서는 “얹기”만 하지 막지 않는 소프트 가이드, 출구 문지기만 멈춤 자체를 거부하는 하드 게이트다. 이 차이가 이 노트의 알맹이다.

또 하나 기억할 점: fable-ish는 CLAUDE.md / AGENTS.md 같은 영구 지침 파일을 만들지 않는다. 오히려 agents.md라는 파일은 변경 분류기가 “코드”가 아니라 “docs(문서)“로만 취급한다.

핵심 정리

세 채널은 같은 훅 규약을 쓰지만 효과의 세기가 다르다.

시점채널효과
UserPromptSubmitadditionalContext (다줄)모드+위험+규칙 선주입 (소프트)
PostToolUse 실패 시additionalContext (한 문장)“완료 보고 금지” 억제 (소프트)
Stop 차단 시decision:block + reason멈춤 거부, 강제 재가동 (하드)

훅이 내보내는 JSON 봉투의 키

  • hookSpecificOutput — 컨텍스트 주입용 컨테이너 (주입 시 필수)
  • hookSpecificOutput.hookEventName"UserPromptSubmit" / "PostToolUse" / "Stop" 중 하나
  • hookSpecificOutput.additionalContext모델에 실제로 주입되는 자연어 문자열
  • decision — Stop 훅에서 "block"이면 멈춤을 막고 재가동
  • reasondecision=block일 때 필수. 차단 사유 = 모델에게 다시 일하라는 지시
  • systemMessage — 선택. 사용자/세션에 보이는 메시지(주입과 별개, fail-open·경고용)
  • 빈 객체 {} 를 내보내면 무개입(아무것도 주입 안 함)

주입 문장을 만드는 context_for_mode()의 출력 구성

  • fable-ish task mode: {mode}. — 항상 (모드: quick/normal/deep/blocked)
  • Risk flags: a, b. — risk_flags가 있을 때만
  • 모드별 한 줄 — quick=간결히 / normal=변경 시 검증 1개 / deep=종료증명 정의 후 검증 / blocked=경계 경고
  • Never claim verification that was not actually observed. — 항상 (마지막 고정 문장)
  • 최종적으로 최대 10줄로 잘림: "\n".join(lines[:10])

차이의 핵심을 한 번 더: 앞 두 개는 additionalContext로 “조언”만 얹어 멈춤을 막지 않는다. Stop은 additionalContext가 아니라 decision/block 채널을 써서 턴을 끝내지 못하게 강제한다. 단, stop_hook_active가 이미 true이거나 max 차단 횟수에 도달하면 Stop도 additionalContext로 부드럽게 빠진다(무한 루프 방지).

각 훅이 작동하는 조건 체크리스트:

  • UserPromptSubmit — 매 프롬프트마다. classify_prompt()가 quick/normal/deep/blocked + risk_flags(production/database/secret-or-auth/remote-write/destructive) 판정 → context_for_mode()로 문장화 → 주입. 동시에 ledger(세션·cwd 해시 키 JSON) 초기화/기록.
  • "fable-ish: run/add/resolve "로 시작하는 연속 프롬프트는 재분류 없이 ledger의 기존 mode/risks로 같은 컨텍스트를 재주입.
  • PostToolUse^(Bash|Edit|Write|MultiEdit|NotebookEdit)$ 매처에 걸리는 호출 직후. detect_failure()가 종료코드/success/ok 필드 또는 FAILURE_RE로 실패 감지 시 한 문장 주입. 변경 경로·검증 결과·커버리지는 항상 ledger에 누적.
  • Stop — 턴 종료 시점. should_block_stop()이 모드·변경여부·검증여부로 판정. deep인데 미검증·변경됨 / normal인데 변경됨·미검증 / blocked 등이면 차단.
  • stated_but_unstarted() — 마지막 어시스턴트 턴이 “이제 ~하겠습니다”식 예고만 하고 tool_use도 질문도 없으면 차단.
  • MAX_STOP_BLOCKS=2 도달 시·stop_hook_active 시엔 차단 해제.

실제 예시

주입 문자열을 만드는 함수

# /home/seunghyeong/harness-work/fable-ish/scripts/classify_task.py
def context_for_mode(mode: str, risk_flags: list[str]) -> str:
    lines = [f"fable-ish task mode: {mode}."]
    if risk_flags:
        lines.append("Risk flags: " + ", ".join(risk_flags) + ".")
    if mode == "quick":
        lines.append("Keep the response concise; do not force deep planning or broad verification.")
    elif mode == "normal":
        lines.append("If files change, run one relevant verification command or state why none applies.")
    elif mode == "deep":
        lines.append("Define the exit proof before completion and verify changed behavior before final.")
    elif mode == "blocked":
        lines.append(
            "This request touches a sensitive or destructive boundary. Confirm scope, prefer the safest "
            "reversible action, and stop for user confirmation when the next step needs credentials, "
            "irreversible remote writes, or destructive deletes. Rely on Claude Code permissions for hard enforcement."
        )
    lines.append("Never claim verification that was not actually observed.")
    return "\n".join(lines[:10])

세 채널의 실제 주입 코드

# /home/seunghyeong/harness-work/fable-ish/hooks/user_prompt_submit.py
    mode, risks, goal = classify_prompt(prompt)
    # ... 대장(ledger)에 mode/risks 등을 기록한 뒤 ...
    emit_json(
        {
            "hookSpecificOutput": {
                "hookEventName": "UserPromptSubmit",
                "additionalContext": context_for_mode(mode, risks),
            }
        }
    )
# /home/seunghyeong/harness-work/fable-ish/hooks/post_tool_use.py
    if failure:
        emit_json(
            {
                "hookSpecificOutput": {
                    "hookEventName": "PostToolUse",
                    "additionalContext": "fable-ish observed a tool failure. Do not report completion until it is fixed, isolated as baseline, or explicitly documented.",
                }
            }
        )
    else:
        emit_json({})
# /home/seunghyeong/harness-work/fable-ish/hooks/stop_gate.py
    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
# /home/seunghyeong/harness-work/fable-ish/scripts/ledger.py  (agents.md → docs 로만 인식)
def classify_path_kind(path_value: str) -> str:
    path = Path(path_value)
    name = path.name.lower()
    suffix = path.suffix.lower()
    parts = {part.lower() for part in path.parts}
    if suffix in DOC_EXTS or name in {"readme", "readme.md", "agents.md"} or "docs" in parts:
        return "docs"
    ...

직접 만들 때 최소 템플릿

#!/usr/bin/env python3
# my_hook.py — UserPromptSubmit 용 최소 컨텍스트 주입기
import sys, json
 
def main():
    raw = sys.stdin.read()
    data = json.loads(raw) if raw.strip() else {}
    prompt = str(data.get("prompt") or "").lower()
 
    # 1) 아주 단순한 분류
    mode = "deep" if ("deploy" in prompt or "배포" in prompt) else "quick"
    risks = ["production"] if "production" in prompt else []
 
    # 2) 주입 문자열 조립
    lines = [f"my-gate task mode: {mode}."]
    if risks:
        lines.append("Risk flags: " + ", ".join(risks) + ".")
    lines.append("Never claim verification that was not actually observed.")
 
    # 3) additionalContext 채널로 주입
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "UserPromptSubmit",
            "additionalContext": "\n".join(lines),
        }
    }, ensure_ascii=True))
 
if __name__ == "__main__":
    try:
        main()
    except Exception as exc:           # fail-open: 절대 사용자 흐름을 막지 않음
        print(json.dumps({"systemMessage": f"hook failed open: {exc}"}))
    raise SystemExit(0)

Stop 차단(하드 게이트) 형식만 따로:

# 멈춤을 거부하고 다시 일 시키기
print(json.dumps({"decision": "block",
                  "reason": "Run the verification command before finishing."}))

직접 만들 때 잊지 말 원칙

  • 주입은 additionalContext(소프트), 강제 재가동은 decision:block+reason(하드)로 분리.
  • 모든 훅은 fail-open: 예외 시 빈/systemMessage만 내고 exit 0. 무개입은 빈 {}.
  • 무한 차단 방지: 차단 횟수 상한(예: 2) + stop_hook_active 체크.
  • 시스템 프롬프트를 새로 만들지 말 것 — 런타임 컨텍스트 한 문장 주입으로 충분.

요약 & 셀프체크

3줄 요약

  • fable-ish는 시스템 프롬프트를 바꾸지 않고 훅 출력의 additionalContext로 짧은 지침 문장을 런타임에 얹는다.
  • 입구(분류)·현장(실패 감지)은 멈춤을 막지 않는 소프트 가이드, 출구(Stop)만 decision:block으로 하드 게이트를 건다.
  • 모든 훅은 fail-open이고, Stop은 MAX_STOP_BLOCKS·stop_hook_active로 무한 루프를 방지한다.

스스로 답해보기

  1. additionalContext로 주입하는 것과 decision:block으로 막는 것의 결정적 차이는 무엇인가?
  2. PostToolUse 훅이 성공일 때 빈 {}를 내보내는 이유는? (무개입의 의미)
  3. Stop 훅이 끝없이 차단하지 않도록 막는 두 가지 안전장치는?

연결

FB_개요 · _분석축_루브릭 · FB_20_hook-event-loop · FB_40_task-classification-engine · FB_50_evidence-ledger-state · FB_70_stop-completion-gate · FB_90_guardrails-soft-vs-hard

Codex 교차검증 (원문 보존)

이 노트의 근거 파일들이다. 사실관계는 아래 소스에서 교차 확인할 것.

  • /home/seunghyeong/harness-work/fable-ish/scripts/classify_task.py — classify_prompt, context_for_mode
  • /home/seunghyeong/harness-work/fable-ish/hooks/user_prompt_submit.py — UserPromptSubmit additionalContext 주입, 연속 프롬프트 재주입
  • /home/seunghyeong/harness-work/fable-ish/hooks/post_tool_use.py — 실패 감지 시 “완료 보고 금지” 주입
  • /home/seunghyeong/harness-work/fable-ish/hooks/stop_gate.py — decision:block+reason 하드 게이트, stop_hook_active 해제
  • /home/seunghyeong/harness-work/fable-ish/hooks/hooks.json — 세 이벤트 훅 등록·매처·timeout
  • /home/seunghyeong/harness-work/fable-ish/scripts/ledger.py — classify_path_kind(agents.md→docs), redact, ledger 저장
  • /home/seunghyeong/harness-work/fable-ish/scripts/parse_tool_result.py — detect_failure, verification_coverage
  • /home/seunghyeong/harness-work/fable-ish/scripts/verify_state.py — should_block_stop, stated_but_unstarted, MAX_STOP_BLOCKS
  • /home/seunghyeong/harness-work/fable-ish/skills/fable-ish/SKILL.md, references/verification.md — 워크플로 지침 계층(주입과 별개)
  • /home/seunghyeong/harness-work/fable-ish/.claude-plugin/plugin.json, marketplace.json — 플러그인 메타(시스템프롬프트 없음, skills+hooks만)