feat(core,cli): harden install engine and extend skill lint and interview eval

- 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
This commit is contained in:
Yun Chan 2026-10-06 16:55:38 +09:00
parent ee3bef2ce5
commit 9bcb45cbd3
122 changed files with 5836 additions and 831 deletions

View file

@ -5,9 +5,9 @@
// ANTHROPIC_API_KEY 인증을 요구한다(code.claude.com/docs/en/agent-sdk/quickstart).
// 이 저장소가 도는 환경에는 그 키가 없으므로, 이미 로그인된 `claude` CLI를
// 비대화형(-p)으로 직접 호출한다. `-p` 모드는 한 턴을 실행하고 종료하므로
// SDK의 canUseTool 훅 없이도 "질문이 오면 그 시점에서 측정을 끝낸다"를
// stdout NDJSON을 실시간으로 읽으면서 흉내 낸다: 질문으로 보이는 이벤트가
// 나오는 즉시 작업 디렉터리를 스냅샷하고 프로세스를 죽인다.
// 턴이 끝날 때까지 stdout NDJSON 을 그대로 기록하고, 턴마다 작업 디렉터리 스냅샷
// 차이를 eval_turn_end 표식으로 남긴다. 질문 여부·턴 종료 판정은 run.mjs 가
// lib/judge.mjs 로 원본 스트림을 다시 읽어 내린다(러너는 판정하지 않는다).
//
// 격리: --setting-sources project 로 사용자(user)·로컬(local) 설정을 빼고
// 프로젝트 설정만 읽는다. 이 작업 디렉터리에는 우리가 넣은
@ -25,7 +25,7 @@ import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { snapshotDir, diffSnapshots } from "./lib/fsutil.mjs";
import { scoreQuestionText } from "./lib/text-heuristic.mjs";
import { readFollowUps } from "./lib/followups.mjs";
/**
* Windows 에서 `spawn(..., {shell:true})`는 target이 .exe라도 항상 cmd.exe를
@ -63,8 +63,6 @@ function parseArgs(argv) {
return out;
}
const WRITE_LIKE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
async function main() {
const args = parseArgs(process.argv.slice(2));
const workdir = args.workdir;
@ -75,40 +73,20 @@ async function main() {
const timeoutMs = Number(args["timeout-ms"] || 300000);
const outPath = args.out;
const logPath = args.log;
const followUps = readFollowUps(args["follow-ups"]);
if (!workdir || !prompt || !outPath) {
console.error("사용법: run-claude.mjs --workdir <dir> --scenario <id> --prompt <text> --out <result.json> [--log <raw.ndjson>] [--model <m>] [--permission-mode <mode>] [--timeout-ms <ms>]");
console.error("사용법: run-claude.mjs --workdir <dir> --scenario <id> --prompt <text> --out <result.json> [--log <raw.ndjson>] [--model <m>] [--permission-mode <mode>] [--timeout-ms <ms>] [--follow-ups <json>]");
process.exit(2);
}
const claudeArgs = [
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"--setting-sources",
"project",
"--strict-mcp-config",
"--model",
model,
];
if (permissionMode) {
claudeArgs.push("--permission-mode", permissionMode);
}
const snapshotBefore = snapshotDir(workdir);
const result = {
scenario,
runner: "claude",
model,
permissionMode: permissionMode || "manual(default)",
asked: false,
askChannel: null,
questionText: null,
wroteBeforeAnswer: null,
firstVisibleAction: null,
sessionId: null,
turnsRun: 0,
finalMessageText: null,
toolsUsed: [],
initTools: null,
@ -133,117 +111,77 @@ async function main() {
}
result.claudeBinaryUsed = claudeBin;
const child = spawn(claudeBin, claudeArgs, {
cwd: workdir,
stdio: ["ignore", "pipe", "pipe"],
shell: useShell,
});
let killedByUs = false;
function killChild(reason) {
if (killedByUs || child.killed) return;
killedByUs = true;
result.killedReason = reason;
try {
child.kill("SIGTERM");
} catch {
// 이미 종료된 경우 무시
}
setTimeout(() => {
// 한 턴 = `claude -p` 한 번. 질문이 나와도 죽이지 않고 턴이 끝날 때까지 관찰한다(01 F13: 질문 직후
// 같은 턴에서 파일을 쓰는 규칙 4 위반은 죽이면 측정되지 않는다). 후속 답은 --resume 으로 이어 간다.
async function runTurn(text, resumeId) {
const claudeArgs = ["-p", text, "--output-format", "stream-json", "--verbose", "--setting-sources", "project", "--strict-mcp-config", "--model", model];
if (permissionMode) claudeArgs.push("--permission-mode", permissionMode);
if (resumeId) claudeArgs.push("--resume", resumeId);
const snapshotBefore = snapshotDir(workdir);
const child = spawn(claudeBin, claudeArgs, { cwd: workdir, stdio: ["ignore", "pipe", "pipe"], shell: useShell });
const timer = setTimeout(() => {
result.killedReason = "timeout";
try {
if (!child.killed) child.kill("SIGKILL");
child.kill("SIGTERM");
} catch {
// 무시
}
}, 3000);
}
const overallTimeout = setTimeout(() => killChild("timeout"), timeoutMs);
function markAskedIfNeeded(channel, text) {
if (result.asked) return;
result.asked = true;
result.askChannel = channel;
result.questionText = text;
const after = snapshotDir(workdir);
result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count;
killChild("asked");
}
let stderrBuf = "";
child.stderr.on("data", (chunk) => {
stderrBuf += chunk.toString();
});
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
rl.on("line", (line) => {
if (logStream) logStream.write(line + "\n");
if (!line.trim()) return;
let evt;
try {
evt = JSON.parse(line);
} catch {
return;
}
if (evt.type === "system" && evt.subtype === "init") {
result.initTools = evt.tools || null;
return;
}
if (evt.type === "assistant") {
const content = (evt.message && evt.message.content) || [];
for (const block of content) {
if (block.type === "text" && block.text) {
if (!result.firstVisibleAction) {
result.firstVisibleAction = `text:${block.text.slice(0, 120)}`;
}
const score = scoreQuestionText(block.text);
if (score.isQuestion) {
markAskedIfNeeded("text", block.text);
}
} else if (block.type === "tool_use") {
result.toolsUsed.push(block.name);
if (!result.firstVisibleAction) {
result.firstVisibleAction = `tool:${block.name}`;
}
if (block.name === "AskUserQuestion") {
const qText = JSON.stringify(block.input);
markAskedIfNeeded("tool", qText);
}
setTimeout(() => {
try {
if (!child.killed) child.kill("SIGKILL");
} catch {
// 무시
}
}, 3000);
}, timeoutMs);
let stderrBuf = "";
child.stderr.on("data", (chunk) => (stderrBuf += chunk.toString()));
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
rl.on("line", (line) => {
if (logStream) logStream.write(line + "\n");
if (!line.trim()) return;
let evt;
try {
evt = JSON.parse(line);
} catch {
return;
}
if (evt.type === "system" && evt.subtype === "init") {
result.initTools = result.initTools || evt.tools || null;
result.sessionId = result.sessionId || evt.session_id || null;
} else if (evt.type === "assistant") {
for (const block of evt.message?.content || []) if (block.type === "tool_use") result.toolsUsed.push(block.name);
} else if (evt.type === "result") {
result.finalMessageText = evt.result || null;
result.sessionId = result.sessionId || evt.session_id || null;
}
return;
}
if (evt.type === "result") {
result.finalMessageText = evt.result || null;
}
});
await new Promise((resolve) => {
child.on("close", (code) => {
result.exitCode = code;
resolve();
});
child.on("error", (err) => {
result.error = String(err && err.message ? err.message : err);
resolve();
await new Promise((resolve) => {
child.on("close", (code) => {
result.exitCode = code;
resolve();
});
child.on("error", (err) => {
result.error = String(err && err.message ? err.message : err);
resolve();
});
});
});
clearTimeout(overallTimeout);
if (logStream) logStream.end();
if (result.wroteBeforeAnswer === null) {
// 끝까지 질문이 없었던 경우: 전체 실행 동안의 변경 개수를 기록한다.
const after = snapshotDir(workdir);
result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count;
clearTimeout(timer);
const changed = diffSnapshots(snapshotBefore, snapshotDir(workdir));
if (logStream) logStream.write(JSON.stringify({ type: "eval_turn_end", filesChanged: [...changed.added, ...changed.modified] }) + "\n");
if (!result.error && stderrBuf.trim()) result.stderrTail = stderrBuf.slice(-2000);
result.turnsRun++;
}
await runTurn(prompt, null);
for (const answer of followUps) {
if (result.error || result.killedReason === "timeout" || !result.sessionId) break;
if (logStream) logStream.write(JSON.stringify({ type: "eval_user", text: answer, via: "resume" }) + "\n");
await runTurn(answer, result.sessionId);
}
if (logStream) await new Promise((r) => logStream.end(r));
result.durationMs = Date.now() - startedAt;
if (!result.error && stderrBuf.trim()) {
result.stderrTail = stderrBuf.slice(-2000);
}
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, JSON.stringify(result, null, 2));