sbsabo.blog
sabo/posts/fable-orchestration-setup
published 2026-07-08
/fable-orchestration-setup
type=article
claude-codecodexchatgptaiorchestrationdevopshooks

고급 인력에게 서빙을 시키지 마라 — Claude Fable과 Codex GPT-5.6 오케스트레이션 설정기

reading time
29m
published
2026-07-08
code blocks
10
words
1988
On this page

고급 인력에게 서빙을 시키지 마라

Fable이 처음 나왔을 때, 멋모르고 effort를 울트라코드(xhigh + workflow)로 놓고 돌렸습니다. 결과는 참담했어요. 명령 하나 제대로 못 끝낸 채 주간 Fable 한도를 다 써버렸거든요. 아, 이건 진짜 제대로 알고 써야 하는 물건이구나. 그때부터 Fable 잘 쓰는 법을 찾아다녔고, 성공지식백과님의 유튜브를 참고해서 지금의 세팅을 만들었습니다. 이 글은 그 기록입니다.

한도를 날려먹기 전까지, 가장 비싼 모델이 sed를 돌리고, 빌드 로그를 훑고, 한 줄짜리 오타를 고치고 있었습니다. Claude Code에 붙은 Fable 얘기입니다. Opus보다 한 단계 위 티어, 지금 제가 쓸 수 있는 제일 비싼 모델이 그러고 있었어요.

미슐랭 헤드셰프를 데려다 서빙과 설거지를 시키는 식당은 없죠. 헤드셰프는 메뉴를 짜고 주방을 지휘하고 마지막 플레이팅만 봅니다. 접시는 홀 스태프가 나릅니다. 당연한 얘긴데, 제 터미널에서는 그 당연한 게 안 지켜지고 있었습니다. 토큰 단가를 생각하면 꽤 아까운 그림이고요. 그 뒤에 ChatGPT/Codex에서 GPT-5.6 오케스트레이션을 새로 마무리하면서, 같은 원칙이 제품이 달라도 그대로 통한다는 걸 한 번 더 확인했습니다. 다만 강제하는 방법은 제법 달랐어요.

아이디어: 지휘와 실행을 분리한다

그래서 Fable은 지휘만 하게 했습니다. 계획 세우고, 일 쪼개서 나눠주고, 결과 받아서 종합하고, 중요한 판단 내리는 것. 딱 거기까지. 실제 작업 토큰은 아래 티어에서 나오게 했습니다.

역할에이전트담당
심층 추론deep-reasoner (Opus, effort max)설계·아키텍처 결정·복잡한 근본원인 분석
일반 실행executor (Opus)구현·수정·테스트·디버깅·리뷰
잡무runner (Haiku, 도구 제한)명령 실행·빌드·로그/파일 조회

runner는 도구를 Bash/Read/Grep/Glob으로 잘라놨어요. 판단이 필요 없는 일에 판단할 수 있는 도구를 쥐여줄 이유가 없으니까요. 조회하고 명령을 돌리는 데 Edit 권한까지는 필요 없습니다.

ChatGPT/Codex GPT-5.6에서는 어떻게 나눴나

Codex 쪽도 처음엔 비슷하게 생각했습니다. 대화창의 루트에게 일을 쪼개서 native collaborator로 넘기면, 자연스럽게 가벼운 작업은 가벼워지겠지 하고요. 그런데 그 가정이 틀렸습니다. 당시 Codex 0.144.1의 제 환경에서는 native collaborator가 루트의 Sol 모델과 추론 설정을 그대로 물고 태어났어요. 결국 Sol이 Sol을 여러 개 부르는 같은 모델 fan-out이 됐고, 짧은 시간에 5시간 한도를 빠르게 태웠습니다.

여기서 말하는 건 로컬 로그의 raw token 수를 돈처럼 환산한 얘기가 아닙니다. Codex 사용량은 그 숫자만으로 청구 비용을 계산할 수 없고, 저는 그런 식의 절감률도 만들지 않았습니다. 실패 원인은 훨씬 단순했어요. 지휘자와 실무자의 모델·권한·추론 강도가 분리되지 않았다는 것.

그래서 최종 V5는 역할을 이렇게 고정했습니다.

자리모델 / 추론권한맡기는 일
대화형 루트gpt-5.6-sol / xhigh지휘사용자와 대화, 계획·배분·종합
deep-reasonergpt-5.6-sol / maxread-only설계, 위험 검토, 복잡한 원인 분석
executorgpt-5.6-terra / mediumworkspace-write구현, 수정, 테스트, 디버깅
runnergpt-5.6-luna / lowread-only검색, 상태·로그 조회, 가벼운 명령

핵심은 모델 이름만 적어 놓는 게 아니었습니다. Codex native collaboration은 아예 꺼두고, 모든 역할 호출을 ~/.codex/orchestration/bin/codex-role로만 지나게 했습니다. 각 역할의 모델은 profile과 실행 인자에 함께 고정해 두니, executor를 부른다고 해 놓고 루트 Sol이 복제되는 길이 없습니다.

# ~/.codex/orch-executor.config.toml (요지)
model = "gpt-5.6-terra"
model_reasoning_effort = "medium"
sandbox_mode = "workspace-write"
# 루트가 역할을 시작하는 유일한 경로
printf '%s' '구현과 집중 테스트를 맡아줘' \
  | ~/.codex/orchestration/bin/codex-role --cwd "$PWD" executor

처음에는 자식이 다시 일을 나누는 걸 전부 막을까도 생각했습니다. 그런데 그러면 "전문가가 더 알맞은 전문가에게 넘긴다"는 처음 목적이 사라집니다. 그래서 V5는 무제한 재귀 대신 의미가 있는 한 번의 중첩만 허용합니다. 깊이 1의 전문가는 role profile과 policy에 따라 codex-role을 직접 호출할 수 없지만, 필요하면 엄격한 한 번짜리 요청 봉투를 반환할 수 있습니다. 그 봉투로 깊이 2를 이어서 실행하는 것은 루트의 continuation 경로뿐이며, 이때만 role·depth·route·cwd를 신뢰된 registry에서 결정합니다.

deep-reasoner (depth 1) → runner
executor      (depth 1) → runner 또는 deep-reasoner
runner        (depth 1) → 없음
모든 역할    (depth 2) → 없음

이건 모든 자식을 leaf로 만들어 버리는 정책이 아닙니다. 예를 들어 executor가 구현 중에 설계 판단이 막히면 deep-reasoner를, 분석가는 근거를 모아야 하면 runner를 부를 수 있습니다. 다만 그 위임은 루트라는 한 곳에서만 실제 실행돼서, 어느 모델이 어떤 권한으로 몇 단계까지 갔는지를 놓치지 않습니다.

안전장치는 글을 무겁게 만들 만큼 길게 적을 건 아니지만, 이 정도는 남겨둘 만합니다. 요청에는 task hash와 TTL을 넣고, 원자적으로 한 번만 소비합니다. cwd와 route/depth는 루트의 continuation에서만 호출자가 적은 말을 믿지 않고 신뢰된 레코드에서 정규화해 읽습니다. 자식 실행 전에는 일반 환경은 보존하되 신뢰된 CODEX_HOME을 강제하고, 호출자가 넣은 CODEX_ROLE_* 권한값은 제거합니다. executor는 workspace 잠금을 잡고, 시작과 종료는 감사 기록으로 남기며, 형태가 틀린 봉투는 열어보지 않고 fail-closed로 막습니다.

이 정책은 Codex가 시작될 때 읽히는 쪽이라, 바꾼 뒤에는 이미 열려 있던 대화에 기대하면 안 됩니다. 새 Codex chat을 열거나 Codex를 재시작해야 합니다.

~/.codex/orchestration/bin/codex-orchestration verify
# startup policy를 바꾼 뒤: 새 Codex chat을 열거나 Codex 재시작

검증도 따로 해뒀습니다. 단위 테스트는 28/28 통과, py_compile 통과, codex-orchestration verify 통과. 마지막으로 루트 → executor → runner로 이어지는 depth-2 E2E도 실제로 한 번 끝까지 돌렸습니다. 말로만 중첩을 허용한 게 아니라, 최대 깊이와 권한이 그 경로에서 작동하는지 확인한 셈입니다.

Claude Fable 구현: 본체는 한 곳, 설정에는 얇은 로딩 코드만

원칙은 하나였습니다. 본체는 ~/.claude/fable/ 한 곳에만 두고, 각 설정 파일엔 그걸 불러오는 얇은 로딩 코드만 남긴다. 설정을 여기저기 흩어놓으면 나중에 켜고 끄기가 지옥이 되거든요.

~/.claude/
├── CLAUDE.md ──────── @~/.claude/fable/active.md   (선언: import 한 줄)
├── agents/
│   ├── deep-reasoner.md ─┐
│   ├── executor.md       ├─(심링크)─▶ fable/agents/*   (역할 배정)
│   └── runner.md ────────┘
├── settings.json ─────── PreToolUse ─▶ fable/hooks/orchestration-gate.py  (강제)
└── fable/                                    ← 본체는 여기 한 곳
    ├── active.md ─(심링크)─▶ fable.md  또는  empty.md
    ├── fable.md          (지침 본문)
    ├── empty.md          (빈 파일 = 지침 없음)
    ├── agents/           (에이전트 정의 원본)
    └── hooks/orchestration-gate.py   (게이트 스크립트)
  • 선언: ~/.claude/CLAUDE.md 맨 끝의 @~/.claude/fable/active.md import 한 줄이 전부입니다. 이 active.mdfable/ 안의 심링크라, 지침이 담긴 fable.md를 가리키거나 빈 empty.md를 가리킵니다.
  • 역할: ~/.claude/agents/의 서브에이전트 3종은 전부 본체(fable/agents/)로 이어진 심링크고요.
  • 강제: 나머지는 settings.json에 등록된 PreToolUse 훅 하나가 담당합니다.

Claude Fable 게이트: 부탁만으로는 안 지킨다

여기까지는 흔한 프롬프트 엔지니어링이죠. "너는 지휘자야, 실행은 위임해"라고 지침에 써두는 것. 그런데 써보면 압니다. 모델은 이걸 자주 잊어요. 급하면 그냥 자기가 파일을 고쳐버립니다. 눈앞에 칼이 있는데 홀 스태프를 부르러 가는 게 귀찮은 겁니다.

그래서 부탁을 강제로 바꿨습니다. PreToolUse 훅(도구가 실행되기 직전에 끼어드는 훅)인 orchestration-gate.py가 메인 에이전트의 직접 코드 수정을 물리적으로 막습니다. 규칙 자체는 단순합니다. 한 턴(prompt_id, 사용자 입력 하나가 만드는 턴 식별자)에 코드 파일은 2개까지만 직접 손대게 하고, 3개째부터 막습니다. 그리고 막을 때는 어디로 위임하라는 안내까지 에러 메시지에 실어 돌려줍니다.

if len(gate["files"]) < LIMIT:      # LIMIT = 2
    gate["files"].append(path)
    allow()                          # 2개까지는 직접 수정 통과
 
# 실제 메시지는 더 길다 — 요지만 발췌
deny("[fable 게이트] 이번 턴 직접 수정이 이미 %d개입니다. "
     "추가 수정은 서브에이전트에 위임하세요: %s" % (LIMIT, ROUTES))

ROUTES에는 "일반 구현·수정은 executor(Opus), 심층 추론·설계는 deep-reasoner, 단순 명령·조회는 runner(Haiku)"가 담겨 있습니다. 차단당한 모델은 이 문구를 읽고 곧바로 알맞은 에이전트에게 일을 넘기고요. 막기만 하는 게 아니라 갈 길까지 같이 알려준다는 게 이 방식의 포인트입니다.

만드는 김에 몇 군데는 좀 더 신경 썼습니다. 우선 Bash로 새는 걸 막았어요. Edit 도구만 조여봤자 sed -i, perl -i, 리다이렉트(>), tee로 코드 파일에 쓰면 그만이니까, 그런 셸 패턴은 정규식으로 따로 잡아냅니다.

서브에이전트는 반대로 전부 통과시킵니다. payload에 agent_idagent_type가 있으면 게이트를 그냥 건너뛰어요. 위임받은 쪽이 실제로 일하는 경로인데 여기서 막아버리면 앞뒤가 안 맞으니까요.

md·json·yaml 같은 비코드 파일은 지휘자가 직접 고쳐도 됩니다. 그리고 혹시라도 게이트에서 예외가 터지면 무조건 통과(fail-open)시킵니다. 강제 장치 하나가 버그로 세션 전체를 마비시키는 것보다는 그게 낫죠.

try:
    main()
except Exception:
    sys.exit(0)   # fail-open — 게이트가 죽어도 세션은 산다

Claude Fable 토글: 설정 파일은 건드리지 않는다

켜고 끄는 건 fable on / off / status 스크립트 하나로 합니다. 여기서 중요한 건, 이 스크립트가 settings.json이나 CLAUDE.md를 절대 건드리지 않는다는 점이에요. 로딩 코드는 한 번만 심어두고, 켜졌는지 꺼졌는지는 상태 파일과 심링크만으로 갈립니다.

fable on     # .fable-state=on,  active.md -> fable.md   (지침 로드)
fable off    # .fable-state=off, active.md -> empty.md   (지침 없음)
fable status # 상태 + 로딩 코드 설치 여부 점검

on이면 상태 파일에 on을 쓰고 active.md 심링크를 지침 파일로 돌립니다. 게이트 훅도 이 상태 파일을 먼저 읽어서 off면 바로 통과시키고요. 등록은 한 번, 동작 여부는 스위치가 쥐고 있는 이 구조 덕에 껐다 켜는 게 안전합니다. 바뀐 설정은 다음에 새로 여는 세션부터 적용됩니다.

그 "한 번 등록"이 이렇게 생겼습니다. 코드 수정 계열 도구에 게이트를 걸어두면 끝이에요.

// ~/.claude/settings.json
"hooks": { "PreToolUse": [{ "matcher": "Write|Edit|NotebookEdit|MultiEdit|Bash",
  "hooks": [{ "type": "command", "command": "python3 ~/.claude/fable/hooks/orchestration-gate.py" }] }] }

게이트 스크립트 전체 코드는 글 맨 아래 부록에 붙여놨어요. 이 스니펫과 부록 코드만 있으면 그대로 재현할 수 있습니다.

status는 on/off만 보여주고 끝나는 게 아니라 3층이 제대로 깔렸는지까지 훑어줍니다. CLAUDE.md의 import 한 줄, agents 심링크 3종, settings.json의 게이트 등록, 스크립트 실행 권한을 각각 [OK]/[--]로 찍어주거든요. 어디가 빠졌는지 한눈에 보입니다.

Claude Fable만의 반나절 실측: 얼마나 아꼈나

솔직히 이 글을 쓰는 지금, 게이트를 올린 지 반나절밖에 안 됐습니다. 오늘 낮에 설치했거든요. "한 달 굴려본 소감" 같은 걸 말하기엔 이른데, 대신 오늘 하루치 로컬 세션 트랜스크립트(~/.claude/projects/*/*.jsonl, 12개 파일)를 긁어 실제 토큰을 뽑아봤습니다. 표본은 딱 반나절입니다.

아래 표와 그래프는 Claude Fable 구현의 관측치와 Anthropic 단가만 다룹니다. Codex의 절감액이나 사용량을 주장하는 자료가 아니며, 앞 절의 Codex 구성과 숫자를 섞어 비교하면 안 됩니다.

Claude Fable에서 오늘 쓴 토큰

모델역할메시지입력출력캐시 읽기캐시 쓰기
Fable 5오케스트레이터612211,034489,475103,342,2918,274,907
Opus 4.8서브에이전트(executor 등)8896,81595,8204,120,721571,918

runner(Haiku)는 오늘 호출 0건이에요. 설치한 지 반나절이라 "빌드 돌려라, 로그 봐라" 같은 잡무를 넘길 일이 아직 없었습니다. 3층 중 맨 아래 Haiku는 오늘 하루 종일 놀았습니다.

Claude Fable의 Anthropic 단가와 계산

Anthropic 공식 단가(per MTok, MTok은 100만 토큰) 기준으로, Fable은 모든 항목에서 Opus의 정확히 2배, Haiku의 10배입니다.

모델입력출력캐시 읽기캐시 쓰기
Fable 5$10$50$1.00$12.50
Opus 4.8$5$25$0.50$6.25
Haiku 4.5$1$5$0.10$1.25

캐시 쓰기 1.25×는 기본 5분 TTL 기준입니다(1시간 TTL은 2×).

executor(Opus)한테 위임한 작업의 실제 비용은 $8.51이었습니다 (입력 $0.48 + 출력 $2.40 + 캐시 읽기 $2.06 + 캐시 쓰기 $3.57). 이 토큰들을 그대로 Fable 단가로 환산하면 정확히 2배, $17.03이 나옵니다. 같은 일을 한 티어 아래에서 태운 덕에 위임분만 놓고 보면 값이 절반에서 끝난 거예요. 물론 위임을 안 했으면 저 캐시 쓰기·읽기 자체가 일부는 애초에 안 생겼을 겁니다. 그러니 이 $8.5를 '순수하게 아낀 돈'이라 부르기보단, 같은 노동을 어느 티어에서 태웠느냐의 단가 차이로 보는 게 정직해요. 잡무가 runner(Haiku)까지 내려갔다면 그 몫은 Fable 직접 대비 10분의 1이었을 거고요.

$8.51위임 · Opus 실행executor가 한 실제 비용$17.03Fable 직접 · ×2지휘자가 직접 했다면직접의 절반입력출력캐시 읽기캐시 쓰기

뽑아보니 눈에 띈 것

뽑은 숫자에서 세 가지가 눈에 걸렸어요.

  1. 아직 손은 Fable이 제일 많이 놀리고 있습니다. 출력 토큰이 Fable 48.9만, Opus 9.6만 — 실제 '생산' 토큰의 84%쯤이 여전히 지휘자에서 나와요. 반나절짜리 표본이라 위임 비중이 낮은 거고, 이게 executorrunner 쪽으로 넘어갈수록 절감 폭은 더 벌어질 겁니다. 오늘 숫자는 그 곡선의 출발점이고요.
  2. 진짜 돈은 출력에서 나가지, 캐시에서 나가는 게 아닙니다. Fable의 캐시 읽기가 1억 토큰을 넘는데, 오케스트레이터란 게 컨텍스트를 계속 다시 읽는 존재라 그렇습니다. 근데 캐시 읽기 단가가 출력의 50분의 1이라 총액에선 생각보다 조용해요. 지휘자를 비싼 모델로 앉혀도 캐시 덕에 버틸 만하다는 뜻이고, 정작 아까운 건 출력 — '노동' 토큰입니다. 그걸 싼 티어로 내려보내는 게 이 구조의 전부인 이유가 여기 있죠.
  3. 어쨌든 반나절짜리라는 것. Haiku는 아예 안 썼고, 세션이 설계 위주냐 잡무 위주냐에 따라 이 비율은 크게 흔들립니다. '이렇게 아낄 수 있다'는 방향이지, '항상 절반 아낀다'는 약속은 아니에요.

남는 이야기

Claude Fable 쪽에도 한계는 있습니다. 게이트를 턴당 2파일까지 열어둔 건 순전히 타협이에요. 오타 하나, 설정 한 줄 고치자고 서브에이전트를 띄우면 위임 오버헤드(에이전트 기동에 컨텍스트 전달까지)가 정작 그 수정보다 비싸지거든요. 그래서 사소한 건 지휘자가 직접 처리하게 두고, 진짜 노동이 시작되는 지점부터 막습니다. 딱딱한 상한선이라기보단 적당히 걸쳐둔 균형점인 셈이죠.

결국 하고 싶었던 건 하나였어요. 비싼 사람한테 비싼 일을 시키는 것. 헤드셰프는 메뉴랑 최종 검수를 보고, 접시는 홀 스태프가 나르게 하는 것. 이렇게 당연한 걸 AI한테는 말로 부탁해선 안 먹히고 게이트까지 세워야 했다는 게 이번 설정기의 전부입니다. 반나절 치 숫자로도 위임분이 절반으로 깎였으니, 주방이 제대로 돌기 시작하면 어떤 그림이 나올지는 좀 더 굴려보고 다시 들고 오겠습니다.

부록: Claude Fable 게이트 스크립트 전체 코드

~/.claude/fable/hooks/orchestration-gate.py로 저장하고 실행 권한(chmod +x)을 주면 됩니다. 위의 settings.json 등록 스니펫과 합치면 재현 끝.

#!/usr/bin/python3
# ~/.claude/fable/hooks/orchestration-gate.py — fable 강제 게이트 (PreToolUse)
#
# 메인 에이전트가 한 턴(prompt_id)에 코드 파일을 2개까지만 직접 수정하게 허용하고,
# 3개째부터 차단하며 위임 지시를 돌려준다. Bash 를 통한 코드 파일 수정은 항상 차단.
# 서브에이전트(payload 에 agent_id/agent_type 존재)는 전부 통과 — 위임이 실행 경로다.
#
# fable off 상태(~/.claude/.fable-state != on)면 즉시 통과. 등록은 settings.json 에
# 1회(로딩 코드), 동작 여부는 스위치가 지배한다.
# 오류 시 fail-open(통과) — 게이트 버그가 세션을 마비시키지 않게 한다.
 
import json
import os
import re
import sys
import time
 
LIMIT = 2
STATE_FILE = os.path.expanduser("~/.claude/.fable-state")
GATE_STATE_DIR = os.path.expanduser("~/.claude/fable/state")
CODE_EXTS = {
    "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "java", "kt",
    "kts", "swift", "c", "h", "cc", "cpp", "hpp", "cs", "rb", "php", "vue",
    "svelte", "astro", "css", "scss", "sass", "less", "html", "sh", "bash",
    "zsh", "sql", "lua", "dart", "scala", "ex", "exs", "zig", "ipynb",
}
EXT_RE = "|".join(sorted(CODE_EXTS))
 
ROUTES = (
    "일반 구현·수정·테스트·리뷰 → executor(Opus), "
    "심층 추론·설계·근본원인 분석 → deep-reasoner(Opus, max), "
    "단순 명령·조회 → runner(Haiku)."
)
 
 
def allow():
    sys.exit(0)
 
 
def deny(msg):
    sys.stderr.write(msg)
    sys.exit(2)
 
 
def is_code(path):
    return path.rsplit(".", 1)[-1].lower() in CODE_EXTS if "." in path else False
 
 
def main():
    try:
        state = open(STATE_FILE).read().strip()
    except OSError:
        state = "off"
    if state != "on":
        allow()
 
    data = json.load(sys.stdin)
 
    if data.get("agent_id") or data.get("agent_type"):
        allow()  # 서브에이전트는 게이트 대상이 아님
 
    tool = data.get("tool_name", "")
    tool_input = data.get("tool_input") or {}
 
    if tool == "Bash":
        cmd = tool_input.get("command", "")
        writes_inplace = re.search(r"\b(sed|perl)\s+[^|;&]*-\w*i", cmd)
        writes_redirect = re.search(
            r"(?:>>?|\btee\b(?:\s+-\w+)*)\s*['\"]?[^\s'\"|;&<>]+\.(?:%s)\b" % EXT_RE, cmd
        )
        touches_code = re.search(r"\.(?:%s)\b" % EXT_RE, cmd)
        if writes_redirect or (writes_inplace and touches_code):
            deny(
                "[fable 게이트] 메인 에이전트는 Bash로 코드 파일을 수정할 수 없습니다. "
                "코드 수정은 Edit/Write 도구(턴당 %d개 파일까지)를 쓰거나 서브에이전트에 위임하세요: %s"
                % (LIMIT, ROUTES)
            )
        allow()
 
    if tool not in ("Edit", "Write", "NotebookEdit", "MultiEdit"):
        allow()
 
    path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
    if not path or not is_code(path):
        allow()  # 설정·문서 등 비코드 파일은 직접 수정 허용
 
    session = re.sub(r"[^a-zA-Z0-9-]", "", data.get("session_id", "nosession"))
    prompt = data.get("prompt_id", "")
    os.makedirs(GATE_STATE_DIR, exist_ok=True)
    gate_file = os.path.join(GATE_STATE_DIR, session + ".json")
 
    gate = {"prompt_id": prompt, "files": []}
    try:
        saved = json.load(open(gate_file))
        if saved.get("prompt_id") == prompt:
            gate = saved
        else:  # 새 턴 — 카운터 리셋 + 오래된 세션 상태 정리
            now = time.time()
            for fn in os.listdir(GATE_STATE_DIR):
                p = os.path.join(GATE_STATE_DIR, fn)
                if now - os.path.getmtime(p) > 86400:
                    os.remove(p)
    except (OSError, ValueError):
        pass
 
    if path in gate["files"]:
        allow()  # 같은 파일 재수정은 개수에 안 침
 
    if len(gate["files"]) < LIMIT:
        gate["files"].append(path)
        json.dump(gate, open(gate_file, "w"))
        allow()
 
    deny(
        "[fable 게이트] 이번 턴에 메인 에이전트가 직접 수정한 코드 파일이 이미 %d개입니다 (%s). "
        "추가 코드 수정은 반드시 서브에이전트에 위임하세요: %s "
        "지금 하려던 수정(%s)을 명확한 지침과 함께 Agent(Task) 도구로 위임하면 됩니다."
        % (LIMIT, ", ".join(gate["files"]), ROUTES, path)
    )
 
 
if __name__ == "__main__":
    try:
        main()
    except SystemExit:
        raise
    except Exception:
        sys.exit(0)  # fail-open

참고 자료