Skip to content

Latest commit

 

History

70 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

zannabi-code — verification-first external runner

zannabi-code

license runtime tests

Shake the branch before you cross. — 잔나비는 건너기 전에 가지를 흔들어본다

검증 우선 외부 러너 — 증거 없으면 완료가 아니다. 검증 게이트를 에이전트 밖에 두고, 헤드리스 코딩 에이전트를 구동하며, 모든 완료 선언에 기계 검증 가능한 증거를 요구한다.

상태: pre-1.0 (v0.0.1). 실전에서 쓰고 있지만 인터페이스는 아직 바뀔 수 있다. npm에 배포하지 않았으므로 설치는 이 저장소를 받아 cd packages/cli && bun link 한다. 러너 계약 v1만은 판 번호로 관리한다 — 다른 도구가 그것을 읽기 때문이다.

원리

zannabi run "결제 API에 재시도 로직 추가"
  1. PLAN    에이전트가 계획 + 게이트 제안 → 사람이 승인   ← 유일한 사람 개입
  2. EXECUTE 헤드리스 Claude Code 실행
  3. VERIFY  러너가 게이트를 직접 실행 — 에이전트 자기보고는 판정에 쓰지 않음
  4. 실패 → 실패 증거를 넣어 재시도 (기본 4회)
  5. 성공 → .zannabi/runs/<시각>-<슬러그>/ 에 증거 확정

전제조건

  • Bun 1.3+
  • Claude Code 또는 Codex CLI — 러너가 claude/codex를 헤드리스로 구동한다 (--agent로 선택)
  • 대상 프로젝트에 기계 검증 가능한 게이트가 있을 것 (테스트·빌드·린트 등 종료코드로 판정되는 명령)

사용

bun install
# zannabi 명령 등록. **`bin`이 있는 패키지에서 해야 한다** — 저장소 루트에서 하면
# 루트 패키지가 등록될 뿐 명령이 서지 않는다(성공 메시지는 뜬다)
(cd packages/cli && bun link)
# 링크 없이 쓰려면: bun run packages/cli/src/index.ts <명령>

zannabi run "작업 설명" --cwd /path/to/project --gate "test:bun test" --budget 3
옵션 뜻
--cwd <경로> 작업할 프로젝트 (기본 .) — 러너와 대상은 다른 곳에 있어도 된다
--gate "<이름>:<명령>" 검증 게이트. 여러 번 줄 수 있다
--budget <N> 재시도 횟수 (기본 4)
--max-cost <USD> 보고된 누적 비용이 이 금액에 닿으면 라운드를 더 시작하지 않는다
--agent claude|codex 구동할 코딩 에이전트 (기본 claude)
--model <이름> 에이전트 모델 지정
--plan-agent · --plan-model 계획 턴만 다른 런타임/모델로
--exec-agent · --exec-model 실행 턴만 다른 런타임/모델로
--profile <이름> 조합 프리셋 cheap|balanced|safe
--stall-limit <N> 진전 없는 라운드가 N번 연속이면 중단 (기본 3, 0이면 끔)
--verify-repeat <N> 통과한 게이트를 총 N회 돌려 재현 확인 (기본 1 = 안 함)
--no-suggest 에이전트가 제안한 게이트를 받지 않는다
--no-exec-shell 실행 턴이 게이트 명령을 스스로 돌리는 것을 끈다 (기본은 켬)
--gate-timeout <ms> 모든 게이트의 타임아웃 (기본 300000)
--worktree 전용 워크트리에서 돌고 결과를 zannabi/<실행> 브랜치로 남긴다
--yes 승인 프롬프트를 건너뛴다 (배치 실행용). --gate를 하나 이상 함께 줘야 한다
--approve <채널> 승인을 어디서 물을지. terminal(기본) | telegram
--approve-timeout <초> 원격 승인이 이 시간 안에 안 오면 중단 (기본 없음 = 무한히 기다림)

다른 명령 셋. zannabi status [<이름 일부>]는 지금 무슨 일이 벌어지는지 말하고, zannabi resume [<이름 일부>]는 중단된 실행을 이어서 돈다(둘 다 이름을 생략하면 최신 실행이다). zannabi race "<작업>" --arm A --arm B는 같은 작업을 여러 조합으로 동시에 돌려 게이트로 고른다.

프리셋. --profile은 초기 8회 실측에서 나온 운용 방침을 그대로 담은 조합 묶음이다.

프리셋 하는 일 근거
cheap 실행을 claude:claude-haiku-4-5로 · 예산 5 실행을 낮춰도 대체로 통과했고, 대가는 품질이 아니라 라운드 수였다
balanced 실행을 codex로 · 예산 4 실측에서 가장 안정적이었다 (2/2 성공, attempts 1)
safe 런타임을 낮추지 않음 · 예산 4 · 통과 2회 재확인 여기서 안전은 모델 등급이 아니라 검증 강도의 문제다

프리셋은 실행 턴만 건드리고 계획 턴은 손대지 않는다. 계획 모델을 낮춘 조는 실측에서 0/2였으므로, "계획은 낮추지 마라"를 값을 지정하는 방식이 아니라 건드리지 않는 방식으로 지킨다. --plan-*을 따로 주지 않으면 사용자가 이미 고른 기본값이 그대로 쓰인다.

비용 상한. --budget은 라운드 수의 상한이지 지출의 상한이 아니다. 2026-08-20 실측에서 같은 조합의 1라운드가 $1.64 ~ $4.53으로 2.8배 흩어졌다 — 그러면 --budget 4는 실제로는 "$6.5에서 $18 사이 어딘가"라는 뜻이고, 돌리기 전에 그 폭을 알 방법이 없다. --max-cost는 그 두 번째 축이다. 보고된 누적이 상한에 닿으면 다음 라운드를 시작하지 않고, 여기까지의 작업물과 증거는 그대로 남는다(SWE-agent의 per_instance_cost_limit이 같은 방식이다).

예측하지 않는다. "다음 라운드가 얼마 나올지"를 추정해 미리 멈추는 설계도 가능하지만, 편차 2.8배를 관측해 놓고 그 분포로 다음 값을 부르는 것은 근거 없는 숫자를 만드는 일이다.

상한이 무엇을 봤는지 함께 적는다. 비용을 보고하는 런타임과 안 하는 런타임이 섞이면 (codex는 안 준다) 상한은 지출의 일부만 본다. 그때 "상한 안에서 끝났다"고 말하면 그것은 거짓이므로, 리포트에 커버리지(full/partial/none)를 명시한다. 아무도 보고하지 않으면 상한은 걸리지 않으며 그 사실을 숨기지 않는다.

자동 판정(작업 난이도를 보고 조합을 고르는 것)은 아직 없다. 11회 실측에서 전부 1라운드로 끝나 난이도를 가를 신호가 관측되지 않았고, 라우팅 문헌도 사전 예측 라우터는 구조화 출력이 요구되는 에이전트 과제에서 열화가 크다고 본다. 없는 데이터로 규칙을 박는 것보다 프리셋과 비용 상한이 정직하다.

기본값은 프로젝트 루트의 .zannabi.json에서 읽는다. 우선순위는 플래그 > 설정 파일의 개별 항목 > 프리셋 > 기본값이다 — 프리셋은 기본값 묶음을 갈아끼우는 것이지 지정을 덮어쓰는 것이 아니라서, 조합을 고정한 채 한 항목만 바꿔 실험할 수 있다.

{
  "gates": [{ "name": "test", "cmd": "bun test" }],
  "budget": 3,
  "maxCostUsd": 3.0,
  "profile": "balanced",
  "planModel": "claude-opus-5",
  "execAgent": "codex",
  "verifyRepeat": 2,
  "rejectSuggested": true,
  "worktree": true
}

설정 파일이 필요한 이유는 편의보다 재현성이다. 완료 기준과 예산이 셸 히스토리에만 있으면 같은 조건으로 두 번 돌릴 수 없고, 실행끼리 비교하는 이 프로젝트의 측정이 성립하지 않는다. 파일이 있는데 깨졌으면 조용히 무시하지 않고 세운다 — 사용자가 믿는 조건과 실제 조건이 갈리는 것이 측정에서 가장 나쁜 실패다.

증거 저장소는 작업하는 에이전트가 지울 수 있다

가정이 아니라 관측이다. 2026-08-20 실측에서 실행 에이전트가 .zannabi/를 통째로 지웠고 러너가 ENOENT로 죽었다. 에이전트가 어긋난 것도 아니었다 — 작업 지시가 "어떤 파일도 만들거나 수정하거나 삭제하지 않는다" 였고, 러너가 만든 증거 디렉토리는 untracked 새 파일이라 그 지시의 사정권 안에 있었다. 에이전트가 제안한 게이트 이름이 그 해석을 증언한다 (worktree-clean, untracked-unchanged).

그래서 죽지 않고, 삼키지 않는다.

  • 쓰기 직전에 증거가 아직 있는지 확인하고, 없으면 되살리되 사라졌다는 사실을 먼저 기록한다. 조용히 재생성만 하면 증거 공백이 감춰지고, 앞 라운드 증거가 사라진 채 마지막에 리포트만 새로 쓰인 실행이 완결돼 보인다 — 그건 거짓 초록이다
  • 손실이 하나라도 있으면 성공을 성공이라 부르지 않는다(evidence-lost). 게이트는 실제로 통과했을 수 있지만, 통과의 근거가 지워진 실행은 "증거 없으면 완료가 아니다"라는 이 도구의 전제 아래에서 완료가 아니다
  • 실패로 끝난 실행의 사유는 바꾸지 않는다 — 없는 성공을 만들지 않듯, 실패의 원인도 덮지 않는다
  • 프롬프트에도 건드리지 말라는 한 줄을 싣는다. 다만 그건 예방이고 감지가 본체다 — 지시는 강제가 아니다

이 파일은 대상 저장소 안에 있으므로 작업하는 에이전트가 쓸 수 있다. 실전에서 에이전트가 recovery 게이트를 지우고 자기 작업용 게이트를 넣은 실행이 있었다. 그 실행 자체는 시작 시 읽은 원본으로 판정돼 유효했지만, 다음 실행부터 완료의 정의가 조용히 약해진다. 그래서 실행 전후 지문을 비교해 사라진·추가된·바뀐 게이트를 report.md에 세운다. 금지가 아니라 가시화인 이유는 게이트를 더한 실행도 있었고 그건 좋은 방향이었기 때문이다 — 무엇이 옳은 변경인지는 도구가 판단할 수 없다. 다만 작업하는 쪽이 합격선을 낮출 수 있다면 그것은 합격선이 아니다.

함정: 게이트는 "무엇을 확인하지 않는가"를 말하지 않는다. 지시서에 "이 시험을 먼저 보라" 고 경고했더니 에이전트가 시험 전체의 보안 프로파일을 낮춰 버린 실행이 있었다. 경고한 시험은 안 깨졌고 게이트는 전부 초록이었지만, 운영 기본 경로가 검증에서 통째로 빠졌다. 러너는 게이트가 통과했는지만 판정한다 — 게이트가 덮지 않는 자리는 여전히 사람이 봐야 한다.

생성-검증 분리. 계획과 실행에 다른 런타임을 쓸 수 있다.

zannabi run "..." --plan-model claude-opus-5 --exec-model claude-haiku-4-5
zannabi run "..." --plan-agent claude --exec-agent codex

판정은 어차피 게이트가 하므로, 강한 모델이 계획하고 저가 모델이 실행해도 품질이 유지되는가 — 이것이 "생성은 싸고 검증이 병목"이라는 이 프로젝트의 베팅이고, 이 옵션이 그걸 재는 손잡이다. 어떤 조합으로 돌았는지는 goal.json과 report.md의 runtime 에 남는다. 계획 세션은 계획 런타임의 것이므로 분리 실행이면 실행 턴으로 넘기지 않는다 (계획 내용 자체는 프롬프트에 담긴다).

--yes 주의. 설계상 사람의 승인은 유일한 개입 지점이다. 이를 건너뛰는 대신 러너가 게이트의 실행 가능성을 먼저 확인하고, 실행할 수 없는 게이트가 있으면 거부한다. 이 검사는 명령의 존재만 본다 — 작업 전 실패하는 게이트는 정상이므로 통과/불통과는 판정하지 않는다. 경고에는 두 종류가 있고 --yes는 실행 불가(⛔)만 거부한다. 조언(⚠️)으로 배치 실행이 죽으면 사용자는 조언을 읽는 대신 경고 자체를 끄는 쪽으로 가고, 그러면 정작 막아야 할 경고까지 함께 꺼진다.

--yes는 사용자 게이트를 하나 이상 요구한다. 완료 기준이 전부 에이전트 제안인 채로 사람 없이 도는 실행은 거부된다. 이 도구의 주장은 "판정이 러너 밖 종료코드"인데, 그 종료코드를 낼 명령을 에이전트가 골랐다면 판정은 밖에 있지 않다. 실증했다 — 계획 턴이 게이트로 true를 제안하면 코드를 한 줄도 안 쓴 실행이 success로 끝났고, 사전점검은 그 명령이 실재하므로 통과시켰다. 사람이 보는 실행에서는 승인 화면이 막고, --yes에서는 이 규칙이 막는다. 제안 게이트가 섞이는 것 자체는 막지 않는다 — 실측에서 계획 에이전트가 산문 제약을 게이트로 바꿔 붙여 값을 냈다. 막는 것은 기준 전체가 제안일 때다.

승인을 터미널 밖으로 — 텔레그램

긴 실행에서 사람의 선택지는 둘뿐이었다: 자리를 지키거나 --yes로 승인을 포기하거나. --approve telegram은 그 자리를 옮긴다 — 계획과 게이트가 텔레그램으로 오고 y 한 글자로 승인한다.

export ZANNABI_TELEGRAM_BOT_TOKEN='123456:AA...'   # @BotFather가 준 토큰
export ZANNABI_TELEGRAM_CHAT_ID='987654321'        # 봇과 나눈 1:1 대화의 chat id
zannabi run "작업" --gate "build:bun run build" --approve telegram

환경변수인 이유: 봇 토큰을 쥔 쪽이 계획 승인권을 갖는다. .zannabi.json은 저장소에 커밋되는 파일이므로 승인권을 거기 둘 수 없다. ZANNABI_TELEGRAM_CHAT_ID가 아닌 대화에서 온 메시지는 없는 말로 친다 — 봇을 아는 누구나 승인할 수 있으면 이 채널은 승인 화면이 아니라 우회로가 된다.

둘을 함께 쓸 수 없다. --yes는 사람을 빼는 것이고 원격 승인은 사람을 넣는 것이다. 그리고 환경변수가 없으면 조용히 터미널로 내려가지 않고 세운다 — 원격 승인을 지정한 사람은 자리를 뜰 참이고, 조용한 폴백은 아무도 없는 화면에서 실행이 밤을 새우게 한다.

동시 실행과 폴링 소유권. 텔레그램 Bot API는 같은 토큰으로 getUpdates를 동시에 두 번 돌리면 409를 낸다 — 우리가 설계로 피할 수 있는 것이 아니라 API의 제약이다. 그래서 승인을 기다리는 프로세스만 ~/.zannabi/locks/의 락을 잡는다. 다른 실행이 쥐고 있으면 기다리지 않고 터미널 승인으로 내려간다(사유를 화면에 적는다). 기다리는 쪽이 친절해 보이지만, 그러면 아무도 안 보는 터미널에서 두 실행이 함께 멈춘다.

같은 문제를 gajae-code는 상주 데몬(gjc daemon — "봇 토큰당 안전한 long-poll 소유자 하나")과 브로커·컨트롤러 세 층으로 푼다. 그러려면 승인 응답을 세션으로 되돌리는 양방향 계약이 필요한데, 우리 계약은 단방향이라 그 값을 치를 수 없다. 대신 승인 창에만 락을 잡아 상주 프로세스 0개로 끝냈다.

core는 이 채널을 모른다. 승인은 LoopOptions.approve 콜백 하나이고 --yes가 이미 그 자리를 갈아끼우고 있었으므로, 채널을 붙이는 데 core 수정은 0줄이었다 — 어댑터 계약과 같은 구조다. 저널에는 승인이 어디서 왔든 approval-requested/approval-resolved가 같은 모양으로 남는다.

화면은 버튼과 서식으로 온다. 계획은 접히는 인용문에 담기고, 완료 기준은 ●(사용자)와 ○(에이전트 제안)로 갈라 적으며, 승인/중단은 버튼을 누른다. 누르면 그 메시지가 결과로 바뀌고 버튼이 사라진다 — 지나간 요청의 버튼이 남아 있으면 다시 눌리기 때문이다.

버튼이 텍스트보다 안전하다. 버튼 콜백에는 어느 메시지의 버튼인지가 실려 오므로, 지나간 요청의 버튼을 눌러도 이번 승인이 되지 않는다. y라는 글자로는 할 수 없는 출처 확인이다. 물론 텍스트 y/n도 계속 받는다 — 버튼이 안 보이는 상황이 있을 수 있다.

서식이 깨지면 서식을 버리고 다시 보낸다. 텔레그램은 파싱에 실패한 메시지를 거부하므로, 서식을 쓰는 대가가 "승인 화면이 아예 안 뜨는 것"이어서는 안 된다. 계획 본문은 모델 출력이라 무엇이 들어올지 알 수 없어, 이스케이프를 해도 이 안전망은 있어야 한다. (HTML을 쓰는 이유도 같다 — 특별한 문자가 & < > 셋뿐이라 MarkdownV2의 18자보다 훨씬 좁다.)

어느 실행인지 맨 위에 적는다. intent·경로·예산·비용 상한이 먼저 오고, 그다음이 계획이다. 실물에서 같은 모양의 요청이 연달아 오니 구별이 되지 않았다 — 승인은 "무엇에 대한 승인인가"를 아는 상태에서만 승인이다. 같은 이유로 계획 본문에서 게이트 JSON 블록은 걷어낸다(바로 아래 완료 기준으로 다시 그려지는 중복이다).

밀린 메시지는 승인이 아니다. 묻기 전에 폴링 커서를 현재로 옮긴다. 이것이 없으면 몇 시간 전에 보낸 y 한 줄이 다음 실행의 승인으로 읽힌다 — 사람이 보지도 않은 계획이 승인으로 저널에 남는, 승인 게이트에서 낼 수 있는 가장 나쁜 결함이다.

알아듣지 못한 말은 거부가 아니라 무시다. 터미널에서는 y가 아니면 전부 거부지만, 채팅방에 흘러든 한 마디에 실행이 죽어서는 안 된다. 답이 끝내 없으면 --approve-timeout이 정직하게 끝낸다.

증거가 쓰인 그대로인가. 저널의 모든 줄은 seq와 chain을 갖는다 — chain은 앞줄까지의 누적 해시에 이 줄을 이어 만든 값이라, 한 줄만 고쳐도 그 뒤가 전부 어긋난다. zannabi status와 report.md가 그 사실을 말한다.

탐지이지 방지가 아니다. 저널을 통째로 다시 계산하면 이 검사는 통과한다 — 비밀키 없이 위조를 막을 수는 없고, 키를 둘 곳도 없다(증거 옆에 두면 같이 읽힌다). 그런데도 값이 있는 이유는 위협이 악의적 공격자가 아니라 "테스트를 통과시켜라"는 지시를 받은 작업 에이전트 이기 때문이다. evidence.json 한 줄을 고치는 것과 러너의 해시 규칙을 알아내 체인을 다시 잇는 것 사이에는 큰 거리가 있다.

둘은 변조로 치지 않는다: 끝에서 잘린 저널(kill -9가 그 모양이다)과 체인이 없는 옛 저널. 뒤엣것은 "확인할 수 없음"이지 "변조됨"이 아니다 — 확인할 수 없는 것을 매번 알리면 진짜 경고가 그 소음에 묻힌다.

게이트가 확인해 주지 않는 것. 실행 턴은 마칠 때 게이트가 보증하지 않는 주장을 신고한다 — 게이트가 안 건드리는 파일(화면 마크업·문서·설정), "다른 어떤 코드도 이 값을 읽지 않는다" 같은 부정 진술, 읽고 미뤄 짐작했을 뿐 돌려 보지는 않은 동작이 그런 것들이다.

왜 필요한가. 실행 턴에 게이트 명령을 열어 주자 라운드 낭비는 줄었는데, 대가로 "확인 못 한 자리"를 스스로 지목하는 일이 사라졌다. 셸이 닫혀 있을 때는 못 돌리는 것이 명백해서 지목이 나왔는데, 대부분 돌릴 수 있게 되자 돌릴 수 없는 나머지가 눈에 안 띈다 — 그 나머지가 정확히 틀리기 쉬운 자리다. 실측에서 에이전트는 "어떤 코드도 이 값을 보지 않는다"고 단언했고, 사람이 grep 한 번으로 반례를 찾았다.

세 상태를 가른다. 신고 없음(형식대로 답하지 않음) · 없다고 신고함 · N건 신고. 앞의 둘은 다르다 — "없다고 말했다"는 검증 가능한 진술이고 "말하지 않았다"는 아니다. 산문으로 요구해 봤고 실패했다(지시서에 "확인하지 못한 것도 적어라"가 있었는데 비었다). 그래서 형식을 강제하고 러너가 파싱한다.

판정에는 쓰지 않는다. 신고가 없다고 실패시키면 에이전트의 서식 준수가 완료를 좌우하게 되고, 그것은 판정을 게이트 밖으로 흘리는 것이다. 신고 내용이 참인지도 러너는 모른다 — 이 기능이 하는 일은 확인되지 않은 자리를 눈에 보이게 만드는 것까지다.

진전 없는 루프 끊기. 예산은 진전을 사는 값이지 같은 실패를 다시 확인하는 값이 아니다. 러너는 라운드마다 워킹트리의 변경분 해시와 게이트 결과를 함께 보고, 둘 다 그대로인 라운드가 --stall-limit번 연속이면 남은 예산을 태우지 않고 no-progress로 끊는다. --stall-limit이 예산 이상이면 이 감지는 발동할 수 없다 — 연속 N라운드가 같아야 하는데 예산이 그 전에 끝나기 때문이다. 실측에서 옛 기본 조합(한계 3 · 예산 3)이 정확히 그랬다: 라운드 1·2의 diff 해시가 완전히 같았는데도 감지 없이 예산 소진으로 끝났다. 기본 예산이 4인 이유가 여기에 있다 — 기본값 둘이 서로를 무효화해 아무 옵션도 주지 않은 실행에서 감지가 언제나 죽어 있었다. 사용자가 직접 정한 조합이 죽어 있을 때는 러너가 조건을 뒤에서 바꾸지 않고 (그러면 실행끼리 비교가 무너진다) 죽은 조합이라는 사실을 승인 전에 말한다. 게이트 결과만 보는 판정은 실측에서 틀렸다 — 같은 실패가 두 번 나온 뒤 진전한 사례가 있었고, 그때 달라져 있던 것이 diff였다. 반복이 감지되면 다음 실행 프롬프트에도 그 사실을 실어 같은 접근을 되풀이하지 않게 한다. git 저장소가 아니면 diff 축이 없으므로 감지는 스스로 꺼진다.

다만 이것은 거의 발동하지 않는 최후 안전망이다. 실전에서 정체를 일부러 일으켜 세 번 시도해 세 번 다 감지되지 않았고, 세 번의 이유가 전부 달랐다. ① 막힌 에이전트는 가만히 있지 않는다 — 불가능한 게이트를 걸어도 4라운드의 diff가 전부 달랐다. ② 러너 출력을 대상 저장소 안으로 리다이렉트(> run.log)하면 그 파일이 매 라운드 커져 diff가 계속 달라진다 (.zannabi/는 러너가 알아서 빼지만 사용자가 만든 로그 파일은 뺄 수 없으니 저장소 밖에 두는 편이 낫다). ③ 만들었다 지웠다 하는 A-B-A-B 왕복은 연속 꼬리를 세는 판정에 걸리지 않는다 — 사이에 다른 시도가 낀 것을 정체로 부르면 되돌아가며 탐색하는 정상 동작을 끊기 때문에 의도한 설계다. 11회 실측에서 no-progress가 한 번도 걸리지 않은 것도 표본 부족이 아니라 이 때문이다. 남겨 둔 이유는 설계 근거가 실측이고(같은 라운드가 3번 반복된 실행이 실제로 있었다) 오발동이 0회라서다 — 값을 낸다고 광고할 물건은 아니고, 걸리면 그때 벌어야 할 예산을 버는 물건이다.

재현되지 않는 통과는 증거가 아니다. --verify-repeat N을 주면 모든 게이트가 통과한 라운드에서 게이트를 N회까지 다시 돌려 재현을 확인하고, 한 번이라도 갈리면 unreproduced-pass로 끝난다. 실패한 라운드는 재확인하지 않는다 — 어차피 다음 시도로 넘어가고, 비용도 성공 시 한 번으로 묶인다. 기본값은 1(끔)이다.

이것이 실제로 검사하는 것은 게이트의 멱등성이다. flaky라 부르지 않는 이유가 여기 있다. N=2가 잡을 확률을 그대로 적으면:

결함 N=2가 잡을 확률
멱등성 위반 — 2회차부터 항상 실패 사실상 100%
고빈도 간헐 실패 (5회 중 3회) 60%
전형적 간헐 실패 (10회 중 1~2회) 10~20%

낮은 확률로 터지는 경합형을 90% 신뢰로 잡으려면 10회 넘게 돌려야 하는데, 그건 이 도구가 팔 물건이 아니다(CI 리트라이 도구의 자리다). **"게이트가 두 번 돌면 같은 답을 내는가"**는 다르다 — 게이트를 러너가 직접 실행하는 구조에서만 값싸게 물을 수 있고, 검증자가 에이전트인 도구는 구조적으로 못 묻는다.

실전에서 잡은 결함도 정확히 그것이었다. 게이트 4개가 첫 회 전부 초록이었는데 재확인에서 빌드가 갈렸고, 파고드니 간헐 실패가 아니라 두 번째 실행부터 항상 실패하는 오염이었다 (테스트 DB를 파일로 내리면서 이전 실행의 데이터가 전역 집계에 섞였다). 재확인이 없었으면 초록으로 커밋됐을 것이다.

⚠️ 재확인은 게이트 명령이 매번 실제로 도는 경우에만 의미가 있다. Gradle·Maven·Bazel처럼 결과를 캐시하는 도구는 두 번째 실행을 UP-TO-DATE로 건너뛰고, 그러면 러너는 아무것도 확인하지 못한 채 "재확인했다"고 말한다. 실전에서 정확히 이 일이 있었고, 게이트 명령에 cleanTest를 직접 넣어 해결했다.

그래서 실행 후 소요시간으로 본다. 축이 둘이다 — ① 재확인이 첫 회의 40% 미만으로 끝났는가(두 번째가 헛돌았다), ② 청소를 명시한 명령이 첫 회부터 몇 초에 끝났는가 (처음부터 헛돌았다). ②가 필요한 이유는 실측이 알려줬다: :csms:cleanTest build가 2.1초/1.2초로 끝나 비율이 0.56이었는데, 시험이 한 번도 돈 적 없는 초록이었다. 비율은 "두 번째가 더 빨랐는가"만 묻기 때문에 첫 회부터 헛돌면 아무것도 보지 못한다. 같은 리비전에 같은 명령인데 재확인이 첫 회의 40% 미만으로 끝났으면 report.md에 숫자와 함께 남긴다(실측 사례가 54.9s → 14.8s = 0.27이었다). 데몬이 덥혀져 정직하게 빨라지는 경우도 있어 통과를 무르지는 않는다 — 판정은 사람이 한다. 게이트 6개를 돌린 실측에서 정직하게 다시 돈 재확인의 최저 비율은 0.69였고, 오탐은 0이었다.

명령어 문자열을 보고 미리 경고하는 검사도 있었지만 지웠다. 같은 실측에서 뜬 경고가 전부 오탐이었기 때문이다 — 그 게이트들은 빌드 스크립트에서 이미 캐시를 꺼 둔 (outputs.upToDateWhen { false }) 상태였는데, 명령만 보는 검사는 그것을 알 수 없다. 값을 낸 적 없는 경고를 남겨 두면 다른 경고까지 안 읽히게 만드는 비용만 남는다.

성공 경로가 느려진다는 대가도 있다 — 실측에서 게이트 6개의 합이 76.5초 → 재확인 65.3초로, **+85%**였다. 기본값이 1(끔)인 이유다.

사용자 게이트와 제안 게이트는 따로 집계된다. 사람이 건 게이트는 완료의 정의고 에이전트가 제안한 게이트는 자기 검사다. 둘을 한 통에 담으면 "완료 기준은 다 맞췄는데 자기가 건 불가능한 게이트로 죽은" 실행이 그냥 실패로 보인다 — 실제로 그런 실행이 있었다. 이제 그 경우 budget-exhausted의 사유에 "완료 기준은 모두 충족했다"가 함께 실린다.

밀려난 제안은 침묵 속에 사라지지 않는다. 같은 이름의 사용자 게이트가 있으면 사용자 게이트가 이긴다 — 완료의 정의는 사람이 정한다. 다만 실제로 이런 일이 있었다: 계획 에이전트가 "build 게이트를 지금 형태로 두면 2회차가 UP-TO-DATE로 스킵돼 재확인이 헛돈다" 고 정확히 진단하고 cleanTest를 붙인 명령을 제안했는데, 같은 이름의 사용자 게이트에 밀려 경고 한 줄 없이 사라졌다. 에이전트가 러너의 눈먼 지점을 발견했는데 러너가 그 발견을 삼킨 것이다. 지금은 밀려난 제안이 승인 화면에 조언 경고로 뜨고, goal.json과 report.md의 "반영되지 않은 제안 게이트" 절에 실제 실행된 명령과 나란히 남는다. --no-suggest로 통째로 거부한 경우도 무엇을 거부했는지 같은 자리에 남는다. 명령까지 똑같은 충돌은 잃은 것이 없으므로 보고하지 않는다.

남은 일 — 예산이 말하지 않는 것

attempts 4 / budget-exhausted는 몇 번 시도했는지만 말한다. 무엇이 남았는지는 말하지 않는다. 그런데 게이트 5개 중 4개를 닫고 끝난 실행과 하나도 못 닫은 실행은, 이어서 할 사람에게 전혀 다른 상황이다.

게이트 실패는 그 자체로 "할 일 하나"다. build는 통과인데 api-auth가 빨간 것 — 그것을 "라운드 실패"라는 한 마디로 뭉개고 있었다. 그래서 라운드마다 셋을 남긴다:

open 아직 통과하지 못한 게이트
closed 이번 라운드에 처음 통과한 것
reopened 앞 라운드에서 통과했다가 다시 실패한 것 — 고치다 깬 자리
## 라운드별 진행

| 라운드 | 리비전 | 남은 일 | 닫힘 | 되열림 |
|---|---|---|---|---|
| 2 | `a1b2c3` | 2/4 — lint, flaky-api       | build | ·          |
| 3 | `d4e5f6` | 2/4 — regression, flaky-api | lint  | regression |

두 라운드 모두 게이트 2/4 통과다. 통과 수만 보면 같은 라운드인데, 실제로는 lint를 닫으면서 regression을 깼다. 리비전 해시가 같은 표에 있는 이유도 같다 — 남은 일이 그대로여도 파일이 달라졌다면 다른 시도이고, 정체 판정이 그 둘을 함께 보는 것과 같은 근거다. reopened가 없으면 이 차이가 어디에도 안 남는다 — 그리고 그때 필요한 행동은 "계속 고치기"가 아니라 접근을 다시 보는 것이라, 다음 라운드 프롬프트에도 그렇게 전한다.

착안은 gajae-code의 ultragoal에서 빌렸다 — 실패하면 라운드를 더 쓰는 대신 blocker를 하나 더 만드는 구조다. 결정적으로 다른 점은 누가 그것을 만드는가이다: 저쪽 blocker는 에이전트가 판단해 만들고, 우리 남은 일은 게이트 종료코드가 만든다.

⚠️ 이것을 종료 조건으로 쓰지 않는다. "남은 일이 안 줄면 정체"는 그럴듯하지만 실측이 이미 반증한 규칙이다 — 게이트 결과가 같은 2라운드 뒤에 진전한 표본이 있다(그래서 정체 감지는 게이트 결과와 워킹트리 변경분을 함께 본다). 여기서 만든 것은 판정이 아니라 화면이다.

best-of-N — 게이트가 고른다

zannabi race는 같은 작업을 여러 실행 런타임으로 동시에 돌리고 게이트로 고른다.

zannabi race "결제 API에 재시도 로직 추가" --gate "test:bun test" \
  --arm claude:opus-5 --arm codex --arm claude:haiku-4-5
| 조 | 판정 | 라운드 | 실행 비용 | 시간 | 커밋 |
|---|---|---|---|---|---|
| claude:opus-5    | ✅ success          | 2 | $3.2100 | 96.4s | 2 |
| codex            | ✅ success          | 1 | -       | 61.2s | 1 |
| claude:haiku-4-5 | ❌ budget-exhausted | 3 | $0.4400 | 88.0s | 3 |

**판정**: codex가 가장 적은 라운드로 통과했습니다 (1R vs 2R)
가져가려면: git merge zannabi/2026-08-21T...-결제-API-codex

왜 이것이 우리 자리인가. 검증자가 에이전트인 도구는 N개 결과 중 무엇이 나은지를 결국 모델에게 묻는다(자기 확신도·자기 평가). 게이트를 러너가 직접 돌리는 구조에서는 종료코드가 고른다 — 통과와 불통과 사이에 해석의 여지가 없다.

계획은 한 번만 세우고 모든 조가 공유한다. 셋 다 필요한 이유가 있다 — ① 변수가 실행 턴 하나여야 무엇 때문에 이겼는지 말할 수 있고, ② 계획 비용을 N번 내지 않으며, ③ 승인이 N번 뜨는 도구는 쓸 수 없다. 프리셋이 실행 턴만 건드리는 것과 같은 원리이고, "계획은 낮추지 마라"는 실측과도 맞는다. 그래서 --arm은 실행 런타임만 받는다.

이겼다고 함부로 말하지 않는다. 가르는 순서는 ① 라운드 ② 비용 ③ 시간이다. 라운드를 먼저 보는 것은 그것이 모든 런타임이 동등하게 보고하는 유일한 축이기 때문이고, 비용이 2순위인 것은 보고하지 않는 런타임이 있어서다 — 없는 값으로 순위를 매기면 침묵한 런타임이 언제나 이기거나 언제나 진다. 비용으로 가를 수 없었으면 그 사실을 판정문에 적는다.

집계는 개별 합이다. 조마다 독립된 실행 기록(.zannabi/runs/)이 남고, 집계 (.zannabi/races/<id>/summary.json)의 판정과 비용은 그것들의 합이어야 한다 — 집계가 개별 실행과 다른 말을 하기 시작하면 병렬은 측정 도구가 아니라 측정을 망치는 장치가 된다. 비용을 보고하지 않는 조가 섞이면 합계도 partial로 표시된다.

격리는 자동이다. 조들이 워킹트리를 공유하면 서로의 변경을 자기 것으로 보므로, race는 워크트리 없이는 성립하지 않는다. git 저장소가 아니면 실행 전에 거부한다. --concurrency N으로 동시에 도는 조 수를 제한할 수 있다(기본은 전부) — 조 하나가 에이전트 프로세스에 게이트 프로세스를 더 쓰므로, 너무 많으면 게이트 소요시간이 서로의 부하로 늘어나 재확인의 시간 비교가 못 믿을 값이 된다.

💸 비용은 조 수만큼 는다. --max-cost는 조마다 적용되므로 최대 지출은 상한 × 조 수다. 실행 시작 시 그 숫자를 함께 알린다 — 상한을 걸어 놓고 N배를 쓰는 일이 없도록.

격리 — 사람의 워킹트리를 건드리지 않는다

--worktree를 주면 실행이 전용 워크트리에서 돈다. 에이전트도 게이트도 거기서 돌고, 증거(.zannabi/)만 원본 저장소에 남는다 — 실행의 기록은 워크트리보다 오래 살아야 하기 때문이다.

zannabi run "결제 API에 재시도" --worktree
# [zannabi] 워크트리: /tmp/zannabi-wt-abc/work (브랜치 zannabi/2026-08-21T...-결제-API)
# ✅ success
# [zannabi] 결과: 브랜치 zannabi/2026-08-21T...-결제-API (커밋 3개)
#          git merge zannabi/... 로 가져가세요

왜 필요한가. 같은 저장소에서 두 실행이 동시에 돌면 서로의 변경을 자기 것으로 본다. 리비전 결박(diffHash)도 정체 감지도 "이 워킹트리의 변경분"을 재는데 그 워킹트리가 공유되면 증거가 서로를 오염시킨다 — 판정이 틀리는 것을 넘어 증거 자체가 거짓이 된다.

왜 브랜치로 돌려주는가. 성공한 작업물을 사용자의 워킹트리에 자동으로 적용하면 미커밋 변경과 충돌할 수 있고, 그 순간 러너가 사람의 작업을 건드린다. "에이전트를 믿지 않는다"를 전제로 하면서 그 결과물을 묻지도 않고 사람 책상에 올리는 것은 앞뒤가 안 맞는다. 브랜치는 남기고 병합은 사람이 한다.

라운드마다 커밋한다. 실패로 끝난 실행의 작업물도 사라지면 안 되고 — 3라운드를 태운 시도에도 이어받을 것이 있다 — 라운드별 커밋은 "몇 번째 시도에서 무엇이 달라졌나"를 git 이력 자체로 말한다. 바뀐 것이 없는 라운드는 빈 커밋을 만들지 않는다.

빌드 캐시가 없는 첫 라운드는 느리다. 실측에서 Gradle 프로젝트의 콜드 워크트리 빌드가 52.6초였다(의존성 캐시 ~/.gradle는 공유되고 컴파일만 차갑다). 게이트 타임아웃이 15분이면 그 6%다. 재확인의 비율 감지는 이 첫 회를 판정에서 빼는데, 콜드 컴파일을 포함한 첫 회와 시험만 도는 재확인을 비교하면 구조적으로 오탐하기 때문이다.

딸려오지 않는 것이 있다. 워크트리는 HEAD에서 갈라지므로 원본의 미커밋 변경은 포함되지 않는다. 그 사실을 실행 시작 시 파일 수와 함께 알린다 — 모르고 돌리면 "왜 내 수정이 반영 안 됐지"를 나중에 겪는다.

가져올 때는 --squash를 권한다. 러너는 라운드마다 커밋을 남기므로 (zannabi: round 1, zannabi: round 2 …) 그대로 병합하면 그 커밋들이 본 이력에 섞인다. 라운드별 이력은 브랜치에 그대로 있으니 필요할 때 거기서 보면 된다:

git merge --squash zannabi/<실행> && git commit    # 하나로 합쳐 가져온다
git log zannabi/<실행>                              # 라운드별 이력은 브랜치에 남아 있다

라운드가 어떻게 진행됐는지를 본 이력에 남기고 싶으면 그냥 git merge를 쓴다 — 어느 쪽이 옳은지는 그 저장소의 이력 취향이 정한다.

브랜치는 쌓인다. 실행마다 zannabi/<실행> 브랜치가 하나 남고 러너는 그것을 지우지 않는다 — 실패한 실행의 작업물도 사라지면 안 되기 때문이다. 정리는 사람의 몫이다: git branch --list 'zannabi/*'로 보고 git branch -D로 지운다.

⚠️ 재개(zannabi resume)와 워크트리를 함께 쓰면 지금은 원본에서 이어간다. 실행이 끝날 때 워크트리를 치우기 때문에 이어갈 자리가 남아 있지 않다. 작업물은 브랜치에 있으므로 잃지는 않지만, 이어 도는 라운드는 격리되지 않는다. 병렬 실행의 실물을 더 본 뒤에 정한다.

라운드를 완성하지 못하고 끝난 실행의 작업물도 브랜치에 건진다. 라운드 커밋은 라운드가 완성돼야 도는데, 실행 턴이 실패하면(agent-error) 루프가 라운드를 만들지 않고 끝나 커밋이 한 번도 일어나지 않는다. 그 상태로 워크트리를 지우면 에이전트가 쓴 것이 통째로 사라지고 화면에는 "바뀐 파일이 없었습니다"가 뜬다 — 실제로 그랬고, 지금은 치우기 전에 건진다.

재개는 런타임이 바뀌면 세션을 이어받지 않는다. 세션 id는 그것을 만든 런타임의 것이라, claude로 돌던 실행을 codex로 재개하면 남의 대화를 가리키며 깨진다. 실행 자체는 막지 않는다 — 계획과 게이트는 사람이 승인한 그대로 유효하고, 잃는 것은 앞 턴의 대화 맥락뿐이며 실패 증거는 어차피 프롬프트로 다시 들어간다. 러너는 그 사실을 재개 시작 줄에 적는다.

⚠️ 워크트리에는 설치된 의존성이 없다. git worktree는 추적된 파일만 가져오므로 node_modules·vendor 같은 gitignore된 디렉토리가 비어 있다. 게이트가 그것을 전제하면 격리 실행에서만 실패한다 — 이 저장소에서 bun test를 그대로 게이트로 걸었더니 45건이 깨졌다. 게이트에 설치를 포함하면 된다: bun install --frozen-lockfile --silent && bun test. Gradle·Maven처럼 빌드 도구가 의존성을 스스로 받는 프로젝트는 이 문제가 없다.

기본값은 격리가 꺼져 있다. 한 줄 고치는 작업에까지 브랜치 병합을 붙이는 것은 값보다 비용이 크다. 워크트리는 저장소 밖(시스템 임시 디렉토리)에 만든다 — 안에 두면 그 디렉토리가 원본의 미추적 파일로 잡혀 원본의 diff에 섞이고, 격리하려던 것이 도로 오염원이 된다.

죽어도 이어서 돈다 — 이벤트 저널

러너가 죽는 것은 가정이 아니다. 실측에서 사전점검이 타임아웃 143으로 죽었고, 게이트가 30분씩 도는 실행에서 그 사이 무슨 일이 벌어지는지는 밖에서 볼 방법이 없었다.

.zannabi/runs/<id>/journal.jsonl이 그 답이다 — append-only JSONL 한 파일이 세 가지를 겸한다. tail하면 실시간 화면, 재생하면 크래시 재개의 체크포인트, 끝나고 읽으면 측정 데이터. 셋을 따로 만들면 어긋날 때 어느 쪽이 진실인지 정할 수 없다.

run-started · run-resumed · plan-finished · approval-requested/resolved · round-started
· exec-finished · gate-result · round-finished · cost-updated · evidence-lost · run-finished
zannabi status                     # 실행 목록
zannabi status 결제-API            # 이름 일부로 하나를 골라 상세
zannabi resume 결제-API            # 중단된 지점의 다음 라운드부터

status는 실행 중에도 지금 어디까지 왔는지 말한다 — 완료된 게이트, 지금 도는 게이트, 마지막 이벤트로부터의 무음 경과까지. 다만 러너가 죽었는지 지금도 도는 중인지는 말하지 않는다. 프로세스의 생사는 저널의 관할이 아니라서, 무음 경과라는 단서만 주고 판단은 사람이 한다.

status는 저널만 읽는다. report.md도 evidence.json도 열지 않으므로, 저널 하나에서 나오지 않는 정보는 화면에 뜰 수 없다 — 이 계약을 소비할 다른 도구가 같은 것을 할 수 있는지가 그 자리에서 판정된다(테스트가 파생 파일을 전부 빈 파일로 덮고 확인한다).

중단된 라운드는 완료로 세지 않는다. 게이트를 절반 돌다 죽은 라운드를 완료로 치면 돌지 않은 게이트가 판정에서 조용히 빠진다 — 거짓 초록의 가장 값싼 경로다. 그 라운드는 처음부터 다시 돈다.

재개는 승인을 다시 묻지 않는다. 다시 묻는 것은 이어가는 것이 아니라 새 실행이다. 계획과 게이트는 사람이 승인한 그대로 쓰고, 실행 디렉토리도 같은 것을 이어 쓴다 — 새 디렉토리로 가르면 한 작업의 증거가 두 곳에 나뉘고 "몇 라운드 돌았나"에 답이 둘이 된다. 예산은 --budget으로 늘려 이어갈 수 있다(남은 예산이 없어 멈춘 실행에 "예산을 다 썼다"만 말하면 다음 수가 없다).

이월된 지출은 이월된다. 재개할 때마다 누적 비용이 0으로 리셋되면 --max-cost는 상한이 아니게 된다. 계획 턴의 지출도 함께 승계된다 — 재개는 계획을 다시 돌리지 않지만 그 돈은 이미 썼다.

저널이 말하지 않는 것 하나: 이벤트가 끊긴 실행이 죽은 것인지 지금도 도는 중인지는 저널로 알 수 없다(프로세스 생사는 저널의 관할이 아니다). status는 그 경우 단정하지 않고 "끊김"이라고만 말한다. 반면 승인 대기는 구분된다 — 그래서 어휘에 승인 이벤트가 있다.

재개는 계획 본문만 저널 밖(plan.md)에서 읽는다. 계획 전문을 저널에 실으면 한 줄이 수 KB가 되고 같은 내용이 두 벌 남는다. 상태 재구성은 저널 하나로, 재개는 실행 디렉토리 전체로 — 두 요구는 다르다.

증거 디렉토리

plan.md(승인된 계획) · goal.json(intent/게이트/예산/루프 설정) · journal.jsonl(이벤트 저널) · transcript.jsonl(에이전트 이벤트) · evidence.json(라운드별 게이트 결과) · rounds/round-N.patch(라운드별 변경분) · diff.patch(최종 변경분) · report.md(요약·실패 사유·런타임 조합)

report.md에는 계획 턴과 실행 턴의 토큰·비용이 나뉘어 실린다. 비용을 보고하지 않는 러너(codex)는 -로 남는다 — 0원으로 적으면 공짜라는 거짓이 되고, 조합별 비용을 비교하는 것이 이 축의 목적이라 "모름"과 "0"의 차이를 뭉개면 축 자체가 못 쓰게 된다.

토큰 열은 어댑터 경계에서 정규화된다. 같은 이름의 필드가 러너마다 다른 것을 세기 때문이다 — 실측하면 claude의 input_tokens(15)는 cache_read_input_tokens(307,180)와 별개인데, codex의 input_tokens(566,030)는 cached_input_tokens(504,576)를 포함한다. 그대로 한 열에 나란히 놓으면 조합 비교가 성립하지 않고 합계 행도 뜻이 깨진다. 그래서 readUsage가 포함형 러너에서 캐시분을 빼고, in(new)(캐시에 없던 입력)와 cached가 언제나 겹치지 않게 맞춘다. 총 입력이 필요하면 둘을 더하면 된다.

모든 증거는 리비전에 결박된다 — 각 게이트 결과에 그 순간의 head(HEAD 커밋)와 diffHash(미추적 파일 포함 변경분의 sha256 앞 16자)가 붙는다. 증거 한 줄만 떼어 봐도 무엇 위에서 나온 결과인지 특정할 수 있어야 하기 때문이다. 리비전은 라운드마다 게이트를 돌리기 직전에 한 번 찍는다 — 게이트마다 다시 찍으면 파일을 건드리는 게이트 때문에 같은 라운드 안에서도 리비전이 갈린다.

diff.patch 는 신규 파일을 포함한다. 이를 위해 인덱스가 필요하지만 러너는 대상 저장소의 인덱스를 건드리지 않는다 — 실제 인덱스를 임시 파일로 복사해 쓰므로 스테이징 상태는 그대로다.

계약 — 다른 도구가 이 러너를 읽는 법

이 러너가 밖으로 내보이는 것은 러너 계약 v1 에 규정돼 있다. 파일 · 저널 · 능력 신고 셋이고, 소비자(대시보드·에디터·CI)는 이것만 알면 러너의 내부를 몰라도 된다.

zannabi manifest   # 이 러너가 무엇을 보고하는지 JSON으로

능력을 신고하는 이유: 소비자는 여러 러너를 붙일 수 있어야 하는데 러너마다 보고하는 축이 다르다. 코딩 에이전트를 직접 구동하는 쪽은 "3개 실행 중" 까지밖에 못 말하고, 이 러너는 "3개 중 2개 통과, 1개는 증거 소실" 을 말한다. 화면이 러너를 알 필요는 없다 — 능력을 물으면 된다. 검증 축을 신고할 수 있는 러너만 검증 화면을 얻는다.

신고는 자랑이 아니라 계약이다. 이 러너의 cost는 full이 아니라 partial로 신고된다 — claude는 비용을 주고 codex는 안 주기 때문이고, full로 적으면 소비자가 $0.00을 그리게 된다.

단방향이다. 러너는 보고하고 소비자는 읽는다. 승인·중단·라우팅을 지시하는 통로는 계약에 없고, 소비자가 띄운 프로세스의 주인은 띄운 쪽이다. 그 대가로 계약이 이 구현에 묶이지 않는다.

계약 문서는 테스트가 코드와 대조한다 — 저널 어휘 12종, 판정 10종, 능력 축 8개가 문서와 코드에서 일치하지 않으면 테스트가 깨진다. 증거를 요구하는 도구의 계약 문서가 검증되지 않으면 앞뒤가 안 맞는다.

패키지

  • @zannabi-lab/core — 런타임 중립: 스키마, 게이트 러너, PEV 루프, 증거 저장소
  • @zannabi-lab/adapter-claude — Claude Code 헤드리스 어댑터
  • @zannabi-lab/adapter-codex — Codex CLI 헤드리스 어댑터
  • @zannabi-lab/gateway-telegram — 텔레그램 승인 채널 (--approve telegram)
  • @zannabi-lab/cli — zannabi 명령

두 번째 어댑터를 붙이며 확인된 것: PEV 루프·게이트 러너·증거 저장소는 한 줄도 바뀌지 않았다. core 변경분은 두 어댑터가 공유하는 프로세스 구동 배관을 추출한 것뿐이고, AgentAdapter 계약 자체는 그대로다.

같은 ZANNABI LAB의 oh-my-zannabi(Claude Code 조련 레이어)와 상호보완: 러너가 구동하는 Claude Code에 oh-my-zannabi 설정이 그대로 적용된다.

개발

bun test        # 432개
bun run typecheck

라이선스

Apache License 2.0 · 저작권 표기는 NOTICE

About

Verification-first external runner for headless coding agents — no evidence, no done. Gates live outside the agent; every completion claim requires machine-checkable proof.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages