- update only touches the recorded project root; project installs do not follow symlinks or junctions; repeated --force rotates .orig backups; uninstall never deletes outside the recorded root - run npm/npx without a shell on Windows and pin optional tool versions - skip the update check in CI and non-interactive output - lint: cross-document section pointers, preflight row ids, gate rule numbers, per-document size budgets, gate copy equality - eval: scenarios S19-S21, route and read-cost rejudging, interpretation card judging, temp-dir previews and table-form cards no longer fail
219 lines
12 KiB
JavaScript
219 lines
12 KiB
JavaScript
// 읽기 비용 집계(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 = /(?<![A-Za-z0-9_.\/-])((?:SKILL\.md|references\/[A-Za-z0-9_.\/-]*?[A-Za-z0-9_-]+\.md))(?![A-Za-z0-9_.-]|\/)/g;
|
|
const SEARCH_VERB_RE = /(?<![\w-])(?:rg|grep|egrep|fgrep|ag|findstr|Select-String|sls)(?![\w-])/i;
|
|
const READ_VERB_RE = /(?<![\w-])(?:cat|type|Get-Content|gc|head|tail|sed|less|more|bat|nl|awk)(?![\w-])/i;
|
|
|
|
const slash = (s) => 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) || /(?<![\w-])head\s+(?:-n\s*|-)(\d+)/.exec(t);
|
|
const skip = /-Skip\s+(\d+)/i.exec(t);
|
|
if (first) return { from: skip ? Number(skip[1]) + 1 : 1, lines: Number(first[1]) };
|
|
m = /-(?:Tail|Last)\s+(\d+)/i.exec(t) || /(?<![\w-])tail\s+(?:-n\s*|-)(\d+)/.exec(t);
|
|
if (m) return { from: null, lines: Number(m[1]) };
|
|
return null;
|
|
}
|
|
|
|
function readsFromShell(command) {
|
|
const out = [];
|
|
const cmd = slash(command);
|
|
const hasSkillName = /designpaca/.test(cmd);
|
|
for (const stmt of cmd.split(/;|&&|\|\||\n/)) {
|
|
const hits = [...stmt.matchAll(SKILL_PATH_RE)].map((m) => ({ 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(" · ");
|
|
}
|