mirror of
https://github.com/redwoodjs/local-ci.git
synced 2026-09-19 07:54:29 +08:00
47252ffc5f
Adds experiments/skill-evals/, a matrix eval that runs headless Claude Code against broken-repo fixtures with candidate SKILL.md wordings appended to CLAUDE.md, scores tool calls + file edits against per-fixture rubric regexes, and aggregates across N trials per (variant, fixture) cell. Includes: - run.mjs + rescore.mjs (live eval + replay saved transcripts against a changed rubric without re-running the agent) - 3 fixtures: lint-curly-x18, missing-changeset, noisy-failure - 3 variants: v1-current (baseline), v2-run-first, v3-no-mangling - Stub @redwoodjs/agent-ci per fixture — no Docker required for the eval Fixture repos are deliberately broken (syntax errors, missing changesets, etc.); .prettierignore and .oxlintrc ignorePatterns exclude them so the pre-commit hooks stay green. Scoped to experiments/, no changes to published packages, no changeset. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
100 lines
2.6 KiB
JavaScript
100 lines
2.6 KiB
JavaScript
// Headless Claude Code runner.
|
|
// Injects the variant as CLAUDE.md in the scratch repo so the agent picks it
|
|
// up via project-instructions auto-discovery — same path a real user would use.
|
|
// Captures stream-json so we can replay tool calls and file edits into the scorer.
|
|
|
|
import { execa } from "execa";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
export async function run({
|
|
workdir,
|
|
taskPath,
|
|
variantPath,
|
|
model = "claude-sonnet-4-6",
|
|
maxBudgetUsd = 1,
|
|
}) {
|
|
const variant = await readFile(variantPath, "utf8");
|
|
// Append the variant to any existing fixture CLAUDE.md (repo rules + skill text,
|
|
// matching how real users configure it). If no CLAUDE.md exists, this just writes one.
|
|
const claudePath = join(workdir, "CLAUDE.md");
|
|
const existing = await readFile(claudePath, "utf8").catch(() => "");
|
|
const merged = existing ? `${existing.trimEnd()}\n\n${variant}` : variant;
|
|
await writeFile(claudePath, merged);
|
|
|
|
const task = await readFile(taskPath, "utf8");
|
|
|
|
const proc = await execa(
|
|
"claude",
|
|
[
|
|
"--print",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--verbose",
|
|
"--permission-mode",
|
|
"bypassPermissions",
|
|
"--model",
|
|
model,
|
|
"--max-budget-usd",
|
|
String(maxBudgetUsd),
|
|
"--no-session-persistence",
|
|
task,
|
|
],
|
|
{ cwd: workdir, reject: false, all: true, timeout: 5 * 60_000 },
|
|
);
|
|
|
|
const events = [];
|
|
for (const line of (proc.stdout ?? "").split("\n")) {
|
|
if (!line.trim()) {
|
|
continue;
|
|
}
|
|
try {
|
|
events.push(JSON.parse(line));
|
|
} catch {
|
|
/* skip non-json */
|
|
}
|
|
}
|
|
|
|
return { events, exitCode: proc.exitCode, stderr: proc.stderr };
|
|
}
|
|
|
|
export function extractStreams(events) {
|
|
const toolCalls = [];
|
|
const fileEdits = [];
|
|
const text = [];
|
|
|
|
for (const ev of events) {
|
|
const msg = ev?.message;
|
|
if (!msg || !Array.isArray(msg.content)) {
|
|
continue;
|
|
}
|
|
for (const block of msg.content) {
|
|
if (block.type === "tool_use") {
|
|
const { name, input } = block;
|
|
toolCalls.push({ name, input, flat: flatten(input) });
|
|
if (name === "Edit" || name === "Write") {
|
|
fileEdits.push({
|
|
path: input.file_path,
|
|
// Write: whole new contents. Edit: new_string only.
|
|
content: input.new_string ?? input.content ?? "",
|
|
});
|
|
}
|
|
} else if (block.type === "text") {
|
|
text.push(block.text);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { toolCalls, fileEdits, text };
|
|
}
|
|
|
|
function flatten(obj) {
|
|
if (obj == null) {
|
|
return "";
|
|
}
|
|
if (typeof obj === "string") {
|
|
return obj;
|
|
}
|
|
return Object.values(obj).map(flatten).join(" ");
|
|
}
|