designpaca/build/eval/interview/README.md

238 lines
16 KiB
Markdown

# designpaca 인터뷰 게이트 로컬 평가 도구
designpaca 스킬의 0단계 사용자 인터뷰가 실제 하네스(Claude Code, Codex)에서
일어나는지를 실측하는 로컬 전용 평가 도구다. **CI에는 넣지 않는다** — 실제
모델 호출 비용이 들고, 로그인된 CLI 인증을 전제로 하기 때문이다.
스킬을 고치기 전후 결과를 이 도구로 비교해서 "인터뷰 게이트가 실제로
강해졌는지"를 숫자로 확인하는 것이 목적이다.
## Claude 러너 두 가지 (v1 / v2)
- **v1 (`run-claude.mjs`, `--runners claude`)** — `claude -p`(비대화형 CLI)를
직접 서브프로세스로 호출한다. 이 방식으로는 실측 결과 `AskUserQuestion`
도구 자체가 세션 도구 목록에 없다(아래 "AskUserQuestion 가용성" 참고).
- **v2 (`run-claude-sdk.mjs`, `--runners claude-sdk-auto` / `claude-sdk-default`)**
— `@anthropic-ai/claude-agent-sdk`의 `query()`를 쓴다. 오케스트레이터
지시로 추가했다: 사용자가 실제로 겪는 "안 묻는다" 문제는 (1) Claude Code
**auto 권한 모드**(Pro·Max·Team 기본값, 공식 문서가 "명확화 질문 없이
계속 진행하도록 유도한다"고 밝힌 모드)에서, (2) **`AskUserQuestion` 도구가
실제로 있는 대화형 세션**에서 일어나는데, v1(CLI `-p`)은 이 두 조건을
재현하지 못한다(도구 목록에 애초에 없다). v2는 `permissionMode: 'auto'`로
실행하면 실측상 `AskUserQuestion`이 도구 목록에 나타나고, 모델이 실제로
그 도구를 호출하는 것까지 확인했다 — v1보다 실제 보고된 문제에 훨씬
가깝다. v1은 (구현이 더 가볍고 SDK 의존성이 없다는 이유로) 계속 남겨 둔다.
`--runners claude-sdk-auto:3,claude-sdk-default:1`처럼 permissionMode별로
다른 반복 수를 줄 수 있다(`--runners`의 `이름:반복수` 문법, 아래 참고).
`default` 모드는 보조 지표로만 쓴다(auto가 실제 보고된 문제 조건에 더
가깝다).
## 사전 준비
- `claude` CLI가 로그인되어 있어야 한다(`claude -p "OK 라고만 답해"`로 확인).
- **v2(SDK) 러너를 쓰려면** `outputs/eval/deps/`에 SDK를 한 번 설치한다
(저장소 자체의 의존성을 늘리지 않으려고 저장소 밖 스크래치 경로에 둔다):
```bash
mkdir -p outputs/eval/deps && cd outputs/eval/deps
npm init -y && npm install @anthropic-ai/claude-agent-sdk
```
공식 quickstart(code.claude.com/docs/en/agent-sdk/quickstart)는
`ANTHROPIC_API_KEY` 인증을 요구하는데, 이 저장소가 도는 환경에는 그
키가 없다. 그런데도 SDK가 동작하는 것을 실측으로 확인했다 —
`query()`가 이미 로그인된 `claude` CLI를 서브프로세스로 띄워 그 로그인
세션을 그대로 타기 때문이다(`result.json`의 `apiKeySource: "none"`).
- `codex` CLI가 로그인되어 있어야 한다(`~/.codex/auth.json` 존재, `codex doctor`로 확인).
- Node.js — 저장소 루트의 요구 버전(>=20.11)이면 된다. v1 Claude 러너와
Codex 러너는 이미 설치된 CLI를 서브프로세스로 호출할 뿐 추가 설치가
없다.
## 실행
```bash
node build/eval/interview/run.mjs \
--skill-dir <평가할 SKILL.md가 있는 디렉터리> \
--label <결과를 구분할 이름> \
--runners "claude-sdk-auto:3,claude-sdk-default:1,codex:3" \
[--scenarios S1,S4] \
[--claude-model sonnet] [--codex-model gpt-5.1-codex] \
[--timeout-ms 300000]
```
- `--skill-dir`: `SKILL.md`가 직접 들어 있는 디렉터리(예: `packages/skill`,
또는 특정 커밋을 `git archive`로 뽑은 사본).
- `--label`: 결과가 쌓이는 폴더 이름. 같은 label로 러너를 나눠 여러 번
실행해도(`--runners claude`, 그다음 `--runners codex`) `results.jsonl`에
누적되고 `summary.md`는 매번 전체를 다시 집계한다.
- `--runners`: 콤마로 구분한 러너 목록. `이름:반복수`로 러너별 반복 수를
따로 줄 수 있다(생략하면 `--repeat`, 기본 3). 러너 이름은
`claude`(v1 CLI), `claude-sdk-auto`/`claude-sdk-default`(v2 SDK,
permissionMode), `codex`.
- 결과는 `outputs/eval/interview/runs/<label>/`에 쌓인다(추적 안 함).
- `<runner>/<시나리오>-<n>/work/` — 그 실행의 작업 디렉터리(격리된 사본)
- `<runner>/<시나리오>-<n>/result.json` — 그 실행의 판정 레코드
- `<runner>/<시나리오>-<n>/raw.ndjson`(claude/claude-sdk) 또는 `raw.jsonl`(codex) — 원본 스트림
- `results.jsonl` — 모든 실행의 레코드(한 줄에 하나)
- `summary.md` — 시나리오×러너 통과율 표, askChannel 분포·AskUserQuestion
유무·모델 표, 실행별 상세(질문 원문 포함)
### 기준선(v0.12.1) 재현
```bash
git archive 79e79c1 packages/skill | tar -x -C outputs/eval/interview/baseline-src
node build/eval/interview/run.mjs \
--skill-dir outputs/eval/interview/baseline-src/packages/skill \
--label baseline-v0.12.1-v2 \
--runners "claude-sdk-auto:3,claude-sdk-default:1,codex:3"
```
스킬을 고친 뒤에는 `--skill-dir packages/skill --label after-fix`처럼 다른
label로 같은 시나리오를 돌려서 기준선 `summary.md`와 비교한다.
## 시나리오 11종 (`scenarios.json`)
| ID | expect | 요지 |
|---|---|---|
| S1 | ask | 빈 프로젝트 + "꽃집 홍보 사이트 하나 만들어줘." |
| S2 | ask | "개인 포트폴리오 랜딩 페이지 만들어줘. 나는 UX 디자이너야." |
| S3 | ask | "스택노트" SaaS 랜딩 페이지, 이름·업종만 주어짐 |
| S4 | ask | design.md 있음(톤·토큰·브랜드는 확정, 요금제 정보 없음) + "요금제 페이지 추가해줘." — 실제 요금 값은 사용자만 아는 사실 |
| S5 | no-interview | "꽃집 사이트 만들어줘. 질문하지 말고 알아서 해줘." — 명시적 위임 |
| S6 | no-interview | design.md 있음 + "히어로 CTA 버튼 hover 색이 너무 약해. 조금 진하게 해줘." — 국소 경로 |
| S7 | no-interview(범위질문 1개 허용) | index.html만 있음 + "이 페이지 디자인 리뷰해줘." — 리뷰 경로 |
| S8 | ask | README.md가 업종 힌트만 줌("하이엔드 플로럴 스튜디오", 연락처·가격 없음) + "우리 사이트 새로 만들어줘." — 저장소 정보는 가설이지 사용자 승인이 아니다 |
| S9 | ask | 빈 프로젝트 + "카페 '온기' 홈페이지 만들어줘. 따뜻하고 아늑한 느낌으로." — 이름·톤은 있지만 목표 행동·브랜드 자산·실제 값이 없다 |
| S10 | ask | 빈 프로젝트 + "빨리 랜딩 하나 뽑아줘. 요가 스튜디오야." — 급하다는 압박이 있어도 브리프는 비어 있다 |
| S11 | no-interview | 빈 프로젝트 + 업종·목표 행동·톤·브랜드 자산(없음)·연락처/가격(placeholder로 두라는 지시)까지 다 채운 완결 프롬프트. 과잉 질문 여부를 본다 |
fixture는 `fixtures/<name>/`에 있다. 가짜 후기·수치는 넣지 않았다.
## 판정 기준
`result.json`의 필드:
- `asked` — 질문이 나왔는가.
- `askChannel` — `"tool"`(구조화 질문 도구를 호출) 또는 `"text"`(평문으로 물음).
**v1(`claude -p`)과 Codex는 실측상 사실상 `"text"`만 나온다** — v1 세션의
도구 목록(`init` 이벤트)에 `AskUserQuestion`이 아예 없었고, `codex exec`는
공식적으로 `request_user_input`이 "not supported in exec mode"로
거부된다. **v2(`claude-sdk-auto`)는 다르다** — `permissionMode: 'auto'`로
실행하면 도구 목록에 `AskUserQuestion`이 나타나고, 실측상 모델이 실제로
그 도구를 호출해 `askChannel: "tool"`이 나온다(질문 JSON에 header·
options·multiSelect까지 채워서). `result.json`의 `askUserQuestionAvailable`
필드로 그 실행에서 도구가 있었는지 바로 확인할 수 있다. 텍스트로만 묻는
실행에는 휴리스틱(`lib/text-heuristic.mjs` — 물음표 개수, 번호/알파벳
선택지 패턴, 한국어 요청형 어미)을 쓴다. **휴리스틱은 참고용이다.**
`result.json`/`summary.md`에 항상 질문 원문을 그대로 남기므로, 최종
판정은 원문을 사람이 직접 읽고 내려야 한다.
- `wroteBeforeAnswer` — 질문이 나온 시점(또는 끝까지 안 나왔으면 실행 종료
시점)까지 작업 디렉터리에서 생성·수정된 파일 수. 실행 전/후 스냅샷
(`lib/fsutil.mjs`)을 비교해서 센다. 두 러너 모두 질문을 감지하면
**그 즉시 프로세스를 죽인다**(SIGTERM → 3초 뒤 SIGKILL) — "물었으면
턴을 끝낸다"를 재현하기 위해서다.
- `firstVisibleAction` — 이 실행에서 가장 먼저 나온 이벤트(도구 호출 또는
텍스트).
`run.mjs`의 `computeVerdict()`가 내리는 자동 판정:
- `expect: "ask"` → `asked === true && wroteBeforeAnswer === 0`이면 PASS.
질문 없이 끝났거나(FAIL: 질문 없음), 질문 전에 이미 파일을 만들었으면
(FAIL: 파일 N건) 실패.
- `expect: "no-interview"` → `asked === false`면 PASS. 질문이 나왔는데
`allowScopeQuestion`(S7)이면 자동 판정을 PASS/FAIL로 내리지 않고
**REVIEW**로 남긴다 — "범위 확인 질문 1개 이하"인지는 원문을 읽어야
판단할 수 있어서다. `allowScopeQuestion`이 아닌데 질문이 나오면 FAIL.
`summary.md`는 시나리오×러너별 PASS/FAIL/REVIEW/ERROR 개수와 통과율 표,
그리고 실행마다 질문 원문을 그대로 실은 상세 절로 구성된다.
## 격리
### Claude
`claude -p ... --setting-sources project --strict-mcp-config`로 실행한다.
작업 디렉터리 `<work>/.claude/skills/designpaca/`에 평가 대상 스킬 사본
하나만 두고, `--setting-sources project`로 사용자(user)·로컬(local) 설정
소스를 뺀다 — 전역 `~/.claude/CLAUDE.md`(오케스트레이션 규칙)와 전역에
설치된 designpaca가 섞이지 않는다. `result.json`의 `initTools`에 그
세션이 받은 도구 목록을 그대로 남겨서, 이상한 도구가 섞여 있으면 바로
보인다. 실측 결과 `initTools`에는 Claude Code 내장 도구만 있었다
(MCP 도구는 `--strict-mcp-config`로 제거됨).
### Codex — 알려진 한계 (중요)
작업 디렉터리 `<work>/.agents/skills/designpaca/`에 스킬 사본을 두고,
`CODEX_HOME`·`HOME`·`USERPROFILE`을 전부 이 실행 전용 임시 디렉터리로
돌린다. 인증은 사용자의 실제 `~/.codex/auth.json`을 그 임시 CODEX_HOME에
복사해서만 쓰고, 실행이 끝나면 복사본을 지운다(원본은 건드리지 않는다).
**실측으로 확인한 두 가지 문제가 있다:**
1. **사용자 스코프 스킬 노출을 완전히는 못 막는다.** 이 Codex 빌드
(0.153.4, Windows)는 `~/.agents/skills` 사용자 스코프 스킬 루트를
해석할 때 홈 디렉터리를 OS 레벨로 가져오고, `HOME`/`USERPROFILE`
환경변수 재정의를 따르지 않는다(`CODEX_HOME` 자체는 정상적으로
재정의를 따른다 — `codex doctor`로 확인). 그 결과 격리 실행에서도
실제 사용자의 `~/.agents/skills`에 설치된 스킬 이름들이 그대로
보였다. 특히 "designpaca"라는 이름이 겹쳐서, 우리가 넣은 사본이
아니라 실제 설치된 v0.12.0이 선택될 위험이 있었다.
→ **대응**: 실행 전 실제 홈의 `~/.agents/skills`, `~/.codex/skills`를
스캔해 그 안의 스킬 이름을 전부 `CODEX_HOME/config.toml`의
`[[skills.config]]` 규칙으로 끄고, 우리가 넣은 SKILL.md 경로만
`enabled = true`로 다시 켠다(`run-codex.mjs`의
`buildSkillIsolationToml`). 이름 충돌은 이 방식으로 막았다
(`{name="designpaca",enabled=false}` 뒤에 우리 경로를 `enabled=true`로
재활성화 — 실측으로 "우리 사본만 보인다"를 확인했다). **다만 이름이
겹치지 않는 나머지 무관한 스킬들(브랜드킷, gpt-taste 등 20여 개)이
노출되는 것 자체는 막지 못한다** — 노이즈로 남는다.
2. **(해결됨) 완전히 새(fresh) CODEX_HOME에서는 셸 실행 자체가 정책으로
막혔었다.** 실제(격리하지 않은) `~/.codex`로 실행하면 `Get-Content`,
`pwd` 같은 평범한 명령이 정상 동작하는데, 같은 명령을 완전히 새로 만든
`CODEX_HOME`으로 실행하면 **`pwd`조차** 다음 오류로 거부되는 것을
확인했다:
```
error=exec_command failed: CreateProcess { message: "Rejected(\"... rejected: blocked by policy\")" }
```
원인을 실측으로 특정했다: 사용자의 실제 `~/.codex/config.toml`에 있는
`[windows]` 섹션(`sandbox = "unelevated"`류, Windows 샌드박스 부트스트랩
설정)이 fresh `CODEX_HOME`에는 없어서였다. 이 섹션에는 인증·토큰·계정
값이 없어 읽기 전용으로 그대로 복제해도 안전하므로, 매 실행마다
`extractWindowsSectionFromRealConfig()`가 실제 `~/.codex/config.toml`
에서 `[windows]` 섹션만 읽어(원본은 절대 안 씀) 격리된
`CODEX_HOME/config.toml`에 덧붙인다. 재현: fresh CODEX_HOME + `[windows]`
섹션 추가 → `pwd`/`Get-Content` 정상 동작(`command_execution` 아이템이
`exit_code: 0`으로 완료). `sandbox_mode`/`approval_policy` 같은, 우리가
명시적으로 지정한 `-s workspace-write`와 충돌하거나 그것을 무력화할 수
있는 키(예: 사용자 설정의 `sandbox_mode = "danger-full-access"`)는
복제하지 않았다 — 어차피 CLI 인자가 config.toml보다 우선순위가 높아
덮어써지고, 격리된 자동 실행에는 불필요하게 넓은 권한이라 판단했다.
`result.json`의 `windowsSandboxSectionApplied`로 이 섹션이 적용됐는지
확인할 수 있고, 그래도 거부가 남으면 `sandboxPolicyRejections`에 원문이
쌓인다 — **비어 있지 않은 실행은 자동 판정과 무관하게 의심하고 원문을
확인할 것.**
이 문제가 풀리면서 Codex도 `--repeat 3`을 쓸 수 있게 됐다(README 갱신
시점 기준 `baseline-v0.12.1-v2`부터). 다만 항목 1(무관한 스킬 노출)은
여전히 남아 있으므로, Codex 결과를 읽을 때 "이름 충돌은 없지만 노이즈는
있다"는 전제를 유지해야 한다. Claude 쪽은 항목 1·2 어느 것에도 해당하지
않는다(별도 CODEX_HOME 개념이 없고, `--setting-sources`가 명시적으로
문서화된 메커니즘이며, 실측으로 격리를 확인했다).
## 알려진 제약
- `claude -p`/`codex exec`는 한 턴을 실행하고 끝난다(대화형 SDK의
`canUseTool` 콜백 같은 실시간 승인/차단 훅이 없다). 그래서 이 도구는
NDJSON 스트림을 실시간으로 읽다가 질문으로 보이는 이벤트가 나오는 즉시
작업 디렉터리를 스냅샷하고 프로세스를 죽이는 방식으로 "질문이 오면 그
즉시 측정을 끝낸다"를 흉내 낸다. 완전한 실시간 인터셉트(도구 실행 자체를
막는 것)는 아니다.
- 텍스트 채널 질문 판정은 휴리스틱이다. 오탐(질문처럼 안 생겼는데 질문인
경우, 또는 그 반대)이 있을 수 있으므로 `summary.md`의 질문 원문을 사람이
읽고 최종 확인해야 한다.
- Windows 전용 경로 해석 이슈를 몇 가지 우회했다(`run-codex.mjs`의
주석 참고: codex.cmd → powershell.exe → codex.ps1으로 이어지는 셸
릴레이가 공백·한글이 섞인 프롬프트를 깨뜨려서, 가능하면 그 뒤의 네이티브
`codex.exe`를 직접 찾아 셸 없이 실행하고, 프롬프트는 인자 대신 표준
입력으로 넘긴다). macOS/Linux에서는 `codex`가 보통 네이티브 바이너리라
이 문제가 없을 것으로 보이나 이 저장소 환경에서 검증하지는 못했다.