static-fire 0.0.0-stage → 0.54.1

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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -2
  3. package/bin/static-fire.js +46 -0
  4. package/package.json +30 -4
  5. package/pricing.yaml +20 -0
  6. package/src/adapters/index.ts +153 -0
  7. package/src/api.ts +250 -0
  8. package/src/cli.ts +383 -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 +450 -0
  13. package/src/core/batches.ts +221 -0
  14. package/src/core/checks.ts +63 -0
  15. package/src/core/commands.ts +43 -0
  16. package/src/core/compare.ts +206 -0
  17. package/src/core/config.ts +25 -0
  18. package/src/core/copying.ts +18 -0
  19. package/src/core/coverage.ts +84 -0
  20. package/src/core/db.ts +608 -0
  21. package/src/core/derived.ts +74 -0
  22. package/src/core/engine.ts +85 -0
  23. package/src/core/facets.ts +143 -0
  24. package/src/core/grade.ts +197 -0
  25. package/src/core/grading-versions.ts +281 -0
  26. package/src/core/identity.ts +263 -0
  27. package/src/core/init.ts +321 -0
  28. package/src/core/judge-schema.ts +37 -0
  29. package/src/core/judge.ts +166 -0
  30. package/src/core/label.ts +43 -0
  31. package/src/core/lock.ts +40 -0
  32. package/src/core/migrate.ts +288 -0
  33. package/src/core/new-task.ts +85 -0
  34. package/src/core/outside-tools.ts +129 -0
  35. package/src/core/plan.ts +84 -0
  36. package/src/core/profiles.ts +42 -0
  37. package/src/core/project.ts +140 -0
  38. package/src/core/regrade.ts +242 -0
  39. package/src/core/score.ts +148 -0
  40. package/src/core/settings.ts +44 -0
  41. package/src/core/task-file.ts +95 -0
  42. package/src/core/task-page.ts +54 -0
  43. package/src/core/trial-detail.ts +73 -0
  44. package/src/core/types.ts +195 -0
  45. package/src/core/view-query.ts +102 -0
  46. package/src/core/view.ts +576 -0
  47. package/src/guide.md +624 -0
  48. package/src/server.ts +28 -0
  49. package/web/api.ts +6 -0
  50. package/web/app.tsx +1162 -0
  51. package/web/brand/offset-tokens.css +126 -0
  52. package/web/charts.tsx +190 -0
  53. package/web/compare.tsx +317 -0
  54. package/web/encoding.tsx +55 -0
  55. package/web/filter-menu.ts +195 -0
  56. package/web/how-it-works.tsx +92 -0
  57. package/web/index-switches.ts +29 -0
  58. package/web/index.html +17 -0
  59. package/web/levels.tsx +15 -0
  60. package/web/notices.ts +74 -0
  61. package/web/output-view.ts +61 -0
  62. package/web/pages.ts +42 -0
  63. package/web/run-status.ts +51 -0
  64. package/web/styles.css +368 -0
  65. package/web/task-card.ts +6 -0
  66. package/web/tasks.tsx +122 -0
  67. package/web/wording.ts +38 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kenneth Liao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,237 @@
1
- # Temporary Holding Version
1
+ # Static Fire (pre-release)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Find out which **agent configuration** (harness + model + effort + apkit Profile) does your real work best,
4
+ and what it costs. Same tasks, same graders, same judge for every config; the result is an
5
+ Artificial Analysis-style score-vs-cost chart, the **Agent Performance Index**, for *your* workflows.
6
+
7
+ **The whole point is real-world performance.** A trial reproduces the real job as closely as it can: the
8
+ inputs, tools, context and instructions you would actually give the agent, and graders that read the
9
+ output the way its real readers would. A score is only worth something if it predicts how the config does
10
+ on your actual work. Every place a trial differs from the real job is a known gap, and a candidate to remove.
11
+ The engine's gaps are listed below; the gaps of one task go in its project's README.
12
+
13
+ ## Quick start
14
+
15
+ You need Node or Bun. The package brings its own Bun ([ADR-0008](docs/adr/0008-npm-package-brings-bun.md)).
16
+
17
+ ```sh
18
+ npx static-fire init # make your home project in ~/static-fire
19
+ ```
20
+
21
+ Then open your agent in `~/static-fire` and tell it what you want to test, for example "I want to find out which
22
+ agent is the best at making thumbnails". `init` writes an `AGENTS.md` that sends the agent to
23
+ `static-fire guide`, which takes it from your goal to a dashboard with data. The guide also holds the file
24
+ formats, so neither of you needs to read the engine source.
25
+
26
+ A **project** is the folder that holds `static-fire.yaml`. A command works on the nearest project at or above
27
+ the current folder, so you can run it from any subfolder. Outside any project it uses your **home** project, and
28
+ says so in one line. The commands, with
29
+ `npx static-fire` in place of `static-fire` when it is not installed:
30
+
31
+ ```sh
32
+ static-fire init [<folder>] [--json] # make the home (~/static-fire) or a project in <folder>; never overwrites a file
33
+ static-fire guide # the guide for agents: from a goal to a dashboard, and the file formats
34
+ static-fire home [<folder>] # show the home project, or set it to a project folder
35
+ static-fire new task <id> # a task folder with a commented task.yaml
36
+ static-fire configs | judges | suites | tasks
37
+ static-fire run --suites <suite> | --tasks <task> --configs <config>,<config>|all \
38
+ [--trials 3] [--judge <judge>] [--votes 3] [--concurrency 4] # one batch
39
+ static-fire run ... --plan [--json] # the plan in runs: agent runs per config and judge calls; starts nothing
40
+ static-fire rerun <batch> # its failed trials again, as new trials (after a usage limit, for example)
41
+ static-fire regrade [--tasks t] [--configs a,b] [--batch id] [--judge id] [--votes n] [--only-invalid]
42
+ # judge calls only: trials with no valid grading under the current grading hash
43
+ static-fire regrade ... --plan [--json] # the trials it would grade and their judge calls; starts nothing
44
+ static-fire show [--suites s | --tasks t] [--judge id] [--grading hash] [--since date] [--until date] [--json]
45
+ # the comparable trials, pooled across batches (JSON for agents)
46
+ static-fire serve # dashboard at http://localhost:4477
47
+ static-fire list # batches, newest first, with their progress
48
+ static-fire export [path] # every record as JSON, for backup
49
+ static-fire migrate # one-off import of an experiment folder from before 0.25.0
50
+ ```
51
+
52
+ ## How it works
53
+
54
+ Every trial is a record; a chart is a query over them. Running one new config on one task adds its point to
55
+ the existing chart, next to every earlier trial that is comparable: the same task hash, and a valid grading
56
+ under the same grading hash. The dashboard has five pages. **Results** (`/`) shows the chart, or the same
57
+ numbers as a table. Its index buttons (the suites in view) and **Filter** menu (task, harness, model, effort,
58
+ Profile, judge, grading version, date range) are in the page URL, so a view can be bookmarked. The per-task
59
+ table and the trials are one card each, with a menu to pick the task. Harness, model, effort and Profile
60
+ each take several values, for example `?model=opus&model=haiku`. When a question or the judge changes,
61
+ earlier trials are left out of the view, and the notice under the chart offers to regrade them with judge
62
+ calls only. **Runs** (`/runs`) lists every batch with its trials; start one from **New run** there. Runs
63
+ started from the CLI appear live, and the run status in the top bar shows what is running. The **Compare** page (`/compare`) shows what changed between two
64
+ grading versions of a task, and what that did to the scores and to each criterion. Suite and task descriptions,
65
+ what the agent gets and how each task is graded are on the **Tasks** page (`/tasks`). **How it works**
66
+ (`/how-it-works`) explains the scores.
67
+ Terms are defined in [`CONTEXT.md`](CONTEXT.md); the scoring design is
68
+ [ADR-0001](docs/adr/0001-pass-fail-criteria-in-measures.md), and trials as records is
69
+ [ADR-0006](docs/adr/0006-trials-as-records-charts-as-queries.md).
70
+
71
+ ## Develop the engine
72
+
73
+ This repo is the engine. It holds no project: tests use the made-up project in `tests/fixtures/sample-project/`.
74
+
75
+ ```sh
76
+ bun install
77
+ bun test
78
+ bun run typecheck
79
+ bun run dev # the dashboard on a temp copy of the sample project, with hot reload
80
+ cd <a project> && bun <this repo>/src/cli.ts <command> # the engine from this checkout, on that project
81
+ bun scripts/check-drawer.ts # the trial drawer's security check, in a headless browser
82
+ bun scripts/check-results.ts # the Results page's layout check, in a headless browser
83
+ bun scripts/check-notices.ts # the Results page's one alert and its details, in a headless browser
84
+ ```
85
+
86
+ `static-fire serve` bundles the dashboard once, minified. `bun run dev` serves it in development mode instead:
87
+ bundled on each request, with hot reload of `web/` and Bun's error overlay. The temp copy is removed when it
88
+ stops, so it never changes a real project or record store. To use development mode on a real project, run
89
+ `bun <this repo>/src/cli.ts serve --dev` in it.
90
+
91
+ `scripts/check-drawer.ts` shows made-up HTML and Markdown outputs in the trial drawer and checks that they cannot reach the
92
+ dashboard's page, style or API. It needs a Chromium or Chrome on the machine (set `CHROME` to its path when it is not
93
+ found), runs it headless, and writes only in a temporary folder. It is not part of `bun test`.
94
+ `scripts/check-results.ts` checks the Results page the same way on a made-up project with five tasks in three suites:
95
+ one card with a task menu for the per-task table and one for the trials, and a page that does not grow with the
96
+ number of tasks apart from the rows of its tables.
97
+ `scripts/check-notices.ts` checks the same way that the notices are one alert with a short count, with details on a
98
+ click or the keyboard (one item per kind, the configs not plotted grouped by reason, Regrade, the earlier version link
99
+ and the How it works link), and that the chart card does not grow with the number of notices.
100
+
101
+ ## Planned work
102
+
103
+ Planned work is tracked in [GitHub issues](https://github.com/kenneth-liao/static-fire/issues). A design
104
+ written before the work starts is an issue labelled `spec`; decisions that stick are recorded as ADRs in
105
+ [`docs/adr/`](docs/adr/).
106
+
107
+ ## How scoring works
108
+
109
+ Every criterion is a **yes/no question**, decided by a deterministic **check** or an LLM **judge**.
110
+
111
+ ```
112
+ Suite (e.g. Writing) Suite Index = mean task score
113
+ └ Task (e.g. weekly-newsletter) Task score = 0 if a gate failed, else mean of measures
114
+ ├ Gates (output exists, frontmatter has a title, …)
115
+ └ Measure (e.g. "Gets opened") Measure score = % of its criteria passed
116
+ └ Criterion (yes/no, floor | strong | ceiling)
117
+ Agent Performance Index = mean Suite Index over the Suites in view
118
+ ```
119
+
120
+ - Judge criteria get several **votes** (3 by default). A criterion's score is its share of passing votes.
121
+ Votes are adaptive: a majority runs first (2 of 3), and the rest run only if it is split on any criterion
122
+ in the batch or a vote failed.
123
+ Judge criteria that need the same evidence (the output alone, or the output plus the inputs) share one
124
+ call per vote, across measures, with the order shuffled; `isolate: true` gives a criterion its own call
125
+ (ADR-0003). The judge writes each reason before its pass/fail. Answers must match a JSON Schema; one
126
+ retry, then an **error**.
127
+ - A judge criterion can get **derived evidence**: an image a script makes from the output (see below).
128
+ - A grader error makes the grading **invalid** (the trial is left out of the means until regraded), never a fail.
129
+ - Points show the mean of valid trials with a **95% confidence interval**, pooled across batches, with n and
130
+ the date range. A human config counts only its latest trial per task. A config missing a task of a Suite
131
+ gets no Suite Index for it: its row reads "partial (2 of 3 tasks)".
132
+ - **Dead criteria** (a strong/ceiling criterion every config passed, or any criterion every config failed)
133
+ are flagged: they carry no signal.
134
+
135
+ ### Derived evidence
136
+
137
+ A judge criterion can name a script in the task's `grader/` folder. Before the judge is asked, the script
138
+ makes an image from the output, and the judge gets that image as evidence, attached like any other image (by
139
+ path for a judge that cannot take attached images). Use it to show the judge what the real reader sees, for
140
+ example a thumbnail scaled to 320 px wide.
141
+
142
+ ```yaml
143
+ - id: small-text-readable
144
+ question: Can every word of the text be read?
145
+ level: floor
146
+ grader: judge
147
+ derived: scale-320.py # a path under evals/<task>/grader/
148
+ evidence: [] # optional: the judge sees only the derived image, not the full-size output
149
+ ```
150
+
151
+ `static-fire guide` gives the contract for a script: its name, how it runs, its input and output, where
152
+ the image stays, and how scripts share an image.
153
+
154
+ ### Engine revision
155
+
156
+ A task gets a new grading version only when something that can move its score changed. That includes Static
157
+ Fire's own grading code, the **engine revision**, which is a set of parts. A grading hash holds a part only
158
+ when the task uses it:
159
+
160
+ | Part | In a task's grading hash when |
161
+ |---|---|
162
+ | Judge instructions (a hash of the wording and layout) | The task has a judge criterion. The text for derived evidence counts only with derived evidence. |
163
+ | Check, one revision per type (`regex`, `words`, `frontmatter`, `command`) | A gate or criterion uses the type. `command` always: the output gate is one. |
164
+ | Derived evidence | A criterion names a derived evidence script |
165
+ | Judge call, one revision per judge harness | The task has a judge criterion; its own judge's harness only |
166
+ | Judging (how evidence is shown, how criteria share calls, how votes are counted) | The task has a judge criterion |
167
+ | Scoring (how verdicts become a score) | Always |
168
+
169
+ So a change to the `words` check leaves a thumbnail task as it is, and a change to the Codex judge call leaves
170
+ every grading by a Claude Code judge as it is. A grading version name says which part changed, for example
171
+ "Check changed: words" or "Judge instructions changed".
172
+
173
+ For a change to the grading code: `bun test` fails on the engine pin (`tests/identity.test.ts`) and names the
174
+ piece of code that changed and the revision to raise in `src/core/engine.ts`. Raise it only when the change can
175
+ move a score. The judge instructions need no revision: they are hashed.
176
+
177
+ An update to 0.38.0 changes the record store once (schema version 3) and an older Static Fire cannot open it
178
+ after that. Before the update, stop every running Static Fire process (the dashboard, a batch) and copy the
179
+ store: `sqlite3 runs/static-fire.db ".backup '<dest>'"`.
180
+
181
+ ## Files
182
+
183
+ Every path here is in the project folder, the folder that holds `static-fire.yaml`. `static-fire init` makes
184
+ one, and `static-fire guide` gives the format of each file.
185
+
186
+ | Thing | Where | What |
187
+ |---|---|---|
188
+ | Settings | `static-fire.yaml` | Marks the project folder. The tools that trials may not use, per harness |
189
+ | Agent config | `configs/<id>.yaml` | `harness` (claude · codex · agy · pi), `model`, `effort`, optional `profile` (apkit); the label is built from these |
190
+ | Human config | `configs/<id>.yaml` | `harness: human` and `outputs` (task id → the file you wrote): compete with the agents |
191
+ | Judge config | `judges.yaml` | Judges to choose from per run or regrade (first = default) |
192
+ | Suite | `suites/<id>.yaml` | Title, description (shown on the Tasks page) + list of task IDs |
193
+ | Task | `evals/<id>/task.yaml` | Prompt, inputs, project files, references and sources (graders only), gates, measures, criteria |
194
+ | Records | `runs/static-fire.db` (`STATIC_FIRE_DB` overrides it) | The SQLite record store, the only copy of the records: batches, trials with their run conditions, gradings, verdicts, task versions, grading versions, user setups |
195
+ | Trial artifacts | `runs/trials/<trial-id>/` | `workspace/` (the agent's output and files), `agent.log`, and `gradings/<grading-id>/` with the judge logs |
196
+ | Profile cache | `.cache/` | The apkit Profiles of a batch and their lock file. Made again for each batch |
197
+ | Prices | `pricing.yaml` (optional) | Your own token prices. Without it, the prices that ship with the engine are used |
198
+ | Old experiments | `runs/<id>/experiment.json` | The format before 0.25.0. Only `static-fire migrate` reads it; delete the folders by hand once the dashboard shows the imported data |
199
+
200
+ ## How a trial runs
201
+
202
+ 1. Fresh workspace in the system temp folder: the task's `inputs/` (notes, screenshots), its `project/`
203
+ files (what the agent would find in the real project, such as past issues), plus a snapshot of the apkit
204
+ Profile if the config has one (installed once per batch, behind the project's `.cache/apkit.lock`, with `apkit install … --agents <agent> --yes`,
205
+ then `apkit remove`). It is outside any repo, so the task's answer key is not in reach on disk.
206
+ 2. The harness runs the way you run it: headless, in its no-prompt mode, with **your own user-level setup**
207
+ (instructions, settings, skills, MCP servers) and every tool, including the web:
208
+ `claude -p --dangerously-skip-permissions`, `codex exec --dangerously-bypass-approvals-and-sandbox`,
209
+ `agy -p --dangerously-skip-permissions`, `pi -p`. The only limit: tools that act on outside services
210
+ (email, Slack, your newsletter platform, paid infrastructure) are blocked, because in real work you
211
+ would be asked first. Edit the list in the project's `static-fire.yaml`. Each trial records its run conditions: the
212
+ user setup hash (the file list is stored once per setup), Profile hash, blocked tools, harness version
213
+ and start time. They are shown, never part of comparability.
214
+ 3. If the task is built from published work (`heldOut`), an output that copies the original is invalid.
215
+ 4. Gates and checks run; judge criteria are voted on by the run's judge, and the grading is saved under its
216
+ grading hash. Judges do not use your setup: they run with user-level config kept out, so grading stays
217
+ the same across runs.
218
+ 5. The workspace and logs are copied into the project's `runs/trials/<trial-id>/`.
219
+
220
+ A human config skips steps 1–2: its file for the task is copied in and graded the same way. To grade a
221
+ draft while you write it, run `static-fire run --tasks <task> --configs <human config>` (judge calls only).
222
+
223
+ ## Known gaps (pre-release)
224
+
225
+ These are gaps of the engine. A gap of one task, such as inputs that are cleaner than the real ones, goes in
226
+ its project's README.
227
+
228
+ - **Pi** has no user-config switch and no JSON Schema output, so it can be a candidate but not a judge.
229
+ - **Costs** for Codex and Antigravity are estimated from tokens × `pricing.yaml` (Gemini 4 Argon's rate
230
+ doubles on 2026-12-01). Claude Code and Pi report their own. Judge cost is not part of a config's cost.
231
+ Cost that is not in the harness's own report, such as image generation by an outside service, is not counted.
232
+ - **Human grader** (for taste criteria) is planned but not built yet.
233
+ - **Copy check** (`heldOut`) compares text only. An image output that copies a published original is not
234
+ marked invalid.
235
+ - **Realism gaps** (see the rule at the top): a trial is one shot with no feedback and no one to answer
236
+ questions. Agents can reach the web, and no harness can block one site at run time, so a copied original
237
+ is caught after the run instead.
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // Starts the engine (src/cli.ts) with Bun, so `npx static-fire` works with Node alone (docs/adr/0008).
3
+ // Under Bun it uses the Bun that runs it. Under Node it uses the Bun the `bun` dependency installed.
4
+ // The arguments and the current folder pass through unchanged.
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync, readFileSync, statSync } from "node:fs";
7
+ import { createRequire } from "node:module";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const cli = join(here, "..", "src", "cli.ts");
13
+
14
+ // The `bun` package's bin is a placeholder until its postinstall runs (not with --ignore-scripts).
15
+ // Its platform package holds the same binary either way.
16
+ function bunFromDependency() {
17
+ const require = createRequire(import.meta.url);
18
+ let dir;
19
+ try { dir = dirname(require.resolve("bun/package.json")); } catch { return null; }
20
+ const exe = process.platform === "win32" ? "bun.exe" : "bun";
21
+ const installed = join(dir, "bin", "bun.exe");
22
+ if (existsSync(installed) && !(statSync(installed).size < 4096 && readFileSync(installed, "utf8").includes("postinstall"))) return installed;
23
+ const arch = process.arch === "arm64" ? "aarch64" : process.arch;
24
+ try {
25
+ const platform = dirname(createRequire(join(dir, "package.json")).resolve(`@oven/bun-${process.platform}-${arch}/package.json`));
26
+ if (existsSync(join(platform, "bin", exe))) return join(platform, "bin", exe);
27
+ } catch {}
28
+ return null;
29
+ }
30
+
31
+ const bun = process.versions.bun ? process.execPath : bunFromDependency();
32
+ if (!bun) {
33
+ console.error("static-fire: Bun was not found. The `bun` package should bring it: run `npm install` again without --ignore-scripts, or install Bun from https://bun.sh.");
34
+ process.exit(1);
35
+ }
36
+
37
+ const child = spawn(bun, [cli, ...process.argv.slice(2)], { stdio: "inherit" });
38
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
39
+ for (const s of signals) process.on(s, () => child.kill(s));
40
+ child.on("error", (e) => { console.error(`static-fire: could not start Bun (${bun}): ${e.message}`); process.exit(1); });
41
+ child.on("exit", (code, signal) => {
42
+ if (signal) {
43
+ for (const s of signals) process.removeAllListeners(s);
44
+ process.kill(process.pid, signal);
45
+ } else process.exit(code ?? 1);
46
+ });
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "static-fire",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.54.1",
4
+ "type": "module",
5
+ "description": "Benchmark agent configurations (harness + model + effort + profile) on your real workflows.",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "static-fire": "bin/static-fire.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "web/",
14
+ "pricing.yaml"
15
+ ],
16
+ "scripts": {
17
+ "dev": "bun scripts/dev.ts",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "bun": "^1.4.4",
22
+ "marked": "^15.0.0",
23
+ "react": "^19.1.0",
24
+ "react-dom": "^19.1.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "^1.2.0",
28
+ "@types/react": "^19.1.0",
29
+ "@types/react-dom": "^19.1.0",
30
+ "typescript": "^5.8.0"
31
+ }
32
+ }
package/pricing.yaml ADDED
@@ -0,0 +1,20 @@
1
+ # API-equivalent rates (USD per million tokens) for harnesses that don't report their own cost.
2
+ # Claude Code and Pi report cost themselves; Codex and agy only report tokens, so they are priced here.
3
+ # Verify before relying on them.
4
+ codex:
5
+ # Third-party trackers citing OpenAI's pricing page, checked 2026-10-28.
6
+ gpt-6-luna: { input: 0.10, cachedInput: 0.01, output: 0.50 }
7
+ gpt-6.1-sol: { input: 2.00, cachedInput: 0.10, output: 10.00 }
8
+ gpt-5.6-luna: { input: 0.20, cachedInput: 0.02, output: 1.20 }
9
+ gpt-5.6-sol: { input: 5.00, cachedInput: 0.50, output: 30.00 }
10
+ agy:
11
+ # Gemini API standard tier, checked 2026-11-04 (therundown.ai, pricepertoken.com, lmspedia.org).
12
+ # Introductory rate through 2026-11-30; from 2026-12-01 it is input 4.00, cachedInput 0.20, output 20.00.
13
+ gemini-4-argon-low: { input: 2.00, cachedInput: 0.10, output: 10.00 }
14
+ gemini-4-argon-medium: { input: 2.00, cachedInput: 0.10, output: 10.00 }
15
+ gemini-4-argon-high: { input: 2.00, cachedInput: 0.10, output: 10.00 }
16
+ # Launched 2026-11-12; third-party trackers (benchleader.com, aipricing.guru, datastudios.org), checked 2026-11-13.
17
+ gemini-4-hydrogen-low: { input: 1.50, cachedInput: 0.075, output: 7.50 }
18
+ gemini-4-hydrogen-medium: { input: 1.50, cachedInput: 0.075, output: 7.50 }
19
+ gemini-4-hydrogen-high: { input: 1.50, cachedInput: 0.075, output: 7.50 }
20
+ gemini-4-hydrogen-xhigh: { input: 1.50, cachedInput: 0.075, output: 7.50 }
@@ -0,0 +1,153 @@
1
+ // Adapter contract: run one task prompt headless in `cwd`, return usage + cost.
2
+ // Adding a harness = one function here. Realism first (CONTEXT.md, ADR-0004): each harness runs the way
3
+ // the user runs it, with their own user-level setup and every tool, in its no-prompt (yolo) mode. The only
4
+ // limit is `guard`: outside-service tools the user would have to approve (static-fire.yaml). An agent that
5
+ // finds and copies a held-out original on the web is caught after the run (runner, copying.ts).
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import type { AgentConfig, AgentHarness, AgentResult, Harness, Usage } from "../core/types";
10
+ import { currentProject, pricingPath } from "../core/project";
11
+
12
+ export interface Guard { blockedTools: string[] }
13
+ export interface RunArgs { config: AgentConfig; prompt: string; cwd: string; logPath: string; timeoutMs: number; guard?: Guard }
14
+ export type Adapter = (a: RunArgs) => Promise<AgentResult>;
15
+
16
+ const zero = (): Usage => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
17
+
18
+ // engine:judgeCall:begin (judges run through this code: tests/identity.test.ts pins it, see ENGINE.judgeCall in core/engine.ts)
19
+ export async function exec(cmd: string[], cwd: string, logPath: string, timeoutMs: number, env?: Record<string, string>, input?: string) {
20
+ const proc = Bun.spawn(cmd, { cwd, stdin: input === undefined ? "ignore" : new Blob([input]), stdout: "pipe", stderr: "pipe", env: env ? { ...process.env, ...env } : undefined });
21
+ let timedOut = false;
22
+ const timer = setTimeout(() => { timedOut = true; proc.kill(); }, timeoutMs);
23
+ const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
24
+ clearTimeout(timer);
25
+ writeFileSync(logPath, stdout);
26
+ if (stderr.trim()) writeFileSync(`${logPath}.stderr`, stderr);
27
+ return { stdout, stderr, code, timedOut };
28
+ }
29
+
30
+ /** HOME for agy judges: everything in ~/.gemini (so the login works) except the user's GEMINI.md. Trials use the real HOME. */
31
+ let cleanHome: string | null = null;
32
+ export function agyHome() {
33
+ if (cleanHome) return cleanHome;
34
+ const home = join(tmpdir(), "static-fire-agy-home");
35
+ const gem = join(home, ".gemini");
36
+ mkdirSync(gem, { recursive: true });
37
+ const src = join(homedir(), ".gemini");
38
+ for (const f of readdirSync(src)) if (f !== "GEMINI.md" && !existsSync(join(gem, f))) symlinkSync(join(src, f), join(gem, f));
39
+ return (cleanHome = home);
40
+ }
41
+
42
+ // engine:judgeCall:end
43
+
44
+ const jsonl = (s: string) => s.split("\n").flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } });
45
+ // The rates: the pricing.yaml of the project this command uses (the home, outside any project) when it has one, else the engine's.
46
+ let pricing: any;
47
+ const rates = () => (pricing ??= Bun.YAML.parse(readFileSync(pricingPath(currentProject()), "utf8")));
48
+
49
+ export function estimate(harness: Harness, model: string, u: Usage) {
50
+ const rate = rates()[harness]?.[model];
51
+ if (!rate) return null;
52
+ return (u.inputTokens * rate.input + u.cacheReadTokens * (rate.cachedInput ?? rate.input) + u.outputTokens * rate.output) / 1e6;
53
+ }
54
+
55
+ const claude: Adapter = async ({ config, prompt, cwd, logPath, timeoutMs, guard }) => {
56
+ const cmd = ["claude", "-p", prompt, "--model", config.model, "--output-format", "json", "--no-session-persistence",
57
+ "--dangerously-skip-permissions"];
58
+ if (config.effort) cmd.push("--effort", config.effort);
59
+ if (guard?.blockedTools.length) cmd.push("--disallowedTools", ...guard.blockedTools); // variadic: keep it last
60
+ const r = await exec(cmd, cwd, logPath, timeoutMs);
61
+ let parsed: any;
62
+ try { parsed = JSON.parse(r.stdout); } catch { /* fallthrough */ }
63
+ const result = Array.isArray(parsed) ? parsed.find((x: any) => x.type === "result") : parsed;
64
+ if (!result) return { ok: false, error: r.timedOut ? "timed out" : (r.stderr.slice(-400) || "no result"), usage: zero(), costUsd: null, costSource: "unknown" };
65
+ const u = result.usage ?? {};
66
+ return {
67
+ ok: !result.is_error,
68
+ error: result.is_error ? String(result.result).slice(0, 400) : undefined,
69
+ usage: { inputTokens: u.input_tokens ?? 0, outputTokens: u.output_tokens ?? 0, cacheReadTokens: u.cache_read_input_tokens ?? 0, cacheWriteTokens: u.cache_creation_input_tokens ?? 0 },
70
+ costUsd: result.total_cost_usd ?? null,
71
+ costSource: result.total_cost_usd != null ? "reported" : "unknown",
72
+ turns: result.num_turns,
73
+ };
74
+ };
75
+
76
+ /** Codex block list → config overrides. "name@marketplace" is a plugin (its MCP servers are not in config.toml,
77
+ * and overriding one of them as a server leaves Codex unable to start); any other name is a config.toml server. */
78
+ export function codexBlockArgs(names: string[]): string[] {
79
+ return names.flatMap((n) => ["-c", n.includes("@") ? `plugins.${n}.enabled=false` : `mcp_servers.${n}.enabled=false`]);
80
+ }
81
+
82
+ const codex: Adapter = async ({ config, prompt, cwd, logPath, timeoutMs, guard }) => {
83
+ const cmd = ["codex", "exec", "--json", "--skip-git-repo-check", "--ephemeral",
84
+ "-m", config.model, "--dangerously-bypass-approvals-and-sandbox", "-C", cwd];
85
+ if (config.effort) cmd.push("-c", `model_reasoning_effort=${config.effort}`);
86
+ cmd.push(...codexBlockArgs(guard?.blockedTools ?? []));
87
+ cmd.push(prompt);
88
+ const r = await exec(cmd, cwd, logPath, timeoutMs);
89
+ const events = jsonl(r.stdout);
90
+ const usage = zero();
91
+ let turns = 0;
92
+ for (const e of events) {
93
+ if (e.type !== "turn.completed") continue;
94
+ turns++;
95
+ const cached = e.usage?.cached_input_tokens ?? 0;
96
+ usage.inputTokens += (e.usage?.input_tokens ?? 0) - cached;
97
+ usage.cacheReadTokens += cached;
98
+ usage.outputTokens += e.usage?.output_tokens ?? 0; // includes reasoning tokens (OpenAI usage semantics)
99
+ }
100
+ const failed = events.find((e) => e.type === "turn.failed" || e.type === "error");
101
+ const costUsd = estimate("codex", config.model, usage);
102
+ return {
103
+ ok: turns > 0 && !failed,
104
+ error: failed ? String(failed.message ?? failed.error?.message).slice(0, 400) : turns ? undefined : (r.timedOut ? "timed out" : r.stderr.slice(-400) || "no turns"),
105
+ usage, costUsd, costSource: costUsd != null ? "estimated" : "unknown", turns,
106
+ };
107
+ };
108
+
109
+ const agy: Adapter = async ({ config, prompt, cwd, logPath, timeoutMs }) => {
110
+ const cmd = ["agy", "-p", prompt, "--model", config.model, "--output-format", "json", "--dangerously-skip-permissions"];
111
+ if (config.effort) cmd.push("--effort", config.effort);
112
+ const r = await exec(cmd, cwd, logPath, timeoutMs);
113
+ let res: any;
114
+ try { res = JSON.parse(r.stdout); } catch { /* fallthrough */ }
115
+ if (!res) return { ok: false, error: r.timedOut ? "timed out" : (r.stderr.slice(-400) || "no result"), usage: zero(), costUsd: null, costSource: "unknown" };
116
+ const u = res.usage ?? {};
117
+ const cached = u.cache_read_tokens ?? 0;
118
+ const usage: Usage = {
119
+ inputTokens: Math.max(0, (u.input_tokens ?? 0) - cached),
120
+ outputTokens: (u.output_tokens ?? 0) + (u.thinking_tokens ?? 0), // thinking is billed as output
121
+ cacheReadTokens: cached, cacheWriteTokens: 0,
122
+ };
123
+ const costUsd = estimate("agy", config.model, usage);
124
+ return {
125
+ ok: res.status === "SUCCESS",
126
+ error: res.status === "SUCCESS" ? undefined : String(res.error ?? res.response ?? res.status).slice(0, 400),
127
+ usage, costUsd, costSource: costUsd != null ? "estimated" : "unknown", turns: res.num_turns,
128
+ };
129
+ };
130
+
131
+ const pi: Adapter = async ({ config, prompt, cwd, logPath, timeoutMs }) => {
132
+ const cmd = ["pi", "-p", "--mode", "json", "--no-session", "--model", config.model];
133
+ if (config.provider) cmd.push("--provider", config.provider);
134
+ if (config.effort) cmd.push("--thinking", config.effort);
135
+ cmd.push(prompt);
136
+ const r = await exec(cmd, cwd, logPath, timeoutMs);
137
+ const usage = zero();
138
+ let cost = 0, turns = 0, error: string | undefined;
139
+ for (const e of jsonl(r.stdout)) {
140
+ if (e.type !== "turn_end") continue;
141
+ const m = e.message ?? {};
142
+ if (m.stopReason === "error") { error = String(m.errorMessage).slice(0, 400); continue; }
143
+ turns++;
144
+ usage.inputTokens += m.usage?.input ?? 0;
145
+ usage.outputTokens += m.usage?.output ?? 0;
146
+ usage.cacheReadTokens += m.usage?.cacheRead ?? 0;
147
+ usage.cacheWriteTokens += m.usage?.cacheWrite ?? 0;
148
+ cost += m.usage?.cost?.total ?? 0;
149
+ }
150
+ return { ok: turns > 0 && !error, error: error ?? (turns ? undefined : "no turns"), usage, costUsd: turns ? cost : null, costSource: turns ? "reported" : "unknown", turns };
151
+ };
152
+
153
+ export const adapters: Record<AgentHarness, Adapter> = { claude, codex, pi, agy };