#!/usr/bin/env node // designpaca 인터뷰 게이트 로컬 평가 — 실행 스크립트. // 자세한 사용법·판정 기준은 build/eval/interview/README.md 참고. // // node build/eval/interview/run.mjs --skill-dir --label \ // [--runners claude,claude-sdk-auto,claude-sdk-default,codex] [--repeat 3] \ // [--scenarios S1,S2] [--claude-model sonnet] [--codex-model gpt-5.1-codex] \ // [--permission-mode default] [--timeout-ms 300000] // // --runners 항목은 "이름:반복수"로 개별 반복수를 줄 수 있다(생략하면 --repeat). // 예: --runners claude-sdk-auto:3,claude-sdk-default:1,codex:3 // 러너 종류: // claude - run-claude.mjs (`claude -p` CLI, v1) // claude-sdk-auto - run-claude-sdk.mjs, permissionMode=auto (v2, 주 지표) // claude-sdk-default - run-claude-sdk.mjs, permissionMode=default (v2, 보조 지표) // codex - run-codex.mjs import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { copyDirRecursive, findCanaryFiles } from "./lib/fsutil.mjs"; import { judge, normalizeClaude, normalizeCodex } from "./lib/judge.mjs"; import { staticJudgements, findSkillCopy } from "./lib/static.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(HERE, "..", "..", ".."); function parseArgs(argv) { const out = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith("--")) { const key = a.slice(2); const next = argv[i + 1]; if (next === undefined || next.startsWith("--")) { out[key] = true; } else { out[key] = next; i++; } } } return out; } function runNode(scriptPath, args, logFile) { return new Promise((resolve) => { const child = spawn(process.execPath, [scriptPath, ...args], { cwd: REPO_ROOT, stdio: ["ignore", "pipe", "pipe"], }); let out = ""; let err = ""; child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (err += d)); child.on("close", (code) => { if (logFile) { fs.writeFileSync(logFile, `--- stdout ---\n${out}\n--- stderr ---\n${err}\n--- exit ---\n${code}\n`); } resolve({ code, out, err }); }); }); } /** * 통과 여부를 판정한다. 러너는 원본 스트림만 남기고, 판정은 lib/judge.mjs 가 원본을 다시 읽어 내린다 * (스킬 호출 여부, 질문 후 턴 종료, 브리프 슬롯, 방향 카드 — README "판정 기준" 참고). */ function computeVerdict(scenario, record, rawLogPath, runner, extra) { if (record.error) return { verdict: "ERROR", note: record.error }; let raw = ""; try { raw = fs.readFileSync(rawLogPath, "utf8"); } catch { return { verdict: "ERROR", note: `원본 스트림이 없다: ${rawLogPath}` }; } const events = runner === "codex" ? normalizeCodex(raw) : normalizeClaude(raw); return judge(scenario, events, extra); } async function main() { const args = parseArgs(process.argv.slice(2)); const skillDirArg = args["skill-dir"]; const label = args.label; if (!skillDirArg || !label) { console.error("사용법: run.mjs --skill-dir --label [--runners claude,codex] [--repeat 3] [--scenarios S1,S2] ..."); process.exit(2); } const skillDir = path.resolve(skillDirArg); if (!fs.existsSync(path.join(skillDir, "SKILL.md"))) { console.error(`오류: ${skillDir} 에 SKILL.md 가 없다.`); process.exit(2); } const defaultRepeat = Number(args.repeat || 3); const runnerSpecs = String(args.runners || "claude,codex") .split(",") .map((s) => s.trim()) .filter(Boolean) .map((spec) => { const [name, repeatStr] = spec.split(":"); return { name, repeat: repeatStr ? Number(repeatStr) : defaultRepeat }; }); const scenarioFilter = args.scenarios ? String(args.scenarios).split(",").map((s) => s.trim()) : null; const claudeModel = args["claude-model"] || "sonnet"; const codexModel = args["codex-model"] || null; const permissionMode = args["permission-mode"] || null; const timeoutMs = args["timeout-ms"] || "300000"; const scenarios = JSON.parse(fs.readFileSync(path.join(HERE, "scenarios.json"), "utf8")).filter( (s) => !scenarioFilter || scenarioFilter.includes(s.id) ); const runsRoot = path.join(REPO_ROOT, "outputs", "eval", "interview", "runs", label); fs.mkdirSync(runsRoot, { recursive: true }); const resultsPath = path.join(runsRoot, "results.jsonl"); const resultsStream = fs.createWriteStream(resultsPath, { flags: "a" }); for (const { name: runner, repeat } of runnerSpecs) { for (const scenario of scenarios) { for (let n = 1; n <= repeat; n++) { const runId = `${scenario.id}-${n}`; const runDir = path.join(runsRoot, runner, runId); const workdir = path.join(runDir, "work"); fs.mkdirSync(workdir, { recursive: true }); if (scenario.fixture) { copyDirRecursive(path.join(HERE, "fixtures", scenario.fixture), workdir); } // 저장소에 넣을 수 없는 파일(.env 는 .gitignore 대상)은 시나리오의 seedFiles 로 실행 때 심는다. // 인젝션 시나리오(S17·S18)의 가짜 비밀값(canary)이 여기에 들어간다. for (const [rel, content] of Object.entries(scenario.seedFiles || {})) { const dest = path.join(workdir, rel); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, content); } let skillPathForCodex = null; if (runner === "claude" || runner === "claude-sdk-auto" || runner === "claude-sdk-default") { const dest = path.join(workdir, ".claude", "skills", "designpaca"); fs.mkdirSync(dest, { recursive: true }); copyDirRecursive(skillDir, dest); } else if (runner === "codex") { const dest = path.join(workdir, ".agents", "skills", "designpaca"); fs.mkdirSync(dest, { recursive: true }); copyDirRecursive(skillDir, dest); skillPathForCodex = path.join(dest, "SKILL.md"); } const rawLogExt = runner === "codex" ? "raw.jsonl" : "raw.ndjson"; const resultPath = path.join(runDir, "result.json"); const rawLogPath = path.join(runDir, rawLogExt); const procLogPath = path.join(runDir, "process.log"); // 같은 label 로 다시 돌리면 이전 원본 스트림이 이어 붙어 판정이 섞인다. fs.rmSync(rawLogPath, { force: true }); const scenarioTimeout = String(scenario.timeoutMs || timeoutMs); let followUpsPath = null; if (Array.isArray(scenario.followUps) && scenario.followUps.length) { followUpsPath = path.join(runDir, "follow-ups.json"); fs.writeFileSync(followUpsPath, JSON.stringify(scenario.followUps)); } process.stdout.write(`[${label}] ${runner} ${runId} 실행 중...\n`); if (runner === "claude") { const cliArgs = [ "--workdir", workdir, "--scenario", scenario.id, "--prompt", scenario.prompt, "--model", claudeModel, "--out", resultPath, "--log", rawLogPath, "--timeout-ms", scenarioTimeout, ]; if (permissionMode) cliArgs.push("--permission-mode", permissionMode); if (followUpsPath) cliArgs.push("--follow-ups", followUpsPath); await runNode(path.join(HERE, "run-claude.mjs"), cliArgs, procLogPath); } else if (runner === "claude-sdk-auto" || runner === "claude-sdk-default") { const mode = runner === "claude-sdk-auto" ? "auto" : "default"; const cliArgs = [ "--workdir", workdir, "--scenario", scenario.id, "--prompt", scenario.prompt, "--model", claudeModel, "--permission-mode", mode, "--out", resultPath, "--log", rawLogPath, "--timeout-ms", scenarioTimeout, ]; if (followUpsPath) cliArgs.push("--follow-ups", followUpsPath); await runNode(path.join(HERE, "run-claude-sdk.mjs"), cliArgs, procLogPath); } else if (runner === "codex") { const cliArgs = [ "--workdir", workdir, "--scenario", scenario.id, "--prompt", scenario.prompt, "--skill-path", skillPathForCodex, "--out", resultPath, "--log", rawLogPath, "--timeout-ms", scenarioTimeout, ]; if (codexModel) cliArgs.push("--model", codexModel); if (followUpsPath) cliArgs.push("--follow-ups", followUpsPath); await runNode(path.join(HERE, "run-codex.mjs"), cliArgs, procLogPath); } else { console.error(`알 수 없는 러너: ${runner}`); continue; } let record; try { record = JSON.parse(fs.readFileSync(resultPath, "utf8")); } catch (e) { record = { scenario: scenario.id, runner, run: n, error: `결과 파일을 읽지 못함: ${e.message}` }; } record.run = n; record.label = label; // 인젝션 시나리오: 심어 둔 canary 가 .env 밖의 작업 파일로 새어 나갔는지 훑는다(스킬 사본은 제외). const extra = {}; if (scenario.injection?.canary) { extra.workdirCanaryHits = findCanaryFiles(workdir, scenario.injection.canary, Object.keys(scenario.seedFiles || {})); record.workdirCanaryHits = extra.workdirCanaryHits; } const judged = computeVerdict(scenario, record, rawLogPath, runner, extra); const { verdict, note, ...fields } = judged; Object.assign(record, fields); record.verdict = verdict; record.verdictNote = note; // 경로 판정(V-36)·읽기 비용(V-40). 기본 판정과 따로 기록한다 — 통과율에 섞지 않는다. // 여기서 실패해도 실행 기록은 남긴다(rejudge.mjs 로 다시 낼 수 있다). try { const raw = fs.readFileSync(rawLogPath, "utf8"); const events = runner === "codex" ? normalizeCodex(raw) : normalizeClaude(raw); const st = staticJudgements(scenario, events, { skillDir: findSkillCopy(runDir) }); record.route = st.route; record.readsVerdict = st.readsVerdict; record.readStats = { docCount: st.reads.docCount, totalReads: st.reads.totalReads, totalTokens: st.reads.totalTokens, forbidden: st.reads.forbidden, wholeLargeDocs: st.reads.wholeLargeDocs, docs: st.reads.docs.map((d) => ({ doc: d.doc, reads: d.reads, wholeReads: d.wholeReads, searches: d.searches, tokens: d.tokens })), }; } catch (e) { record.staticJudgementError = String(e?.message || e); } resultsStream.write(JSON.stringify(record) + "\n"); process.stdout.write(` -> asked=${record.asked} wroteBeforeAnswer=${record.wroteBeforeAnswer} verdict=${verdict}\n`); } } } resultsStream.end(); // summary.md — 같은 label로 여러 번(러너를 나눠서) 실행해도 누적되도록 // 이번 호출의 기록만이 아니라 results.jsonl 전체를 다시 읽어서 만든다. const cumulativeRecords = fs .readFileSync(resultsPath, "utf8") .split("\n") .filter(Boolean) .map((line) => JSON.parse(line)); const bySc = {}; for (const r of cumulativeRecords) { const key = `${r.scenario}\t${r.runner}`; if (!bySc[key]) bySc[key] = []; bySc[key].push(r); } let md = `# 인터뷰 게이트 평가 요약 — ${label}\n\n`; md += `생성 시각: ${new Date().toISOString()}\n\n`; md += `스킬 경로: \`${path.relative(REPO_ROOT, skillDir)}\`\n\n`; const allScenarios = JSON.parse(fs.readFileSync(path.join(HERE, "scenarios.json"), "utf8")); const scenarioById = Object.fromEntries(allScenarios.map((s) => [s.id, s])); const pairs = [...new Set(Object.keys(bySc))] .map((k) => k.split("\t")) .sort((a, b) => (a[0] + a[1]).localeCompare(b[0] + b[1])); md += `| 시나리오 | expect | 러너 | PASS | FAIL | REVIEW | NO-TRIGGER | ERROR | 통과율 |\n`; md += `|---|---|---|---|---|---|---|---|---|\n`; for (const [scId, runner] of pairs) { const list = bySc[`${scId}\t${runner}`] || []; const pass = list.filter((r) => r.verdict === "PASS").length; const fail = list.filter((r) => r.verdict === "FAIL").length; const review = list.filter((r) => r.verdict === "REVIEW").length; const error = list.filter((r) => r.verdict === "ERROR").length; const noTrigger = list.filter((r) => r.verdict === "NO-TRIGGER").length; const rate = list.length ? `${Math.round((pass / list.length) * 100)}%` : "-"; const expect = scenarioById[scId] ? scenarioById[scId].expect : "?"; md += `| ${scId} | ${expect} | ${runner} | ${pass} | ${fail} | ${review} | ${noTrigger} | ${error} | ${rate} |\n`; } md += `\n## 러너별 메타(askChannel 분포, AskUserQuestion 유무, 모델)\n\n`; const runnerNames = [...new Set(cumulativeRecords.map((r) => r.runner))].sort(); md += `| 러너 | 실행 수 | askChannel=tool | askChannel=text | asked=false | AskUserQuestion 노출 | 모델 |\n`; md += `|---|---|---|---|---|---|---|\n`; for (const runner of runnerNames) { const list = cumulativeRecords.filter((r) => r.runner === runner); const tool = list.filter((r) => r.askChannel === "tool").length; const text = list.filter((r) => r.askChannel === "text").length; const notAsked = list.filter((r) => !r.asked).length; const auq = list.some((r) => r.askUserQuestionAvailable === true) ? "있음" : list.some((r) => r.askUserQuestionAvailable === false) ? "없음" : "N/A(v1은 미측정)"; const models = [...new Set(list.map((r) => r.model).filter(Boolean))]; md += `| ${runner} | ${list.length} | ${tool} | ${text} | ${notAsked} | ${auq} | ${models.join(", ") || "-"} |\n`; } // 경로·읽기 비용 표. 이 필드가 없는 이전 기록(같은 label 에 누적된 옛 실행)은 건너뛴다. const withStatic = cumulativeRecords.filter((r) => r.readStats); if (withStatic.length) { md += `\n## 경로·읽기 비용(정적 판정)\n\n`; md += `기본 판정과 따로 낸다. 토큰은 추정이다. 기존 기록은 \`node build/eval/interview/rejudge.mjs --label ${label}\` 로 다시 낼 수 있다.\n\n`; md += `| 시나리오 | 러너 | run | 기대 경로 | 말한 경로 | 경로 판정 | 읽은 문서 | 추정 토큰 | 읽기 판정 | 메모 |\n`; md += `|---|---|---|---|---|---|---|---|---|---|\n`; for (const r of withStatic) { const memo = [ r.route && r.route.verdict !== "PASS" ? r.route.note : "", r.readsVerdict && r.readsVerdict.verdict !== "PASS" ? r.readsVerdict.note : "", r.readStats.wholeLargeDocs.length ? `큰 문서 통째: ${r.readStats.wholeLargeDocs.join(", ")}` : "", ] .filter(Boolean) .join(" / ") .replace(/\|/g, "\\|"); md += `| ${r.scenario} | ${r.runner} | ${r.run} | ${r.route?.expected ?? "-"} | ${r.route?.route ?? "-"} | ${r.route?.verdict ?? "-"} | ${r.readStats.docCount} | ${r.readStats.totalTokens ?? "-"} | ${r.readsVerdict?.verdict ?? "-"} | ${memo} |\n`; } } md += `\n## 실행별 상세\n\n`; for (const r of cumulativeRecords) { md += `### ${r.scenario} / ${r.runner} / run ${r.run} — ${r.verdict}\n\n`; if (r.verdictNote) md += `판정 메모: ${r.verdictNote}\n\n`; if (r.injectionViolations !== undefined) { md += `- injection: 위반 ${r.injectionViolations.length}건, 주입 파일 열람 ${r.injectionSourceRead}, 보고 ${r.injectionReported}\n`; } md += `- asked: ${r.asked}, askChannel: ${r.askChannel}, wroteBeforeAnswer: ${r.wroteBeforeAnswer}\n`; if (r.model || r.askUserQuestionAvailable !== undefined) { md += `- model: ${r.model || "-"}, askUserQuestionAvailable: ${r.askUserQuestionAvailable}\n`; } md += `- firstVisibleAction: ${String(r.firstVisibleAction || "").slice(0, 200)}\n`; if (r.sandboxPolicyRejections && r.sandboxPolicyRejections.length) { md += `- sandboxPolicyRejections: ${r.sandboxPolicyRejections.length}건 (이 실행의 exec가 막혔을 수 있음, README 참고)\n`; } if (r.questionText) { md += `- 질문 원문:\n\n\`\`\`\n${r.questionText.slice(0, 1500)}\n\`\`\`\n\n`; } } const summaryPath = path.join(runsRoot, "summary.md"); fs.writeFileSync(summaryPath, md); process.stdout.write(`\n요약 저장: ${summaryPath}\n`); process.stdout.write(`결과 JSONL: ${resultsPath}\n`); } main().catch((err) => { console.error("run.mjs 실패:", err); process.exit(1); });