static-fire 0.0.0-stage → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +230 -2
  3. package/bin/static-fire.js +46 -0
  4. package/package.json +29 -4
  5. package/pricing.yaml +20 -0
  6. package/src/adapters/index.ts +153 -0
  7. package/src/api.ts +218 -0
  8. package/src/cli.ts +297 -0
  9. package/src/core/apkit-install.ts +47 -0
  10. package/src/core/authored.ts +47 -0
  11. package/src/core/batch-state.ts +25 -0
  12. package/src/core/batch.ts +435 -0
  13. package/src/core/batches.ts +221 -0
  14. package/src/core/checks.ts +63 -0
  15. package/src/core/commands.ts +42 -0
  16. package/src/core/compare.ts +203 -0
  17. package/src/core/config.ts +25 -0
  18. package/src/core/copying.ts +18 -0
  19. package/src/core/db.ts +572 -0
  20. package/src/core/derived.ts +74 -0
  21. package/src/core/engine.ts +85 -0
  22. package/src/core/facets.ts +128 -0
  23. package/src/core/grade.ts +197 -0
  24. package/src/core/grading-versions.ts +281 -0
  25. package/src/core/identity.ts +263 -0
  26. package/src/core/init.ts +231 -0
  27. package/src/core/judge-schema.ts +37 -0
  28. package/src/core/judge.ts +166 -0
  29. package/src/core/label.ts +43 -0
  30. package/src/core/lock.ts +40 -0
  31. package/src/core/migrate.ts +288 -0
  32. package/src/core/new-task.ts +85 -0
  33. package/src/core/outside-tools.ts +129 -0
  34. package/src/core/profiles.ts +42 -0
  35. package/src/core/project.ts +54 -0
  36. package/src/core/regrade.ts +233 -0
  37. package/src/core/score.ts +148 -0
  38. package/src/core/settings.ts +44 -0
  39. package/src/core/task-file.ts +95 -0
  40. package/src/core/trial-detail.ts +73 -0
  41. package/src/core/types.ts +195 -0
  42. package/src/core/view-query.ts +85 -0
  43. package/src/core/view.ts +498 -0
  44. package/src/guide.md +547 -0
  45. package/src/server.ts +24 -0
  46. package/web/api.ts +6 -0
  47. package/web/app.tsx +1019 -0
  48. package/web/brand/offset-tokens.css +126 -0
  49. package/web/charts.tsx +190 -0
  50. package/web/compare.tsx +317 -0
  51. package/web/encoding.tsx +55 -0
  52. package/web/filter-menu.ts +164 -0
  53. package/web/index.html +17 -0
  54. package/web/pages.ts +16 -0
  55. package/web/run-status.ts +51 -0
  56. package/web/styles.css +333 -0
  57. package/web/suite-notes.ts +20 -0
  58. package/web/wording.ts +38 -0
@@ -0,0 +1,231 @@
1
+ // `static-fire init`: make a project in the current folder. It finds the harness CLIs on the PATH (it never runs
2
+ // them), writes one config per harness found, judges.yaml with one default judge, static-fire.yaml, empty evals/
3
+ // and suites/, a .gitignore and a short AGENTS.md that points an agent to `static-fire guide`.
4
+ //
5
+ // The folder belongs to the user. init only adds: every file is created with the exclusive flag, so a file that
6
+ // exists (or appears while init runs) is kept as it is and reported. In a project, or a subfolder of one, init
7
+ // writes nothing. static-fire.yaml, the project marker, is written last.
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+ import { configLabel } from "./label";
11
+ import { findOutsideTools, type HarnessTools } from "./outside-tools";
12
+ import { findProject, PROJECT_FILE } from "./project";
13
+ import type { AgentConfig, AgentHarness, JudgeConfig } from "./types";
14
+
15
+ /** The harnesses init looks for, in the order a default judge is chosen. */
16
+ export const INIT_HARNESSES = ["claude", "codex", "agy", "pi"] as const satisfies readonly AgentHarness[];
17
+ type InitHarness = (typeof INIT_HARNESSES)[number];
18
+
19
+ type ConfigFields = Pick<AgentConfig, "model" | "effort" | "provider">;
20
+ type JudgeFields = Pick<JudgeConfig, "model" | "effort">;
21
+
22
+ /**
23
+ * The one table of model ids that init writes: per harness, the config's fields, a comment for its file, and the
24
+ * judge's fields for a harness that can judge (Pi has no JSON Schema output, so it cannot).
25
+ */
26
+ export const HARNESS_MODELS: Record<InitHarness, { config: ConfigFields; comment?: string; judge?: JudgeFields }> = {
27
+ claude: { config: { model: "claude-opus-5-5", effort: "medium" }, judge: { model: "claude-sonnet-5-5", effort: "medium" } },
28
+ codex: { config: { model: "gpt-6.1-sol", effort: "medium" }, judge: { model: "gpt-6.1-sol", effort: "medium" } },
29
+ agy: {
30
+ config: { model: "gemini-4-hydrogen-medium" },
31
+ comment: "agy model ids carry the effort level (low / medium / high).",
32
+ judge: { model: "gemini-4-argon-medium" },
33
+ },
34
+ pi: {
35
+ config: { provider: "openai-codex", model: "gpt-6.1-sol", effort: "medium" },
36
+ comment: "The provider must match the one you are logged in to in Pi (run `pi` and /login).",
37
+ },
38
+ };
39
+
40
+ export type HarnessFound = { found: true; path: string } | { found: false };
41
+ /** A harness as init reports it: where its program is, and the outside tools in its user setup (outside-tools.ts). */
42
+ export type HarnessReport = HarnessFound & HarnessTools;
43
+
44
+ /** What init found and did. `harnesses` is keyed by harness, so each entry can carry more facts later. */
45
+ export interface InitReport {
46
+ project: string;
47
+ /** false when the folder is already in a project: nothing was written. */
48
+ created: boolean;
49
+ harnesses: Record<InitHarness, HarnessReport>;
50
+ /** The id of the judge init wrote, or null when it wrote none. */
51
+ judge: string | null;
52
+ /** Paths written, relative to the project; a folder ends with "/". */
53
+ written: string[];
54
+ /** Paths that existed and were not changed. */
55
+ kept: string[];
56
+ notes: string[];
57
+ }
58
+
59
+ /** Each harness's program on `PATH`, found and never run. */
60
+ export function findHarnesses(PATH = process.env.PATH ?? ""): Record<InitHarness, HarnessFound> {
61
+ return Object.fromEntries(INIT_HARNESSES.map((h) => {
62
+ const path = Bun.which(h, { PATH });
63
+ return [h, path ? { found: true, path } : { found: false }];
64
+ })) as Record<InitHarness, HarnessFound>;
65
+ }
66
+
67
+ /** Each harness found, with the outside tools of its user setup. Reads the setup files and changes none. */
68
+ function reportHarnesses(PATH: string): Record<InitHarness, HarnessReport> {
69
+ const found = findHarnesses(PATH);
70
+ return Object.fromEntries(INIT_HARNESSES.map((h) => [h, { ...found[h], ...findOutsideTools(h, found[h].found) }])) as Record<InitHarness, HarnessReport>;
71
+ }
72
+
73
+ const GITIGNORE = ["runs/", ".cache/"];
74
+
75
+ const AGENTS_MD = `# Static Fire project
76
+
77
+ This folder is a Static Fire project. It compares agent configurations on tasks from the user's own work.
78
+
79
+ Before you work here, run \`static-fire guide\` (or \`npx static-fire guide\` when \`static-fire\` is not installed)
80
+ and read all of it. It gives the approach from the user's goal to a dashboard with data, and the formats of the
81
+ files in this folder, for the installed version. Use it instead of reading the engine source.
82
+ `;
83
+
84
+ /** static-fire.yaml: the project marker and the settings for trials. It blocks nothing by itself. */
85
+ export function settingsFile(): string {
86
+ return `# Settings for the trials of this Static Fire project.
87
+ trials:
88
+ # Tools that trials may not use, per harness. A trial runs with no permission prompts, so nobody is there to
89
+ # approve a tool that acts on an outside service (sends mail, posts a message, spends money, deploys).
90
+ # Run \`static-fire init --json\` in this folder to list the MCP servers and plugins found in each harness's
91
+ # setup, with the entry that blocks each one. It changes no file here.
92
+ # Claude Code: a tool rule; "mcp__<server>" blocks every tool of that server.
93
+ # Codex: an MCP server name from config.toml, or a plugin as "name@marketplace".
94
+ # Antigravity and Pi tools cannot be blocked in trials yet.
95
+ # For example:
96
+ # blockedTools:
97
+ # claude:
98
+ # - mcp__example
99
+ # codex:
100
+ # - example@marketplace
101
+ blockedTools: {}
102
+ `;
103
+ }
104
+
105
+ const yamlLines = (fields: object, indent = "") =>
106
+ Object.entries(fields).filter(([, v]) => v !== undefined).map(([k, v]) => `${indent}${k}: ${v}\n`).join("");
107
+
108
+ function configFile(h: InitHarness): string {
109
+ const { config, comment } = HARNESS_MODELS[h];
110
+ return `${comment ? `# ${comment}\n` : ""}harness: ${h}\n${yamlLines(config)}`;
111
+ }
112
+
113
+ function judgeOf(h: InitHarness): JudgeConfig | null {
114
+ const fields = HARNESS_MODELS[h].judge;
115
+ if (!fields || h === "pi") return null;
116
+ return { id: fields.model, label: configLabel({ harness: h, ...fields }), harness: h, ...fields };
117
+ }
118
+
119
+ const JUDGES_HEAD = `# Judge configurations. The first one is the default and runs every judge criterion.
120
+ # A judge runs on claude, codex or agy (Pi cannot return JSON Schema output).
121
+ `;
122
+
123
+ function judgesFile(judge: JudgeConfig | null): string {
124
+ if (!judge) {
125
+ return `${JUDGES_HEAD}# No harness that can judge was found, so judge criteria cannot run until a judge is added here, for example:
126
+ # - id: claude-sonnet-5-5
127
+ # label: Claude Code · Sonnet 5.5 · medium
128
+ # harness: claude
129
+ # model: claude-sonnet-5-5
130
+ # effort: medium
131
+ judges: []
132
+ `;
133
+ }
134
+ const { id, ...rest } = judge;
135
+ return `${JUDGES_HEAD}judges:\n - id: ${id}\n${yamlLines(rest, " ")}`;
136
+ }
137
+
138
+ /** Make a project in `dir` (the current folder by default), or change nothing when `dir` is already in one. */
139
+ export function initProject(dir = process.cwd(), PATH = process.env.PATH ?? ""): InitReport {
140
+ const root = resolve(dir);
141
+ const harnesses = reportHarnesses(PATH);
142
+ const existing = findProject(root);
143
+ if (existing) return { project: existing, created: false, harnesses, judge: null, written: [], kept: [], notes: [] };
144
+
145
+ const written: string[] = [], kept: string[] = [], notes: string[] = [];
146
+ /** Create `rel` with `text`, or keep the file that is there. True when written. */
147
+ const put = (rel: string, text: string) => {
148
+ try {
149
+ writeFileSync(join(root, rel), text, { flag: "wx" });
150
+ written.push(rel);
151
+ return true;
152
+ } catch (e) {
153
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
154
+ kept.push(rel);
155
+ return false;
156
+ }
157
+ };
158
+ const folder = (rel: string, report: boolean) => {
159
+ if (existsSync(join(root, rel))) return;
160
+ mkdirSync(join(root, rel));
161
+ if (report) written.push(`${rel}/`);
162
+ };
163
+
164
+ const found = INIT_HARNESSES.filter((h) => harnesses[h].found);
165
+ folder("configs", false);
166
+ for (const h of found) if (!put(`configs/${h}.yaml`, configFile(h))) notes.push(`configs/${h}.yaml exists and was not changed.`);
167
+ if (!found.length) notes.push(`no harness was found on the PATH (looked for ${INIT_HARNESSES.join(", ")}), so configs/ has no config.`);
168
+
169
+ const judgeHarness = found.find((h) => judgeOf(h));
170
+ const judge = judgeHarness ? judgeOf(judgeHarness) : null;
171
+ let judgeId: string | null = null;
172
+ if (put("judges.yaml", judgesFile(judge))) {
173
+ judgeId = judge?.id ?? null;
174
+ if (!judge) notes.push("no judge was written: no harness that can judge (claude, codex, agy) was found. judges.yaml lists none, so judge criteria cannot run until one is added.");
175
+ } else {
176
+ notes.push("judges.yaml exists and was not changed.");
177
+ }
178
+
179
+ folder("evals", true);
180
+ folder("suites", true);
181
+
182
+ if (!put(".gitignore", `${GITIGNORE.join("\n")}\n`)) {
183
+ const lines = readFileSync(join(root, ".gitignore"), "utf8").split(/\r?\n/).map((l) => l.trim());
184
+ const missing = GITIGNORE.filter((g) => !lines.includes(g) && !lines.includes(g.slice(0, -1)) && !lines.includes(`/${g}`));
185
+ if (missing.length) notes.push(`.gitignore exists and was not changed; it lacks ${missing.join(", ")}.`);
186
+ }
187
+ if (!put("AGENTS.md", AGENTS_MD) && !readFileSync(join(root, "AGENTS.md"), "utf8").includes("static-fire guide")) {
188
+ notes.push("AGENTS.md exists and was not changed; it has no pointer to `static-fire guide`.");
189
+ }
190
+
191
+ // The marker last: the folder becomes a project only once everything else is in place.
192
+ if (!put(PROJECT_FILE, settingsFile())) notes.push(`${PROJECT_FILE} exists and was not changed.`);
193
+
194
+ return { project: root, created: true, harnesses, judge: judgeId, written, kept, notes };
195
+ }
196
+
197
+ /** The outside tools section of the text report. */
198
+ function formatTools(r: InitReport): string[] {
199
+ const lines = ["outside tools in each harness's setup. To block one in trials, add its entry under trials.blockedTools.<harness> in static-fire.yaml:"];
200
+ for (const h of INIT_HARNESSES) {
201
+ const x = r.harnesses[h];
202
+ if (!x.blockable) lines.push(` ${h}: its tools cannot be blocked in trials yet`);
203
+ else if (!x.found) lines.push(` ${h}: not found`);
204
+ else if (!x.tools.length) lines.push(` ${h}: none found`);
205
+ else {
206
+ lines.push(` ${h}:`);
207
+ for (const t of x.tools) lines.push(` ${t.name} (${t.kind}, ${t.foundIn}): ${t.block.length ? t.block.join(", ") : "nothing to block (no MCP server)"}`);
208
+ }
209
+ if (x.blockable) for (const n of x.notes) lines.push(` note: ${n}`);
210
+ }
211
+ return lines;
212
+ }
213
+
214
+ /** The report as text, for a person. */
215
+ export function formatReport(r: InitReport): string {
216
+ if (!r.created) return `${[`already a Static Fire project: ${r.project}`, "nothing changed", ...formatTools(r)].join("\n")}\n`;
217
+ const found = INIT_HARNESSES.filter((h) => r.harnesses[h].found);
218
+ const missing = INIT_HARNESSES.filter((h) => !r.harnesses[h].found);
219
+ const lines = [
220
+ `made a Static Fire project in ${r.project}`,
221
+ `harnesses found: ${found.length ? found.map((h) => `${h} (${(r.harnesses[h] as { path: string }).path})`).join(", ") : "none"}`,
222
+ ...(missing.length && found.length ? [`not found: ${missing.join(", ")}`] : []),
223
+ `judge: ${r.judge ?? "none"}`,
224
+ `wrote: ${r.written.join(", ") || "nothing"}`,
225
+ ...(r.kept.length ? [`kept as they were: ${r.kept.join(", ")}`] : []),
226
+ ...r.notes.map((n) => `note: ${n}`),
227
+ ...formatTools(r),
228
+ "next: run `static-fire guide`",
229
+ ];
230
+ return `${lines.join("\n")}\n`;
231
+ }
@@ -0,0 +1,37 @@
1
+ // The one shape every judge call must return, as a JSON Schema (sent to the harness) and a
2
+ // validator (run on whatever comes back, because not every harness enforces the schema).
3
+ import type { Vote } from "./types";
4
+
5
+ export function verdictSchema(ids: string[]) {
6
+ return {
7
+ type: "object", additionalProperties: false, required: ["verdicts"],
8
+ properties: {
9
+ verdicts: {
10
+ type: "array",
11
+ items: {
12
+ // Reason before pass: the judge states its evidence before it commits to a verdict.
13
+ type: "object", additionalProperties: false, required: ["id", "reason", "pass"],
14
+ properties: { id: { type: "string", enum: ids }, reason: { type: "string" }, pass: { type: "boolean" } },
15
+ },
16
+ },
17
+ },
18
+ };
19
+ }
20
+
21
+ export type Parsed = { ok: true; verdicts: Record<string, Vote> } | { ok: false; error: string };
22
+
23
+ export function parseVerdicts(data: unknown, ids: string[]): Parsed {
24
+ const list = (data as any)?.verdicts;
25
+ if (!Array.isArray(list)) return { ok: false, error: "no verdicts array" };
26
+ const out: Record<string, Vote> = {};
27
+ for (const v of list) {
28
+ if (!ids.includes(v?.id)) return { ok: false, error: `unknown criterion id ${JSON.stringify(v?.id)}` };
29
+ if (out[v.id]) return { ok: false, error: `duplicate criterion id ${v.id}` };
30
+ if (typeof v.pass !== "boolean") return { ok: false, error: `${v.id}: pass is not a boolean` };
31
+ if (typeof v.reason !== "string" || !v.reason.trim()) return { ok: false, error: `${v.id}: empty reason` };
32
+ out[v.id] = { pass: v.pass, reason: v.reason.trim() };
33
+ }
34
+ const missing = ids.filter((id) => !out[id]);
35
+ if (missing.length) return { ok: false, error: `missing verdicts: ${missing.join(", ")}` };
36
+ return { ok: true, verdicts: out };
37
+ }
@@ -0,0 +1,166 @@
1
+ // Judge calls: one prompt + a JSON Schema → validated verdicts, on any judge Host.
2
+ // Every Host returns the same shape (see judge-schema.ts); one retry, then an error.
3
+ import { mkdirSync, readFileSync } from "node:fs";
4
+ import { basename, extname, join } from "node:path";
5
+ import { agyHome, exec } from "../adapters";
6
+ import { parseVerdicts, verdictSchema, type Parsed } from "./judge-schema";
7
+ import type { JudgeConfig } from "./types";
8
+
9
+ export type Attached = { label: string; path: string };
10
+
11
+ /**
12
+ * What a judge gets besides the prompt text. `images` are attached to its message (null when its Host
13
+ * cannot attach images); `toRead` are files it must open with its file tool, all inside `readDir`.
14
+ */
15
+ export interface JudgeFiles { images: Attached[] | null; toRead: string[]; readDir: string }
16
+
17
+ /** The one list of image types judges take: extension → media type. */
18
+ export const IMAGE_MEDIA: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
19
+ export const isImage = (path: string) => extname(path).toLowerCase() in IMAGE_MEDIA;
20
+
21
+ /** Judge Hosts that take images in the message itself. Antigravity takes text only, so it opens them by path. */
22
+ export const attachesImages = (harness: JudgeConfig["harness"]) => harness === "claude" || harness === "codex";
23
+
24
+ // engine:judgeCall.claude:begin
25
+ /** One stream-json user message for `claude -p --input-format stream-json`: the prompt, then each image after its label. */
26
+ export function claudeImageInput(prompt: string, images: Attached[]): string {
27
+ const content: unknown[] = [{ type: "text", text: prompt }];
28
+ images.forEach((im, i) => {
29
+ content.push({ type: "text", text: `Image ${i + 1}: ${im.label}` });
30
+ content.push({ type: "image", source: { type: "base64", media_type: IMAGE_MEDIA[extname(im.path).toLowerCase()], data: readFileSync(im.path).toString("base64") } });
31
+ });
32
+ return JSON.stringify({ type: "user", message: { role: "user", content } }) + "\n";
33
+ }
34
+
35
+ // engine:judgeCall.claude:end
36
+
37
+ // engine:judgeCall.codex:begin
38
+ /** `codex exec -i` per image, in prompt order. The option takes many values, so the prompt must follow `--`. */
39
+ export const codexImageArgs = (images: Attached[]) => images.flatMap((im) => ["-i", im.path]);
40
+ // engine:judgeCall.codex:end
41
+
42
+ // engine:judgeCall.claude:begin
43
+ /**
44
+ * The Claude Code judge command, and its stdin when images are attached. Output is always stream-json, so
45
+ * the log shows each file the judge opened (see missingReads).
46
+ */
47
+ export function claudeJudgeCmd(judge: JudgeConfig, prompt: string, schema: unknown, files: JudgeFiles): { cmd: string[]; input?: string } {
48
+ const images = files.images ?? [];
49
+ const cmd = ["claude", "-p", ...(images.length ? ["--input-format", "stream-json"] : [prompt]), "--output-format", "stream-json", "--verbose",
50
+ "--model", judge.model, "--no-session-persistence", "--setting-sources", "project,local", "--strict-mcp-config",
51
+ "--json-schema", JSON.stringify(schema), "--tools", files.toRead.length ? "Read" : ""];
52
+ // Without --add-dir a headless read outside the judge's own folder is refused.
53
+ if (files.toRead.length) cmd.push("--add-dir", files.readDir);
54
+ if (judge.effort) cmd.push("--effort", judge.effort);
55
+ return images.length ? { cmd, input: claudeImageInput(prompt, images) } : { cmd };
56
+ }
57
+
58
+ const jsonl = (s: string): any[] => s.split("\n").flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } });
59
+
60
+ /** The verdict data, cost and refused reads from a Claude Code stream-json log. */
61
+ export function parseClaudeResult(stdout: string): { data: unknown; cost: number; denied: number } {
62
+ const res = jsonl(stdout).find((x) => x.type === "result");
63
+ return { data: res?.structured_output ?? tryJson(res?.result), cost: res?.total_cost_usd ?? 0, denied: res?.permission_denials?.length ?? 0 };
64
+ }
65
+
66
+ // engine:judgeCall.claude:end
67
+
68
+ // engine:judgeCall.agy:begin
69
+ /** The verdict data from an Antigravity stream-json log. */
70
+ export function parseAgyResult(stdout: string): unknown {
71
+ const res = jsonl(stdout).find((x) => x.event === "result")?.result;
72
+ return res?.structured_output ?? tryJson(res?.response);
73
+ }
74
+
75
+ // engine:judgeCall.agy:end
76
+
77
+ /** Every tool call's arguments in a judge log, as text. */
78
+ function toolCalls(harness: JudgeConfig["harness"], stdout: string): string[] {
79
+ const events = jsonl(stdout);
80
+ if (harness === "claude") return events.flatMap((e) => e.type === "assistant" ? (e.message?.content ?? []).filter((c: any) => c.type === "tool_use").map((c: any) => JSON.stringify(c.input)) : []);
81
+ if (harness === "codex") return events.flatMap((e) => e.type === "item.completed" && e.item?.type === "command_execution" ? [String(e.item.command ?? "")] : []);
82
+ return events.flatMap((e) => e.step_update?.tool_info ? [JSON.stringify(e.step_update.tool_info.parameters ?? {})] : []);
83
+ }
84
+
85
+ /** The files a judge was told to open that its log shows no tool call for. */
86
+ export function missingReads(harness: JudgeConfig["harness"], stdout: string, toRead: string[]): string[] {
87
+ const calls = toolCalls(harness, stdout);
88
+ return toRead.filter((p) => !calls.some((c) => c.includes(p)));
89
+ }
90
+
91
+ // A trial can fan out into many judge processes; keep the machine responsive.
92
+ const MAX_JUDGE_PROCS = 6;
93
+ let active = 0;
94
+ const waiting: (() => void)[] = [];
95
+ async function slot<T>(fn: () => Promise<T>): Promise<T> {
96
+ if (active >= MAX_JUDGE_PROCS) await new Promise<void>((r) => waiting.push(r));
97
+ active++;
98
+ try { return await fn(); } finally { active--; waiting.shift()?.(); }
99
+ }
100
+
101
+ async function callOnce(judge: JudgeConfig, prompt: string, ids: string[], cwd: string, log: string, files: JudgeFiles) {
102
+ const schema = verdictSchema(ids);
103
+ const timeout = 5 * 60_000;
104
+ const images = files.images ?? [];
105
+ let data: unknown, cost = 0, stdout = "";
106
+ // engine:judgeCall.claude:begin
107
+ if (judge.harness === "claude") {
108
+ const { cmd, input } = claudeJudgeCmd(judge, prompt, schema, files);
109
+ stdout = (await exec(cmd, cwd, log, timeout, undefined, input)).stdout;
110
+ const r = parseClaudeResult(stdout);
111
+ data = r.data; cost = r.cost;
112
+ // A judge that could not read its evidence answered blind: an error, never a verdict.
113
+ if (r.denied) return { parsed: { ok: false as const, error: `judge was refused ${r.denied} file read(s)` }, cost };
114
+ // engine:judgeCall.claude:end
115
+ // engine:judgeCall.codex:begin
116
+ } else if (judge.harness === "codex") {
117
+ const schemaPath = join(cwd, "schema.json");
118
+ await Bun.write(schemaPath, JSON.stringify(schema));
119
+ const cmd = ["codex", "exec", "--json", "--skip-git-repo-check", "--ephemeral", "--ignore-user-config", "-m", judge.model,
120
+ "-s", "read-only", "-C", cwd, "--output-schema", schemaPath];
121
+ if (judge.effort) cmd.push("-c", `model_reasoning_effort=${judge.effort}`);
122
+ cmd.push(...codexImageArgs(images), "--", prompt);
123
+ stdout = (await exec(cmd, cwd, log, timeout)).stdout;
124
+ const msg = jsonl(stdout).filter((e) => e.type === "item.completed" && e.item?.type === "agent_message").pop();
125
+ data = tryJson(msg?.item?.text);
126
+ // Codex reports tokens only; judge cost for Codex is left at 0 (not part of any config's cost).
127
+ // engine:judgeCall.codex:end
128
+ // engine:judgeCall.agy:begin
129
+ } else {
130
+ const cmd = ["agy", "-p", prompt, "--model", judge.model, "--output-format", "stream-json", "--json-schema", JSON.stringify(schema)];
131
+ if (judge.effort) cmd.push("--effort", judge.effort);
132
+ stdout = (await exec(cmd, cwd, log, timeout, { HOME: agyHome() })).stdout;
133
+ data = parseAgyResult(stdout);
134
+ }
135
+ // engine:judgeCall.agy:end
136
+ // Backstop: a judge that skipped a file it had to open answered without part of its evidence.
137
+ const missing = missingReads(judge.harness, stdout, files.toRead);
138
+ if (missing.length) return { parsed: { ok: false as const, error: `judge did not open ${missing.length} evidence file(s): ${missing.map((p) => basename(p)).join(", ")}` }, cost };
139
+ return { parsed: parseVerdicts(data, ids), cost };
140
+ }
141
+
142
+ function tryJson(s: unknown): unknown {
143
+ if (typeof s !== "string") return undefined;
144
+ try { return JSON.parse(s); } catch { /* fall through */ }
145
+ const m = s.match(/\{[\s\S]*\}/);
146
+ try { return m ? JSON.parse(m[0]) : undefined; } catch { return undefined; }
147
+ }
148
+
149
+ /** One vote for a batch of criteria. Retries once on an invalid answer. */
150
+ export async function judgeVote(judge: JudgeConfig, prompt: string, ids: string[], dir: string, files: JudgeFiles): Promise<{ parsed: Parsed; cost: number }> {
151
+ mkdirSync(dir, { recursive: true });
152
+ return slot(async () => {
153
+ let total = 0, last: Parsed = { ok: false, error: "not run" };
154
+ for (let attempt = 1; attempt <= 2; attempt++) {
155
+ try {
156
+ const { parsed, cost } = await callOnce(judge, prompt, ids, dir, join(dir, `attempt-${attempt}.log`), files);
157
+ total += cost;
158
+ last = parsed;
159
+ if (parsed.ok) break;
160
+ } catch (e) {
161
+ last = { ok: false, error: e instanceof Error ? e.message : String(e) };
162
+ }
163
+ }
164
+ return { parsed: last, cost: total };
165
+ });
166
+ }
@@ -0,0 +1,43 @@
1
+ // Config labels: built from the config's own fields, so no label is authored and none can drift from the config.
2
+ import type { AgentConfig, Harness } from "./types";
3
+
4
+ export const HARNESS_NAME: Record<Harness, string> = { claude: "Claude Code", codex: "Codex", agy: "Antigravity", pi: "Pi", human: "By hand" };
5
+ export const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max", "minimal"]);
6
+
7
+ const title = (w: string) => w.charAt(0).toUpperCase() + w.slice(1);
8
+
9
+ /** A readable model name for known id shapes; any other id is shown as it is. */
10
+ export function modelName(model: string): string {
11
+ const claude = model.match(/^claude-([a-z]+)-(\d+(?:-\d+)*)$/);
12
+ if (claude) return `${title(claude[1])} ${claude[2].replace(/-/g, ".")}`;
13
+ const gpt = model.match(/^gpt-(\d+(?:\.\d+)*)-([a-z]+)$/);
14
+ if (gpt) return `GPT-${gpt[1]} ${title(gpt[2])}`;
15
+ const gemini = model.match(/^gemini-(\d+(?:\.\d+)*)-([a-z]+)$/);
16
+ if (gemini) return `Gemini ${gemini[1]} ${title(gemini[2])}`;
17
+ return model;
18
+ }
19
+
20
+ type Labelled = Pick<AgentConfig, "harness" | "model" | "effort" | "profile">;
21
+ const USER_LABEL = "User (by hand)";
22
+
23
+ /** "Model · effort · Profile", leaving out what the config does not set. */
24
+ function parts(c: Labelled): string {
25
+ let { model, effort } = c;
26
+ const suffix = model.match(/^(.*)-([a-z]+)$/);
27
+ // agy ids carry the effort (gemini-4-argon-medium); other harnesses' ids are shown as they are.
28
+ if (c.harness === "agy" && !effort && suffix && EFFORTS.has(suffix[2])) [model, effort] = [suffix[1], suffix[2]];
29
+ return [modelName(model), effort, c.profile].filter(Boolean).join(" · ");
30
+ }
31
+
32
+ /** "Harness · Model · effort · Profile". A human config is the user, whoever runs Static Fire. */
33
+ export const configLabel = (c: Labelled) => (c.harness === "human" ? USER_LABEL : `${HARNESS_NAME[c.harness]} · ${parts(c)}`);
34
+
35
+ /** The label without the harness, for charts where the marker shape shows the harness. */
36
+ export const shortConfigLabel = (c: Labelled) => (c.harness === "human" ? USER_LABEL : parts(c));
37
+
38
+ /** Config id → chart label: the short label, or the full one where two configs would otherwise read the same. */
39
+ export function chartLabels(configs: (Labelled & { configId: string })[]): Map<string, string> {
40
+ const count = new Map<string, number>();
41
+ for (const c of configs) count.set(shortConfigLabel(c), (count.get(shortConfigLabel(c)) ?? 0) + 1);
42
+ return new Map(configs.map((c) => [c.configId, count.get(shortConfigLabel(c))! > 1 ? configLabel(c) : shortConfigLabel(c)]));
43
+ }
@@ -0,0 +1,40 @@
1
+ // A lock shared by every process on this machine, for work that must never run twice at once (an apkit
2
+ // install: apkit keeps one record of installs). The lock file is a small SQLite database; the holder keeps
3
+ // an EXCLUSIVE transaction open on it while `fn` runs. The operating system drops that lock when the
4
+ // holder's process ends in any way, so a crashed `run` never leaves a stale lock behind.
5
+ import { Database } from "bun:sqlite";
6
+ import { mkdirSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+
9
+ export interface LockOptions {
10
+ /** How long to sleep between tries while another process holds the lock. */
11
+ pollMs?: number;
12
+ /** Called once, when the lock is busy and this process starts to wait. */
13
+ onWait?: () => void;
14
+ }
15
+
16
+ /** Runs `fn` while holding the lock in file `path`. Waits (without blocking the event loop) while another process holds it. */
17
+ export async function withFileLock<T>(path: string, fn: () => Promise<T>, o: LockOptions = {}): Promise<T> {
18
+ mkdirSync(dirname(path), { recursive: true });
19
+ const db = new Database(path, { create: true });
20
+ try {
21
+ db.run("PRAGMA busy_timeout = 0"); // never wait inside SQLite: that would block this process's event loop
22
+ for (let waited = false; ; waited = true) {
23
+ try {
24
+ db.run("BEGIN EXCLUSIVE");
25
+ break;
26
+ } catch (e) {
27
+ if ((e as { code?: string }).code !== "SQLITE_BUSY") throw e;
28
+ if (!waited) o.onWait?.();
29
+ await Bun.sleep(o.pollMs ?? 100);
30
+ }
31
+ }
32
+ try {
33
+ return await fn();
34
+ } finally {
35
+ db.run("ROLLBACK"); // nothing was written: this only lets go of the lock
36
+ }
37
+ } finally {
38
+ db.close();
39
+ }
40
+ }