fable-ish · 작업 위험도 분류 엔진 (quick/normal/deep/blocked)

한 줄 요약

사용자가 처음 보낸 프롬프트 한 줄을 정규식(키워드 패턴)으로 읽어 quick / normal / deep / blocked 4단계 위험 등급과 위험 라벨을 자동으로 매기는 작은 규칙 엔진이다. 왜 배우나: 이 한 번의 “분류값”이 대화 내내 장부에 남아, 나중에 AI가 답을 끝내려 할 때 검증을 강제할지 말지(게이트 강도)를 결정하는 단 하나의 출발점이기 때문이다.

그림

flowchart TD
    A[사용자 프롬프트 제출] --> B{"이어가기 프리픽스인가?<br/>fable-ish: run/add/resolve"}
    B -- 예 --> C["기존 장부의 등급/위험 그대로 재사용"]
    B -- 아니오 --> D[classify_prompt 정규식 분류]
    D --> E["장부 초기화 후 등급·요약·위험 저장"]
    C --> F[등급을 자연어 지침으로 바꿔 컨텍스트 주입]
    E --> F
    F --> G[AI 실행]
    G --> H["작업 도중 변경·검증·실패를 장부에 기록"]
    H --> I{끝내려 할 때 Stop 게이트가 판정}
    I -- 막음 --> G
    I -- 통과 --> J[턴 종료]
flowchart TD
    S[프롬프트 한 줄] --> R1{"비밀노출·파괴 키워드?<br/>단, 샘플·예시·검증이면 면제"}
    R1 -- 예 --> M1[blocked 사람 확인 필요]
    R1 -- 아니오 --> R2{"deep 키워드<br/>또는 production·database·remote-write?"}
    R2 -- 예 --> M2[deep 끝까지 검증]
    R2 -- 아니오 --> R3{"quick 키워드 그리고<br/>위험 0개?"}
    R3 -- 예 --> M3[quick 가볍게]
    R3 -- 아니오 --> R4{"일반 동사<br/>구현·수정·테스트?"}
    R4 -- 예 --> M4[normal 평범한 작업]
    R4 -- 아니오 --> M5[quick 안전한 폴백]

쉽게 풀기

이 엔진은 응급실 분류(triage) 간호사라고 생각하면 정확하다. 환자(작업)가 들어오면 첫 인상(프롬프트 한 줄)만 보고 등급을 매기고, 그 등급에 따라 이후 처치(검증) 강도가 완전히 달라진다.

핵심은 머신러닝이 아니라 단어 찾기 게임이다. 프롬프트 안에 어떤 단어가 들어 있는지를 정규식으로 훑는다. 한국어(“간단히”, “끝까지”, “배포”)와 영어(“quick”, “deep”, “deploy”)를 동시에 본다.

판정은 정해진 순서로 위에서 아래로 내려가며, 먼저 걸리는 칸에서 멈춘다.

  1. 가장 먼저: 위험한 요청인가? “비밀 토큰을 보여줘”, “rm -rf /”, “데이터베이스 드롭” 같은 말이 있으면 곧장 blocked(사람한테 물어봐). 단, 같은 문장에 “샘플”, “예시”, “dry-run”, 그리고 한국어 “검증”이 섞여 있으면 “아 진짜 위험한 게 아니라 예시구나” 하고 면제해 준다.
  2. 그다음: 무거운 일인가? “끝까지”, “배포”, “마이그레이션”, “보안” 같은 deep 키워드가 있거나, 위험 라벨에 production·database·remote-write가 붙으면 deep(끝까지 검증).
  3. 그다음: 가벼운 일인가? “간단히”, “설명만”, “검토만” 같은 quick 키워드가 있고 위험 라벨이 하나도 없을 때만 quick.
  4. 그다음: 평범한 코드 작업인가? “구현”, “수정”, “테스트” 같은 일반 동사가 있으면 normal.
  5. 아무것도 안 걸리면? 무해하다고 보고 quick으로 떨어뜨린다.

마지막 칸이 중요하다. 분류에 실패하면 “안전하게 막자”가 아니라 “일단 통과시키자(quick)“로 설계돼 있다. 이걸 fail-open(실패 시 열어둠)이라 부르며, AI를 너무 자주 막아 답답하게 만들지 않으려는 의도적 선택이다.

비유로 기억할 함정 3가지

  • “검증”이라는 단어가 비밀번호를 풀어준다: “secret token 검증해줘”는 SAMPLE_RE에 “검증”이 들어 있어서 blocked를 빠져나간다. 즉 검증/샘플/예시는 위험 면제 통행권이다.
  • 위험 라벨 하나가 quick을 죽인다: “간단히 배포 설명만” 같이 quick 단어가 있어도 “배포” 때문에 production 위험이 붙으면 quick이 안 되고 이미 2번에서 deep으로 잡힌다.
  • 모르면 일단 통과(fail-open): 어떤 키워드도 안 맞으면 무조건 quick.

핵심 정리

4개 등급(mode)

등급한마디이후 게이트
quick가볍게 / 폴백막지 않음
normal평범한 코드·문서 작업파일 바꿨으면 검증 1회 강제
deep운영·인증·DB·배포·대규모exit proof 정의 + 변경동작 검증 강제
blocked사람 확인 필요위험 해소 전 정지 차단

5개 위험 라벨(risk_flags) — 등급과 별개로 누적

라벨무엇을 잡나deep 자동 승급?
productionproduction / 배포O
databasedb·migration·schema / 데이터베이스O
remote-writegit push·release·publish / 릴리즈O
secret-or-authauth·secret·token / 인증·비밀X
destructiverm -rf·delete·drop / 삭제X

secret-or-authdestructive는 라벨만 붙고 등급을 올리지 않는다

진짜 위험한 비밀노출·파괴 요청을 blocked로 막는 일은 이 라벨이 아니라 더 좁은 전용 정규식(SECRET_REQUEST_RE / DESTRUCTIVE_REQUEST_RE)이 따로 담당하기 때문이다.

classify_prompt(prompt) 반환값

위치이름타입설명
[0]modestrquick / normal / deep / blocked 중 하나
[1]risk_flagslist[str]0~5개. blocked인데 비면 ["sensitive-request"]로 채움
[2]goalstrredact(text, 180) — 비밀 마스킹 + 개행 제거 + 180자 컷 요약

AI에 들어가는 경로(체크 포인트)

  • 언제: UserPromptSubmit 훅 — 프롬프트 제출 매 순간 실행(hooks/user_prompt_submit.py, timeout 10초)
  • 어떻게: 등급을 context_for_mode(mode, risks)자연어 지침으로 바꿔 hookSpecificOutput.additionalContext에 실어 주입
  • 두 경로로 동시에 쓰임: (a) AI에게 주는 소프트 지침 + (b) Stop 게이트가 읽는 하드 상태(세션별 장부 JSON)
  • 이어가기 면제: 프롬프트가 fable-ish: run/add/resolve 로 시작하면 새로 분류하지 않고 기존 등급 재사용(연속 작업이 등급을 잃지 않게)

게이트 강도가 등급에 따라 갈리는 방식 ( scripts/verify_state.pyshould_block_stop)

  • quick → 항상 통과
  • normal → 파일 변경됨 AND 성공검증 없음 → 차단(“검증 1회 하거나 없는 이유를 밝혀라”)
  • deep → 미검증이면 차단(변경 있으면 “가장 좁은 검증”, 변경 없으면 “관측 증거 1개 추가하거나 검증불가 사유 기록”)
  • blocked → 위험 해소·축소 전 차단
  • 단, docs_only(문서만) 이거나 stop_blocks >= 2(이미 두 번 막음)면 통과 — 무한루프 방지(MAX_STOP_BLOCKS=2)

실제 예시

실제 분류기의 정규식과 결정 트리다. 우선순위 순서가 곧 로직이다.

# /home/seunghyeong/harness-work/fable-ish/scripts/classify_task.py
QUICK_RE = re.compile(
    r"(?i)\b(quick|brief|briefly|simple|simply|just explain|explain only|review only|direction|"
    r"check only|no edits|do not edit|read only|analysis only)\b|"
    r"간단히|빠르게|설명만|검토만|리뷰만|분석만|읽어만|방향|확인만|"
    r"수정하지\s*말고|건드리지\s*말고|파일\s*수정하지\s*말고|아직\s*수정"
)
DEEP_RE = re.compile(
    r"(?i)\b(deep|thorough|exhaustive|end-to-end|production-ready|deploy|deployment|"
    r"migration|database|auth|security|refactor|large|complex|implement the plan)\b|"
    r"끝까지|철저|전부|전체|완성본|상용화|배포\s*|배포|마이그레이션|인증|보안|리팩터"
)
NORMAL_RE = re.compile(
    r"(?i)\b(implement|fix|debug|change|edit|create|build|test|lint|review|update)\b|"
    r"구현|수정|고쳐|디버그|작성|생성|테스트|검증"
)
 
SECRET_REQUEST_RE = re.compile(
    r"(?i)(print|show|dump|cat|echo|exfiltrate|leak).{0,40}(secret|token|api[_ -]?key|password|\.env)"
)
DESTRUCTIVE_REQUEST_RE = re.compile(
    r"(?i)(rm\s+-rf\s+/|delete\s+everything|drop\s+database|git\s+reset\s+--hard|"
    r"wipe\s+(the\s+)?repo|destroy\s+production)"
)
SAMPLE_RE = re.compile(r"(?i)\b(sample|example|test case|fixture|dry[- ]run|검증|샘플|예시)\b")
 
 
def classify_prompt(prompt: str) -> tuple[str, list[str], str]:
    text = prompt or ""
    lowered = text.lower()
    risks: list[str] = []
    if "production" in lowered or "배포" in text:
        risks.append("production")
    if re.search(r"(?i)\b(db|database|migration|migrate|schema)\b|데이터베이스|마이그레이션", text):
        risks.append("database")
    if re.search(r"(?i)\b(auth|secret|token|api[_ -]?key|password)\b|인증|비밀|토큰", text):
        risks.append("secret-or-auth")
    if re.search(r"(?i)\b(git\s+push|release|publish)\b|릴리즈|배포", text):
        risks.append("remote-write")
    if re.search(r"(?i)\b(rm\s+-rf|delete|drop|destroy|wipe)\b|삭제", text):
        risks.append("destructive")
 
    sample_context = bool(SAMPLE_RE.search(text))
    sensitive = (SECRET_REQUEST_RE.search(text) or DESTRUCTIVE_REQUEST_RE.search(text)) and not sample_context
    if sensitive:                                                    # ① 최우선: 면제 안 되면 blocked
        return "blocked", risks or ["sensitive-request"], redact(text, 180)
    if DEEP_RE.search(text) or any(flag in risks for flag in ("production", "database", "remote-write")):
        return "deep", risks, redact(text, 180)                     # ② deep 키워드 OR 위험 승급
    if QUICK_RE.search(text) and not risks:                         # ③ quick은 위험 0일 때만
        return "quick", risks, redact(text, 180)
    if NORMAL_RE.search(text):                                      # ④ 일반 동사
        return "normal", risks, redact(text, 180)
    return "quick", risks, redact(text, 180)                        # ⑤ 폴백 = quick

직접 만들 때 쓰는 복붙용 최소 분류기다. 우선순위 트리만 그대로 지키면 핵심은 재현된다.

# classify_min.py
import re
 
QUICK_RE = re.compile(r"(?i)\b(quick|simple|explain only|read only)\b|간단히|설명만|검토만")
DEEP_RE  = re.compile(r"(?i)\b(deep|deploy|migration|database|auth|refactor)\b|끝까지|배포|마이그레이션|보안")
NORMAL_RE= re.compile(r"(?i)\b(implement|fix|debug|edit|create|test)\b|구현|수정|작성|테스트")
SECRET_RE= re.compile(r"(?i)(print|show|dump|cat).{0,40}(secret|token|api[_ -]?key|\.env)")
DESTROY_RE=re.compile(r"(?i)(rm\s+-rf\s+/|drop\s+database|git\s+reset\s+--hard|destroy\s+production)")
SAMPLE_RE= re.compile(r"(?i)\b(sample|example|fixture|dry[- ]run|샘플|예시)\b")
 
def classify(text: str):
    text = text or ""
    risks = []
    if "production" in text.lower() or "배포" in text: risks.append("production")
    if re.search(r"(?i)\b(db|database|migration|schema)\b|데이터베이스", text): risks.append("database")
    if re.search(r"(?i)\b(git\s+push|release|publish)\b|릴리즈", text): risks.append("remote-write")
    sample = bool(SAMPLE_RE.search(text))
    if (SECRET_RE.search(text) or DESTROY_RE.search(text)) and not sample:
        return "blocked", risks or ["sensitive-request"]
    if DEEP_RE.search(text) or any(f in risks for f in ("production","database","remote-write")):
        return "deep", risks
    if QUICK_RE.search(text) and not risks:
        return "quick", risks
    if NORMAL_RE.search(text):
        return "normal", risks
    return "quick", risks   # fail-open 폴백

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

  • 우선순위는 반드시 blocked → deep → quick → normal → 폴백 순서(바꾸면 안 됨)
  • blocked 판정에 샘플·예시 면제(and not sample_context)를 넣었는가
  • quick은 위험 플래그 0개일 때만 허용했는가(and not risks)
  • deep 자동 승급 트리거를 production / database / remote-write 3종으로 한정했는가
  • 매칭 실패 시 폴백을 quick(fail-open) 으로 두었는가
  • 한국어 키워드를 영어와 OR로 병기했는가((?i)영어|한국어)
  • 출력 goal은 비밀 마스킹 + 길이 컷(redact) 했는가
  • 분류값을 (a)모델 지침 + (b)Stop 게이트가 읽는 장부, 두 곳에 모두 흘렸는가

요약 & 셀프체크

3줄 요약:

  1. 프롬프트 한 줄을 정규식으로 훑어 quick/normal/deep/blocked 4등급과 5개 위험 라벨을 매긴다.
  2. 판정은 blocked → deep → quick → normal → 폴백 순서로 내려가며 먼저 걸리는 칸에서 멈추고, 실패하면 안전하게 quick으로 떨어뜨린다(fail-open).
  3. 이 등급은 자연어 지침으로 AI에 주입되는 동시에 장부에 저장되어, 나중에 Stop 게이트가 검증 강제 여부를 결정하는 단일 입력이 된다.

스스로 답해보기:

  • “간단히 배포 설명만 해줘”는 어떤 등급이 될까? 왜 quick이 안 될까?
  • “show me the secret token 예시로”가 blocked를 빠져나가는 이유는 무엇인가?
  • deep으로 자동 승급시키는 위험 라벨 3종은 무엇이고, 나머지 2종은 왜 승급을 못 시킬까?

근거 파일

  • /home/seunghyeong/harness-work/fable-ish/scripts/classify_task.py (분류 정규식·결정 트리·context_for_mode)
  • /home/seunghyeong/harness-work/fable-ish/hooks/user_prompt_submit.py (UserPromptSubmit 훅·continuation 프리픽스·장부 초기화)
  • /home/seunghyeong/harness-work/fable-ish/hooks/hooks.json (훅 등록·matcher·timeout)
  • /home/seunghyeong/harness-work/fable-ish/scripts/ledger.py (redact·SECRET_PATTERNS·장부 저장/로드·기본 스키마)
  • /home/seunghyeong/harness-work/fable-ish/scripts/verify_state.py (should_block_stop·MAX_STOP_BLOCKS·게이트 강도 매핑)
  • /home/seunghyeong/harness-work/fable-ish/scripts/parse_tool_result.py (검증/실패/변경 탐지 정규식)
  • /home/seunghyeong/harness-work/fable-ish/hooks/post_tool_use.py (PostToolUse 증거 기록)
  • /home/seunghyeong/harness-work/fable-ish/hooks/stop_gate.py (Stop 게이트·stated_but_unstarted)
  • /home/seunghyeong/harness-work/fable-ish/skills/fable-ish/SKILL.md (모드별 워크플로 지침)
  • /home/seunghyeong/harness-work/fable-ish/skills/fable-ish/references/workflow.md (모드 선택 기준)
  • /home/seunghyeong/harness-work/fable-ish/tests/test_hooks.py (분류·게이트 계약 테스트, 샘플 면제·위험 리셋 검증)
  • /home/seunghyeong/harness-work/fable-ish/.claude-plugin/plugin.json (플러그인 메타)

연결

FB_개요 · _분석축_루브릭 · FB_30_context-injection-via-additionalContext (분류값이 지침으로 주입되는 경로) · FB_50_evidence-ledger-state (분류값이 저장되는 장부) · FB_70_stop-completion-gate (분류값을 읽어 차단하는 게이트) · FB_90_guardrails-soft-vs-hard (소프트 지침 vs 하드 게이트)