// 읽기 비용 집계(V-40). 실행 로그에서 스킬 문서를 읽은 도구 호출을 뽑아 문서별 읽은 횟수·추정 토큰을 낸다. // 모델을 부르지 않는다 — 러너가 이미 남긴 원본 스트림(raw.ndjson·raw.jsonl)을 다시 읽는 정적 재판정이다. // // 왜 재는가: SKILL.md 는 "해당 절만 읽는다", evidence-ledger.md 는 "원장이다 — 통째로 읽지 않는다"고 적지만 // 실제로 그렇게 읽히는지 본 적이 없다. 레드팀 03 의 전체 경로 22만 토큰은 추정이었다. // // 실측한 로그 형식(outputs/eval/interview/runs/redteam-2026-10-02 등 293건): // Claude `Read {file_path, offset?, limit?}`, `Grep {pattern, path}`, 드물게 `Bash`·`PowerShell` 의 cat·Get-Content // Codex `command_execution {command: "pwsh -Command 'Get-Content -Raw a.md; Get-Content b.md -TotalCount 80; // Get-Content c.md | Select-Object -Skip 91 -First 75'"}` — 한 명령에 문서 여러 개가 `;` 로 이어진다 // // 못 잡는 것(집계에서 빠진다): `cd …/designpaca; cat references/x.md` 처럼 스킬 디렉터리 이름 없이 부른 상대 경로 // (명령 어딘가에 designpaca 가 있으면 잡는다), 변수에 담은 경로(`$p`), 스크립트 파일 안에서 읽은 것, // `cat a.md b.md | head -50` 의 합쳐진 줄 수(둘째 문서에만 50줄로 적용한다), 따옴표 안의 `;`. // Claude 의 `Skill` 도구 호출은 SKILL.md 본문을 하네스가 넣어 주므로 SKILL.md 통째 읽기 1회로 센다. import fs from "node:fs"; import path from "node:path"; import { estimateTokens, REF_GUIDE_TOKEN_THRESHOLD } from "../../../ci/lint-skill.mjs"; // 금지된 읽기 패턴. coverage 는 그 문서 줄의 몇 할을 읽었는가다(여러 번 나눠 읽은 것을 합친다). export const FORBIDDEN_READS = [ { doc: "references/evidence-ledger.md", minCoverage: 0.9, label: "evidence-ledger.md 를 통째로 읽었다 — 원장은 출처 ID 로 검색해 해당 행만 읽는다(SKILL.md 1단계)", }, ]; // 여러 번 나눠 읽어도 이 비율을 넘으면 통째로 읽은 것으로 본다. export const WHOLE_COVERAGE = 0.9; const SHELL_TOOLS = new Set(["Bash", "PowerShell", "shell", "command_execution"]); // 스킬 디렉터리 이름이 붙은 경로. 설치 위치(.claude/skills, .agents/skills, 절대 경로)와 무관하게 designpaca/ 뒤를 본다. const SKILL_PATH_RE = /designpaca\/((?:SKILL\.md|(?:references|tools|agents)\/[A-Za-z0-9_.\/-]*?[A-Za-z0-9_-]+\.[A-Za-z0-9]+))(?![A-Za-z0-9_.-]|\/)/g; // 스킬 디렉터리 이름 없이 부른 상대 경로. 명령에 designpaca 가 따로 있을 때만 쓴다(`cd …/designpaca; cat references/x.md`). const BARE_PATH_RE = /(? String(s || "").replace(/\\+/g, "/"); /** 줄 범위 제한을 읽는다. 반환: { from, lines } | null. from 이 null 이면 끝에서 lines 줄이다. */ function parseLimit(s) { const t = String(s || ""); let m = /sed\s+(?:-n\s+|-ne\s+)['"]?(\d+),(\d+)p/.exec(t); if (m) return { from: Number(m[1]), lines: Number(m[2]) - Number(m[1]) + 1 }; m = /-Index\s+\(?(\d+)\.\.(\d+)\)?/i.exec(t); if (m) return { from: Number(m[1]) + 1, lines: Number(m[2]) - Number(m[1]) + 1 }; const first = /-(?:TotalCount|First|Head)\s+(\d+)/i.exec(t) || /(? ({ doc: m[1], start: m.index, end: m.index + m[0].length })); if (hits.length === 0 && hasSkillName) { for (const m of stmt.matchAll(BARE_PATH_RE)) hits.push({ doc: m[1], start: m.index, end: m.index + m[0].length }); } if (hits.length === 0) continue; const kind = SEARCH_VERB_RE.test(stmt) ? "search" : READ_VERB_RE.test(stmt) ? "read" : null; if (!kind) continue; // ls·wc·Test-Path·node 실행 등은 읽기가 아니다 const lead = parseLimit(stmt.slice(0, hits[0].start)); hits.forEach((h, i) => { if (kind === "search") { out.push({ doc: h.doc, kind, source: "shell" }); return; } const own = parseLimit(stmt.slice(h.end, hits[i + 1]?.start ?? stmt.length)); const limit = own ?? lead; out.push({ doc: h.doc, kind, source: "shell", from: limit ? limit.from : 1, lines: limit ? limit.lines : null }); }); } return out; } /** * 정규화된 이벤트에서 스킬 문서 읽기를 뽑는다. * 반환: [{ doc, kind: "read"|"search", source: "read-tool"|"search-tool"|"shell"|"skill-tool", from?, lines? }] * doc 은 스킬 루트 기준 경로(`SKILL.md`, `references/color.md`)다. lines 가 null 이면 끝까지 읽은 것이다. */ export function extractSkillReads(events) { const out = []; for (const e of events) { if (e.kind !== "tool") continue; const inp = e.input || {}; if (e.name === "Skill") { if (/designpaca/i.test(String(inp.skill || inp.name || inp.command || ""))) out.push({ doc: "SKILL.md", kind: "read", source: "skill-tool", from: 1, lines: null }); continue; } if (SHELL_TOOLS.has(e.name)) { out.push(...readsFromShell(inp.command)); continue; } if (e.write) continue; const p = slash(inp.file_path || inp.path || ""); const m = new RegExp(SKILL_PATH_RE.source).exec(p); if (!m) continue; if (e.name === "Read") { const from = Number.isFinite(Number(inp.offset)) && Number(inp.offset) > 0 ? Number(inp.offset) : 1; const lines = Number.isFinite(Number(inp.limit)) && Number(inp.limit) > 0 ? Number(inp.limit) : null; out.push({ doc: m[1], kind: "read", source: "read-tool", from, lines }); } else if (e.name === "Grep") { out.push({ doc: m[1], kind: "search", source: "search-tool" }); } } return out; } /** * 문서별 읽기 횟수·추정 토큰을 집계한다. * @param {Array} events normalizeClaude/normalizeCodex 결과 * @param {{skillDir?: string}} [opts] 평가한 스킬 사본(실행 디렉터리의 `work/.claude/skills/designpaca` 등). * 주면 읽은 줄 범위의 실제 글자로 토큰을 추정한다. 없으면 횟수와 통째 여부만 낸다(tokensKnown: false). */ export function readStats(events, opts = {}) { const cache = new Map(); const linesOf = (doc) => { if (!opts.skillDir) return null; if (!cache.has(doc)) { let v = null; try { v = fs.readFileSync(path.join(opts.skillDir, doc), "utf8").split("\n"); } catch { v = null; } cache.set(doc, v); } return cache.get(doc); }; const byDoc = new Map(); for (const r of extractSkillReads(events)) { if (!byDoc.has(r.doc)) byDoc.set(r.doc, { doc: r.doc, reads: 0, wholeReads: 0, searches: 0, tokens: 0, docTokens: null, coverage: null, covered: null, known: true }); const d = byDoc.get(r.doc); if (r.kind === "search") { d.searches += 1; continue; } d.reads += 1; const all = linesOf(r.doc); if (!all) { // 문서 내용을 모르면 범위 제한이 없는 읽기만 통째로 센다. d.known = false; if (r.lines === null && r.from === 1) d.wholeReads += 1; continue; } if (!d.covered) d.covered = new Uint8Array(all.length); d.docTokens = estimateTokens(all.join("\n")); let start = r.from === null ? Math.max(0, all.length - r.lines) : Math.max(0, r.from - 1); let end = r.lines === null ? all.length : Math.min(all.length, start + r.lines); if (start > all.length) start = end = all.length; for (let i = start; i < end; i++) d.covered[i] = 1; d.tokens += estimateTokens(all.slice(start, end).join("\n")); if (all.length > 0 && (end - start) / all.length >= WHOLE_COVERAGE) d.wholeReads += 1; } const docs = [...byDoc.values()].map((d) => { const coverage = d.covered ? d.covered.reduce((a, b) => a + b, 0) / Math.max(1, d.covered.length) : d.wholeReads > 0 ? 1 : null; const { covered, known, ...rest } = d; return { ...rest, tokens: known ? d.tokens : null, coverage: coverage === null ? null : Math.round(coverage * 100) / 100 }; }); docs.sort((a, b) => (b.tokens ?? 0) - (a.tokens ?? 0) || b.reads - a.reads || a.doc.localeCompare(b.doc)); const read = docs.filter((d) => d.reads > 0); const tokensKnown = read.length > 0 && read.every((d) => d.tokens !== null); const forbidden = []; for (const f of FORBIDDEN_READS) { const d = byDoc.get(f.doc); if (!d) continue; const cov = docs.find((x) => x.doc === f.doc).coverage; if (d.wholeReads > 0 || (cov !== null && cov >= f.minCoverage)) forbidden.push({ doc: f.doc, label: f.label, coverage: cov }); } return { docs, docCount: read.length, totalReads: read.reduce((a, d) => a + d.reads, 0), totalSearches: docs.reduce((a, d) => a + d.searches, 0), totalTokens: tokensKnown ? read.reduce((a, d) => a + d.tokens, 0) : null, tokensKnown, forbidden, // 관찰값(실패는 아니다): "이 문서를 읽는 법" 표가 있어야 하는 크기의 문서를 통째로 읽었다, 같은 문서를 두 번 통째로 읽었다. wholeLargeDocs: docs.filter((d) => d.doc !== "SKILL.md" && d.wholeReads > 0 && (d.docTokens ?? 0) > REF_GUIDE_TOKEN_THRESHOLD).map((d) => d.doc), rereadDocs: docs.filter((d) => d.wholeReads >= 2).map((d) => d.doc), }; } /** * 읽기 비용 판정. 기본 판정(judge)과 따로 낸다 — 인터뷰 게이트 통과율과 섞지 않는다. * FAIL 금지된 읽기 패턴, 또는 시나리오의 `maxSkillReadTokens` 초과 * REVIEW 상한이 있는데 토큰을 추정하지 못했다(스킬 사본이 없다) * PASS 그 밖 * 스킬 문서를 하나도 읽지 않은 실행은 null 이다(판정 대상이 아니다). */ export function judgeReads(scenario, stats) { if (!stats || stats.docCount === 0) return null; if (stats.forbidden.length) return { verdict: "FAIL", note: stats.forbidden.map((f) => f.label).join(" / ") }; const cap = scenario?.maxSkillReadTokens; if (typeof cap === "number") { if (!stats.tokensKnown) return { verdict: "REVIEW", note: `읽기 상한 ${cap} 이 있는데 토큰을 추정하지 못했다(스킬 사본 없음) — 문서 ${stats.docCount}개` }; if (stats.totalTokens > cap) { const top = stats.docs.filter((d) => d.reads > 0).slice(0, 3).map((d) => `${d.doc} ${d.tokens}`).join(", "); return { verdict: "FAIL", note: `스킬 문서 읽기 추정 ${stats.totalTokens}토큰 > 상한 ${cap} — 큰 것: ${top}` }; } } return { verdict: "PASS", note: "" }; } /** 한 줄 요약(표·로그용). */ export function formatReadStats(stats) { if (!stats || stats.docCount === 0) return "스킬 문서 읽기 없음"; const tok = stats.tokensKnown ? `추정 ${stats.totalTokens}토큰` : "토큰 미상"; const parts = [`문서 ${stats.docCount}개`, `읽기 ${stats.totalReads}회`, tok]; if (stats.wholeLargeDocs.length) parts.push(`큰 문서 통째 ${stats.wholeLargeDocs.length}개`); if (stats.forbidden.length) parts.push(`금지 패턴 ${stats.forbidden.length}건`); return parts.join(" · "); }