Shake the branch before you cross. — 잔나비는 건너기 전에 가지를 흔들어본다
검증 우선 외부 러너 — 증거 없으면 완료가 아니다. 검증 게이트를 에이전트 밖에 두고, 헤드리스 코딩 에이전트를 구동하며, 모든 완료 선언에 기계 검증 가능한 증거를 요구한다.
상태: pre-1.0 (v0.0.1). 실전에서 쓰고 있지만 인터페이스는 아직 바뀔 수 있다. npm에 배포하지 않았으므로 설치는 이 저장소를 받아
bun link한다. 러너 계약 v1만은 판 번호로 관리한다 — 다른 도구가 그것을 읽기 때문이다.
zannabi run "결제 API에 재시도 로직 추가"
1. PLAN 에이전트가 계획 + 게이트 제안 → 사람이 승인 ← 유일한 사람 개입
2. EXECUTE 헤드리스 Claude Code 실행
3. VERIFY 러너가 게이트를 직접 실행 — 에이전트 자기보고는 판정에 쓰지 않음
4. 실패 → 실패 증거를 넣어 재시도 (기본 3회)
5. 성공 → .zannabi/runs/<시각>-<슬러그>/ 에 증거 확정
- Bun 1.3+
- Claude Code 또는 Codex CLI
— 러너가
claude/codex를 헤드리스로 구동한다 (--agent로 선택) - 대상 프로젝트에 기계 검증 가능한 게이트가 있을 것 (테스트·빌드·린트 등 종료코드로 판정되는 명령)
bun install
bun link # zannabi 명령 등록 (생략하려면 bun run packages/cli/src/index.ts 로 대체)
zannabi run "작업 설명" --cwd /path/to/project --gate "test:bun test" --budget 3| 옵션 | 뜻 |
|---|---|
--cwd <경로> |
작업할 프로젝트 (기본 .) — 러너와 대상은 다른 곳에 있어도 된다 |
--gate "<이름>:<명령>" |
검증 게이트. 여러 번 줄 수 있다 |
--budget <N> |
재시도 횟수 (기본 3) |
--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 |
에이전트가 제안한 게이트를 받지 않는다 |
--gate-timeout <ms> |
모든 게이트의 타임아웃 (기본 300000) |
--worktree |
전용 워크트리에서 돌고 결과를 zannabi/<실행> 브랜치로 남긴다 |
--yes |
승인 프롬프트를 건너뛴다 (배치 실행용) |
다른 명령 셋. zannabi status [<이름 일부>]는 지금 무슨 일이 벌어지는지 말하고,
zannabi resume [<이름 일부>]는 중단된 실행을 이어서 돈다(둘 다 이름을 생략하면 최신 실행이다).
zannabi race "<작업>" --arm A --arm B는 같은 작업을 여러 조합으로 동시에 돌려 게이트로 고른다.
프리셋. --profile은 초기 8회 실측에서 나온 운용 방침을 그대로 담은 조합 묶음이다.
| 프리셋 | 하는 일 | 근거 |
|---|---|---|
cheap |
실행을 claude:claude-haiku-4-5로 · 예산 5 |
실행을 낮춰도 대체로 통과했고, 대가는 품질이 아니라 라운드 수였다 |
balanced |
실행을 codex로 · 예산 3 |
실측에서 가장 안정적이었다 (2/2 성공, attempts 1) |
safe |
런타임을 낮추지 않음 · 예산 3 · 통과 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는 실행 불가(⛔)만 거부한다. 조언(
진전 없는 루프 끊기. 예산은 진전을 사는 값이지 같은 실패를 다시 확인하는 값이 아니다.
러너는 라운드마다 워킹트리의 변경분 해시와 게이트 결과를 함께 보고, 둘 다 그대로인 라운드가
--stall-limit번 연속이면 남은 예산을 태우지 않고 no-progress로 끊는다.
--stall-limit이 예산 이상이면 이 감지는 발동할 수 없다 — 연속 N라운드가 같아야 하는데
예산이 그 전에 끝나기 때문이다. 실측에서 기본 조합(한계 3 · 예산 3)이 정확히 그랬다:
라운드 1·2의 diff 해시가 완전히 같았는데도 감지 없이 예산 소진으로 끝났다. 러너는 조건을
뒤에서 바꾸지 않고(그러면 실행끼리 비교가 무너진다) 죽은 조합이라는 사실을 승인 전에 말한다.
게이트 결과만 보는 판정은 실측에서 틀렸다 — 같은 실패가 두 번 나온 뒤 진전한 사례가 있었고,
그때 달라져 있던 것이 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로 통째로
거부한 경우도 무엇을 거부했는지 같은 자리에 남는다. 명령까지 똑같은 충돌은 잃은 것이 없으므로
보고하지 않는다.
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에서 갈라지므로 원본의 미커밋 변경은
포함되지 않는다. 그 사실을 실행 시작 시 파일 수와 함께 알린다 — 모르고 돌리면
"왜 내 수정이 반영 안 됐지"를 나중에 겪는다.
브랜치는 쌓인다. 실행마다
zannabi/<실행>브랜치가 하나 남고 러너는 그것을 지우지 않는다 — 실패한 실행의 작업물도 사라지면 안 되기 때문이다. 정리는 사람의 몫이다:git branch --list 'zannabi/*'로 보고git branch -D로 지운다.
⚠️ 재개(zannabi resume)와 워크트리를 함께 쓰면 지금은 원본에서 이어간다. 실행이 끝날 때 워크트리를 치우기 때문에 이어갈 자리가 남아 있지 않다. 작업물은 브랜치에 있으므로 잃지는 않지만, 이어 도는 라운드는 격리되지 않는다. 병렬 실행의 실물을 더 본 뒤에 정한다.
기본값은 격리가 꺼져 있다. 한 줄 고치는 작업에까지 브랜치 병합을 붙이는 것은 값보다 비용이 크다. 워크트리는 저장소 밖(시스템 임시 디렉토리)에 만든다 — 안에 두면 그 디렉토리가 원본의 미추적 파일로 잡혀 원본의 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/cli—zannabi명령
두 번째 어댑터를 붙이며 확인된 것: PEV 루프·게이트 러너·증거 저장소는 한 줄도 바뀌지 않았다.
core 변경분은 두 어댑터가 공유하는 프로세스 구동 배관을 추출한 것뿐이고, AgentAdapter 계약 자체는 그대로다.
같은 ZANNABI LAB의 oh-my-zannabi(Claude Code 조련 레이어)와 상호보완: 러너가 구동하는 Claude Code에 oh-my-zannabi 설정이 그대로 적용된다.
bun test # 275개
bun run typecheckApache License 2.0 · 저작권 표기는 NOTICE
