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 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 **Workflow Index**, for *your* workflows.
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
- mkdir my-evals && cd my-evals
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 that folder and tell it what you want to test, for example "I want to find out which
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`. 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
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] # make a project in the current folder; never overwrites a file
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 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
+ 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. A suite's description is shown with
61
- every chart that draws the suite.
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
- Workflow Index = mean Suite Index over the Suites in view
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
- 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.
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 with the suite's charts) + list of task IDs |
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.44.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": {
@@ -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 { findProject, pricingPath } from "../core/project";
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 found from the current folder when it has one, else the engine's.
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(findProject()), "utf8")));
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: an image with its media type, else Markdown text; 404 with the reason when there is none. */
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
- return "missing" in out ? new Response(out.missing, { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } })
195
- : "image" in out ? new Response(out.image, { headers: { "content-type": out.mediaType } })
196
- : new Response(out.text, { headers: { "content-type": "text/markdown; charset=utf-8" } });
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] make a project in the current folder (core/init.ts); never overwrites a file
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 serve [--port 4477]
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 found by walking up from the current folder to static-fire.yaml (core/project.ts).
22
- // Each command that needs one calls needProject() before it reads or writes anything; outside a project it
23
- // stops with an error that names `static-fire init`. The record store is the project's runs/static-fire.db,
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, projectRoot, type ProjectPaths } from "./core/project";
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
- /** The project of this command, or exit 1 with an error that names `static-fire init`, before anything is written. */
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
- return projectPaths(projectRoot());
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
- const { formatReport, initProject } = await import("./core/init");
68
- const report = initProject();
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 store = openStore(dbPath);
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 counts = await regrade(store, { tasks: list(values.tasks), configs: list(values.configs), batch: values.batch }, {
145
- dbPath, catalog: projectCatalog(root), judge: values.judge, votes: values.votes ? Number(values.votes) : undefined, onlyInvalid: values["only-invalid"],
146
- concurrency: Number(values.concurrency), onEvent: log,
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
- for (const { task, t } of ids) for (const c of plan.configs) for (let n = 1; n <= trialCount(c, task.id, plan.trials); n++) {
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,
@@ -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
- serve: { flags: ["port"] },
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"] },
@@ -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
- const { judge: _judge, ...rest } = filters;
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]));