@hackerrank/astra-cli 0.1.2 → 0.1.4

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
@@ -115,24 +115,69 @@ astra \
115
115
  --repeat 3 \
116
116
  --tar # write a bench/ tarball locally
117
117
 
118
- # push results to S3 (tars bench/ then `aws s3 cp` to the URI)
118
+ # push results to S3 (tars bench/ and uploads it + report.html to the URI)
119
119
  astra -m m1,m2 -p tasks/dummy-slugify --repeat 3 \
120
120
  --push s3://hackerrank-astra-bench-results-dev/runs/2025-09-02/
121
121
  ```
122
122
 
123
- Leaderboard columns: `solved` (pass rate), average `steps` / `tokens`, and
123
+ Leaderboard columns: completed generation rate, average `steps` / `tokens`, and
124
124
  `cost`. Cost is per-run summed; a model whose route reports no cost and has no
125
125
  price entry shows **`n/a`** (never `$0`), and estimated costs (native routes) are
126
126
  marked `~`. `--push` requires an `s3://` URI and the `aws` CLI on PATH; if the
127
127
  upload fails the local tarball is kept.
128
128
 
129
+ Next to the tarball, `--push` also uploads the self-contained `report.html`
130
+ dashboard with `Content-Type: text/html` and prints a **7-day presigned
131
+ "view report" link** at the end of the run. The bucket is private (no public
132
+ read), so that presigned URL is the only way to open the dashboard — e.g.
133
+ clickable straight from a Jenkins console log.
134
+
135
+ ### Project-configured bench runs
136
+
137
+ For repeatable benchmarks, keep the benchmark definition with the task and run
138
+ it through the existing bench persona:
139
+
140
+ ```bash
141
+ astra bench --project ./task
142
+ ```
143
+
144
+ `./task/.astra/project.toml` selects the public workspace, instruction,
145
+ models, limits, output root, and optional bench template/extensions. CLI flags
146
+ still override bench limits and output paths. Astra copies only the configured
147
+ workspace; container lifecycle, services, secrets, verification, and scoring
148
+ remain harness responsibilities. Run artifacts include `project.json` with the
149
+ project and input hashes. `completed` means the agent emitted its completion
150
+ sentinel—not that the candidate is correct; `model_error` and `request_error`
151
+ distinguish generation/provider failures from invalid invocation/configuration.
152
+
153
+ ```toml
154
+ [project]
155
+ id = "peoplecore-scim"
156
+ version = 1
157
+ instruction = "instruction.md"
158
+ workspace = "public/codebase"
159
+
160
+ [bench]
161
+ models = ["gpt-5.6-sol"]
162
+ reasoning = ["medium"]
163
+ repeat = 3
164
+ steps = 60
165
+ wall_seconds = 3600
166
+ command_timeout_seconds = 120
167
+
168
+ [templates]
169
+ bench = ".astra/templates/bench.md"
170
+ [extensions]
171
+ directories = [".astra/extensions"]
172
+ ```
173
+
129
174
  ### HTML report
130
175
 
131
176
  Every matrix run (and every single `-p`/`-t`/`-f` bench run) rebuilds
132
177
  `bench/summary.json` and a self-contained `bench/report.html` dashboard —
133
178
  no server, no build step, no external JS: open it straight from `file://` or
134
179
  from inside the `bench-*.tgz` tarball. It has a leaderboard, a model × task
135
- pass-rate heatmap, cost/token charts, per-step trend lines, and an
180
+ completion-rate heatmap, cost/token charts, per-step trend lines, and an
136
181
  expandable command timeline for every run.
137
182
 
138
183
  Regenerate it on demand (e.g. after manually editing/pruning `bench/`) with:
@@ -144,7 +189,7 @@ astra --report --bench-root ./other-bench
144
189
 
145
190
  `summary.json` follows the `astra-bench-1` schema: `kpis` (totals), a tidy
146
191
  `leaderboard[]` (one row per model×reasoning), `matrix[]` (model×reasoning×task
147
- pass@k), `runs[]` (every attempt, with a compact per-step `timeline`), and
192
+ completion telemetry), `runs[]` (every attempt, with a compact per-step `timeline`), and
148
193
  `step_series[]` (token/cost distributions by step, for the trend charts). Each
149
194
  run folder also gets a standalone `run.json` for drill-down without loading
150
195
  the full `trajectory.json`.
@@ -172,10 +217,13 @@ credits — that's a quota issue, not a bug.
172
217
  -t, --task <text> Task text -> autonomous mode
173
218
  -f, --task-file <path> Read task text from a file -> autonomous mode
174
219
  -p, --path <dir> Task directory -> isolated bench workspace
220
+ --project <path> Load .astra/project.toml for an explicit bench run
175
221
  --repeat <n> Attempts per (model,reasoning) in a matrix (default: 1)
176
222
  --bench-root <dir> Root folder for bench runs (default: ./bench)
177
223
  --tar After a matrix, write a bench/ tarball locally
178
- --push <s3-uri> After a matrix, tar bench/ and `aws s3 cp` to the URI
224
+ --push <s3-uri> After a matrix, tar bench/ and `aws s3 cp` it (plus
225
+ report.html) to the URI; prints presigned
226
+ download/view links
179
227
  --report Rebuild bench/summary.json + bench/report.html from
180
228
  whatever runs already exist on disk, then exit
181
229
  -C, --cwd <path> Working directory for commands (default: cwd)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackerrank/astra-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Minimal zero-dependency AI coding agent for the HackerRank AI Gateway.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,8 @@
16
16
  },
17
17
  "scripts": {
18
18
  "start": "node src/cli.js",
19
- "astra": "node src/cli.js"
19
+ "astra": "node src/cli.js",
20
+ "test": "node --test"
20
21
  },
21
22
  "publishConfig": {
22
23
  "registry": "https://registry.npmjs.org/",
@@ -26,5 +27,8 @@
26
27
  "type": "git",
27
28
  "url": "git+ssh://git@github.com/interviewstreet/hrbench.git"
28
29
  },
29
- "license": "MIT"
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "@iarna/toml": "2.2.5"
33
+ }
30
34
  }
package/src/bench.js CHANGED
@@ -30,7 +30,8 @@ export const METRIC_COLUMNS = [
30
30
  "model",
31
31
  "reasoning",
32
32
  "exit_status",
33
- "resolved",
33
+ "status",
34
+ "completed",
34
35
  "steps",
35
36
  "n_commands",
36
37
  "n_failed_commands",
@@ -104,6 +105,7 @@ export function allocateRun({ model, reasoning, root } = {}) {
104
105
  */
105
106
  export function seedWorkspace(workspace, { taskPath, taskFile, taskText } = {}) {
106
107
  let text = taskText ?? "";
108
+ const hasExplicitTask = taskText !== undefined && taskText !== null;
107
109
  let copyFrom = null;
108
110
  let instructionName = null;
109
111
 
@@ -114,7 +116,7 @@ export function seedWorkspace(workspace, { taskPath, taskFile, taskText } = {})
114
116
  copyFrom = resolved;
115
117
  // Prefer a conventional instruction file for the task text.
116
118
  for (const name of ["instruction.md", "INSTRUCTION.md", "task.md", "README.md"]) {
117
- if (fs.existsSync(path.join(resolved, name))) {
119
+ if (!hasExplicitTask && fs.existsSync(path.join(resolved, name))) {
118
120
  text = fs.readFileSync(path.join(resolved, name), "utf8");
119
121
  break;
120
122
  }
@@ -122,13 +124,13 @@ export function seedWorkspace(workspace, { taskPath, taskFile, taskText } = {})
122
124
  } else {
123
125
  copyFrom = path.dirname(resolved);
124
126
  instructionName = path.basename(resolved);
125
- text = fs.readFileSync(resolved, "utf8");
127
+ if (!hasExplicitTask) text = fs.readFileSync(resolved, "utf8");
126
128
  }
127
129
  } else if (taskFile) {
128
130
  const resolved = path.resolve(taskFile);
129
131
  copyFrom = path.dirname(resolved);
130
132
  instructionName = path.basename(resolved);
131
- text = fs.readFileSync(resolved, "utf8");
133
+ if (!hasExplicitTask) text = fs.readFileSync(resolved, "utf8");
132
134
  }
133
135
 
134
136
  if (copyFrom) copyDir(copyFrom, workspace);
@@ -162,13 +164,14 @@ function copyDir(from, to) {
162
164
  /** Build a metrics row object from a finished agent + run info. */
163
165
  export function collectMetrics({ agent, model, reasoning }) {
164
166
  const info = agent.serialize().info;
165
- const resolved = info.exit_status === "Submitted" ? 1 : 0;
167
+ const status = generationStatus(info.exit_status);
166
168
  return {
167
169
  timestamp: new Date().toISOString(),
168
170
  model: model.model,
169
171
  reasoning: reasoning || "none",
170
172
  exit_status: info.exit_status || "",
171
- resolved,
173
+ status,
174
+ completed: status === "completed" ? 1 : 0,
172
175
  steps: info.n_steps ?? 0,
173
176
  n_commands: agent.nCommands ?? 0,
174
177
  n_failed_commands: agent.nFailedCommands ?? 0,
@@ -188,6 +191,11 @@ export function collectMetrics({ agent, model, reasoning }) {
188
191
  };
189
192
  }
190
193
 
194
+ /** Classify autonomous generation without making a correctness claim. */
195
+ export function generationStatus(exitStatus) {
196
+ return exitStatus === "Submitted" ? "completed" : "model_error";
197
+ }
198
+
191
199
  /** Serialize a metrics row to a single CSV line (columns in METRIC_COLUMNS order). */
192
200
  export function metricsRow(m) {
193
201
  return METRIC_COLUMNS.map((c) => csvCell(m[c])).join(",");
@@ -219,6 +227,14 @@ export function writeMetrics({ runDir, root, metrics }) {
219
227
  return { runMetrics: path.join(runDir, "metrics.csv"), index: indexPath };
220
228
  }
221
229
 
230
+ /** Persist the project-owned inputs that defined a bench attempt. */
231
+ export function writeProjectMetadata(runDir, metadata) {
232
+ if (!metadata) return null;
233
+ const file = path.join(runDir, "project.json");
234
+ fs.writeFileSync(file, JSON.stringify({ schema: "astra-project-1", ...metadata }, null, 2) + "\n");
235
+ return file;
236
+ }
237
+
222
238
  function csvCell(v) {
223
239
  const s = v == null ? "" : String(v);
224
240
  return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
@@ -264,6 +280,7 @@ export async function runCell({
264
280
  task,
265
281
  taskPath,
266
282
  taskFile,
283
+ projectMetadata,
267
284
  root,
268
285
  steps = 40,
269
286
  wall = 0,
@@ -278,6 +295,7 @@ export async function runCell({
278
295
  const seeded = seedWorkspace(alloc.workspace, { taskPath, taskFile, taskText: task });
279
296
  const taskText = seeded.taskText || task || "";
280
297
  fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText);
298
+ writeProjectMetadata(alloc.dir, projectMetadata);
281
299
 
282
300
  const gw = new GatewayModel({
283
301
  model,
@@ -340,7 +358,7 @@ export async function runMatrix({
340
358
 
341
359
  /**
342
360
  * Aggregate metrics rows into a per-(model,reasoning) leaderboard.
343
- * `resolved` counts as a solve. Cost is summed only over runs that reported a
361
+ * `completed` counts a completed generation. Cost is summed only over runs that reported a
344
362
  * cost; a group with no cost data is reported as null (rendered "n/a"), never $0.
345
363
  */
346
364
  export function aggregate(rows) {
@@ -352,7 +370,7 @@ export function aggregate(rows) {
352
370
  model: r.model,
353
371
  reasoning: r.reasoning || "none",
354
372
  runs: 0,
355
- solved: 0,
373
+ completed: 0,
356
374
  steps: 0,
357
375
  totalTokens: 0,
358
376
  costUsd: 0,
@@ -362,7 +380,7 @@ export function aggregate(rows) {
362
380
  }
363
381
  const g = groups.get(key);
364
382
  g.runs++;
365
- g.solved += Number(r.resolved) ? 1 : 0;
383
+ g.completed += Number(r.completed) ? 1 : 0;
366
384
  g.steps += Number(r.steps) || 0;
367
385
  g.totalTokens += Number(r.total_tokens) || 0;
368
386
  if (r.cost_source && r.cost_source !== "") {
@@ -375,8 +393,8 @@ export function aggregate(rows) {
375
393
  model: g.model,
376
394
  reasoning: g.reasoning,
377
395
  runs: g.runs,
378
- solved: g.solved,
379
- solved_rate: g.runs ? g.solved / g.runs : 0,
396
+ completed: g.completed,
397
+ completion_rate: g.runs ? g.completed / g.runs : 0,
380
398
  avg_steps: g.runs ? g.steps / g.runs : 0,
381
399
  avg_tokens: g.runs ? g.totalTokens / g.runs : 0,
382
400
  cost_usd: g.costRuns ? g.costUsd : null,
@@ -455,17 +473,38 @@ function s3ConsoleUrl(s3Uri) {
455
473
  return `https://s3.console.aws.amazon.com/s3/object/${bucket}?prefix=${encodeURIComponent(key)}`;
456
474
  }
457
475
 
476
+ /**
477
+ * Generate a time-limited presigned URL for an S3 object via the AWS CLI
478
+ * (`aws s3 presign`). `expiresIn` defaults to 7 days (604800s). Returns null
479
+ * (rather than throwing) if the AWS CLI is missing or presigning fails, so
480
+ * a presign failure never blocks the run.
481
+ */
482
+ export function presignS3Url(s3Uri, expiresIn = 604800) {
483
+ const res = spawnSync(
484
+ "aws",
485
+ ["s3", "presign", s3Uri, "--expires-in", String(expiresIn)],
486
+ { stdio: ["ignore", "pipe", "pipe"] }
487
+ );
488
+ if (res.error && res.error.code === "ENOENT") return null;
489
+ if (res.status !== 0) return null;
490
+ return (res.stdout?.toString() || "").trim() || null;
491
+ }
492
+
458
493
  /**
459
494
  * Upload a file to S3 via the AWS CLI. Returns { uri, url } where `uri` is the
460
495
  * final s3:// destination and `url` is a browsable https console link.
461
- * Fails gracefully (throws a descriptive error) when the AWS CLI is missing or
462
- * the copy fails; callers should keep the local tarball on failure.
496
+ * Optionally sets the object's Content-Type (e.g. "text/html" so presigned
497
+ * URLs open in a browser instead of downloading). Fails gracefully (throws a
498
+ * descriptive error) when the AWS CLI is missing or the copy fails; callers
499
+ * should keep the local tarball on failure.
463
500
  */
464
- export function pushToS3(file, s3Uri) {
501
+ export function pushToS3(file, s3Uri, { contentType } = {}) {
465
502
  if (!/^s3:\/\//.test(s3Uri)) throw new Error(`--push destination must be an s3:// URI, got: ${s3Uri}`);
466
503
  // If the URI ends with "/", upload into it under the file's basename.
467
504
  const dest = s3Uri.endsWith("/") ? s3Uri + path.basename(file) : s3Uri;
468
- const res = spawnSync("aws", ["s3", "cp", file, dest], {
505
+ const cmd = ["s3", "cp", file, dest];
506
+ if (contentType) cmd.push("--content-type", contentType);
507
+ const res = spawnSync("aws", cmd, {
469
508
  stdio: ["ignore", "pipe", "pipe"],
470
509
  });
471
510
  if (res.error && res.error.code === "ENOENT") {
@@ -474,5 +513,5 @@ export function pushToS3(file, s3Uri) {
474
513
  if (res.status !== 0) {
475
514
  throw new Error(`aws s3 cp failed: ${res.stderr?.toString() || "unknown"}`);
476
515
  }
477
- return { uri: dest, url: s3ConsoleUrl(dest) };
516
+ return { uri: dest, url: s3ConsoleUrl(dest), presigned: presignS3Url(dest) };
478
517
  }
package/src/cli.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * astra -m <model> -t "<task text>" bench run from task text
17
17
  * astra -m <model> -f path/to/instruction.md
18
18
  * astra -m <model> -p tasks/dummy-slugify bench run from a task directory
19
+ * astra bench --project ./task project-configured bench run
19
20
  * astra --resume <session-id> resume a saved session
20
21
  * astra --sessions list saved sessions
21
22
  *
@@ -28,13 +29,15 @@
28
29
  * comma-separated to sweep levels in bench mode).
29
30
  * Sent to the model and recorded in the bench name.
30
31
  * --repeat <n> Attempts per (model,reasoning) in a matrix (1)
31
- * --push After a matrix, tar bench/ and upload to
32
- * s3://astra-bench-results/<timestamp>/
32
+ * --push After a matrix, tar bench/ and upload it (plus
33
+ * report.html) to s3://astra-bench-results/<timestamp>/;
34
+ * prints presigned download/view links
33
35
  * --push-uri <s3-uri> Override the S3 push destination
34
36
  * --tar After a matrix, write a bench/ tarball locally
35
37
  * --report Rebuild bench/summary.json + bench/report.html
36
38
  * from whatever runs already exist on disk, then
37
39
  * exit (no model/API key needed).
40
+ * --project <path> Load .astra/project.toml and run its bench setup
38
41
  * -t, --task <text> Task text -> bench mode (run to completion)
39
42
  * -f, --task-file <path> Read task text from a file -> bench mode
40
43
  * -p, --path <dir> Task directory (copied into an isolated bench
@@ -85,11 +88,13 @@ import {
85
88
  deriveTitle,
86
89
  } from "./session.js";
87
90
  import { runRepl } from "./repl.js";
91
+ import { ProjectConfigError, loadProject } from "./project.js";
88
92
  import {
89
93
  allocateRun,
90
94
  seedWorkspace,
91
95
  collectMetrics,
92
96
  writeMetrics,
97
+ writeProjectMetadata,
93
98
  runMatrix,
94
99
  aggregate,
95
100
  packBench,
@@ -108,7 +113,7 @@ function reasoningKwargs(level) {
108
113
  }
109
114
 
110
115
  function parseArgs(argv) {
111
- const args = { steps: 40, wall: 0, timeout: 60, quiet: false, "max-output": 16000 };
116
+ const args = { steps: 40, wall: 0, timeout: 60, quiet: false, "max-output": 16000, _provided: new Set() };
112
117
  const alias = {
113
118
  "-m": "model", "--model": "model",
114
119
  "-t": "task", "--task": "task",
@@ -120,6 +125,7 @@ function parseArgs(argv) {
120
125
  "--push-uri": "push-uri",
121
126
  "--tar": "tar",
122
127
  "--report": "report",
128
+ "--project": "project",
123
129
  "--bench-root": "bench-root",
124
130
  "-C": "cwd", "--cwd": "cwd",
125
131
  "-o": "output", "--output": "output",
@@ -136,11 +142,17 @@ function parseArgs(argv) {
136
142
  "-h": "help", "--help": "help",
137
143
  };
138
144
  const flags = new Set(["quiet", "yolo", "help", "sessions", "tar", "push", "report"]);
139
- for (let i = 2; i < argv.length; i++) {
145
+ let start = 2;
146
+ if (argv[start] === "bench") {
147
+ args.bench = true;
148
+ start++;
149
+ }
150
+ for (let i = start; i < argv.length; i++) {
140
151
  const key = alias[argv[i]];
141
152
  if (!key) { console.error(`Unknown option: ${argv[i]}`); process.exit(2); }
142
153
  if (flags.has(key)) { args[key] = true; continue; }
143
154
  args[key] = argv[++i];
155
+ args._provided.add(key);
144
156
  }
145
157
  return args;
146
158
  }
@@ -182,11 +194,28 @@ async function main() {
182
194
  resumeDoc = loadSession(args.resume);
183
195
  }
184
196
 
185
- // A task (-t/-f) or task path (-p) selects bench mode; otherwise agent mode.
197
+ // A task (-t/-f), task path (-p), or explicit bench project selects bench mode.
186
198
  // Bench mode uses the autonomous engine; agent mode uses the interactive one.
187
199
  let task = args.task;
188
200
  if (args["task-file"]) task = fs.readFileSync(args["task-file"], "utf8");
189
- const wantsBench = !!(task || args.path);
201
+ let project = null;
202
+ if (args.project) {
203
+ try {
204
+ project = loadProject(args.project);
205
+ } catch (error) {
206
+ const message = error instanceof ProjectConfigError ? error.message : String(error?.message || error);
207
+ console.error(`\x1b[31m[astra] request_error: ${message}\x1b[0m`);
208
+ process.exit(2);
209
+ }
210
+ task = project.taskText;
211
+ args.path = project.workspace;
212
+ if (!args._provided.has("bench-root")) args["bench-root"] = project.outputRoot;
213
+ if (!args._provided.has("steps")) args.steps = project.bench.steps;
214
+ if (!args._provided.has("wall")) args.wall = project.bench.wall;
215
+ if (!args._provided.has("timeout")) args.timeout = project.bench.timeout;
216
+ if (!args._provided.has("repeat")) args.repeat = project.bench.repeat;
217
+ }
218
+ const wantsBench = !!(task || args.path || args.bench);
190
219
  const mode = wantsBench
191
220
  ? "autonomous"
192
221
  : resumeDoc?.info?.mode === "autonomous"
@@ -235,6 +264,8 @@ async function main() {
235
264
  let reasoning = args.reasoning || "";
236
265
 
237
266
  if (mode === "autonomous") {
267
+ if (project && !modelId) modelId = project.bench.models.join(",");
268
+ if (project && !reasoning) reasoning = project.bench.reasoning.join(",");
238
269
  if (!modelId) {
239
270
  console.error("\x1b[31m[astra] --model is required for bench mode.\x1b[0m\n");
240
271
  console.log(HELP);
@@ -253,7 +284,7 @@ async function main() {
253
284
  const isMatrix = models.length > 1 || reasonings.length > 1 || repeat > 1;
254
285
 
255
286
  if (isMatrix && !resumeDoc) {
256
- await runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, quiet: !!args.quiet });
287
+ await runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, project, quiet: !!args.quiet });
257
288
  return; // runBenchMatrix exits the process
258
289
  }
259
290
  } else {
@@ -309,6 +340,7 @@ async function main() {
309
340
  task = seeded.taskText || task || "";
310
341
  // Persist the task text alongside the run for reproducibility.
311
342
  fs.writeFileSync(path.join(benchRun.dir, "task.md"), task || "");
343
+ writeProjectMetadata(benchRun.dir, project?.metadata);
312
344
  }
313
345
 
314
346
  const cmdCwd = benchRun ? benchRun.workspace : cwd;
@@ -365,6 +397,12 @@ async function main() {
365
397
  const metrics = collectMetrics({ agent: benchAgent, model, reasoning });
366
398
  const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
367
399
  log(`[astra] bench done: exit=${res.exit_status} · metrics → ${paths.runMetrics}`);
400
+ try {
401
+ const report = refreshReport(alloc.root);
402
+ log(`[astra] report → ${report.html}`);
403
+ } catch (err) {
404
+ log(`[astra] report generation skipped: ${err.message}`);
405
+ }
368
406
  };
369
407
  await runRepl(agent, { model, autoRun: !!args.yolo, fresh: !resumeDoc, reasoning, runBench });
370
408
  process.exit(0);
@@ -465,6 +503,30 @@ function archiveAndPush(args, root, links) {
465
503
  const pushed = pushToS3(tarball, dest);
466
504
  links.push(["s3 uri", pushed.uri]);
467
505
  links.push(["s3 url", pushed.url]);
506
+ if (pushed.presigned) {
507
+ links.push(["download (presigned, 7d)", pushed.presigned]);
508
+ }
509
+ // Also push the self-contained HTML dashboard next to the tarball and
510
+ // emit a presigned view link: the bucket is private (no public read),
511
+ // so the presigned URL is the only way to open the report — e.g. by
512
+ // clicking it straight from the Jenkins console output. Uploaded with
513
+ // Content-Type: text/html so browsers render it instead of downloading.
514
+ const reportHtml = path.join(benchRoot(root), "report.html");
515
+ if (fs.existsSync(reportHtml)) {
516
+ const htmlDest = dest.endsWith("/")
517
+ ? dest + "report.html"
518
+ : dest.slice(0, dest.lastIndexOf("/") + 1) + "report.html";
519
+ try {
520
+ const html = pushToS3(reportHtml, htmlDest, { contentType: "text/html" });
521
+ if (html.presigned) {
522
+ links.push(["view report (presigned, 7d)", html.presigned]);
523
+ } else {
524
+ links.push(["report (s3 url)", html.url]);
525
+ }
526
+ } catch (err) {
527
+ console.error(`\x1b[31m[astra] S3 report push failed: ${err.message}\x1b[0m`);
528
+ }
529
+ }
468
530
  } catch (err) {
469
531
  console.error(`\x1b[31m[astra] S3 push failed: ${err.message}\x1b[0m`);
470
532
  console.error(`\x1b[33m[astra] tarball kept locally: ${tarball}\x1b[0m`);
@@ -479,7 +541,7 @@ function archiveAndPush(args, root, links) {
479
541
  * Run a multi-model / multi-reasoning / repeated bench matrix, print a
480
542
  * leaderboard, optionally tar + upload the bench/ folder, then exit.
481
543
  */
482
- async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, quiet }) {
544
+ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, project, quiet }) {
483
545
  const root = args["bench-root"];
484
546
  const total = models.length * reasonings.length * repeat;
485
547
  console.error(
@@ -497,6 +559,7 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
497
559
  task,
498
560
  taskPath: args.path,
499
561
  taskFile: args["task-file"],
562
+ projectMetadata: project?.metadata,
500
563
  root,
501
564
  steps: Number(args.steps),
502
565
  wall: Number(args.wall),
@@ -509,8 +572,8 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
509
572
  }
510
573
  },
511
574
  onDone: (r) => {
512
- const mark = r.resolved ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
513
- const status = r.error ? `Error: ${r.error.split("\n")[0]}` : r.exit_status;
575
+ const mark = r.completed ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
576
+ const status = r.error ? `model_error: ${r.error.split("\n")[0]}` : r.status;
514
577
  console.error(
515
578
  ` ${mark} ${String(r.model).padEnd(20)} ${String(r.reasoning).padEnd(8)} ` +
516
579
  `${String(status).padEnd(16)} ${String(r.steps).padStart(3)} steps · ` +
@@ -539,8 +602,8 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
539
602
  // Tar + optional S3 push of the whole bench/ folder.
540
603
  archiveAndPush(args, root, links);
541
604
 
542
- const solved = rows.filter((r) => r.resolved).length;
543
- console.error(`\n\x1b[1m[astra] matrix done · ${solved}/${rows.length} resolved\x1b[0m`);
605
+ const completed = rows.filter((r) => r.completed).length;
606
+ console.error(`\n\x1b[1m[astra] matrix done · ${completed}/${rows.length} completed\x1b[0m`);
544
607
 
545
608
  // Print the important URLs / paths, aligned, at the very end.
546
609
  const w = Math.max(...links.map(([k]) => k.length));
@@ -549,23 +612,23 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
549
612
  console.error(`\x1b[1m ${k.padEnd(w)}\x1b[0m ${v}`);
550
613
  }
551
614
 
552
- process.exit(solved === rows.length ? 0 : 1);
615
+ process.exit(completed === rows.length ? 0 : 1);
553
616
  }
554
617
 
555
618
  /** Print the per-(model,reasoning) leaderboard table. */
556
619
  function printLeaderboard(board) {
557
- board.sort((a, b) => b.solved_rate - a.solved_rate || a.avg_steps - b.avg_steps);
620
+ board.sort((a, b) => b.completion_rate - a.completion_rate || a.avg_steps - b.avg_steps);
558
621
  console.error(
559
- `\n\x1b[1m${"model".padEnd(20)} ${"reason".padEnd(8)} ${"solved".padEnd(8)} ` +
622
+ `\n\x1b[1m${"model".padEnd(20)} ${"reason".padEnd(8)} ${"complete".padEnd(8)} ` +
560
623
  `${"steps".padStart(6)} ${"tokens".padStart(9)} ${"cost".padStart(9)} source\x1b[0m`
561
624
  );
562
625
  for (const g of board) {
563
- const solved = `${g.solved}/${g.runs}`;
564
- const rate = `${Math.round(g.solved_rate * 100)}%`;
626
+ const completed = `${g.completed}/${g.runs}`;
627
+ const rate = `${Math.round(g.completion_rate * 100)}%`;
565
628
  const cost = g.cost_usd == null ? "n/a" : fmtUsd(g.cost_usd);
566
629
  console.error(
567
630
  `${String(g.model).padEnd(20)} ${String(g.reasoning).padEnd(8)} ` +
568
- `${(solved + " " + rate).padEnd(8)} ${g.avg_steps.toFixed(1).padStart(6)} ` +
631
+ `${(completed + " " + rate).padEnd(8)} ${g.avg_steps.toFixed(1).padStart(6)} ` +
569
632
  `${fmt(Math.round(g.avg_tokens)).padStart(9)} ${cost.padStart(9)} ${g.cost_source}`
570
633
  );
571
634
  }
package/src/project.js ADDED
@@ -0,0 +1,177 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import toml from "@iarna/toml";
5
+
6
+ export class ProjectConfigError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "ProjectConfigError";
10
+ }
11
+ }
12
+
13
+ const DEFAULT_BENCH = {
14
+ models: [],
15
+ reasoning: [""],
16
+ repeat: 1,
17
+ steps: 40,
18
+ wall_seconds: 0,
19
+ command_timeout_seconds: 60,
20
+ };
21
+
22
+ function requireObject(value, name) {
23
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
24
+ throw new ProjectConfigError(`${name} must be a TOML table`);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ function requireString(value, name) {
30
+ if (typeof value !== "string" || !value.trim()) {
31
+ throw new ProjectConfigError(`${name} must be a non-empty string`);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ function positiveInteger(value, name, fallback, { allowZero = false } = {}) {
37
+ if (value === undefined) return fallback;
38
+ if (!Number.isInteger(value) || value < (allowZero ? 0 : 1)) {
39
+ throw new ProjectConfigError(`${name} must be ${allowZero ? "a non-negative" : "a positive"} integer`);
40
+ }
41
+ return value;
42
+ }
43
+
44
+ function stringArray(value, name, fallback) {
45
+ if (value === undefined) return fallback;
46
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) {
47
+ throw new ProjectConfigError(`${name} must be an array of non-empty strings`);
48
+ }
49
+ return value;
50
+ }
51
+
52
+ function resolveInside(root, relative, name) {
53
+ const candidate = path.resolve(root, requireString(relative, name));
54
+ if (candidate !== root && !candidate.startsWith(root + path.sep)) {
55
+ throw new ProjectConfigError(`${name} must stay inside the project directory`);
56
+ }
57
+ return candidate;
58
+ }
59
+
60
+ function readRegularFile(file, name, { optional = false } = {}) {
61
+ if (!fs.existsSync(file)) {
62
+ if (optional) return null;
63
+ throw new ProjectConfigError(`${name} does not exist: ${file}`);
64
+ }
65
+ const stat = fs.lstatSync(file);
66
+ if (!stat.isFile() || stat.isSymbolicLink()) {
67
+ throw new ProjectConfigError(`${name} must be a regular file: ${file}`);
68
+ }
69
+ return fs.readFileSync(file, "utf8");
70
+ }
71
+
72
+ function sha256(value) {
73
+ return createHash("sha256").update(value).digest("hex");
74
+ }
75
+
76
+ function extensionFiles(root, directories) {
77
+ const seen = new Set();
78
+ const extensions = [];
79
+ for (const directory of directories) {
80
+ const resolved = resolveInside(root, directory, "extensions.directories entry");
81
+ if (!fs.existsSync(resolved)) {
82
+ throw new ProjectConfigError(`extension directory does not exist: ${resolved}`);
83
+ }
84
+ const stat = fs.lstatSync(resolved);
85
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
86
+ throw new ProjectConfigError(`extension directory must be a regular directory: ${resolved}`);
87
+ }
88
+ for (const entry of fs.readdirSync(resolved, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
89
+ if (!entry.isFile() || entry.isSymbolicLink()) continue;
90
+ const file = path.join(resolved, entry.name);
91
+ if (seen.has(file)) throw new ProjectConfigError(`duplicate extension path: ${file}`);
92
+ seen.add(file);
93
+ const content = readRegularFile(file, "extension");
94
+ extensions.push({ path: file, content, sha256: sha256(content) });
95
+ }
96
+ }
97
+ return extensions;
98
+ }
99
+
100
+ export function loadProject(projectPath) {
101
+ const root = path.resolve(requireString(projectPath, "project path"));
102
+ if (!fs.existsSync(root) || !fs.lstatSync(root).isDirectory()) {
103
+ throw new ProjectConfigError(`project directory does not exist: ${root}`);
104
+ }
105
+
106
+ const configPath = path.join(root, ".astra", "project.toml");
107
+ const configText = readRegularFile(configPath, "project configuration");
108
+ let config;
109
+ try {
110
+ config = toml.parse(configText);
111
+ } catch (error) {
112
+ throw new ProjectConfigError(`invalid project TOML: ${error.message}`);
113
+ }
114
+
115
+ const project = requireObject(config.project, "project");
116
+ const bench = requireObject(config.bench ?? {}, "bench");
117
+ const output = requireObject(config.output ?? {}, "output");
118
+ const templates = requireObject(config.templates ?? {}, "templates");
119
+ const extensionConfig = requireObject(config.extensions ?? {}, "extensions");
120
+ const provenance = requireObject(config.provenance ?? {}, "provenance");
121
+ const id = requireString(project.id, "project.id");
122
+ const version = positiveInteger(project.version, "project.version", undefined);
123
+
124
+ const instructionPath = resolveInside(root, project.instruction, "project.instruction");
125
+ const workspace = resolveInside(root, project.workspace, "project.workspace");
126
+ const instruction = readRegularFile(instructionPath, "instruction");
127
+ if (!instruction.trim()) throw new ProjectConfigError("instruction must not be empty");
128
+ if (!fs.existsSync(workspace) || !fs.lstatSync(workspace).isDirectory() || fs.lstatSync(workspace).isSymbolicLink()) {
129
+ throw new ProjectConfigError(`project.workspace must be a regular directory: ${workspace}`);
130
+ }
131
+
132
+ const templatePath = templates.bench === undefined ? null : resolveInside(root, templates.bench, "templates.bench");
133
+ const template = templatePath === null ? null : readRegularFile(templatePath, "bench template");
134
+ const extensions = extensionFiles(root, stringArray(extensionConfig.directories, "extensions.directories", []));
135
+ const taskText = [instruction, template, ...extensions.map((extension) => extension.content)]
136
+ .filter(Boolean)
137
+ .join("\n\n");
138
+
139
+ const baselineSha256 = provenance.baseline_sha256;
140
+ if (baselineSha256 !== undefined && (typeof baselineSha256 !== "string" || !/^[a-f0-9]{64}$/.test(baselineSha256))) {
141
+ throw new ProjectConfigError("provenance.baseline_sha256 must be a lowercase SHA-256 hash");
142
+ }
143
+
144
+ return {
145
+ root,
146
+ id,
147
+ version,
148
+ instructionPath,
149
+ instruction,
150
+ instructionSha256: sha256(instruction),
151
+ workspace,
152
+ outputRoot: resolveInside(root, output.root ?? "bench", "output.root"),
153
+ template: template === null ? null : { path: templatePath, content: template, sha256: sha256(template) },
154
+ extensions,
155
+ taskText,
156
+ provenance: { baselineSha256: baselineSha256 ?? null },
157
+ metadata: {
158
+ id,
159
+ version,
160
+ instruction_sha256: sha256(instruction),
161
+ baseline_sha256: baselineSha256 ?? null,
162
+ template_sha256: template === null ? null : sha256(template),
163
+ extension_sha256: extensions.map((extension) => ({
164
+ path: path.relative(root, extension.path),
165
+ sha256: extension.sha256,
166
+ })),
167
+ },
168
+ bench: {
169
+ models: stringArray(bench.models, "bench.models", DEFAULT_BENCH.models),
170
+ reasoning: stringArray(bench.reasoning, "bench.reasoning", DEFAULT_BENCH.reasoning),
171
+ repeat: positiveInteger(bench.repeat, "bench.repeat", DEFAULT_BENCH.repeat),
172
+ steps: positiveInteger(bench.steps, "bench.steps", DEFAULT_BENCH.steps),
173
+ wall: positiveInteger(bench.wall_seconds, "bench.wall_seconds", DEFAULT_BENCH.wall_seconds, { allowZero: true }),
174
+ timeout: positiveInteger(bench.command_timeout_seconds, "bench.command_timeout_seconds", DEFAULT_BENCH.command_timeout_seconds),
175
+ },
176
+ };
177
+ }
package/src/report.html CHANGED
@@ -4,41 +4,15 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>astra bench report</title>
7
+ <link rel="stylesheet" href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700&display=swap" />
7
8
  <style>
8
- :root {
9
- --bg: #0f1115;
10
- --panel: #171a21;
11
- --panel-2: #1d212b;
12
- --border: #262b36;
13
- --text: #e6e8ee;
14
- --muted: #8b93a7;
15
- --accent: #5b9dff;
16
- --good: #3ecf8e;
17
- --bad: #ef5a6f;
18
- --warn: #e8b339;
19
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
20
- }
21
- * { box-sizing: border-box; }
22
- body { margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.45; }
23
- a { color: var(--accent); }
24
- .wrap { max-width: 1180px; margin: 0 auto; padding: 28px 20px 64px; }
25
- header.top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; flex-wrap: wrap; gap: 8px; }
26
- header.top h1 { font-size: 20px; margin: 0; font-weight: 650; letter-spacing: .2px; }
27
- header.top .meta { color: var(--muted); font-size: 12.5px; }
28
- .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin: 20px 0 28px; }
29
- .kpi { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; }
30
- .kpi .v { font-size: 22px; font-weight: 700; }
31
- .kpi .l { color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; margin-top: 2px; }
32
- section { margin: 34px 0; }
33
- section > h2 { font-size: 15px; margin: 0 0 12px; font-weight: 650; color: var(--text); display: flex; align-items: center; gap: 8px; }
34
- section > h2 .sub { color: var(--muted); font-weight: 400; font-size: 12px; }
35
- .grid2 { display: grid; grid-template-columns: 1.1fr 1fr; gap: 16px; }
36
- .grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
37
- @media (max-width: 900px) { .grid2, .grid3 { grid-template-columns: 1fr; } }
38
- .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
39
- .panel h3 { margin: 0 0 10px; font-size: 12.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .05em; }
9
+ :root { --ink:#11131a; --muted:#69707d; --wash:#f5f6f8; --line:#e4e7eb; --lime:#a7ff63; --blue:#5285f7; --purple:#9160d8; --green:#39ae76; --orange:#f3a021; --red:#d85668; font-family:Satoshi,"Avenir Next",Arial,sans-serif; }
10
+ * { box-sizing:border-box; } body { margin:0; background:#fff; color:var(--ink); font-size:14px; line-height:1.45; } .wrap { max-width:1240px; margin:auto; padding:28px 32px 68px; }
11
+ header.top { display:flex; justify-content:space-between; gap:20px; align-items:flex-start; padding-bottom:28px; border-bottom:1px solid var(--line); } header.top h1 { font-size:32px; letter-spacing:-.045em; line-height:1; margin:8px 0 0; font-weight:700; } .eyebrow { display:inline-block; padding:5px 14px; border-radius:99px; background:var(--lime); font-size:12px; font-weight:700; } header.top .meta { color:var(--muted); font-size:12px; text-align:right; }
12
+ .kpis { display:grid; grid-template-columns:repeat(4,1fr); margin:24px 0 52px; } .kpi { min-height:96px; padding:8px 18px; border-left:1px solid var(--line); } .kpi:first-child { border:0; padding-left:0; } .kpi .v { font-size:28px; letter-spacing:-.045em; font-weight:700; } .kpi .l { color:var(--muted); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; margin-top:8px; }
13
+ section { margin:56px 0; } section > h2 { font-size:20px; letter-spacing:-.035em; margin:0 0 22px; } section > h2 .sub { color:var(--muted); font-size:13px; font-weight:400; letter-spacing:0; } .grid2 { display:grid; grid-template-columns:1.1fr 1fr; gap:44px; } .panel { padding:0; } .panel h3 { color:var(--muted); letter-spacing:.07em; text-transform:uppercase; font-size:12px; margin:0 0 16px; }
40
14
  table { width: 100%; border-collapse: collapse; font-size: 13px; }
41
- th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
15
+ th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; }
42
16
  th { color: var(--muted); font-weight: 600; font-size: 11.5px; text-transform: uppercase; letter-spacing: .03em; cursor: pointer; user-select: none; }
43
17
  th:hover { color: var(--text); }
44
18
  th .arrow { opacity: .5; font-size: 10px; margin-left: 3px; }
@@ -52,7 +26,7 @@
52
26
  .muted { color: var(--muted); }
53
27
  .bar-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
54
28
  .bar-row .lbl { width: 150px; flex: 0 0 150px; font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
55
- .bar-track { flex: 1; height: 14px; background: var(--panel-2); border-radius: 6px; overflow: hidden; display: flex; }
29
+ .bar-track { flex: 1; height: 9px; background: var(--wash); border-radius: 99px; overflow: hidden; display: flex; }
56
30
  .bar-seg { height: 100%; }
57
31
  .bar-val { width: 64px; flex: 0 0 64px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
58
32
  .legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; font-size: 11.5px; color: var(--muted); }
@@ -61,7 +35,7 @@
61
35
  .heat-cell { display: inline-block; min-width: 44px; padding: 3px 6px; border-radius: 5px; font-size: 12px; }
62
36
  details.run-detail { margin: 0; }
63
37
  details.run-detail > summary { display: none; }
64
- .timeline { margin: 0 0 18px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
38
+ .timeline { margin: 0 0 18px; background:var(--wash); padding:0 14px; overflow: hidden; }
65
39
  .timeline .step { padding: 10px 14px; border-bottom: 1px solid var(--border); }
66
40
  .timeline .step:last-child { border-bottom: none; }
67
41
  .timeline .step .head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; margin-bottom: 4px; }
@@ -71,35 +45,30 @@
71
45
  .timeline .step pre { background: var(--panel-2); border-radius: 6px; padding: 8px 10px; margin: 4px 0; font-size: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
72
46
  .rc0 { color: var(--good); } .rcN { color: var(--bad); }
73
47
  .toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
74
- .toolbar select, .toolbar input { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 9px; font-size: 12.5px; }
48
+ .toolbar select, .toolbar input { background: var(--wash); border:0; color:var(--ink); border-radius:4px; padding:7px 9px; font-size:12px; } .model-filter { align-items:end; } .model-filter > span { color:var(--muted); font-size:11px; } .model-menu { position:relative; } .model-menu summary { cursor:pointer; list-style:none; min-width:220px; padding:9px 32px 9px 11px; background:var(--wash); border-radius:5px; font-size:12px; position:relative; } .model-menu summary::-webkit-details-marker { display:none; } .model-menu summary::after { content:"⌄"; position:absolute; right:11px; color:var(--muted); } .model-options { position:absolute; z-index:2; top:calc(100% + 6px); left:0; width:260px; max-height:240px; overflow:auto; padding:6px; background:#fff; border:1px solid var(--line); box-shadow:0 10px 24px rgba(17,19,26,.1); } .model-option { display:flex; gap:9px; align-items:center; padding:8px; font-size:12px; cursor:pointer; } .model-option:hover { background:var(--wash); } .model-option input { accent-color:var(--blue); }
75
49
  .toolbar label { color: var(--muted); font-size: 12px; }
76
50
  footer { color: var(--muted); font-size: 11.5px; margin-top: 40px; text-align: center; }
77
51
  svg text { fill: var(--muted); font-size: 10.5px; }
78
- .axis line, .axis path { stroke: var(--border); }
52
+ .axis line, .axis path { stroke: var(--line); }
79
53
  .n-a { color: var(--muted); }
80
54
  </style>
81
55
  </head>
82
56
  <body>
83
57
  <div class="wrap">
84
58
  <header class="top">
85
- <h1>astra bench report</h1>
86
- <div class="meta" id="generated"></div>
59
+ <div><h1>Benchmark report</h1><div class="meta" id="generated"></div></div>
87
60
  </header>
88
61
 
89
62
  <div class="kpis" id="kpis"></div>
90
63
 
91
64
  <section>
92
- <h2>Leaderboard <span class="sub">one row per model · reasoning</span></h2>
93
- <div class="grid2">
94
- <div class="panel">
95
- <h3>Solve rate</h3>
96
- <div id="chart-solve"></div>
97
- </div>
98
- <div class="panel">
99
- <h3>Outcomes</h3>
100
- <div id="chart-outcomes"></div>
101
- <div class="legend" id="legend-outcomes"></div>
102
- </div>
65
+ <h2>Performance <span class="sub">run volume and reliability by model</span></h2>
66
+ <div class="toolbar model-filter">
67
+ <span>Models</span>
68
+ <details class="model-menu">
69
+ <summary id="model-filter-label">All models</summary>
70
+ <div id="leaderboard-model-options" class="model-options"></div>
71
+ </details>
103
72
  </div>
104
73
  <div class="panel" style="margin-top:16px;">
105
74
  <table id="tbl-leaderboard"></table>
@@ -107,22 +76,29 @@
107
76
  </section>
108
77
 
109
78
  <section>
110
- <h2>Cost &amp; tokens</h2>
79
+ <h2>Efficiency <span class="sub">spend and token composition</span></h2>
111
80
  <div class="grid2">
112
81
  <div class="panel">
113
- <h3>Cost vs. solve rate</h3>
82
+ <h3>Cost vs. average duration</h3>
114
83
  <div id="chart-scatter"></div>
115
84
  </div>
116
85
  <div class="panel">
117
- <h3>Token mix (prompt / completion / reasoning / cached)</h3>
118
- <div id="chart-tokens"></div>
119
- <div class="legend" id="legend-tokens"></div>
86
+ <h3>Cost vs. average steps</h3>
87
+ <div id="chart-scatter-steps"></div>
120
88
  </div>
121
89
  </div>
122
90
  </section>
123
91
 
92
+ <section>
93
+ <h2>Token mix <span class="sub">prompt, completion, reasoning, and cached tokens</span></h2>
94
+ <div class="panel">
95
+ <div id="chart-tokens"></div>
96
+ <div class="legend" id="legend-tokens"></div>
97
+ </div>
98
+ </section>
99
+
124
100
  <section id="section-series">
125
- <h2>Per-step trend <span class="sub">median tokens &amp; cumulative cost across the trajectory</span></h2>
101
+ <h2>Trajectory <span class="sub">where context and cost accumulate</span></h2>
126
102
  <div class="grid2">
127
103
  <div class="panel">
128
104
  <h3>Prompt tokens (context) vs. step</h3>
@@ -136,31 +112,6 @@
136
112
  <div class="legend" id="legend-series"></div>
137
113
  </section>
138
114
 
139
- <section id="section-matrix">
140
- <h2>Model &times; task <span class="sub">pass@k</span></h2>
141
- <div class="panel">
142
- <table id="tbl-matrix" class="heat-table"></table>
143
- </div>
144
- </section>
145
-
146
- <section>
147
- <h2>Runs <span class="sub">every attempt · click a row to expand the command timeline</span></h2>
148
- <div class="toolbar">
149
- <label>Model
150
- <select id="filter-model"><option value="">all</option></select>
151
- </label>
152
- <label>Task
153
- <select id="filter-task"><option value="">all</option></select>
154
- </label>
155
- <label>Outcome
156
- <select id="filter-outcome"><option value="">all</option></select>
157
- </label>
158
- </div>
159
- <div class="panel">
160
- <table id="tbl-runs"></table>
161
- </div>
162
- </section>
163
-
164
115
  <footer>generated by <span class="mono">astra bench --report</span> · schema <span id="schema"></span></footer>
165
116
  </div>
166
117
 
@@ -169,8 +120,19 @@
169
120
  </script>
170
121
  <script>
171
122
  (function () {
172
- var DATA = window.DATA || { kpis: {}, leaderboard: [], matrix: [], runs: [], step_series: [] };
173
- var PALETTE = ["#5b9dff", "#3ecf8e", "#e8b339", "#ef5a6f", "#b57bff", "#33c2c2", "#ff8a5b", "#a3d977"];
123
+ function loadReportData() {
124
+ try {
125
+ var request = new XMLHttpRequest();
126
+ request.open("GET", "summary.json", false);
127
+ request.send(null);
128
+ if ((request.status >= 200 && request.status < 300) || request.status === 0) return JSON.parse(request.responseText);
129
+ } catch (_) {
130
+ // Direct file access can block JSON reads; use the embedded snapshot then.
131
+ }
132
+ return window.__ASTRA_EMBEDDED_DATA__ || { kpis: {}, leaderboard: [], matrix: [], runs: [], step_series: [] };
133
+ }
134
+ var DATA = loadReportData();
135
+ var PALETTE = ["#5285f7", "#9160d8", "#39ae76", "#f3a021", "#d85668", "#37a8ad"];
174
136
  var colorCache = {};
175
137
  function colorFor(key) {
176
138
  if (!colorCache[key]) {
@@ -209,21 +171,19 @@
209
171
  }
210
172
 
211
173
  // ---------------------------------------------------------------- header
212
- document.getElementById("generated").textContent = "generated " + (DATA.generated_at || "");
174
+ document.getElementById("generated").textContent = DATA.generated_at
175
+ ? "Generated " + new Date(DATA.generated_at).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })
176
+ : "";
213
177
  document.getElementById("schema").textContent = DATA.schema || "";
214
178
 
215
179
  // ---------------------------------------------------------------- KPIs
216
180
  (function renderKpis() {
217
181
  var k = DATA.kpis || {};
218
182
  var cards = [
219
- ["Runs", fmt(k.runs)],
220
- ["Models", fmt(k.models)],
221
- ["Tasks", fmt(k.tasks)],
222
- ["Solved", fmt(k.solved) + " / " + fmt(k.runs)],
223
- ["Solve rate", pct(k.solved_rate)],
224
- ["Total tokens", fmt(k.tokens)],
183
+ ["Models Benchmarked", fmt(k.models)],
184
+ ["Total duration", fmt(k.elapsed_seconds) + "s"],
225
185
  ["Total cost", fmtUsd(k.cost_usd) + (k.cost_source === "estimated" ? "~" : "")],
226
- ["Elapsed", fmt(k.elapsed_seconds) + "s"],
186
+ ["Tokens used", fmt(k.tokens)],
227
187
  ];
228
188
  var host = document.getElementById("kpis");
229
189
  cards.forEach(function (c) {
@@ -266,20 +226,22 @@
266
226
  draw();
267
227
  }
268
228
 
269
- // ---------------------------------------------------------------- leaderboard chart: solve rate bars
229
+ // ---------------------------------------------------------------- leaderboard chart: run volume bars
270
230
  (function () {
271
231
  var host = document.getElementById("chart-solve");
232
+ if (!host) return;
233
+ var maxRuns = Math.max(1, Math.max.apply(null, (DATA.leaderboard || []).map(function (g) { return g.runs || 0; })));
272
234
  (DATA.leaderboard || []).forEach(function (g) {
273
235
  var row = el("div", { class: "bar-row" });
274
236
  row.appendChild(el("div", { class: "lbl mono" }, esc(g.slug)));
275
237
  var track = el("div", { class: "bar-track" });
276
238
  var seg = el("div", {
277
239
  class: "bar-seg",
278
- style: "width:" + Math.round((g.solved_rate || 0) * 100) + "%;background:" + colorFor(g.slug),
240
+ style: "width:" + Math.round((g.runs || 0) / maxRuns * 100) + "%;background:" + colorFor(g.slug),
279
241
  });
280
242
  track.appendChild(seg);
281
243
  row.appendChild(track);
282
- row.appendChild(el("div", { class: "bar-val" }, pct(g.solved_rate) + " (" + g.solved + "/" + g.runs + ")"));
244
+ row.appendChild(el("div", { class: "bar-val" }, fmt(g.runs) + " runs"));
283
245
  host.appendChild(row);
284
246
  });
285
247
  })();
@@ -288,6 +250,7 @@
288
250
  (function () {
289
251
  var host = document.getElementById("chart-outcomes");
290
252
  var legend = document.getElementById("legend-outcomes");
253
+ if (!host || !legend) return;
291
254
  var outcomeColors = { Submitted: "#3ecf8e", LimitsExceeded: "#e8b339", TimeExceeded: "#e8b339", RepeatedFormatError: "#ef5a6f", ContextWindowExceeded: "#ef5a6f", Error: "#ef5a6f", Unknown: "#8b93a7" };
292
255
  function colorForOutcome(k) { return outcomeColors[k] || "#8b93a7"; }
293
256
  var allOutcomes = {};
@@ -313,18 +276,31 @@
313
276
  // ---------------------------------------------------------------- leaderboard table
314
277
  (function () {
315
278
  var container = document.getElementById("tbl-leaderboard");
279
+ var options = document.getElementById("leaderboard-model-options");
280
+ var filterLabel = document.getElementById("model-filter-label");
316
281
  var columns = [
317
282
  { key: "model", label: "Model", render: function (r) { return esc(r.model); } },
318
283
  { key: "reasoning", label: "Reasoning", render: function (r) { return esc(r.reasoning); } },
319
284
  { key: "runs", label: "Runs", render: function (r) { return fmt(r.runs); } },
320
- { key: "solved_rate", label: "Solved", render: function (r) { return r.solved + "/" + r.runs + " (" + pct(r.solved_rate) + ")"; } },
321
- { key: "avg_steps", label: "Avg steps", render: function (r) { return fmt1(r.avg_steps); } },
322
- { key: "avg_elapsed_seconds", label: "Avg time", render: function (r) { return fmt1(r.avg_elapsed_seconds) + "s"; } },
323
- { key: "avg_tokens", label: "Avg tokens", render: function (r) { return fmt(Math.round(r.avg_tokens)); } },
285
+ { key: "sum_steps", label: "Total steps", render: function (r) { return fmt(r.sum_steps); } },
286
+ { key: "sum_elapsed_seconds", label: "Time taken", render: function (r) { return fmt(r.sum_elapsed_seconds) + "s"; } },
287
+ { key: "sum_tokens", label: "Tokens", render: function (r) { return fmt(r.sum_tokens); } },
324
288
  { key: "cost_usd", label: "Cost", render: function (r) { return fmtUsd(r.cost_usd) + (r.cost_source === "estimated" ? "~" : ""); } },
325
- { key: "avg_n_failed_commands", label: "Fail/run", render: function (r) { return fmt1(r.avg_n_failed_commands); } },
326
289
  ];
327
- sortableTable(container, columns, DATA.leaderboard || [], { defaultKey: "solved_rate" });
290
+ (DATA.leaderboard || []).forEach(function (r) {
291
+ var option = el("label", { class: "model-option" });
292
+ option.appendChild(el("input", { type: "checkbox", value: r.slug }));
293
+ option.appendChild(document.createTextNode(r.model + " · " + r.reasoning));
294
+ options.appendChild(option);
295
+ });
296
+ function draw() {
297
+ var selected = Array.prototype.slice.call(options.querySelectorAll("input:checked")).map(function (o) { return o.value; });
298
+ var rows = selected.length ? (DATA.leaderboard || []).filter(function (r) { return selected.indexOf(r.slug) !== -1; }) : DATA.leaderboard || [];
299
+ filterLabel.textContent = selected.length ? selected.length + " model" + (selected.length === 1 ? "" : "s") + " selected" : "All models";
300
+ sortableTable(container, columns, rows, { defaultKey: "sum_tokens" });
301
+ }
302
+ options.addEventListener("change", draw);
303
+ draw();
328
304
  })();
329
305
 
330
306
  // ---------------------------------------------------------------- token mix stacked bar
@@ -354,29 +330,30 @@
354
330
  });
355
331
  })();
356
332
 
357
- // ---------------------------------------------------------------- scatter: cost vs solve rate
333
+ // ---------------------------------------------------------------- scatter: cost vs duration
358
334
  (function () {
359
335
  var host = document.getElementById("chart-scatter");
360
336
  var W = host.clientWidth || 460, H = 260, pad = { l: 42, r: 16, t: 14, b: 30 };
361
337
  var svg = svgEl("svg", { width: "100%", height: H, viewBox: "0 0 " + W + " " + H });
362
338
  var data = (DATA.leaderboard || []).filter(function (g) { return g.cost_usd != null; });
363
339
  var maxCost = Math.max(0.001, Math.max.apply(null, data.map(function (g) { return g.cost_usd; }).concat([0.001])));
340
+ var maxDuration = Math.max(1, Math.max.apply(null, data.map(function (g) { return g.avg_elapsed_seconds || 0; }).concat([1])));
364
341
  function x(v) { return pad.l + (v / maxCost) * (W - pad.l - pad.r); }
365
- function y(v) { return H - pad.b - v * (H - pad.t - pad.b); }
342
+ function y(v) { return H - pad.b - (v / maxDuration) * (H - pad.t - pad.b); }
366
343
  // axes
367
344
  svg.appendChild(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: H - pad.b, y2: H - pad.b, stroke: "#262b36" }));
368
345
  svg.appendChild(svgEl("line", { x1: pad.l, x2: pad.l, y1: pad.t, y2: H - pad.b, stroke: "#262b36" }));
369
346
  [0, 0.25, 0.5, 0.75, 1].forEach(function (t) {
370
- var ty = y(t);
347
+ var ty = y(maxDuration * t);
371
348
  var lab = svgEl("text", { x: pad.l - 8, y: ty + 3, "text-anchor": "end" });
372
- lab.textContent = Math.round(t * 100) + "%";
349
+ lab.textContent = Math.round(maxDuration * t) + "s";
373
350
  svg.appendChild(lab);
374
351
  });
375
352
  var lab2 = svgEl("text", { x: W - pad.r, y: H - pad.b + 20, "text-anchor": "end" });
376
353
  lab2.textContent = "cost \u2192 (max " + fmtUsd(maxCost) + ")";
377
354
  svg.appendChild(lab2);
378
355
  data.forEach(function (g) {
379
- var cx = x(g.cost_usd), cy = y(g.solved_rate);
356
+ var cx = x(g.cost_usd), cy = y(g.avg_elapsed_seconds || 0);
380
357
  var r = 6 + Math.min(10, (g.avg_tokens || 0) / 4000);
381
358
  var c = svgEl("circle", { cx: cx, cy: cy, r: r, fill: colorFor(g.slug), "fill-opacity": 0.85 });
382
359
  svg.appendChild(c);
@@ -387,6 +364,38 @@
387
364
  host.appendChild(svg);
388
365
  })();
389
366
 
367
+ // ---------------------------------------------------------------- scatter: cost vs average steps
368
+ (function () {
369
+ var host = document.getElementById("chart-scatter-steps");
370
+ var W = host.clientWidth || 460, H = 260, pad = { l: 42, r: 16, t: 14, b: 30 };
371
+ var svg = svgEl("svg", { width: "100%", height: H, viewBox: "0 0 " + W + " " + H });
372
+ var data = (DATA.leaderboard || []).filter(function (g) { return g.cost_usd != null; });
373
+ var maxCost = Math.max(0.001, Math.max.apply(null, data.map(function (g) { return g.cost_usd; }).concat([0.001])));
374
+ var maxSteps = Math.max(1, Math.max.apply(null, data.map(function (g) { return g.avg_steps || 0; }).concat([1])));
375
+ function x(v) { return pad.l + (v / maxCost) * (W - pad.l - pad.r); }
376
+ function y(v) { return H - pad.b - (v / maxSteps) * (H - pad.t - pad.b); }
377
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: H - pad.b, y2: H - pad.b, stroke: "#e4e7eb" }));
378
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: pad.l, y1: pad.t, y2: H - pad.b, stroke: "#e4e7eb" }));
379
+ [0, 0.25, 0.5, 0.75, 1].forEach(function (t) {
380
+ var ty = y(maxSteps * t);
381
+ var lab = svgEl("text", { x: pad.l - 8, y: ty + 3, "text-anchor": "end" });
382
+ lab.textContent = fmt1(maxSteps * t);
383
+ svg.appendChild(lab);
384
+ });
385
+ var lab2 = svgEl("text", { x: W - pad.r, y: H - pad.b + 20, "text-anchor": "end" });
386
+ lab2.textContent = "cost \u2192 (max " + fmtUsd(maxCost) + ")";
387
+ svg.appendChild(lab2);
388
+ data.forEach(function (g) {
389
+ var cx = x(g.cost_usd), cy = y(g.avg_steps || 0);
390
+ var r = 6 + Math.min(10, (g.avg_tokens || 0) / 4000);
391
+ svg.appendChild(svgEl("circle", { cx: cx, cy: cy, r: r, fill: colorFor(g.slug), "fill-opacity": 0.85 }));
392
+ var label = svgEl("text", { x: cx + r + 4, y: cy + 3 });
393
+ label.textContent = g.slug;
394
+ svg.appendChild(label);
395
+ });
396
+ host.appendChild(svg);
397
+ })();
398
+
390
399
  // ---------------------------------------------------------------- per-step line charts
391
400
  function lineChart(host, series, valueFn, opts) {
392
401
  opts = opts || {};
@@ -444,6 +453,7 @@
444
453
  (function () {
445
454
  var matrix = DATA.matrix || [];
446
455
  var container = document.getElementById("tbl-matrix");
456
+ if (!container) return;
447
457
  if (matrix.length === 0) {
448
458
  document.getElementById("section-matrix").style.display = "none";
449
459
  return;
@@ -467,7 +477,7 @@
467
477
  var cells = tasks.map(function (t) {
468
478
  var m = cell[slug + "\u0000" + t];
469
479
  if (!m) return "<td>&mdash;</td>";
470
- return '<td><span class="heat-cell" style="background:' + heatColor(m.pass_at_k) + '">' + m.passed + "/" + m.k + "<br>" + pct(m.pass_at_k) + "</span></td>";
480
+ return '<td><span class="heat-cell" style="background:' + heatColor(m.completion_rate) + '">' + m.completed + "/" + m.k + "<br>" + pct(m.completion_rate) + "</span></td>";
471
481
  }).join("");
472
482
  return "<tr><td class=\"mono\">" + esc(slug) + "</td>" + cells + "</tr>";
473
483
  }).join("") + "</tbody>";
@@ -481,6 +491,7 @@
481
491
  var fModel = document.getElementById("filter-model");
482
492
  var fTask = document.getElementById("filter-task");
483
493
  var fOutcome = document.getElementById("filter-outcome");
494
+ if (!container || !fModel || !fTask || !fOutcome) return;
484
495
 
485
496
  function uniq(fn) {
486
497
  var seen = {}, out = [];
@@ -489,15 +500,15 @@
489
500
  }
490
501
  uniq(function (r) { return r.slug; }).forEach(function (v) { fModel.appendChild(el("option", { value: v }, esc(v))); });
491
502
  uniq(function (r) { return r.task_id; }).forEach(function (v) { fTask.appendChild(el("option", { value: v }, esc(v))); });
492
- uniq(function (r) { return r.exit_status; }).forEach(function (v) { fOutcome.appendChild(el("option", { value: v }, esc(v))); });
503
+ uniq(function (r) { return r.status; }).forEach(function (v) { fOutcome.appendChild(el("option", { value: v }, esc(v))); });
493
504
 
494
505
  var columns = [
495
506
  { key: "slug", label: "Model", render: function (r) { return '<span class="mono">' + esc(r.slug) + "</span>"; } },
496
507
  { key: "task_id", label: "Task", render: function (r) { return esc(r.task_id); } },
497
508
  { key: "run_id", label: "Run", render: function (r) { return esc(r.run_id); } },
498
- { key: "exit_status", label: "Outcome", render: function (r) {
499
- var cls = r.resolved ? "ok" : (r.exit_status === "Error" ? "bad" : "warn");
500
- return '<span class="pill ' + cls + '">' + esc(r.exit_status || "Unknown") + "</span>";
509
+ { key: "status", label: "Status", render: function (r) {
510
+ var cls = r.completed ? "ok" : (r.status === "request_error" ? "bad" : "warn");
511
+ return '<span class="pill ' + cls + '">' + esc(r.status || "Unknown") + "</span>";
501
512
  } },
502
513
  { key: "steps", label: "Steps", render: function (r) { return fmt(r.steps); } },
503
514
  { key: "elapsed_seconds", label: "Time", render: function (r) { return fmt(r.elapsed_seconds) + "s"; } },
@@ -530,7 +541,7 @@
530
541
  var filtered = runs.filter(function (r) {
531
542
  if (fModel.value && r.slug !== fModel.value) return false;
532
543
  if (fTask.value && r.task_id !== fTask.value) return false;
533
- if (fOutcome.value && r.exit_status !== fOutcome.value) return false;
544
+ if (fOutcome.value && r.status !== fOutcome.value) return false;
534
545
  return true;
535
546
  });
536
547
  sortableTable(container, columns, filtered, {
package/src/report.js CHANGED
@@ -91,17 +91,19 @@ export function buildSummary(runs) {
91
91
  const k = r.exit_status || "Unknown";
92
92
  outcomes[k] = (outcomes[k] || 0) + 1;
93
93
  }
94
- const solved = rs.filter((r) => r.resolved).length;
94
+ const completed = rs.filter((r) => r.completed).length;
95
95
  return {
96
96
  model: rs[0].model,
97
97
  reasoning: rs[0].reasoning,
98
98
  slug,
99
99
  runs: rs.length,
100
- solved,
101
- solved_rate: rs.length ? solved / rs.length : 0,
100
+ completed,
101
+ completion_rate: rs.length ? completed / rs.length : 0,
102
102
  outcomes,
103
103
  avg_steps: avg(rs, (r) => r.steps),
104
+ sum_steps: sum(rs, (r) => r.steps),
104
105
  avg_elapsed_seconds: avg(rs, (r) => r.elapsed_seconds),
106
+ sum_elapsed_seconds: sum(rs, (r) => r.elapsed_seconds),
105
107
  avg_tokens: avg(rs, (r) => r.tokens.total),
106
108
  sum_tokens: sum(rs, (r) => r.tokens.total),
107
109
  tokens: {
@@ -118,10 +120,10 @@ export function buildSummary(runs) {
118
120
  avg_n_retries: avg(rs, (r) => r.n_retries),
119
121
  };
120
122
  });
121
- leaderboard.sort((a, b) => b.solved_rate - a.solved_rate || a.avg_steps - b.avg_steps);
123
+ leaderboard.sort((a, b) => b.completion_rate - a.completion_rate || a.avg_steps - b.avg_steps);
122
124
 
123
125
  const matrix = groupBy(runs, (r) => `${r.slug}\u0000${r.task_id}`).map(([, rs]) => {
124
- const passed = rs.filter((r) => r.resolved).length;
126
+ const completed = rs.filter((r) => r.completed).length;
125
127
  const costRuns = rs.filter((r) => r.cost_source);
126
128
  return {
127
129
  model: rs[0].model,
@@ -130,8 +132,8 @@ export function buildSummary(runs) {
130
132
  task_id: rs[0].task_id,
131
133
  task_title: rs[0].task_title,
132
134
  k: rs.length,
133
- passed,
134
- pass_at_k: rs.length ? passed / rs.length : 0,
135
+ completed,
136
+ completion_rate: rs.length ? completed / rs.length : 0,
135
137
  avg_steps: avg(rs, (r) => r.steps),
136
138
  avg_tokens: avg(rs, (r) => r.tokens.total),
137
139
  avg_elapsed_seconds: avg(rs, (r) => r.elapsed_seconds),
@@ -190,7 +192,7 @@ export function buildSummary(runs) {
190
192
  .sort((a, b) => a.slug.localeCompare(b.slug) || a.step - b.step);
191
193
 
192
194
  const costRuns = runs.filter((r) => r.cost_source);
193
- const solved = runs.filter((r) => r.resolved).length;
195
+ const completed = runs.filter((r) => r.completed).length;
194
196
  const sources = new Set(costRuns.map((r) => r.cost_source));
195
197
 
196
198
  return {
@@ -200,8 +202,8 @@ export function buildSummary(runs) {
200
202
  models: new Set(runs.map((r) => r.slug)).size,
201
203
  tasks: new Set(runs.map((r) => r.task_id)).size,
202
204
  runs: runs.length,
203
- solved,
204
- solved_rate: runs.length ? solved / runs.length : 0,
205
+ completed,
206
+ completion_rate: runs.length ? completed / runs.length : 0,
205
207
  cost_usd: costRuns.length ? round(sum(costRuns, (r) => r.cost_usd), 6) : null,
206
208
  cost_source: sources.size === 0 ? "unknown" : sources.size === 1 ? [...sources][0] : "mixed",
207
209
  tokens: sum(runs, (r) => r.tokens.total),
@@ -221,7 +223,7 @@ export function renderReportHtml(summary) {
221
223
  if (!tpl.includes("/*__ASTRA_DATA__*/")) {
222
224
  throw new Error("src/report.html is missing the /*__ASTRA_DATA__*/ injection marker");
223
225
  }
224
- return tpl.replace("/*__ASTRA_DATA__*/", `window.DATA = ${data};`);
226
+ return tpl.replace("/*__ASTRA_DATA__*/", `window.__ASTRA_EMBEDDED_DATA__ = ${data};`);
225
227
  }
226
228
 
227
229
  function toRunDoc(run) {
@@ -232,6 +234,7 @@ function readRunDir({ rootDir, slug, runId, dir }) {
232
234
  const trajPath = path.join(dir, "trajectory.json");
233
235
  const metricsPath = path.join(dir, "metrics.csv");
234
236
  const taskPath = path.join(dir, "task.md");
237
+ const projectPath = path.join(dir, "project.json");
235
238
  if (!fs.existsSync(trajPath) && !fs.existsSync(metricsPath)) return null;
236
239
 
237
240
  let traj = null;
@@ -243,6 +246,7 @@ function readRunDir({ rootDir, slug, runId, dir }) {
243
246
  }
244
247
  }
245
248
  const metricsRow = fs.existsSync(metricsPath) ? parseCsv(fs.readFileSync(metricsPath, "utf8"))[0] : null;
249
+ const project = readJson(projectPath);
246
250
  const taskMd = fs.existsSync(taskPath) ? fs.readFileSync(taskPath, "utf8") : traj?.info?.task || "";
247
251
  const parsed = parseSlug(slug);
248
252
  const model = metricsRow?.model || traj?.info?.model || parsed.model;
@@ -259,11 +263,11 @@ function readRunDir({ rootDir, slug, runId, dir }) {
259
263
  last_context: num(metricsRow?.last_context_tokens ?? traj?.info?.tokens?.last_context),
260
264
  };
261
265
  const costSource = metricsRow?.cost_source || traj?.info?.cost?.source || "";
262
- const resolvedRaw = metricsRow?.resolved;
263
- const resolved =
264
- resolvedRaw == null
265
- ? traj?.info?.exit_status === "Submitted"
266
- : resolvedRaw === true || resolvedRaw === "1" || resolvedRaw === 1;
266
+ const legacyResolved = metricsRow?.resolved;
267
+ const status = metricsRow?.status ||
268
+ ((legacyResolved == null ? traj?.info?.exit_status === "Submitted" : legacyResolved === true || legacyResolved === "1" || legacyResolved === 1)
269
+ ? "completed"
270
+ : "model_error");
267
271
 
268
272
  return {
269
273
  id: `${slug}/${runId}`,
@@ -271,12 +275,16 @@ function readRunDir({ rootDir, slug, runId, dir }) {
271
275
  model,
272
276
  reasoning,
273
277
  slug,
274
- task_id: inferTaskId({ taskMd }),
278
+ task_id: typeof project?.id === "string" && project.id ? project.id : inferTaskId({ taskMd }),
275
279
  task_title: inferTaskTitle(taskMd),
280
+ project: project && typeof project.id === "string"
281
+ ? { id: project.id, version: project.version }
282
+ : null,
276
283
  run: runNum,
277
284
  run_id: runId,
278
285
  exit_status: metricsRow?.exit_status || traj?.info?.exit_status || "",
279
- resolved: !!resolved,
286
+ status,
287
+ completed: status === "completed",
280
288
  error: null,
281
289
  steps: num(metricsRow?.steps ?? traj?.info?.n_steps),
282
290
  n_calls: num(metricsRow?.n_calls ?? traj?.info?.n_calls),
@@ -299,6 +307,15 @@ function readRunDir({ rootDir, slug, runId, dir }) {
299
307
  };
300
308
  }
301
309
 
310
+ function readJson(file) {
311
+ if (!fs.existsSync(file)) return null;
312
+ try {
313
+ return JSON.parse(fs.readFileSync(file, "utf8"));
314
+ } catch {
315
+ return null;
316
+ }
317
+ }
318
+
302
319
  export function extractTimeline(doc) {
303
320
  const messages = doc?.messages || [];
304
321
  const steps = [];