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.
- package/LICENSE +21 -0
- package/README.md +230 -2
- package/bin/static-fire.js +46 -0
- package/package.json +29 -4
- package/pricing.yaml +20 -0
- package/src/adapters/index.ts +153 -0
- package/src/api.ts +218 -0
- package/src/cli.ts +297 -0
- package/src/core/apkit-install.ts +47 -0
- package/src/core/authored.ts +47 -0
- package/src/core/batch-state.ts +25 -0
- package/src/core/batch.ts +435 -0
- package/src/core/batches.ts +221 -0
- package/src/core/checks.ts +63 -0
- package/src/core/commands.ts +42 -0
- package/src/core/compare.ts +203 -0
- package/src/core/config.ts +25 -0
- package/src/core/copying.ts +18 -0
- package/src/core/db.ts +572 -0
- package/src/core/derived.ts +74 -0
- package/src/core/engine.ts +85 -0
- package/src/core/facets.ts +128 -0
- package/src/core/grade.ts +197 -0
- package/src/core/grading-versions.ts +281 -0
- package/src/core/identity.ts +263 -0
- package/src/core/init.ts +231 -0
- package/src/core/judge-schema.ts +37 -0
- package/src/core/judge.ts +166 -0
- package/src/core/label.ts +43 -0
- package/src/core/lock.ts +40 -0
- package/src/core/migrate.ts +288 -0
- package/src/core/new-task.ts +85 -0
- package/src/core/outside-tools.ts +129 -0
- package/src/core/profiles.ts +42 -0
- package/src/core/project.ts +54 -0
- package/src/core/regrade.ts +233 -0
- package/src/core/score.ts +148 -0
- package/src/core/settings.ts +44 -0
- package/src/core/task-file.ts +95 -0
- package/src/core/trial-detail.ts +73 -0
- package/src/core/types.ts +195 -0
- package/src/core/view-query.ts +85 -0
- package/src/core/view.ts +498 -0
- package/src/guide.md +547 -0
- package/src/server.ts +24 -0
- package/web/api.ts +6 -0
- package/web/app.tsx +1019 -0
- package/web/brand/offset-tokens.css +126 -0
- package/web/charts.tsx +190 -0
- package/web/compare.tsx +317 -0
- package/web/encoding.tsx +55 -0
- package/web/filter-menu.ts +164 -0
- package/web/index.html +17 -0
- package/web/pages.ts +16 -0
- package/web/run-status.ts +51 -0
- package/web/styles.css +333 -0
- package/web/suite-notes.ts +20 -0
- 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,231 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Static Fire (pre-release)
|
|
2
2
|
|
|
3
|
-
|
|
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 **Workflow 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
|
+
mkdir my-evals && cd my-evals
|
|
19
|
+
npx static-fire init # make a project in this folder
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then open your agent in that folder and tell it what you want to test, for example "I want to find out which
|
|
23
|
+
agent is the best at making thumbnails". `init` writes an `AGENTS.md` that sends the agent to
|
|
24
|
+
`static-fire guide`, which takes it from your goal to a dashboard with data. The guide also holds the file
|
|
25
|
+
formats, so neither of you needs to read the engine source.
|
|
26
|
+
|
|
27
|
+
A **project** is the folder that holds `static-fire.yaml`. Every command works on the project it finds by
|
|
28
|
+
walking up from the current folder, so you can run it from any subfolder. The commands, with
|
|
29
|
+
`npx static-fire` in place of `static-fire` when it is not installed:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
static-fire init [--json] # make a project in the current 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 new task <id> # a task folder with a commented task.yaml
|
|
35
|
+
static-fire configs | judges | suites | tasks
|
|
36
|
+
static-fire run --suites <suite> | --tasks <task> --configs <config>,<config>|all \
|
|
37
|
+
[--trials 3] [--judge <judge>] [--votes 3] [--concurrency 4] # one batch
|
|
38
|
+
static-fire rerun <batch> # its failed trials again, as new trials (after a usage limit, for example)
|
|
39
|
+
static-fire regrade [--tasks t] [--configs a,b] [--batch id] [--judge id] [--votes n] [--only-invalid]
|
|
40
|
+
# judge calls only: trials with no valid grading under the current grading hash
|
|
41
|
+
static-fire show [--suites s | --tasks t] [--judge id] [--grading hash] [--since date] [--until date] [--json]
|
|
42
|
+
# the comparable trials, pooled across batches (JSON for agents)
|
|
43
|
+
static-fire serve # dashboard at http://localhost:4477
|
|
44
|
+
static-fire list # batches, newest first, with their progress
|
|
45
|
+
static-fire export [path] # every record as JSON, for backup
|
|
46
|
+
static-fire migrate # one-off import of an experiment folder from before 0.25.0
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
Every trial is a record; a chart is a query over them. Running one new config on one task adds its point to
|
|
52
|
+
the existing chart, next to every earlier trial that is comparable: the same task hash, and a valid grading
|
|
53
|
+
under the same grading hash. The dashboard has three pages. **Results** (`/`) shows the chart, or the same
|
|
54
|
+
numbers as a table. Its scope chips and **Filter** menu (harness, model, effort, Profile, judge, grading
|
|
55
|
+
version, date range) are in the page URL, so a view can be bookmarked. Harness, model, effort and Profile
|
|
56
|
+
each take several values, for example `?model=opus&model=haiku`. When a question or the judge changes,
|
|
57
|
+
earlier trials are left out of the view, and the notice under the chart offers to regrade them with judge
|
|
58
|
+
calls only. **Runs** (`/runs`) lists every batch with its trials; start one from **New run** there. Runs
|
|
59
|
+
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
|
|
60
|
+
grading versions of a task, and what that did to the scores and to each criterion. A suite's description is shown with
|
|
61
|
+
every chart that draws the suite.
|
|
62
|
+
Terms are defined in [`CONTEXT.md`](CONTEXT.md); the scoring design is
|
|
63
|
+
[ADR-0001](docs/adr/0001-pass-fail-criteria-in-measures.md), and trials as records is
|
|
64
|
+
[ADR-0006](docs/adr/0006-trials-as-records-charts-as-queries.md).
|
|
65
|
+
|
|
66
|
+
## Develop the engine
|
|
67
|
+
|
|
68
|
+
This repo is the engine. It holds no project: tests use the made-up project in `tests/fixtures/sample-project/`.
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
bun install
|
|
72
|
+
bun test
|
|
73
|
+
bun run typecheck
|
|
74
|
+
cd <a project> && bun <this repo>/src/cli.ts <command> # the engine from this checkout, on that project
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Planned work
|
|
78
|
+
|
|
79
|
+
Planned work is tracked in [GitHub issues](https://github.com/kenneth-liao/static-fire/issues). A design
|
|
80
|
+
written before the work starts is an issue labelled `spec`; decisions that stick are recorded as ADRs in
|
|
81
|
+
[`docs/adr/`](docs/adr/).
|
|
82
|
+
|
|
83
|
+
## How scoring works
|
|
84
|
+
|
|
85
|
+
Every criterion is a **yes/no question**, decided by a deterministic **check** or an LLM **judge**.
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
Suite (e.g. Writing) Suite Index = mean task score
|
|
89
|
+
└ Task (e.g. weekly-newsletter) Task score = 0 if a gate failed, else mean of measures
|
|
90
|
+
├ Gates (output exists, frontmatter has a title, …)
|
|
91
|
+
└ Measure (e.g. "Gets opened") Measure score = % of its criteria passed
|
|
92
|
+
└ Criterion (yes/no, floor | strong | ceiling)
|
|
93
|
+
Workflow Index = mean Suite Index over the Suites in view
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- Judge criteria get several **votes** (3 by default). A criterion's score is its share of passing votes.
|
|
97
|
+
Votes are adaptive: a majority runs first (2 of 3), and the rest run only if it is split on any criterion
|
|
98
|
+
in the batch or a vote failed.
|
|
99
|
+
Judge criteria that need the same evidence (the output alone, or the output plus the inputs) share one
|
|
100
|
+
call per vote, across measures, with the order shuffled; `isolate: true` gives a criterion its own call
|
|
101
|
+
(ADR-0003). The judge writes each reason before its pass/fail. Answers must match a JSON Schema; one
|
|
102
|
+
retry, then an **error**.
|
|
103
|
+
- A judge criterion can get **derived evidence**: an image a script makes from the output (see below).
|
|
104
|
+
- A grader error makes the grading **invalid** (the trial is left out of the means until regraded), never a fail.
|
|
105
|
+
- Points show the mean of valid trials with a **95% confidence interval**, pooled across batches, with n and
|
|
106
|
+
the date range. A human config counts only its latest trial per task. A config missing a task of a Suite
|
|
107
|
+
gets no Suite Index for it: its row reads "partial (2 of 3 tasks)".
|
|
108
|
+
- **Dead criteria** (a strong/ceiling criterion every config passed, or any criterion every config failed)
|
|
109
|
+
are flagged: they carry no signal.
|
|
110
|
+
|
|
111
|
+
### Derived evidence
|
|
112
|
+
|
|
113
|
+
A judge criterion can name a script in the task's `grader/` folder. Before the judge is asked, the script
|
|
114
|
+
makes an image from the output, and the judge gets that image as evidence, attached like any other image (by
|
|
115
|
+
path for a judge that cannot take attached images). Use it to show the judge what the real reader sees, for
|
|
116
|
+
example a thumbnail scaled to 320 px wide.
|
|
117
|
+
|
|
118
|
+
```yaml
|
|
119
|
+
- id: small-text-readable
|
|
120
|
+
question: Can every word of the text be read?
|
|
121
|
+
level: floor
|
|
122
|
+
grader: judge
|
|
123
|
+
derived: scale-320.py # a path under evals/<task>/grader/
|
|
124
|
+
evidence: [] # optional: the judge sees only the derived image, not the full-size output
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The contract for a script:
|
|
128
|
+
|
|
129
|
+
- **Name:** `derived:` is a path relative to the task's `grader/` folder, one script per criterion. Task
|
|
130
|
+
validation reports a script that does not exist, and `derived` on a check or human criterion.
|
|
131
|
+
- **How it runs:** by extension. `.ts` and `.js` with the Bun that runs the engine (so a Node-only install
|
|
132
|
+
needs no `bun` on the `PATH`), `.py` with `uv run --script` (declare packages such as Pillow in the script's
|
|
133
|
+
inline `# /// script` header), `.sh` with `sh`.
|
|
134
|
+
- **Input:** argument 1 and `$OUTPUT` are the trial's output file. `$TASK_DIR` is the task folder, so a script
|
|
135
|
+
can read other task files, for example `$TASK_DIR/project/youtube/thumbnail-regions.json`. `$WORKSPACE` is
|
|
136
|
+
the trial workspace, which is also the working folder. The time limit is 60 s.
|
|
137
|
+
- **Output:** write one PNG to the path in argument 2 (also `$DERIVED`). A non-zero exit, a time-out, no file
|
|
138
|
+
or a file that is not a PNG makes the criterion's verdict an error, so the grading is invalid.
|
|
139
|
+
- **Where it stays:** `runs/trials/<trial-id>/gradings/<grading-id>/_derived/<script>.png`, with the script's
|
|
140
|
+
output next to it as `.log`. A script at the top of `grader/` keeps its name (`scale-320.py.png`); in the file
|
|
141
|
+
name, `/` is written as `%2F` and any other character outside letters, digits and `+ = . _ -` as `%` and its
|
|
142
|
+
hex code, so two scripts never share a file (`sub/overlay.ts` gives `sub%2Foverlay.ts.png`).
|
|
143
|
+
This is the same for a run and a regrade.
|
|
144
|
+
- **Hashing:** the script name is part of the criterion in the grading hash, and every file in `grader/` is
|
|
145
|
+
hashed, so a new name or new script content gives a new grading version. Criteria that name the same script
|
|
146
|
+
share the image and, when their other evidence is the same, one judge call.
|
|
147
|
+
|
|
148
|
+
### Engine revision
|
|
149
|
+
|
|
150
|
+
A task gets a new grading version only when something that can move its score changed. That includes Static
|
|
151
|
+
Fire's own grading code, the **engine revision**, which is a set of parts. A grading hash holds a part only
|
|
152
|
+
when the task uses it:
|
|
153
|
+
|
|
154
|
+
| Part | In a task's grading hash when |
|
|
155
|
+
|---|---|
|
|
156
|
+
| 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. |
|
|
157
|
+
| Check, one revision per type (`regex`, `words`, `frontmatter`, `command`) | A gate or criterion uses the type. `command` always: the output gate is one. |
|
|
158
|
+
| Derived evidence | A criterion names a derived evidence script |
|
|
159
|
+
| Judge call, one revision per judge harness | The task has a judge criterion; its own judge's harness only |
|
|
160
|
+
| Judging (how evidence is shown, how criteria share calls, how votes are counted) | The task has a judge criterion |
|
|
161
|
+
| Scoring (how verdicts become a score) | Always |
|
|
162
|
+
|
|
163
|
+
So a change to the `words` check leaves a thumbnail task as it is, and a change to the Codex judge call leaves
|
|
164
|
+
every grading by a Claude Code judge as it is. A grading version name says which part changed, for example
|
|
165
|
+
"Check changed: words" or "Judge instructions changed".
|
|
166
|
+
|
|
167
|
+
For a change to the grading code: `bun test` fails on the engine pin (`tests/identity.test.ts`) and names the
|
|
168
|
+
piece of code that changed and the revision to raise in `src/core/engine.ts`. Raise it only when the change can
|
|
169
|
+
move a score. The judge instructions need no revision: they are hashed.
|
|
170
|
+
|
|
171
|
+
An update to 0.38.0 changes the record store once (schema version 3) and an older Static Fire cannot open it
|
|
172
|
+
after that. Before the update, stop every running Static Fire process (the dashboard, a batch) and copy the
|
|
173
|
+
store: `sqlite3 runs/static-fire.db ".backup '<dest>'"`.
|
|
174
|
+
|
|
175
|
+
## Files
|
|
176
|
+
|
|
177
|
+
Every path here is in the project folder, the folder that holds `static-fire.yaml`. `static-fire init` makes
|
|
178
|
+
one, and `static-fire guide` gives the format of each file.
|
|
179
|
+
|
|
180
|
+
| Thing | Where | What |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| Settings | `static-fire.yaml` | Marks the project folder. The tools that trials may not use, per harness |
|
|
183
|
+
| Agent config | `configs/<id>.yaml` | `harness` (claude · codex · agy · pi), `model`, `effort`, optional `profile` (apkit); the label is built from these |
|
|
184
|
+
| Human config | `configs/<id>.yaml` | `harness: human` and `outputs` (task id → the file you wrote): compete with the agents |
|
|
185
|
+
| Judge config | `judges.yaml` | Judges to choose from per run or regrade (first = default) |
|
|
186
|
+
| Suite | `suites/<id>.yaml` | Title, description (shown with the suite's charts) + list of task IDs |
|
|
187
|
+
| Task | `evals/<id>/task.yaml` | Prompt, inputs, project files, references and sources (graders only), gates, measures, criteria |
|
|
188
|
+
| 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 |
|
|
189
|
+
| Trial artifacts | `runs/trials/<trial-id>/` | `workspace/` (the agent's output and files), `agent.log`, and `gradings/<grading-id>/` with the judge logs |
|
|
190
|
+
| Profile cache | `.cache/` | The apkit Profiles of a batch and their lock file. Made again for each batch |
|
|
191
|
+
| Prices | `pricing.yaml` (optional) | Your own token prices. Without it, the prices that ship with the engine are used |
|
|
192
|
+
| 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 |
|
|
193
|
+
|
|
194
|
+
## How a trial runs
|
|
195
|
+
|
|
196
|
+
1. Fresh workspace in the system temp folder: the task's `inputs/` (notes, screenshots), its `project/`
|
|
197
|
+
files (what the agent would find in the real project, such as past issues), plus a snapshot of the apkit
|
|
198
|
+
Profile if the config has one (installed once per batch, behind the project's `.cache/apkit.lock`, with `apkit install … --agents <agent> --yes`,
|
|
199
|
+
then `apkit remove`). It is outside any repo, so the task's answer key is not in reach on disk.
|
|
200
|
+
2. The harness runs the way you run it: headless, in its no-prompt mode, with **your own user-level setup**
|
|
201
|
+
(instructions, settings, skills, MCP servers) and every tool, including the web:
|
|
202
|
+
`claude -p --dangerously-skip-permissions`, `codex exec --dangerously-bypass-approvals-and-sandbox`,
|
|
203
|
+
`agy -p --dangerously-skip-permissions`, `pi -p`. The only limit: tools that act on outside services
|
|
204
|
+
(email, Slack, your newsletter platform, paid infrastructure) are blocked, because in real work you
|
|
205
|
+
would be asked first. Edit the list in the project's `static-fire.yaml`. Each trial records its run conditions: the
|
|
206
|
+
user setup hash (the file list is stored once per setup), Profile hash, blocked tools, harness version
|
|
207
|
+
and start time. They are shown, never part of comparability.
|
|
208
|
+
3. If the task is built from published work (`heldOut`), an output that copies the original is invalid.
|
|
209
|
+
4. Gates and checks run; judge criteria are voted on by the run's judge, and the grading is saved under its
|
|
210
|
+
grading hash. Judges do not use your setup: they run with user-level config kept out, so grading stays
|
|
211
|
+
the same across runs.
|
|
212
|
+
5. The workspace and logs are copied into the project's `runs/trials/<trial-id>/`.
|
|
213
|
+
|
|
214
|
+
A human config skips steps 1–2: its file for the task is copied in and graded the same way. To grade a
|
|
215
|
+
draft while you write it, run `static-fire run --tasks <task> --configs <human config>` (judge calls only).
|
|
216
|
+
|
|
217
|
+
## Known gaps (pre-release)
|
|
218
|
+
|
|
219
|
+
These are gaps of the engine. A gap of one task, such as inputs that are cleaner than the real ones, goes in
|
|
220
|
+
its project's README.
|
|
221
|
+
|
|
222
|
+
- **Pi** has no user-config switch and no JSON Schema output, so it can be a candidate but not a judge.
|
|
223
|
+
- **Costs** for Codex and Antigravity are estimated from tokens × `pricing.yaml` (Gemini 4 Argon's rate
|
|
224
|
+
doubles on 2026-12-01). Claude Code and Pi report their own. Judge cost is not part of a config's cost.
|
|
225
|
+
Cost that is not in the harness's own report, such as image generation by an outside service, is not counted.
|
|
226
|
+
- **Human grader** (for taste criteria) is planned but not built yet.
|
|
227
|
+
- **Copy check** (`heldOut`) compares text only. An image output that copies a published original is not
|
|
228
|
+
marked invalid.
|
|
229
|
+
- **Realism gaps** (see the rule at the top): a trial is one shot with no feedback and no one to answer
|
|
230
|
+
questions. Agents can reach the web, and no harness can block one site at run time, so a copied original
|
|
231
|
+
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,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "static-fire",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.44.0",
|
|
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
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"bun": "^1.4.4",
|
|
21
|
+
"marked": "^15.0.0",
|
|
22
|
+
"react": "^19.1.0",
|
|
23
|
+
"react-dom": "^19.1.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/bun": "^1.2.0",
|
|
27
|
+
"@types/react": "^19.1.0",
|
|
28
|
+
"@types/react-dom": "^19.1.0",
|
|
29
|
+
"typescript": "^5.8.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
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 { findProject, 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 found from the current folder when it has one, else the engine's.
|
|
46
|
+
let pricing: any;
|
|
47
|
+
const rates = () => (pricing ??= Bun.YAML.parse(readFileSync(pricingPath(findProject()), "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 };
|