Use when a design or decision needs adversarial multi-round scrutiny from several independent model vendors. Runs anonymized A/B/C reviewer rounds with a rotating judge and produces a consensus report.
Installs into .claude/skills of the current project.
Are you the author of Agora?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/baekenough-agora)
---
name: agora
description: Use when a design or decision needs adversarial multi-round scrutiny from several independent model vendors. Runs anonymized A/B/C reviewer rounds with a rotating judge and produces a consensus report.
scope: core
version: 1.0.0
user-invocable: true
argument-hint: "<topic> [--attach <path>] [--max-rounds <N>] [--auto]"
---
# Agora
## 개요
`agora`는 하나의 설계·결정 주제를 3개 독립 벤더 CLI에게 익명 A/B/C 라벨로 검토시키고, 라운드마다 모델이 바뀌는 심판이 판정하는 다중턴 합의 스킬입니다. 라벨-벤더 매핑은 `SEALED/`에 격리되어 심판의 입력 경로에 오르지 않으며, 벤더 공개는 최종 보고서 생성 단계에서만 이루어집니다.
**이 설계는 결정적 익명성을 주장하지 않습니다.** 목표는 "벤더 식별 불가"가 아니라 **"벤더 식별이 심판의 기본 관찰 경로에 놓이지 않음"** 수준입니다. 라벨 뒤에 벤더가 계속 숨겨진다고 가정하지 마십시오 — 남는 누출 경로는 [신뢰 경계](#신뢰-경계)에 열거되어 있습니다.
리뷰어 3벤더(`claude -p`, `omx exec`, `agy -p`)와 심판 로테이션 3슬롯은 **모델 단위로만** 겹치지 않습니다(스펙 REQ-3). 겹치는 축과 겹치지 않는 축을 구분해 적습니다.
**아래 표는 리뷰어를 벤더 슬러그(`claude`/`omx`/`agy`)로 지칭합니다 — `A`/`B`/`C`가 아닙니다.** A/B/C는 라운드마다 시드 `agora-<epoch>-r<N>`으로 다시 섞이는 익명 라벨이므로 어떤 벤더에도 고정되지 않으며, **"리뷰어 A" 같은 고정 벤더는 존재하지 않습니다**. 따라서 게이트가 출력하는 `리뷰어: A BUILD_WITH_CHANGES · B BUILD · C REDESIGN`을 벤더 판정으로 읽지 마십시오 — 같은 세션 안에서도 다음 라운드의 `A`는 다른 벤더입니다. 라벨과 벤더의 대응은 `report.md`에서만 공개됩니다.
| 축 | 겹침 |
|----|------|
| 모델 ID | 없음 — 리뷰어는 `claude-opus-4-8`(`claude` CLI) / `omx` 기본 모델(`omx` CLI) / `gemini-3.1-pro-high`(`agy` CLI), 심판 슬롯은 1: `claude-opus-5-5`(`claude` CLI) / 2: `claude-opus-4-6-thinking`(`agy` CLI) / 3: `gpt-oss-120b-medium`(`agy` CLI) |
| CLI 바이너리 | **있음** — `claude` 바이너리는 `claude` 리뷰어와 심판 슬롯 1이, `agy` 바이너리는 `agy` 리뷰어와 심판 슬롯 2·3이 공유합니다 |
| 모델 계열 | **있음** — 심판 3슬롯 중 2슬롯(`claude-opus-5-5`, `claude-opus-4-6-thinking`)이 `claude` 리뷰어의 모델(`claude-opus-4-8`)과 같은 Claude 계열입니다. 이 2슬롯은 CLI로는 각각 `claude`와 `agy`이므로 CLI 축과 계열 축은 서로 다른 모양으로 겹칩니다 |
따라서 "리뷰-판정 간 교차 오염이 없다"고 말할 수 있는 범위는 **모델 ID 단위까지**입니다. 같은 계열 모델이 공유하는 사전 학습 편향은 이 분리로 제거되지 않습니다.
## 라운드 파이프라인
각 라운드는 세 스크립트를 순서대로 호출합니다.
| 단계 | 명령 | 산출 |
|------|------|------|
| 리뷰어 | `bash scripts/reviewers.sh --run --session-dir <dir> --round <N> --prompt-file <f>` | `SEALED/raw/round-N/{claude,omx,agy}.json` |
| 익명화 | `bash scripts/anonymize.sh --build --session-dir <dir> --round <N> --seed agora-<epoch>-r<N> --topic <t> --attachments <json> --agenda <json>` | `SEALED/mapping/round-N.json` + `anon/round-N.json` |
| 심판 | `bash scripts/judge.sh --run --anon-file <dir>/anon/round-N.json --out-file <dir>/verdict/round-N.json --round <N>` | `verdict/round-N.json` |
| 종료 판정 | `bash scripts/agora.sh --decide-stop < <dir>/state.json` | `CONSENSUS\|STALLED\|MAX_ROUNDS\|USER\|CONTINUE` |
라운드 1은 백지 상태로 진행됩니다 — `agenda`, `prior_rounds`, 심판 초안 없이 `topic`과 `attachments`만 리뷰어에게 전달됩니다. 세션 전체에서 진짜 독립 의견을 얻는 유일한 라운드이기 때문입니다. 라운드 2부터는 직전 심판의 `agenda` + 직전 2라운드의 `prior_rounds` + 직전 초안이 함께 전달되어 수렴을 유도합니다.
### CLI 진입점
```
bash agora.sh --decide-stop < <dir>/state.json
bash agora.sh --start "<topic>" [--attach <path>]... [--max-rounds <N>] [--auto]
bash agora.sh --round <N> --session-dir <dir> [--extra-agenda <json-array>]
bash agora.sh --gate --session-dir <dir> --round <N>
bash agora.sh --set-stop <CODE> --session-dir <dir>
bash agora.sh --report --session-dir <dir>
```
| 진입점 | 파일을 쓰는가 | 실행 주체 |
|--------|:---:|-----------|
| `--decide-stop` | 아니오 | `agora-runner` (라운드 실행 직후 정지 코드 산출) |
| `--start` | **예** | `agora-runner` |
| `--round` | **예** | `agora-runner` |
| `--gate` | 아니오 | 오케스트레이터 (사용자 상호작용) |
| `--set-stop` | **예** | `agora-runner` |
| `--report` | **예** | `agora-runner` |
#### 채널 계약
`--start`의 **stdout에는 세션 디렉토리 절대경로 한 줄만** 실립니다. 게이트드 모드에서 라운드 1의 게이트 블록은 **stderr로 나갑니다** — 문서화된 관용구 `dir=$(bash agora.sh --start ...)`가 stdout을 통째로 캡처하므로, 게이트가 stdout에 있으면 `$dir`이 게이트 17줄에 경로가 붙은 문자열이 되어 쓸 수 없게 되기 때문입니다.
반면 **독립 서브커맨드 `--gate`는 블록을 stdout에 출력합니다** — 그 서브커맨드에서는 게이트 렌더링이 부산물이 아니라 산출물 전부이기 때문입니다.
`agora-runner`가 `--start`를 실행하면 게이트 블록은 러너의 stderr로 흘러가 반환 계약에 담기지 못합니다. 오케스트레이터는 `--start` 반환 후 `--gate --session-dir <dir> --round 1`로 게이트를 **다시 렌더**해 사용자에게 표시하십시오.
#### `--set-stop <CODE>`
`.stop` 필드(= `report.md`의 `종료 사유`)를 기록하는 **유일한 수단**입니다. 게이트드 모드에서 라운드 2 이후에는 `.stop`에 다른 writer가 없으므로, 이 명령을 건너뛰면 세션이 아무리 깨끗하게 끝나도 `report.md`가 `종료 사유: UNKNOWN`을 출력합니다.
| 항목 | 값 |
|------|-----|
| 허용 코드 | `CONSENSUS` · `STALLED` · `MAX_ROUNDS` · `USER` |
| 거부 코드 | `CONTINUE` → exit 64. "아직 정지하지 않았다"는 뜻이므로 종료 사유로 기록될 수 없습니다 — 다음 라운드를 도십시오 |
| 그 밖의 미지 코드 | exit 64. stderr에 허용 목록을 출력합니다 |
| 코드 자체가 없거나 `--`로 시작 | exit 64 |
| 범위 | 세션 단위(`--round` 없음). `.stop`은 세션이 **어떻게** 끝났는지를, `.round`가 **어디서** 끝났는지를 담습니다. 재기록은 멱등이라 무해합니다 |
허용 코드 집합은 하드코딩이 아니라 `decide_stop` 함수 본문에서 런타임에 읽어냅니다. 정지 조건이 추가·개명되면 자동으로 따라갑니다.
**절차**: 호출자는 매 라운드 `--decide-stop` 결과가 `CONTINUE`가 **아니면** 그 코드를 그대로 `--set-stop`으로 기록한 뒤 `--report`를 실행합니다. 사용자가 게이트에서 `s`를 고른 경우도 `--set-stop USER`로 직접 기록해야 합니다. (`--start --auto`와 게이트드 `--start`의 라운드 1은 이 기록을 스크립트가 내부에서 수행하므로 예외입니다.)
`--extra-agenda`는 게이트의 `e` 옵션을 실행하는 유일한 수단입니다. 값은 **JSON 배열 문자열**이어야 하며(예: `--extra-agenda '["롤백 명령 시퀀스를 제시할 것"]'`), 심판 `agenda[]` 뒤에 이어붙습니다. 문자열 하나를 그대로 넘기면 `jq` 병합이 실패합니다. 생략 시 기본값은 `[]`입니다.
환경 오버라이드:
| 변수 | 기본값 | 용도 |
|------|--------|------|
| `AGORA_SESSION_EPOCH` | 없음 (자동 생성) | 세션 시드 고정 (재현용) |
| `AGORA_OUTPUT_ROOT` | `.claude/outputs/sessions` | 아티팩트 루트 |
| `AGORA_TIMEOUT_SECS` | `300` | 리뷰어/심판 CLI 타임아웃 |
| `AGORA_CLAUDE_BIN` | `claude` (PATH 탐색) | 리뷰어·심판의 Claude CLI 경로 오버라이드 |
| `AGORA_AGY_BIN` | `agy` (PATH 탐색) | 리뷰어·심판의 agy CLI 경로 오버라이드 |
| `AGORA_OMX_BIN` | **`/opt/homebrew/bin/omx` (절대 경로)** | 리뷰어 omx CLI 경로 오버라이드. 다른 셋과 달리 PATH를 탐색하지 않으므로, omx가 이 경로에 없는 머신에서는 반드시 설정해야 합니다 |
| `AGORA_FORCE_TIMEOUT_FALLBACK` | `0` | **테스트 전용** — `gtimeout` 존재 여부와 무관하게 wait 기반 타임아웃 폴백 경로를 강제합니다. 운영 실행에서는 설정하지 마십시오 |
| `AGORA_VERDICT_SCHEMA` | 스크립트 옆의 `verdict-schema.json` | **테스트 전용** — `judge.sh`가 검증에 쓰는 스키마 경로 대체. 존재하지 않는 경로를 가리켜 exit 68 경로를 재현하는 용도 |
이 변수들은 **자식 CLI에는 전달되지 않습니다** — 아래 [벤더 호출 환경 정화](#벤더-호출-환경-정화)를 참조하십시오.
## 권한 우회 플래그 (실행 전 반드시 확인)
**이 스킬은 리뷰어 벤더 CLI 중 둘을 권한 프롬프트를 건너뛰는 플래그와 함께 실행합니다.** 운영자 승인을 요구하지 않고 자동으로 붙으므로, 스킬을 돌리기 전에 이 사실을 알고 있어야 합니다.
| 호출 | CLI | 붙는 플래그 |
|------|-----|-------------|
| 리뷰어 | `claude` | `--enable-auto-mode` |
| 리뷰어 | `omx` | 없음 |
| 리뷰어 | `agy` | `--dangerously-skip-permissions` |
| 심판 슬롯 1 | `claude` | **없음** |
| 심판 슬롯 2·3 | `agy` | **없음** |
**리뷰어에 붙는 이유**: 이 두 이름은 사용자 셸의 **별칭(alias)**이며 별칭 정의에 해당 플래그가 이미 들어 있습니다. 별칭은 비대화형 `bash script.sh` 실행에서 확장되지 않으므로, 스크립트가 명시적으로 붙이지 않으면 CLI가 대화형 실행과 다르게 동작합니다 — `claude`는 auto mode 없이 돌고, `agy`는 권한 프롬프트에서 멈춰 라운드 타임아웃(→ 결측)에 걸립니다. `omx`는 별칭이 아닌 실제 바이너리라 추가 플래그가 없습니다.
**심판에는 붙지 않습니다 — 비대칭은 실재합니다.** `judge.sh`는 같은 두 CLI를 호출하면서 두 플래그를 모두 생략하며, 심판 CLI에 넘어가는 인자는 `-p --model` (그리고 `agy`의 경우 `--output-format json --json-schema`)와 프롬프트 문자열뿐입니다. 스크립트 주석은 리뷰어 쪽 플래그의 근거만 적고 심판 쪽 생략의 근거는 적지 않으므로, **여기서는 관측된 사실만 기술합니다**: 심판은 리뷰어보다 낮은 도구 권한으로 실행되며, 심판이 권한 프롬프트를 띄우는 환경에서는 그 슬롯이 타임아웃되어 로테이션이 다음 슬롯으로 전진합니다(3슬롯 모두 그러면 exit `4`). 이 비대칭은 심판이 익명 번들 외의 자료에 손대지 못하게 하는 방향과는 정합하지만, 코드가 그 의도를 명시하지는 않았습니다.
## 종료 코드
스크립트 4종이 실제로 반환하는 코드 전부입니다. `agora.sh`는 `reviewers.sh`/`anonymize.sh`/`judge.sh`의 코드를 **그대로 전파**하므로(예외: 68은 자체 진단 한 줄을 덧붙인 뒤 전파), 오케스트레이터가 `--round`에서 보는 코드는 아래 전부가 될 수 있습니다.
| 코드 | 반환 스크립트 | 의미 | 재시도 |
|------|--------------|------|:---:|
| `0` | 전부 | 성공 | — |
| `1` | `anonymize.sh` | **지문 검출** — 검사 대상 텍스트에서 금칙 패턴이 걸림. 익명성 파기를 막기 위한 하드 스톱 | 아니오 |
| `3` | `reviewers.sh` · `anonymize.sh` | **유효 리뷰어 2인 미만**으로 라운드 중단. 두 스크립트가 같은 코드를 쓰는 것은 의도적이며, 어느 단계에서 걸렸는지는 stderr로 구분합니다(아래 [결측 판정](#결측-판정은-2단계입니다)) | 아니오 |
| `4` | `judge.sh` | 심판 로테이션 3슬롯이 **전부** 실패 — CLI 실패·타임아웃·JSON 파싱 실패·스키마 위반을 모두 포함 | 아니오 (로테이션 대체까지가 이미 시도의 전부) |
| `64` | 전부 | 사용법 오류 — 미지의 옵션, 필수 플래그 누락, `--extra-agenda`가 JSON 배열이 아님, `--set-stop`의 코드 누락·미지 코드·`CONTINUE` | 아니오 |
| `65` | `anonymize.sh` | 직전 라운드의 sealed 데이터가 **존재하는데 파싱 불가**(무결성 문제) 또는 미지의 벤더 식별자. `1`(지문)과 일부러 구분합니다 — "번들이 벤더를 누출할 뻔했다"와 "직전 라운드 데이터가 손상됐다"는 운영자 대응이 다릅니다 | 아니오 |
| `66` | `agora.sh` · `judge.sh` | 입력 부재 — 세션 디렉토리를 찾을 수 없음(`agora.sh`) / `--anon-file`이 없음(`judge.sh`) | 아니오 |
| `68` | `judge.sh` | **설정 오류** — `verdict-schema.json`을 읽거나 파싱할 수 없음. 심판의 실패가 아니라 배포 설정의 결함이므로 재시도해도 같은 원인으로 즉시 재실패합니다 | 아니오 |
| `73` | `agora.sh` | **기록 실패** — `state.json` 또는 `report.md`를 쓸 수 없음. 아래 별항 참조 | 아니오 |
내부 전용 코드: `124`(타임아웃)는 `run_with_timeout`이 GNU `timeout` 관례에 맞춰 정규화한 값이며 **호출자에게 그대로 노출되지 않습니다** — 리뷰어에서는 결측으로(→ `3`), 심판에서는 로테이션 전진으로(→ `4`) 흡수됩니다. `reviewers.sh`/`judge.sh`의 `65`(미지 CLI 슬러그)도 로스터가 하드코딩이라 실제로는 도달하지 않습니다.
### exit 73 — 라운드는 돌았는데 기록되지 않음
`73`(EX_CANTCREAT)은 다른 코드와 성격이 완전히 다릅니다. 여기까지 왔다는 것은 **리뷰어 3벤더와 심판이 이미 호출되어 과금까지 끝났다**는 뜻이고, 실패한 것은 그 결과를 `state.json`(또는 `report.md`)에 남기는 마지막 단계뿐입니다.
오케스트레이터의 대응:
- **산출물을 소비하지 마십시오.** `state.json`이 갱신되지 않았으므로 `.round`가 뒤처져 있고, 그 상태에서 `--decide-stop`은 `MAX_ROUNDS`/`STALLED`를 영원히 내지 못하며 `--report`는 뒤처진 라운드의 verdict를 읽습니다.
- **라운드를 재실행하지 마십시오.** 벤더가 다시 과금됩니다.
- 실패 원인(디렉토리 권한, 디스크, 심판이 내놓은 비정수 `new_findings` 등)을 stderr 진단 줄에서 확인해 사용자에게 보고하고 지시를 기다리십시오.
`--set-stop`이 73을 반환한 경우도 같습니다 — 종료 사유가 기록되지 않았으므로 `--report`를 실행하면 `종료 사유: UNKNOWN`이 박힌 보고서가 나옵니다. 보고서를 만들기 전에 멈추십시오.
### 결측 판정은 2단계입니다
리뷰어 결측은 **서로 다른 두 스크립트가 순차로** 판정하며, 둘 다 하한은 "유효 응답 2인"이고 둘 다 exit `3`입니다.
| 단계 | 스크립트 | 무엇을 결측으로 세는가 |
|------|----------|----------------------|
| 1 | `reviewers.sh` | CLI가 응답하지 못한 벤더 — 타임아웃(124), 비영 종료, **구문상 JSON이 아닌 출력**. 각 벤더는 2회 시도(최초 + 재시도 1회) 후 결측 처리되며, 결측 벤더는 파일을 아예 남기지 않습니다. 2개 이상 결측이면 exit 3 |
| 2 | `anonymize.sh` | 파일이 없거나, 있어도 **응답 스키마 계약을 위반**한 벤더(`overall` enum, `findings[]`의 `severity`/`verdict` enum, 빈 문자열 금지 필드 등). 이 검사는 `reviewers.sh`가 통과시킨 뒤에 걸리므로 별도 하한이 필요합니다 — 유효 응답이 2 미만이면 exit 3 |
2단계 하한이 없으면 "1개 유효 + 2개 스키마 위반"이 리뷰어 1인짜리 번들을 만들고, 심판이 단일 의견을 놓고 합의를 판정하게 됩니다. 두 단계의 코드가 같으므로 운영자에게 보이는 의미("리뷰어 부족으로 라운드 중단")는 어느 쪽이 걸렸든 동일하며, 구분이 필요하면 stderr 메시지를 보십시오 — 1단계는 `2 or more reviewers missing`, 2단계는 `valid reviewer response(s); 2 or more are required`입니다.
### 심판 응답 검증
`judge.sh`는 심판 출력이 구문상 JSON인지만 보지 않고, `verdict-schema.json`을 기준으로 세 가지를 검사합니다. 필수 필드 목록·선언 타입·enum 값을 모두 **스키마 파일에서 읽어오므로** 스키마가 바뀌면 검사도 따라갑니다.
| 검사 | 위반 예 |
|------|---------|
| 필수 필드 존재(그리고 non-null) | `draft` 누락 |
| **선언 타입 일치** | 스키마가 배열로 선언한 `agenda`에 `"1. 단일 의제"` 문자열이 옴 |
| enum 값(`consensus`·`verdict`) | `verdict: "MERGE"` |
셋 중 하나라도 걸리면 그 슬롯은 실패로 처리되어 **로테이션이 다음 슬롯으로 전진**하고, 3슬롯이 모두 소진되면 exit `4`입니다. 타입 검사가 특히 중요한 이유는, 타입이 틀린 `agenda`가 검증을 통과해 저장되면 다음 라운드의 `jq '. + $extra'` 병합이 죽고 → 의제가 빈 값으로 붕괴하고 → 리뷰어 프롬프트에 의제 섹션이 **비어 있는 채로** 3벤더가 전부 호출·과금된 뒤에야 문제가 드러나기 때문입니다.
## 사용자 게이트
`mode: gated`(기본값)에서 매 라운드 종료 후 오케스트레이터가 아래 형식을 표시합니다. 벤더는 노출하지 않고 라벨만 노출합니다 — 벤더 공개는 `report.md` 생성 단계에서만 이루어집니다.
```
─── Agora Round 2/5 ───────────────────────────────
심판: (모델 로테이션 #2)
Consensus: MAJORITY Verdict: BUILD_WITH_CHANGES
리뷰어: A BUILD_WITH_CHANGES · B BUILD · C REDESIGN
신규 지적: 2건 최고 심각도: HIGH
라운드 소요: 412초 (참고: 심판 로테이션 3슬롯 순차 재시도 시 최악 약 15분)
해소됨(1)
F1 상태 파일 경합 — 세션 디렉토리 격리로 충분
미해소(1)
F4 [HIGH] 롤백 경로 부재 — A REJECT / C KEEP / B 미언급
다음 라운드 의제
- F4 의 심각도 판정 근거를 각자 제시할 것
- 롤백 경로의 구체적 명령 시퀀스
[c] 계속 [s] 중단하고 보고서 [e] 의제 추가 후 계속
```
**`리뷰어:` 줄의 A/B/C는 벤더가 아닙니다.** 이 라벨은 해당 라운드의 시드로 새로 섞인 것이라 라운드마다 가리키는 벤더가 바뀝니다. `A BUILD_WITH_CHANGES`를 특정 벤더의 입장으로 읽지 마십시오 — 라운드 간 비교도 불가합니다. 벤더 대응은 `report.md`의 「라운드별 참여자(익명 해제)」에서만 확인할 수 있습니다. 결측 벤더가 있으면 이 줄에는 라벨이 2개만 나옵니다.
**토큰 누적은 표시되지 않습니다.** 벤더 CLI들이 토큰 사용량을 일관된 형식으로 보고하지 않아 계측을 신뢰할 수 없으므로 구현하지 않았습니다 — `state.json`의 `history[].tokens`는 항상 `0`으로 기록됩니다. 게이트에 누적 토큰 줄을 두는 설계 스펙 §10과의 **의도적 차이**입니다. 비용 감각은 아래 [`--auto` 경고](#--auto-경고)의 라운드당 추정치로 대체하고, 게이트가 제공하는 실측값은 `라운드 소요` 초 하나뿐임을 전제하십시오.
| 키 | 동작 |
|----|------|
| `c` | 다음 라운드 진행. 심판 의제를 그대로 사용 |
| `s` | 루프 종료(`stop: "USER"`), 현재까지의 결과로 `report.md` 생성 |
| `e` | 사용자 의제를 입력받아 심판 `agenda[]`에 **추가**한 뒤 다음 라운드 진행 |
`e`는 추가만 가능하며 심판 의제를 덮어쓰지 못합니다. 사용자가 심판 의제를 삭제할 수 있으면 심판의 의제 설정 권한이 형해화되고, 사용자가 불편해하는 쟁점이 조용히 사라지는 경로가 생기기 때문입니다.
### 게이트드 모드의 책임 분리
게이트드 모드에서 **루프를 도는 주체는 스크립트가 아니라 호출자**입니다. 스크립트는 라운드 하나만 실행하고 반환하며, 정지 여부를 스스로 판단하지 않습니다.
| 명령 | 하는 일 | 하지 않는 일 |
|------|---------|--------------|
| `--start` (`--auto` 없이) | 세션 생성 + **라운드 1만** 실행 후 반환 | 라운드 2 이후를 돌지 않음 |
| `--round <N>` | 지정 라운드를 실행하고 `state.json`에 기록 | `--decide-stop`을 호출하지 않음. `.stop`도 `max_rounds`도 **검사하지 않음** |
| `--gate` | 게이트 화면 렌더 (stdout) | 사용자 입력을 받지 않음 |
| `--set-stop` | `.stop`에 종료 사유 기록 | 정지 여부를 스스로 판단하지 않음 — 호출자가 준 코드를 그대로 씀 |
| `--report` | `report.md` 생성 | 정지 판단을 하지 않음. `.stop`을 쓰지도 않음 — 미기록이면 `UNKNOWN`을 출력 |
- **라운드 상한은 `--round`를 막지 않습니다.** `max_rounds`를 넘긴 라운드도, 이미 `.stop`이 기록된 세션의 라운드도 거부 없이 실행됩니다. 정지 판단은 전적으로 호출자 몫입니다.
- **게이트드 `--start`가 게이트 없이 끝나는 경로가 있습니다.** 라운드 1에서 종료 조건이 걸리면 `.stop` 기록과 `report.md` 생성까지 수행한 뒤 게이트를 렌더하지 않고 반환합니다. 따라서 `--start` 반환 후에는 게이트 출력 유무가 아니라 `state.json`의 `.stop`을 읽어 분기하십시오.
여기서 **호출자는 두 층**입니다 — 오케스트레이터(메인 대화)와 `agora-runner` 서브에이전트. 파일을 쓰는 명령(`--start`·`--round`·`--set-stop`·`--report`)은 `agora-runner`가, 파일을 쓰지 않는 순수 출력 명령(`--gate`)은 오케스트레이터가 실행합니다(R010). 매 라운드 흐름은 다음과 같습니다.
1. **오케스트레이터 → `agora-runner` 위임**: 러너가 `--round <N> --session-dir <dir>` 를 실행한 뒤 `--decide-stop < <dir>/state.json` 으로 정지 코드를 산출해, **verdict 요약 + `stop_code`** 를 반환합니다.
2. **오케스트레이터가 직접 실행**: `--gate --session-dir <dir> --round <N>` 로 게이트를 표시하고 사용자 응답(`c`/`s`/`e`)을 받습니다. `--gate`는 파일을 쓰지 않으므로 오케스트레이터가 실행해도 R010에 걸리지 않으며, 서브에이전트는 사용자와 상호작용하지 않으므로 이 단계를 위임할 수도 없습니다.
3. **계속이면 다음 라운드를 다시 위임**하고(사용자가 `e`를 골랐으면 `--extra-agenda <json-array>` 동반), **정지면 `--set-stop <CODE>` + `--report --session-dir <dir>` 를 `agora-runner`에 위임**합니다. `<CODE>`는 1단계의 `stop_code`를 그대로 쓰되, 사용자가 `s`를 골라 멈추는 경우에는 `USER`입니다. 이 기록을 건너뛰면 보고서가 `종료 사유: UNKNOWN`으로 나옵니다.
## `--auto` 경고
**`--auto`는 매 라운드 게이트를 생략합니다.** 비용 상한이 명확할 때만 사용하십시오.
| 항목 | 값 |
|------|-----|
| 라운드당 토큰 추정 | 60~120k (리뷰어 3 + 심판 1) |
| 기본 라운드 상한 | 5 (`--max-rounds`로 조정) |
| **최악의 경우 총 토큰** | **약 600k** |
**소요 시간 주의**: 리뷰어 단계와 심판 단계는 병렬성이 다르므로 벽시계 계산이 다릅니다.
| 단계 | 실행 형태 | 최악 벽시계 (`AGORA_TIMEOUT_SECS`=300 기준) |
|------|-----------|-------------------------------------------|
| 리뷰어 | 3벤더 **병렬 팬아웃**(`&` + `wait`), 벤더당 최초 1회 + 재시도 1회 | 300초 × 2시도 = **600초**. 벤더 수는 벽시계에 곱해지지 않습니다 |
| 심판 | 로테이션 3슬롯 **순차** 시도 | 300초 × 3슬롯 = **900초** |
| 라운드 합계 | 두 단계는 순차 | **약 1500초 ≈ 25분** |
기본 상한 5라운드를 `--auto`로 끝까지 돌면 최악 약 2시간이며, 게이트가 없으므로 이 지연이 중단 없이 누적됩니다. (게이트가 표시하는 `참고: 심판 로테이션 3슬롯 순차 재시도 시 최악 약 15분`은 **심판 단계만**의 값이며 라운드 전체가 아닙니다.)
## 신뢰 경계
익명성은 프롬프트 지시가 아니라 **디렉토리 경계**로 유지됩니다.
| 주체 | `SEALED/` 접근 | `anon/` 접근 |
|------|:---:|:---:|
| 오케스트레이터 (메인 대화) | 금지 | 허용 |
| `agora-runner` 에이전트 | 금지 | 허용 |
| 리뷰어 CLI 3종 | 금지 | 금지 (라운드 내에서 서로를 보지 않음) |
| 심판 CLI | 금지 | 허용 (익명 번들만이 유일한 입력) |
| `anonymize.sh` | **허용** — `SEALED/raw/`(원문 읽기)와 `SEALED/mapping/`(직전 라운드 매핑 읽기 + 이번 라운드 매핑 봉인) | 허용 (`anon/round-N.json`을 생산하는 주체) |
| `report.md` 생성 단계 | **허용** — `SEALED/mapping/` 읽기 | 허용 (익명 해제가 이 단계의 목적) |
`SEALED/`를 읽는 주체는 **둘**입니다 — 파이프라인 안쪽의 `anonymize.sh`와 마지막의 보고서 생성 단계(스펙 §4). `anonymize.sh`는 봉인된 원문·매핑을 읽지만 그 산출물인 `anon/` 번들에는 라벨만 남기므로, 봉인을 읽는 것과 봉인을 푸는 것은 구분됩니다. 익명 해제는 `report.md` 생성 단계에서만 일어납니다.
**한계 1 — 격리를 지켜도 남는 누출**(스펙 §6): 위 표가 완벽히 지켜져도 심판은 벤더를 추론할 단서를 갖습니다. 익명성은 라벨이 유지되는 동안에도 아래 세 경로로 새어 나갑니다.
1. **라운드 내 일관성** — 한 라운드 안에서 하나의 라벨은 그 벤더의 전체 응답을 묶습니다. 심판은 "A의 findings 5건이 모두 성능 축에 집중되어 있다"와 같은 스타일 패턴을 관찰할 수 있습니다.
2. **라운드 간 일관성** — 재라벨링(과거 라운드의 라벨을 현재 라운드 매핑으로 변환)을 도입한 이상 `prior_rounds[]`를 통해 라운드 간 일관성도 관찰 가능해집니다. 이는 의도된 트레이드오프입니다 — "A가 입장을 바꿨다"는 오독을 막는 편익이 벤더 추론 위험보다 크다고 판단했습니다.
3. **논증 스타일 지문** — 서식 정규화는 문체 지문을 줄이지만 **논증 스타일 지문은 줄이지 못합니다**. 특정 모델이 반복적으로 특정 축을 파고드는 경향은 템플릿으로 흡수되지 않습니다.
그러므로 개요에 적은 대로 이 설계는 **결정적 익명성을 주장하지 않습니다**. 달성하는 것은 "벤더 식별 불가"가 아니라 "벤더 식별이 심판의 기본 관찰 경로에 놓이지 않음"이며, 심판이 추론을 시도하면 부분적으로 성공할 수 있다고 전제해야 합니다.
**한계 2 — 격리 자체가 강제되지 않음**: 이 격리는 **관례이지 하드 블록이 아닙니다**. 오케스트레이터도 심판 CLI도 `SEALED/`를 읽는 파일 읽기 능력 자체는 갖고 있습니다 — 읽지 못하는 것이 아니라 읽지 않도록 설계된 것입니다. PreToolUse 훅으로 `SEALED/` 경로 Read를 하드 블록하는 방안은 검토했으나, 훅은 프로젝트 전역에 영향을 주므로 초기 도입에서는 제외했습니다(위반율 관측 시 R021 Hard Enforcement Candidates 승격 검토 대상). 실질 방어선은 아래 지문 검사이며, 이는 "누출이 발생했는가"를 사후 탐지할 뿐 "누출을 시도할 수 없게" 만들지는 않습니다.
**한계 3 — 자식 CLI가 cwd를 상속함**: 벤더·심판 CLI는 `AGORA_*` 환경변수를 제거한 채 실행되지만(아래 [벤더 호출 환경 정화](#벤더-호출-환경-정화)), **작업 디렉토리는 상속합니다**. 기본 출력 루트 `AGORA_OUTPUT_ROOT`가 상대경로(`.claude/outputs/sessions`)이므로, 상속받은 cwd에서 `.claude/outputs/sessions/**`를 훑으면 세션 트리 — `SEALED/` 포함 — 에 도달할 수 있습니다. 환경 정화는 "세션 좌표를 **건네주지** 않는다"를 보장할 뿐 "찾을 수 **없게** 한다"를 보장하지 않습니다. 절대경로 `AGORA_OUTPUT_ROOT`를 세션 트리 밖으로 지정하면 이 경로는 좁아지지만, cwd 상속 자체는 남습니다.
### 지문 검사
`anon/round-N.json`을 그대로 검사하지 않습니다. `anonymize.sh`가 번들에서 **벤더 유래 텍스트만 뽑아낸 파생 문서**를 만들어 그것을 검사합니다.
| 필드 | 검사 대상 | 이유 |
|------|:---:|------|
| `reviewers[]` (이번 라운드) | **예** | 리뷰어 저작 — 익명화 대상 당사자 |
| `prior_rounds[].reviewers[]` | **예** | 재라벨링되어 다음 라운드 심판에게 전달되는 리뷰어 저작 텍스트 |
| `prior_rounds[].draft` · `.verdict` | **예** (운영자 어휘 제거 후) | 심판 저작. 심판은 익명화의 *주체*이지만, 그 초안·판정은 `relabel_prior`를 타고 **다음 라운드의 새 심판**에게 넘어갑니다. 심판이 쓴 귀속 추정("A는 Claude 계열로 보인다")이 후임에게 무검사로 전달되는 것이 바로 이 검사가 막으려는 누출입니다 |
| `topic` · `agenda` · `attachments` | **아니오** | **운영자 저작 — 의도적 제외(Ruling 10)**. 토론 주제 자체가 "제미나이를 도입할까"일 수 있으며, 주제가 벤더를 언급했다는 이유로 매 라운드가 중단되면 스킬이 성립하지 않습니다 |
| `round` 등 나머지 메타데이터 | 아니오 | 벤더 유래 텍스트가 아님 |
심판 저작 필드는 검사 전에 **운영자 어휘를 단어 단위로 걸러냅니다** — 심판이 주제·의제를 정당하게 인용할 수 있기 때문입니다. 필터는 토큰화 → 운영자 단어 제거 → 재결합 방식이며, **부분 문자열이 아니라 온전한 단어에만** 작동합니다(운영자 단어 "mini"가 "gemini"를 쪼개 검사를 무력화하는 역방향 회피를 막기 위함). `/`, `-`, `.`, `_`는 단어 문자로 취급되어 경로 형태와 버전 표기가 보존됩니다. 잔여 오탐(의도적, 차단 쪽으로 기움): 심판 텍스트의 운영자 단어에 한국어 조사가 붙으면(주제의 `제미나이` vs 심판의 `제미나이는`) 다른 토큰이라 면제되지 않고 중단시킵니다. 리뷰어 텍스트는 **필터하지 않습니다** — 리뷰어는 익명화 대상이므로 주제를 그대로 되뇌는 것도 누출로 취급합니다.
### 금칙 패턴이 실제로 막는 것
패턴은 대소문자 무시(`grep -Eiq`)로 적용되며 3계층입니다. **"모델명을 막는다"고 뭉뚱그리면 사실과 다릅니다** — 계층마다 걸리는 조건이 다릅니다.
| 계층 | 토큰 | 매칭 방식 |
|------|------|-----------|
| 1. 명백한 벤더·제품 토큰 | `codex` `omx` `gpt` `claude` `gemini` `antigravity` `anthropic` `openai` + 한글 음차(`클로드` `제미나이` `지피티` `앤트로픽` `오픈에이아이`) | **단어 경계 없는 부분 문자열**. 경계를 두면 `claudecode`나 `chatgpt` 같은 실제 자기 식별을 통과시키므로 일부러 뺐습니다 |
| 1'. `agy` | `agy` | **양쪽 단어 경계 유지**. 3글자라 무관한 단어(stagy, cagy, 성씨) 안에 들어갈 수 있고, 모델이 자기를 "agycode"라 부르는 형태도 없기 때문 |
| 2. 영어 일반 단어와 겹치는 모델 계열명 | `opus` `sonnet` `haiku` `flash` | **두 형태에서만** — (a) 버전 인접(`Opus 4.8`, `sonnet-5`, `flash 2.0`) (b) 비ASCII 문자 인접(`Sonnet 관점에서`). 맨 단어를 막으면 정상 리뷰 산문("magnum opus", "flash memory")에서 중단됩니다 |
| 3. 봉인 경로 형태 | `SEALED/` `/mapping/` `raw/round-` | 부분 문자열 |
실제로 이 세션에서 쓰이는 모델 ID는 전부 계층 1에 걸립니다 — `claude-opus-4-8`/`claude-opus-5-5`/`claude-opus-4-6-thinking`은 `claude`로, `gemini-3.1-pro-high`는 `gemini`로, `gpt-oss-120b-medium`은 `gpt`로, `omx:default`는 `omx`로 걸립니다.
**감수한 미탐(false negative)**: 버전도 한글 인접도 없이 영어 산문 안에 놓인 맨 모델 계열명 — 예: `Sonnet would argue` — 은 `sonnet-length prose` 와 정규식으로 구별할 수 없어 **통과합니다**. **감수한 오탐(false positive)**: `flash 메모리` 같은 한영 혼용 기술 용어는 계층 2-(b)에 걸려 라운드를 중단시킵니다. 오탐은 시끄럽고 복구 가능하지만 미탐은 조용히 익명성을 깨므로, 과차단 쪽을 택한 결과입니다.
지문이 걸리면 `anonymize.sh`가 exit `1`로 중단하며, `SEALED/mapping/`과 `anon/`에는 **아무것도 쓰이지 않습니다** — 매핑과 번들은 검사를 통과할 때까지 임시 디렉토리에 머뭅니다.
### 벤더 호출 환경 정화
`reviewers.sh`와 `judge.sh`는 자식 CLI를 실행할 때 **접두사 `AGORA_`로 시작하는 모든 환경변수를 제거**합니다(`env -u`, 실제 export된 이름을 `compgen -e`로 수집하므로 이름 목록 하드코딩이 아님). 스크립트가 그 값을 넘기지 않는다는 것만으로는 부족했기 때문입니다 — 운영자 셸에 `AGORA_OUTPUT_ROOT`가 export되어 있으면 그 값이 `agora.sh`를 거쳐 모든 벤더 CLI에 그대로 상속되고, 세션 트리(봉인 기록 포함)는 그 아래 한 단계입니다.
`AGORA_` 밖의 변수는 건드리지 않습니다 — `PATH`·`HOME`과 벤더 자신의 인증 토큰·프록시 설정은 그대로 살아 있어야 CLI가 인증할 수 있습니다. 읽는 시점과 넘기는 시점은 분리되어 있어, 스크립트 자신은 계속 `AGORA_TIMEOUT_SECS` 등을 평범한 셸 변수로 사용합니다(테스트 주입 설정이 여전히 동작하는 이유).
**이 정화가 덮지 못하는 것은 cwd입니다 — 위 [한계 3](#신뢰-경계)을 참조하십시오.**
## R010 위임 구조
오케스트레이터는 파일을 직접 쓸 수 없습니다(R010). `agora`는 라운드마다 다수의 아티팩트 파일을 기록하므로, 라운드 실행은 `agora-runner` 에이전트에 위임합니다.
- **위임 단위는 라운드 1개 = 위임 1건**입니다. 다중 라운드를 한 위임에 묶지 않습니다 — Phase 경계가 곧 mid-step 종료 지점이 되는 것을 방지하기 위함입니다(R020).
- **파일을 쓰는 진입점은 `--start`·`--round`·`--set-stop`·`--report` 넷**이며 모두 러너가 실행합니다. `--set-stop`은 이름이 판정처럼 보이지만 `state.json`을 변경하므로 오케스트레이터가 직접 실행하지 않습니다.
- `agora-runner`는 스크립트 실행과 아티팩트 기록을 담당하고, **verdict 요약만** 오케스트레이터에 반환합니다. 리뷰어 원문, 벤더 출처, `SEALED/` 경로는 반환하지 않습니다.
- **사용자 게이트 표시와 게이트 응답 처리는 오케스트레이터 전담**입니다 — 서브에이전트는 사용자와 상호작용하지 않습니다.
- `agora-runner` 에이전트 정의 자체는 이 스킬의 범위 밖입니다. `.claude/agents/agora-runner.md`를 참조하십시오.