static-fire 0.44.0 → 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.
- package/README.md +40 -34
- package/package.json +2 -1
- package/src/adapters/index.ts +3 -3
- package/src/api.ts +38 -6
- package/src/cli.ts +106 -20
- package/src/core/batch.ts +16 -1
- package/src/core/commands.ts +7 -6
- package/src/core/compare.ts +4 -1
- package/src/core/coverage.ts +84 -0
- package/src/core/db.ts +36 -0
- package/src/core/facets.ts +23 -8
- package/src/core/init.ts +99 -9
- package/src/core/plan.ts +84 -0
- package/src/core/project.ts +95 -9
- package/src/core/regrade.ts +10 -1
- package/src/core/score.ts +1 -1
- package/src/core/task-page.ts +54 -0
- package/src/core/trial-detail.ts +3 -3
- package/src/core/types.ts +1 -1
- package/src/core/view-query.ts +30 -13
- package/src/core/view.ts +131 -53
- package/src/guide.md +128 -51
- package/src/server.ts +7 -3
- package/web/app.tsx +276 -133
- package/web/filter-menu.ts +35 -4
- package/web/how-it-works.tsx +92 -0
- package/web/index-switches.ts +29 -0
- package/web/levels.tsx +15 -0
- package/web/notices.ts +74 -0
- package/web/output-view.ts +61 -0
- package/web/pages.ts +26 -0
- package/web/styles.css +57 -22
- package/web/task-card.ts +6 -0
- package/web/tasks.tsx +122 -0
- package/web/suite-notes.ts +0 -20
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Find out which **agent configuration** (harness + model + effort + apkit Profile) does your real work best,
|
|
4
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 **
|
|
5
|
+
Artificial Analysis-style score-vs-cost chart, the **Agent Performance Index**, for *your* workflows.
|
|
6
6
|
|
|
7
7
|
**The whole point is real-world performance.** A trial reproduces the real job as closely as it can: the
|
|
8
8
|
inputs, tools, context and instructions you would actually give the agent, and graders that read the
|
|
@@ -15,29 +15,32 @@ The engine's gaps are listed below; the gaps of one task go in its project's REA
|
|
|
15
15
|
You need Node or Bun. The package brings its own Bun ([ADR-0008](docs/adr/0008-npm-package-brings-bun.md)).
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
|
|
19
|
-
npx static-fire init # make a project in this folder
|
|
18
|
+
npx static-fire init # make your home project in ~/static-fire
|
|
20
19
|
```
|
|
21
20
|
|
|
22
|
-
Then open your agent in
|
|
21
|
+
Then open your agent in `~/static-fire` and tell it what you want to test, for example "I want to find out which
|
|
23
22
|
agent is the best at making thumbnails". `init` writes an `AGENTS.md` that sends the agent to
|
|
24
23
|
`static-fire guide`, which takes it from your goal to a dashboard with data. The guide also holds the file
|
|
25
24
|
formats, so neither of you needs to read the engine source.
|
|
26
25
|
|
|
27
|
-
A **project** is the folder that holds `static-fire.yaml`.
|
|
28
|
-
|
|
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
29
|
`npx static-fire` in place of `static-fire` when it is not installed:
|
|
30
30
|
|
|
31
31
|
```sh
|
|
32
|
-
static-fire init [--json]
|
|
32
|
+
static-fire init [<folder>] [--json] # make the home (~/static-fire) or a project in <folder>; never overwrites a file
|
|
33
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
|
|
34
35
|
static-fire new task <id> # a task folder with a commented task.yaml
|
|
35
36
|
static-fire configs | judges | suites | tasks
|
|
36
37
|
static-fire run --suites <suite> | --tasks <task> --configs <config>,<config>|all \
|
|
37
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
|
|
38
40
|
static-fire rerun <batch> # its failed trials again, as new trials (after a usage limit, for example)
|
|
39
41
|
static-fire regrade [--tasks t] [--configs a,b] [--batch id] [--judge id] [--votes n] [--only-invalid]
|
|
40
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
|
|
41
44
|
static-fire show [--suites s | --tasks t] [--judge id] [--grading hash] [--since date] [--until date] [--json]
|
|
42
45
|
# the comparable trials, pooled across batches (JSON for agents)
|
|
43
46
|
static-fire serve # dashboard at http://localhost:4477
|
|
@@ -50,15 +53,17 @@ static-fire migrate # one-off import of an experiment fol
|
|
|
50
53
|
|
|
51
54
|
Every trial is a record; a chart is a query over them. Running one new config on one task adds its point to
|
|
52
55
|
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
|
|
54
|
-
numbers as a table. Its
|
|
55
|
-
version, date range) are in the page URL, so a view can be bookmarked.
|
|
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
|
|
56
60
|
each take several values, for example `?model=opus&model=haiku`. When a question or the judge changes,
|
|
57
61
|
earlier trials are left out of the view, and the notice under the chart offers to regrade them with judge
|
|
58
62
|
calls only. **Runs** (`/runs`) lists every batch with its trials; start one from **New run** there. Runs
|
|
59
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
|
|
60
|
-
grading versions of a task, and what that did to the scores and to each criterion.
|
|
61
|
-
|
|
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.
|
|
62
67
|
Terms are defined in [`CONTEXT.md`](CONTEXT.md); the scoring design is
|
|
63
68
|
[ADR-0001](docs/adr/0001-pass-fail-criteria-in-measures.md), and trials as records is
|
|
64
69
|
[ADR-0006](docs/adr/0006-trials-as-records-charts-as-queries.md).
|
|
@@ -71,9 +76,28 @@ This repo is the engine. It holds no project: tests use the made-up project in `
|
|
|
71
76
|
bun install
|
|
72
77
|
bun test
|
|
73
78
|
bun run typecheck
|
|
79
|
+
bun run dev # the dashboard on a temp copy of the sample project, with hot reload
|
|
74
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
|
|
75
84
|
```
|
|
76
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
|
+
|
|
77
101
|
## Planned work
|
|
78
102
|
|
|
79
103
|
Planned work is tracked in [GitHub issues](https://github.com/kenneth-liao/static-fire/issues). A design
|
|
@@ -90,7 +114,7 @@ Suite (e.g. Writing) Suite Index = mean task score
|
|
|
90
114
|
├ Gates (output exists, frontmatter has a title, …)
|
|
91
115
|
└ Measure (e.g. "Gets opened") Measure score = % of its criteria passed
|
|
92
116
|
└ Criterion (yes/no, floor | strong | ceiling)
|
|
93
|
-
|
|
117
|
+
Agent Performance Index = mean Suite Index over the Suites in view
|
|
94
118
|
```
|
|
95
119
|
|
|
96
120
|
- Judge criteria get several **votes** (3 by default). A criterion's score is its share of passing votes.
|
|
@@ -124,26 +148,8 @@ example a thumbnail scaled to 320 px wide.
|
|
|
124
148
|
evidence: [] # optional: the judge sees only the derived image, not the full-size output
|
|
125
149
|
```
|
|
126
150
|
|
|
127
|
-
|
|
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.
|
|
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.
|
|
147
153
|
|
|
148
154
|
### Engine revision
|
|
149
155
|
|
|
@@ -183,7 +189,7 @@ one, and `static-fire guide` gives the format of each file.
|
|
|
183
189
|
| Agent config | `configs/<id>.yaml` | `harness` (claude · codex · agy · pi), `model`, `effort`, optional `profile` (apkit); the label is built from these |
|
|
184
190
|
| Human config | `configs/<id>.yaml` | `harness: human` and `outputs` (task id → the file you wrote): compete with the agents |
|
|
185
191
|
| Judge config | `judges.yaml` | Judges to choose from per run or regrade (first = default) |
|
|
186
|
-
| Suite | `suites/<id>.yaml` | Title, description (shown
|
|
192
|
+
| Suite | `suites/<id>.yaml` | Title, description (shown on the Tasks page) + list of task IDs |
|
|
187
193
|
| Task | `evals/<id>/task.yaml` | Prompt, inputs, project files, references and sources (graders only), gates, measures, criteria |
|
|
188
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 |
|
|
189
195
|
| Trial artifacts | `runs/trials/<trial-id>/` | `workspace/` (the agent's output and files), `agent.log`, and `gradings/<grading-id>/` with the judge logs |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "static-fire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Benchmark agent configurations (harness + model + effort + profile) on your real workflows.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"pricing.yaml"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
|
+
"dev": "bun scripts/dev.ts",
|
|
17
18
|
"typecheck": "tsc --noEmit"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
package/src/adapters/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil
|
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import type { AgentConfig, AgentHarness, AgentResult, Harness, Usage } from "../core/types";
|
|
10
|
-
import {
|
|
10
|
+
import { currentProject, pricingPath } from "../core/project";
|
|
11
11
|
|
|
12
12
|
export interface Guard { blockedTools: string[] }
|
|
13
13
|
export interface RunArgs { config: AgentConfig; prompt: string; cwd: string; logPath: string; timeoutMs: number; guard?: Guard }
|
|
@@ -42,9 +42,9 @@ export function agyHome() {
|
|
|
42
42
|
// engine:judgeCall:end
|
|
43
43
|
|
|
44
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
|
|
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
46
|
let pricing: any;
|
|
47
|
-
const rates = () => (pricing ??= Bun.YAML.parse(readFileSync(pricingPath(
|
|
47
|
+
const rates = () => (pricing ??= Bun.YAML.parse(readFileSync(pricingPath(currentProject()), "utf8")));
|
|
48
48
|
|
|
49
49
|
export function estimate(harness: Harness, model: string, u: Usage) {
|
|
50
50
|
const rate = rates()[harness]?.[model];
|
package/src/api.ts
CHANGED
|
@@ -5,9 +5,10 @@ import { batchState } from "./core/batch-state";
|
|
|
5
5
|
import { listBatchSummaries, summarizeBatch, type BatchSummary } from "./core/batches";
|
|
6
6
|
import { openStore } from "./core/db";
|
|
7
7
|
import type { Store } from "./core/db";
|
|
8
|
-
import { listTasks, loadConfigs, loadJudges } from "./core/authored";
|
|
8
|
+
import { listTasks, loadConfigs, loadJudges, loadTask } from "./core/authored";
|
|
9
9
|
import { projectRoot } from "./core/project";
|
|
10
10
|
import { readTrialOutputFile, trialDetail } from "./core/trial-detail";
|
|
11
|
+
import { taskPage } from "./core/task-page";
|
|
11
12
|
import type { Task } from "./core/types";
|
|
12
13
|
import { compareGradings } from "./core/compare";
|
|
13
14
|
import { buildFacets } from "./core/facets";
|
|
@@ -65,6 +66,28 @@ export interface RegradeEnded extends RegradeRun {
|
|
|
65
66
|
|
|
66
67
|
const bad = (e: unknown) => Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 });
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A request that may change something (any method but GET and HEAD) from another site: its `Origin` is not the
|
|
71
|
+
* page's own (the Host it was sent to), or `Sec-Fetch-Site` says cross-site. A trial's HTML output runs in a sandboxed
|
|
72
|
+
* frame whose requests carry `Origin: null`, and any web site open in the browser can post to localhost: neither may
|
|
73
|
+
* start a run or a regrade (#68). The CLI and tests send no Origin and pass.
|
|
74
|
+
*/
|
|
75
|
+
export function fromAnotherSite(req: Request): boolean {
|
|
76
|
+
if (req.method === "GET" || req.method === "HEAD") return false;
|
|
77
|
+
if (req.headers.get("sec-fetch-site") === "cross-site") return true;
|
|
78
|
+
const origin = req.headers.get("origin"), host = req.headers.get("host");
|
|
79
|
+
return origin !== null && (!host || (origin !== `http://${host}` && origin !== `https://${host}`));
|
|
80
|
+
}
|
|
81
|
+
const ANOTHER_SITE = "This request came from another site. The dashboard's API takes changes only from its own page.";
|
|
82
|
+
|
|
83
|
+
/** Every handler of `routes`, refusing a request from another site (403) before it runs. */
|
|
84
|
+
function ownPageOnly<R extends Record<string, unknown>>(routes: R): R {
|
|
85
|
+
type Handler = (req: Request, ...rest: unknown[]) => unknown;
|
|
86
|
+
const wrap = (h: Handler): Handler => (req, ...rest) => (fromAnotherSite(req) ? Response.json({ error: ANOTHER_SITE }, { status: 403 }) : h(req, ...rest));
|
|
87
|
+
return Object.fromEntries(Object.entries(routes).map(([path, r]) => [path, typeof r === "function" ? wrap(r as Handler)
|
|
88
|
+
: Object.fromEntries(Object.entries(r as Record<string, Handler>).map(([method, h]) => [method, wrap(h)]))])) as R;
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
/** The API routes, and `close` to release the event stream's database connection. */
|
|
69
92
|
export function apiRoutes({ store, dbPath, catalog, pollMs = 1000, root: givenRoot, cacheDir, checkPlan, now = () => new Date(), checkRegrade, runRegrade = regrade }: ApiOptions) {
|
|
70
93
|
const root = () => givenRoot ?? projectRoot(); // looked up per request, so a server built with only a catalog needs no project
|
|
@@ -82,6 +105,12 @@ export function apiRoutes({ store, dbPath, catalog, pollMs = 1000, root: givenRo
|
|
|
82
105
|
"/api/judges": () => Response.json(loadJudges(root())),
|
|
83
106
|
"/api/suites": () => Response.json(catalog().suites), // the suites the view reads, so the scope selector matches it
|
|
84
107
|
"/api/tasks": () => Response.json(listTasks(root())),
|
|
108
|
+
/** One task as the Tasks page shows it (core/task-page.ts): never the graders' own material. 404 for a task the project does not have, 400 for an invalid task file. */
|
|
109
|
+
"/api/tasks/:id": (req: Request & { params: { id: string } }) => {
|
|
110
|
+
const r = root(), id = req.params.id;
|
|
111
|
+
if (!listTasks(r).some((t) => t.id === id)) return Response.json({ error: `no task ${id}` }, { status: 404 });
|
|
112
|
+
try { return Response.json(taskPage(loadTask(id, r))); } catch (e) { return bad(e); }
|
|
113
|
+
},
|
|
85
114
|
"/api/view": (req: Request) => {
|
|
86
115
|
try { return Response.json(buildView(store, toViewFilters(parseViewQuery(new URL(req.url).searchParams)), catalog())); } catch (e) { return bad(e); }
|
|
87
116
|
},
|
|
@@ -188,12 +217,15 @@ export function apiRoutes({ store, dbPath, catalog, pollMs = 1000, root: givenRo
|
|
|
188
217
|
const d = trialDetail(store, req.params.id);
|
|
189
218
|
return d ? Response.json(d) : Response.json({ error: `no trial ${req.params.id}` }, { status: 404 });
|
|
190
219
|
},
|
|
191
|
-
/** The trial's output file from its saved workspace
|
|
220
|
+
/** The trial's output file from its saved workspace, with its output path in `x-output-path`: an image with its media type, else text (served as Markdown, never as a page); 404 with the reason when there is none. */
|
|
192
221
|
"/api/trials/:id/output": (req: Request & { params: { id: string } }) => {
|
|
193
222
|
const out = readTrialOutputFile(store, dbPath, req.params.id);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
223
|
+
if ("missing" in out) return new Response(out.missing, { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
224
|
+
// The output path (URI-encoded) tells the drawer how to show the text: an HTML page, Markdown or other text (#68).
|
|
225
|
+
const path = { "x-output-path": encodeURIComponent(out.path) };
|
|
226
|
+
return "image" in out ? new Response(out.image, { headers: { "content-type": out.mediaType, ...path } })
|
|
227
|
+
// Text a browser never runs as a page, even when this URL is opened on its own: the drawer frames it.
|
|
228
|
+
: new Response(out.text, { headers: { "content-type": "text/markdown; charset=utf-8", "x-content-type-options": "nosniff", "content-security-policy": "sandbox", ...path } });
|
|
197
229
|
},
|
|
198
230
|
/** Server-sent events: one on connect, then one each time the database changes. The data is the data_version. */
|
|
199
231
|
"/api/events": () => {
|
|
@@ -214,5 +246,5 @@ export function apiRoutes({ store, dbPath, catalog, pollMs = 1000, root: givenRo
|
|
|
214
246
|
return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } });
|
|
215
247
|
},
|
|
216
248
|
};
|
|
217
|
-
return { routes, close: () => watcher.close() };
|
|
249
|
+
return { routes: ownPageOnly(routes), close: () => watcher.close() };
|
|
218
250
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// static-fire — the same primitives the dashboard uses, for people and agents.
|
|
3
3
|
// static-fire --version the installed version
|
|
4
|
-
// static-fire init [--json]
|
|
4
|
+
// static-fire init [<folder>] [--json] make the home (~/static-fire, or <folder>), report the home that is set, or
|
|
5
|
+
// make a second project in <folder> (core/init.ts); never overwrites a file
|
|
5
6
|
// static-fire guide the guide for agents (src/guide.md); needs no project
|
|
6
|
-
// static-fire
|
|
7
|
+
// static-fire home [<folder>] show the home, or save a project folder as the home; needs no project
|
|
8
|
+
// static-fire serve [--port 4477] [--dev]
|
|
9
|
+
// the dashboard, in production mode; --dev for development mode (hot reload)
|
|
7
10
|
// static-fire configs | judges | suites | tasks
|
|
8
11
|
// static-fire new task <id> a new task folder: evals/<id>/task.yaml (commented, to fill in) and inputs/
|
|
9
12
|
// static-fire run --suites writing[,visual] | --tasks a[,b] [--configs a,b|all] [--trials 3] [--judge id] [--votes 3] [--concurrency 4]
|
|
10
13
|
// one batch in the store; trial artifacts in the project's runs/trials/<trial-id>/
|
|
11
14
|
// static-fire regrade [--tasks t] [--configs a,b] [--batch id] [--judge id] [--votes n] [--only-invalid] [--concurrency 4]
|
|
12
15
|
// grade stored outputs again (judge calls only) where the current grading is missing
|
|
16
|
+
// static-fire run|regrade ... --plan [--json]
|
|
17
|
+
// the plan in runs (core/plan.ts): agent runs per config and judge calls; starts
|
|
18
|
+
// nothing and writes nothing. Without --plan, run and regrade print it first.
|
|
13
19
|
// static-fire rerun <batch> run its failed trials again, as new trials in the same batch
|
|
14
20
|
// static-fire list every batch in the store, newest first, with its trial counts
|
|
15
21
|
// static-fire show [--suites s | --tasks t] [--judge id] [--grading hash] [--since date] [--until date] [--json]
|
|
@@ -18,17 +24,20 @@
|
|
|
18
24
|
// static-fire migrate [--from <folder>] one-off import of the 2026-11-06 experiment into the store
|
|
19
25
|
//
|
|
20
26
|
// The commands and the flags each takes are listed in core/commands.ts; a flag a command does not take is an error.
|
|
21
|
-
// The project is the folder
|
|
22
|
-
// Each command that needs one calls needProject() before it reads
|
|
23
|
-
//
|
|
27
|
+
// The project is the nearest folder at or above the current folder with static-fire.yaml; outside any project it
|
|
28
|
+
// is the saved home (core/project.ts, ADR-0010). Each command that needs one calls needProject() before it reads
|
|
29
|
+
// or writes anything. When it uses the home, it says so in one line on stderr, so stdout (and --json) is
|
|
30
|
+
// unchanged. With no home it stops with an error that names `static-fire init`, and when the saved home is not a
|
|
31
|
+
// project any more, with an error that says how to set it again. The record store is the project's runs/static-fire.db,
|
|
24
32
|
// or STATIC_FIRE_DB when set; trial artifacts go next to the store, and the Profile cache in the project's .cache/.
|
|
25
|
-
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
33
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
26
34
|
import { join } from "node:path";
|
|
27
35
|
import { parseArgs } from "node:util";
|
|
28
36
|
import { configLabel } from "./core/label";
|
|
37
|
+
import { coverageLine } from "./core/coverage";
|
|
29
38
|
import { listTasks, loadConfigs, loadJudges, loadSuites } from "./core/authored";
|
|
30
39
|
import { COMMANDS, OPTIONS, unknownFlags, USAGE } from "./core/commands";
|
|
31
|
-
import { ENGINE_ROOT, NoProjectError, projectPaths,
|
|
40
|
+
import { ENGINE_ROOT, HomeNotProjectError, NoProjectError, projectPaths, resolveProject, type ProjectPaths } from "./core/project";
|
|
32
41
|
|
|
33
42
|
const LABEL_WIDTH = 52; // fits the longest built config label, so `show` columns line up
|
|
34
43
|
|
|
@@ -47,25 +56,57 @@ if (cmd && COMMANDS[cmd]) {
|
|
|
47
56
|
process.exit(1);
|
|
48
57
|
}
|
|
49
58
|
}
|
|
59
|
+
// run and regrade print the plan as JSON only on its own: their progress lines are not JSON.
|
|
60
|
+
if ((cmd === "run" || cmd === "regrade") && values.json && !values.plan) {
|
|
61
|
+
console.error(`${cmd}: --json needs --plan (static-fire ${cmd} ... --plan --json prints the plan as JSON and starts nothing)`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
50
64
|
|
|
51
65
|
const fmt = (n: number | null | undefined, d = 1) => (n == null ? "—" : n.toFixed(d));
|
|
52
66
|
const log = (m: string) => console.log(m);
|
|
53
67
|
|
|
54
|
-
|
|
68
|
+
let saidHome = false;
|
|
69
|
+
/**
|
|
70
|
+
* The project of this command, or exit 1 before anything is written: with an error that names `static-fire init`
|
|
71
|
+
* when there is no project and no home, or that says how to set the home again when it is not a project any more.
|
|
72
|
+
* A command that uses the home from outside it says so, once, on stderr.
|
|
73
|
+
*/
|
|
55
74
|
function needProject(): ProjectPaths {
|
|
75
|
+
let found;
|
|
56
76
|
try {
|
|
57
|
-
|
|
77
|
+
found = resolveProject();
|
|
58
78
|
} catch (e) {
|
|
59
|
-
if (!(e instanceof NoProjectError)) throw e;
|
|
79
|
+
if (!(e instanceof NoProjectError || e instanceof HomeNotProjectError)) throw e;
|
|
60
80
|
console.error(`${cmd}: ${e.message}`);
|
|
61
81
|
process.exit(1);
|
|
62
82
|
}
|
|
83
|
+
if (found.home && !saidHome) {
|
|
84
|
+
saidHome = true;
|
|
85
|
+
console.error(`using the home project: ${found.root}`);
|
|
86
|
+
}
|
|
87
|
+
return projectPaths(found.root);
|
|
63
88
|
}
|
|
64
89
|
|
|
65
90
|
switch (cmd) {
|
|
66
91
|
case "init": {
|
|
67
|
-
|
|
68
|
-
const
|
|
92
|
+
// Needs no project: it makes one, or reports the home or the project it runs in.
|
|
93
|
+
const { formatReport, NotAFolderError, runInit } = await import("./core/init");
|
|
94
|
+
if (positionals.length > 1) {
|
|
95
|
+
console.error("usage: static-fire init [<folder>] [--json]");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
let report;
|
|
99
|
+
try {
|
|
100
|
+
report = runInit({ folder: positionals[0] });
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e instanceof HomeNotProjectError) {
|
|
103
|
+
console.error(`init: ${e.message} Or run \`static-fire init <folder>\` to make a new project there, then \`static-fire home <folder>\`.`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
if (!(e instanceof NotAFolderError)) throw e;
|
|
107
|
+
console.error(`init: ${e.message}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
69
110
|
process.stdout.write(values.json ? `${JSON.stringify(report, null, 2)}\n` : formatReport(report));
|
|
70
111
|
break;
|
|
71
112
|
}
|
|
@@ -73,10 +114,41 @@ switch (cmd) {
|
|
|
73
114
|
// An engine file, so it matches the installed version. It needs no project.
|
|
74
115
|
process.stdout.write(readFileSync(join(ENGINE_ROOT, "src", "guide.md"), "utf8"));
|
|
75
116
|
break;
|
|
117
|
+
case "home": {
|
|
118
|
+
// Needs no project. With a folder, saves it as the home when it is a project; the home changes only here.
|
|
119
|
+
const { NotAProjectError, readHome, saveHome, PROJECT_FILE } = await import("./core/project");
|
|
120
|
+
if (positionals.length > 1) {
|
|
121
|
+
console.error("usage: static-fire home [<folder>]");
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
const folder = positionals[0];
|
|
125
|
+
if (folder !== undefined) {
|
|
126
|
+
try {
|
|
127
|
+
log(`the home is now ${saveHome(folder)}`);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (!(e instanceof NotAProjectError)) throw e;
|
|
130
|
+
console.error(`home: ${e.message}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
const home = readHome();
|
|
136
|
+
if (!home) {
|
|
137
|
+
log("no home is set. Run `static-fire home <folder>` with a project folder to set one, or `static-fire init` to make a project.");
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
log(home);
|
|
141
|
+
const { existsSync } = await import("node:fs");
|
|
142
|
+
if (!existsSync(join(home, PROJECT_FILE))) {
|
|
143
|
+
console.error(`home: ${new HomeNotProjectError(home).message}`);
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
76
148
|
case "serve": {
|
|
77
149
|
const project = needProject();
|
|
78
150
|
const { serve } = await import("./server");
|
|
79
|
-
serve(Number(values.port), project);
|
|
151
|
+
serve(Number(values.port), project, { development: values.dev === true });
|
|
80
152
|
break;
|
|
81
153
|
}
|
|
82
154
|
case "configs":
|
|
@@ -123,6 +195,10 @@ switch (cmd) {
|
|
|
123
195
|
tasks: list(values.tasks), suites: values.suites === "all" ? undefined : list(values.suites), configs: list(values.configs),
|
|
124
196
|
trials: Number(values.trials), judge: values.judge, votes: values.votes ? Number(values.votes) : undefined,
|
|
125
197
|
}, root);
|
|
198
|
+
const { batchPlanInRuns, formatPlan } = await import("./core/plan");
|
|
199
|
+
const inRuns = batchPlanInRuns(plan);
|
|
200
|
+
if (values.plan) { log(values.json ? JSON.stringify(inRuns, null, 2) : formatPlan(inRuns)); break; }
|
|
201
|
+
log(formatPlan(inRuns));
|
|
126
202
|
const store = openStore(dbPath);
|
|
127
203
|
try {
|
|
128
204
|
const started = await startBatch(store, plan, { dbPath, root, cacheDir: cache });
|
|
@@ -135,16 +211,24 @@ switch (cmd) {
|
|
|
135
211
|
}
|
|
136
212
|
case "regrade": {
|
|
137
213
|
const { root, dbPath } = needProject();
|
|
138
|
-
const { formatCounts, regrade } = await import("./core/regrade");
|
|
139
|
-
const { openStore } = await import("./core/db");
|
|
214
|
+
const { formatCounts, planRegrade, regrade } = await import("./core/regrade");
|
|
215
|
+
const { emptyStore, openStore, snapshotStore } = await import("./core/db");
|
|
140
216
|
const { projectCatalog } = await import("./core/view");
|
|
217
|
+
const { formatPlan, regradePlanInRuns } = await import("./core/plan");
|
|
141
218
|
const list = (v?: string) => (v === undefined ? undefined : v.split(",").map((x) => x.trim()).filter(Boolean));
|
|
142
|
-
const
|
|
219
|
+
const filters = { tasks: list(values.tasks), configs: list(values.configs), batch: values.batch };
|
|
220
|
+
const opts = {
|
|
221
|
+
dbPath, catalog: projectCatalog(root), judge: values.judge, votes: values.votes ? Number(values.votes) : undefined, onlyInvalid: values["only-invalid"],
|
|
222
|
+
concurrency: Number(values.concurrency), onEvent: log,
|
|
223
|
+
};
|
|
224
|
+
// The plan writes nothing: it is planned on a copy of the store in memory (an empty one when there is no store
|
|
225
|
+
// yet), so no file is made and an existing store keeps its schema and content.
|
|
226
|
+
const store = !values.plan ? openStore(dbPath) : existsSync(dbPath) ? snapshotStore(dbPath) : emptyStore();
|
|
143
227
|
try {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
228
|
+
const inRuns = regradePlanInRuns(planRegrade(store, filters, opts));
|
|
229
|
+
if (values.plan) { log(values.json ? JSON.stringify(inRuns, null, 2) : formatPlan(inRuns)); break; }
|
|
230
|
+
log(formatPlan(inRuns));
|
|
231
|
+
const counts = await regrade(store, filters, opts);
|
|
148
232
|
log(formatCounts(counts));
|
|
149
233
|
if (counts.failed) {
|
|
150
234
|
console.error(`regrade: ${counts.failed} trial(s) could not be graded (listed above)`);
|
|
@@ -255,6 +339,8 @@ function printView(v: View) {
|
|
|
255
339
|
const { filters: f } = v;
|
|
256
340
|
const range = f.since || f.until ? ` · ${f.since?.slice(0, 10) ?? "…"} to ${f.until?.slice(0, 10) ?? "…"}` : "";
|
|
257
341
|
log(`${f.suiteIds.length ? `suites ${f.suiteIds.join(", ")}` : `tasks ${f.taskIds.join(", ")}`} · judge ${f.judge.label}${range}`);
|
|
342
|
+
const covers = coverageLine(v.coverage, f.judge.label); // the line the dashboard shows under its index buttons
|
|
343
|
+
if (covers) log(covers);
|
|
258
344
|
for (const [id, t] of Object.entries(v.tasks)) {
|
|
259
345
|
const grading = t.gradingName ? `${t.gradingName} (${t.gradingHash.slice(0, 8)})` : t.gradingHash.slice(0, 8);
|
|
260
346
|
log(` ${id}${t.label != null ? ` v${t.label}` : ""} · task ${t.taskHash.slice(0, 8)} · grading ${grading}${t.current ? "" : " (earlier)"}`);
|
package/src/core/batch.ts
CHANGED
|
@@ -164,6 +164,19 @@ export async function harnessVersions(harnesses: AgentHarness[], read = readHarn
|
|
|
164
164
|
/** A human wrote one file per task, so it gets one trial, and only for tasks it has a file for. */
|
|
165
165
|
const trialCount = (c: AgentConfig, taskId: string, n: number) => (c.harness !== "human" ? n : c.outputs?.[taskId] ? 1 : 0);
|
|
166
166
|
|
|
167
|
+
/** One trial a batch starts: its task, config and number. */
|
|
168
|
+
export interface PlannedTrial { task: Task; config: AgentConfig; n: number }
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Every trial of a planned batch, in the order startBatch queues them: each task, each config, n = 1..trials (one
|
|
172
|
+
* for a human config with a file for the task, none without). startBatch inserts this list, and the plan in runs
|
|
173
|
+
* (plan.ts) counts it, so the counts are what starts.
|
|
174
|
+
*/
|
|
175
|
+
export function plannedTrials(plan: Pick<BatchPlan, "tasks" | "configs" | "trials">): PlannedTrial[] {
|
|
176
|
+
return plan.tasks.flatMap((task) => plan.configs.flatMap((config) =>
|
|
177
|
+
Array.from({ length: trialCount(config, task.id, plan.trials) }, (_, i) => ({ task, config, n: i + 1 }))));
|
|
178
|
+
}
|
|
179
|
+
|
|
167
180
|
/**
|
|
168
181
|
* Records a new batch in one transaction: the batch (id = UTC timestamp, with -2, -3, ... when a batch
|
|
169
182
|
* started in the same second has it), each task version and grading setup when first seen, the user setup,
|
|
@@ -198,7 +211,9 @@ export async function startBatch(store: Store, plan: BatchPlan, o: StartOptions)
|
|
|
198
211
|
}
|
|
199
212
|
if (files && setupHash) store.putUserSetup({ hash: setupHash, files });
|
|
200
213
|
const trials: TrialRecord[] = [];
|
|
201
|
-
|
|
214
|
+
const hashes = new Map(ids.map(({ task, t }) => [task.id, t]));
|
|
215
|
+
for (const { task, config: c, n } of plannedTrials(plan)) {
|
|
216
|
+
const t = hashes.get(task.id)!;
|
|
202
217
|
const tid = `${id}--${task.id}--${c.id}--${n}`;
|
|
203
218
|
const r: TrialRecord = {
|
|
204
219
|
id: tid, batchId: id, taskId: task.id, taskHash: t.hash, configKey: configKey(c), configId: c.id,
|
package/src/core/commands.ts
CHANGED
|
@@ -7,24 +7,25 @@ export const OPTIONS = {
|
|
|
7
7
|
suites: { type: "string" }, tasks: { type: "string" }, grading: { type: "string" }, since: { type: "string" }, until: { type: "string" },
|
|
8
8
|
configs: { type: "string" }, trials: { type: "string", default: "3" },
|
|
9
9
|
judge: { type: "string" }, votes: { type: "string" }, "only-invalid": { type: "boolean" },
|
|
10
|
-
concurrency: { type: "string", default: "4" }, port: { type: "string", default: "4477" }, json: { type: "boolean" },
|
|
11
|
-
from: { type: "string" }, batch: { type: "string" },
|
|
10
|
+
concurrency: { type: "string", default: "4" }, port: { type: "string", default: "4477" }, dev: { type: "boolean" }, json: { type: "boolean" },
|
|
11
|
+
from: { type: "string" }, batch: { type: "string" }, plan: { type: "boolean" },
|
|
12
12
|
} as const;
|
|
13
13
|
|
|
14
14
|
export type Flag = keyof typeof OPTIONS;
|
|
15
15
|
|
|
16
16
|
/** Each command: the flags its code reads, and its arguments for the usage text. */
|
|
17
17
|
export const COMMANDS: Record<string, { flags: readonly Flag[]; args?: string }> = {
|
|
18
|
-
init: { flags: ["json"] },
|
|
18
|
+
init: { flags: ["json"], args: "[<folder>]" },
|
|
19
19
|
guide: { flags: [] },
|
|
20
|
-
|
|
20
|
+
home: { flags: [], args: "[<folder>]" },
|
|
21
|
+
serve: { flags: ["port", "dev"] },
|
|
21
22
|
configs: { flags: [] },
|
|
22
23
|
judges: { flags: [] },
|
|
23
24
|
suites: { flags: [] },
|
|
24
25
|
tasks: { flags: [] },
|
|
25
26
|
new: { flags: [], args: "task <id>" },
|
|
26
|
-
run: { flags: ["suites", "tasks", "configs", "trials", "judge", "votes", "concurrency"] },
|
|
27
|
-
regrade: { flags: ["tasks", "configs", "batch", "judge", "votes", "only-invalid", "concurrency"] },
|
|
27
|
+
run: { flags: ["suites", "tasks", "configs", "trials", "judge", "votes", "concurrency", "plan", "json"] },
|
|
28
|
+
regrade: { flags: ["tasks", "configs", "batch", "judge", "votes", "only-invalid", "concurrency", "plan", "json"] },
|
|
28
29
|
rerun: { flags: ["concurrency"], args: "<batch>" },
|
|
29
30
|
list: { flags: [] },
|
|
30
31
|
show: { flags: ["suites", "tasks", "judge", "grading", "since", "until", "json"] },
|
package/src/core/compare.ts
CHANGED
|
@@ -117,7 +117,10 @@ export function compareGradings(store: Store, filters: ViewFilters, a: string, b
|
|
|
117
117
|
if (sa.taskId !== sb.taskId) throw new Error(`grading versions ${sa.hash.slice(0, 8)} and ${sb.hash.slice(0, 8)} are of two tasks (${sa.taskId}, ${sb.taskId}); compare two grading versions of one task`);
|
|
118
118
|
const taskId = sa.taskId;
|
|
119
119
|
// Each setup brings its own judge (the view refuses another judge with a picked setup).
|
|
120
|
-
|
|
120
|
+
// With no scope given, the scope is the task: only its trials are compared, and the default set of suites
|
|
121
|
+
// (DEC-019) may leave its suite out.
|
|
122
|
+
const { judge: _judge, ...given } = filters;
|
|
123
|
+
const rest = given.suites?.length || given.tasks?.length ? given : { ...given, tasks: [taskId] };
|
|
121
124
|
const va = buildView(store, { ...rest, grading: sa.hash }, catalog); // throws when the task is not in scope
|
|
122
125
|
const vb = buildView(store, { ...rest, grading: sb.hash }, catalog);
|
|
123
126
|
const ofTask = (v: View) => new Map(v.trials.filter((t) => t.taskId === taskId && valid(t)).map((t) => [t.id, t]));
|