16 KiB
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가 실제 보고된 문제 조건에 더 가깝다).
사전 준비
claudeCLI가 로그인되어 있어야 한다(claude -p "OK 라고만 답해"로 확인).- v2(SDK) 러너를 쓰려면
outputs/eval/deps/에 SDK를 한 번 설치한다 (저장소 자체의 의존성을 늘리지 않으려고 저장소 밖 스크래치 경로에 둔다):
공식 quickstart(code.claude.com/docs/en/agent-sdk/quickstart)는mkdir -p outputs/eval/deps && cd outputs/eval/deps npm init -y && npm install @anthropic-ai/claude-agent-sdkANTHROPIC_API_KEY인증을 요구하는데, 이 저장소가 도는 환경에는 그 키가 없다. 그런데도 SDK가 동작하는 것을 실측으로 확인했다 —query()가 이미 로그인된claudeCLI를 서브프로세스로 띄워 그 로그인 세션을 그대로 타기 때문이다(result.json의apiKeySource: "none"). codexCLI가 로그인되어 있어야 한다(~/.codex/auth.json존재,codex doctor로 확인).- Node.js — 저장소 루트의 요구 버전(>=20.11)이면 된다. v1 Claude 러너와 Codex 러너는 이미 설치된 CLI를 서브프로세스로 호출할 뿐 추가 설치가 없다.
실행
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) 재현
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에
복사해서만 쓰고, 실행이 끝나면 복사본을 지운다(원본은 건드리지 않는다).
실측으로 확인한 두 가지 문제가 있다:
-
사용자 스코프 스킬 노출을 완전히는 못 막는다. 이 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여 개)이 노출되는 것 자체는 막지 못한다 — 노이즈로 남는다. -
(해결됨) 완전히 새(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 샌드박스 부트스트랩 설정)이 freshCODEX_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가 보통 네이티브 바이너리라 이 문제가 없을 것으로 보이나 이 저장소 환경에서 검증하지는 못했다.