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:
parent
ee3bef2ce5
commit
9bcb45cbd3
122 changed files with 5836 additions and 831 deletions
|
|
@ -7,11 +7,10 @@
|
|||
// 서브프로세스로 띄워 그대로 동작한다(공식 quickstart의 ANTHROPIC_API_KEY
|
||||
// 요구사항과 별개로, CLI 로그인 세션을 그대로 탄다 — apiKeySource: "none").
|
||||
//
|
||||
// canUseTool 로 AskUserQuestion 호출을 실시간으로 가로챈다: 호출이 오면
|
||||
// 질문 JSON을 기록하고 askChannel="tool"로 표시한 뒤 deny 하고 즉시
|
||||
// abortController.abort() 한다("평가 종료"). Write/Edit/NotebookEdit 등은
|
||||
// 허용하되 호출을 기록한다. 텍스트로만 묻는 경우는 run-claude.mjs와 같은
|
||||
// 휴리스틱으로 감지해서 같은 방식으로 abort 한다.
|
||||
// canUseTool 로 AskUserQuestion 호출을 가로챈다: 사용자를 기다리는 지점이므로
|
||||
// deny 하고 그 턴을 끝낸다(abort). 그 밖의 도구는 허용하고 기록한다. 텍스트
|
||||
// 질문은 죽이지 않고 턴이 끝날 때까지 관찰한다. 질문 여부·턴 종료·슬롯 판정은
|
||||
// run.mjs 가 lib/judge.mjs 로 원본 스트림을 다시 읽어 내린다.
|
||||
//
|
||||
// 설치(1회):
|
||||
// cd outputs/eval/deps && npm init -y && npm install @anthropic-ai/claude-agent-sdk
|
||||
|
|
@ -26,14 +25,12 @@ import fs from "node:fs";
|
|||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { snapshotDir, diffSnapshots } from "./lib/fsutil.mjs";
|
||||
import { scoreQuestionText } from "./lib/text-heuristic.mjs";
|
||||
import { readFollowUps } from "./lib/followups.mjs";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, "..", "..", "..");
|
||||
const SDK_ENTRY = path.join(REPO_ROOT, "outputs", "eval", "deps", "node_modules", "@anthropic-ai", "claude-agent-sdk", "sdk.mjs");
|
||||
|
||||
const WRITE_LIKE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
|
|
@ -62,9 +59,10 @@ 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-sdk.mjs --workdir <dir> --scenario <id> --prompt <text> --out <result.json> [--log <raw.jsonl>] [--model <m>] [--permission-mode auto|default] [--timeout-ms <ms>]");
|
||||
console.error("사용법: run-claude-sdk.mjs --workdir <dir> --scenario <id> --prompt <text> --out <result.json> [--log <raw.jsonl>] [--model <m>] [--permission-mode auto|default] [--timeout-ms <ms>] [--follow-ups <json>]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +73,6 @@ async function main() {
|
|||
|
||||
const { query } = await import(pathToFileURL(SDK_ENTRY).href);
|
||||
|
||||
const snapshotBefore = snapshotDir(workdir);
|
||||
const logStream = logPath ? fs.createWriteStream(logPath, { flags: "a" }) : null;
|
||||
const startedAt = Date.now();
|
||||
|
||||
|
|
@ -86,11 +83,8 @@ async function main() {
|
|||
modelRequested: model,
|
||||
permissionMode,
|
||||
permissionModeRejected: false,
|
||||
asked: false,
|
||||
askChannel: null,
|
||||
questionText: null,
|
||||
wroteBeforeAnswer: null,
|
||||
firstVisibleAction: null,
|
||||
sessionId: null,
|
||||
turnsRun: 0,
|
||||
finalMessageText: null,
|
||||
toolsUsed: [],
|
||||
initTools: null,
|
||||
|
|
@ -104,107 +98,86 @@ async function main() {
|
|||
error: null,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
let finished = false;
|
||||
const overallTimeout = setTimeout(() => {
|
||||
if (!finished) {
|
||||
result.killedReason = "timeout";
|
||||
controller.abort();
|
||||
}
|
||||
}, 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;
|
||||
result.killedReason = result.killedReason || "asked";
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
const canUseTool = async (toolName, input) => {
|
||||
if (logStream) logStream.write(JSON.stringify({ type: "canUseTool", toolName, input }) + "\n");
|
||||
result.toolsUsed.push(toolName);
|
||||
if (!result.firstVisibleAction) {
|
||||
result.firstVisibleAction = `tool:${toolName}`;
|
||||
}
|
||||
if (toolName === "AskUserQuestion") {
|
||||
markAskedIfNeeded("tool", JSON.stringify(input));
|
||||
return { behavior: "deny", message: "평가 종료" };
|
||||
}
|
||||
// Write/Edit 등은 허용하되 기록만 한다(설계: 기록하고 허용).
|
||||
return { behavior: "allow", updatedInput: input };
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const message of query({
|
||||
prompt,
|
||||
options: {
|
||||
cwd: workdir,
|
||||
model,
|
||||
permissionMode,
|
||||
settingSources: ["project"],
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
abortController: controller,
|
||||
maxTurns: 15,
|
||||
canUseTool,
|
||||
},
|
||||
})) {
|
||||
if (logStream) logStream.write(JSON.stringify(message).slice(0, 20000) + "\n");
|
||||
|
||||
if (message.type === "system" && message.subtype === "init") {
|
||||
result.model = message.model || null;
|
||||
result.initTools = message.tools || null;
|
||||
result.askUserQuestionAvailable = Array.isArray(message.tools) ? message.tools.includes("AskUserQuestion") : null;
|
||||
result.initSkills = message.skills || null;
|
||||
result.initPlugins = message.plugins || null;
|
||||
result.claudeCodeVersion = message.claude_code_version || null;
|
||||
result.apiKeySource = message.apiKeySource || null;
|
||||
continue;
|
||||
// 한 턴 = query() 한 번. 구조화 질문 도구 호출은 사용자를 기다리는 지점이므로 거기서 턴이 끝난 것으로
|
||||
// 보고 deny + abort 한다. 텍스트 질문은 죽이지 않고 턴이 끝날 때까지 관찰한다(질문 뒤 같은 턴의 도구
|
||||
// 호출·파일 쓰기를 측정하기 위해서다, 01 F13). 후속 답은 resume 으로 같은 세션에 이어 보낸다.
|
||||
async function runTurn(text, resumeId) {
|
||||
const controller = new AbortController();
|
||||
let finished = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (!finished) {
|
||||
result.killedReason = "timeout";
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
if (message.type === "assistant") {
|
||||
const content = (message.message && message.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);
|
||||
}
|
||||
}
|
||||
}, timeoutMs);
|
||||
const snapshotBefore = snapshotDir(workdir);
|
||||
const canUseTool = async (toolName, input) => {
|
||||
if (logStream) logStream.write(JSON.stringify({ type: "canUseTool", toolName, input }) + "\n");
|
||||
result.toolsUsed.push(toolName);
|
||||
if (toolName === "AskUserQuestion") {
|
||||
// 답은 다음 턴(resume)의 사용자 메시지로 보낸다. abort 는 이 콜백이 돌아간 뒤에 건다.
|
||||
setTimeout(() => controller.abort(), 50);
|
||||
// interrupt: abort 만으로는 턴이 바로 끝나지 않았다(실측 redteam-2026-10-02 S11-2: deny 오류를 본 모델이
|
||||
// 5초 뒤 ToolSearch 를 더 호출했고 판정기가 "카드 뒤 도구"로 집계). 실제 Claude Code 에서는 이 지점에서
|
||||
// 사용자를 기다리므로 SDK 의 deny interrupt 로 그 자리에서 실행을 멈춘다.
|
||||
return { behavior: "deny", message: "평가 하네스: 사용자 답은 다음 메시지로 온다.", interrupt: true };
|
||||
}
|
||||
return { behavior: "allow", updatedInput: input };
|
||||
};
|
||||
try {
|
||||
for await (const message of query({
|
||||
prompt: text,
|
||||
options: {
|
||||
cwd: workdir,
|
||||
model,
|
||||
permissionMode,
|
||||
settingSources: ["project"],
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
abortController: controller,
|
||||
maxTurns: 40,
|
||||
canUseTool,
|
||||
...(resumeId ? { resume: resumeId } : {}),
|
||||
},
|
||||
})) {
|
||||
if (logStream) logStream.write(JSON.stringify(message).slice(0, 20000) + "\n");
|
||||
if (message.type === "system" && message.subtype === "init") {
|
||||
result.model = result.model || message.model || null;
|
||||
result.sessionId = result.sessionId || message.session_id || null;
|
||||
result.initTools = result.initTools || message.tools || null;
|
||||
result.askUserQuestionAvailable = Array.isArray(message.tools) ? message.tools.includes("AskUserQuestion") : result.askUserQuestionAvailable;
|
||||
result.initSkills = result.initSkills || message.skills || null;
|
||||
result.initPlugins = result.initPlugins || message.plugins || null;
|
||||
result.claudeCodeVersion = message.claude_code_version || result.claudeCodeVersion;
|
||||
result.apiKeySource = message.apiKeySource || result.apiKeySource;
|
||||
} else if (message.type === "result") {
|
||||
result.finalMessageText = message.result || null;
|
||||
result.sessionId = result.sessionId || message.session_id || null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === "result") {
|
||||
result.finalMessageText = message.result || null;
|
||||
} catch (e) {
|
||||
const msg = String(e && e.message ? e.message : e);
|
||||
const isAbort = e && (e.name === "AbortError" || /abort/i.test(msg));
|
||||
if (!isAbort) {
|
||||
if (/permission[_-]?mode/i.test(msg) || /auto/i.test(msg)) result.permissionModeRejected = true;
|
||||
result.error = msg;
|
||||
result.errorStack = e && e.stack ? String(e.stack).slice(0, 2000) : null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = String(e && e.message ? e.message : e);
|
||||
const isAbort = e && (e.name === "AbortError" || /abort/i.test(msg));
|
||||
if (!isAbort) {
|
||||
if (/permission[_-]?mode/i.test(msg) || /auto/i.test(msg)) {
|
||||
result.permissionModeRejected = true;
|
||||
}
|
||||
result.error = msg;
|
||||
result.errorStack = e && e.stack ? String(e.stack).slice(0, 2000) : null;
|
||||
}
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
const changed = diffSnapshots(snapshotBefore, snapshotDir(workdir));
|
||||
if (logStream) logStream.write(JSON.stringify({ type: "eval_turn_end", filesChanged: [...changed.added, ...changed.modified] }) + "\n");
|
||||
result.turnsRun++;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
clearTimeout(overallTimeout);
|
||||
if (logStream) logStream.end();
|
||||
|
||||
if (result.wroteBeforeAnswer === null) {
|
||||
const after = snapshotDir(workdir);
|
||||
result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count;
|
||||
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;
|
||||
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue