@hackerrank/astra-cli 0.1.3 → 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
@@ -120,7 +120,7 @@ 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
@@ -132,13 +132,52 @@ dashboard with `Content-Type: text/html` and prints a **7-day presigned
132
132
  read), so that presigned URL is the only way to open the dashboard — e.g.
133
133
  clickable straight from a Jenkins console log.
134
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
+
135
174
  ### HTML report
136
175
 
137
176
  Every matrix run (and every single `-p`/`-t`/`-f` bench run) rebuilds
138
177
  `bench/summary.json` and a self-contained `bench/report.html` dashboard —
139
178
  no server, no build step, no external JS: open it straight from `file://` or
140
179
  from inside the `bench-*.tgz` tarball. It has a leaderboard, a model × task
141
- 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
142
181
  expandable command timeline for every run.
143
182
 
144
183
  Regenerate it on demand (e.g. after manually editing/pruning `bench/`) with:
@@ -150,7 +189,7 @@ astra --report --bench-root ./other-bench
150
189
 
151
190
  `summary.json` follows the `astra-bench-1` schema: `kpis` (totals), a tidy
152
191
  `leaderboard[]` (one row per model×reasoning), `matrix[]` (model×reasoning×task
153
- 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
154
193
  `step_series[]` (token/cost distributions by step, for the trend charts). Each
155
194
  run folder also gets a standalone `run.json` for drill-down without loading
156
195
  the full `trajectory.json`.
@@ -178,6 +217,7 @@ credits — that's a quota issue, not a bug.
178
217
  -t, --task <text> Task text -> autonomous mode
179
218
  -f, --task-file <path> Read task text from a file -> autonomous mode
180
219
  -p, --path <dir> Task directory -> isolated bench workspace
220
+ --project <path> Load .astra/project.toml for an explicit bench run
181
221
  --repeat <n> Attempts per (model,reasoning) in a matrix (default: 1)
182
222
  --bench-root <dir> Root folder for bench runs (default: ./bench)
183
223
  --tar After a matrix, write a bench/ tarball locally
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackerrank/astra-cli",
3
- "version": "0.1.3",
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,
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
  *
@@ -36,6 +37,7 @@
36
37
  * --report Rebuild bench/summary.json + bench/report.html
37
38
  * from whatever runs already exist on disk, then
38
39
  * exit (no model/API key needed).
40
+ * --project <path> Load .astra/project.toml and run its bench setup
39
41
  * -t, --task <text> Task text -> bench mode (run to completion)
40
42
  * -f, --task-file <path> Read task text from a file -> bench mode
41
43
  * -p, --path <dir> Task directory (copied into an isolated bench
@@ -86,11 +88,13 @@ import {
86
88
  deriveTitle,
87
89
  } from "./session.js";
88
90
  import { runRepl } from "./repl.js";
91
+ import { ProjectConfigError, loadProject } from "./project.js";
89
92
  import {
90
93
  allocateRun,
91
94
  seedWorkspace,
92
95
  collectMetrics,
93
96
  writeMetrics,
97
+ writeProjectMetadata,
94
98
  runMatrix,
95
99
  aggregate,
96
100
  packBench,
@@ -109,7 +113,7 @@ function reasoningKwargs(level) {
109
113
  }
110
114
 
111
115
  function parseArgs(argv) {
112
- 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() };
113
117
  const alias = {
114
118
  "-m": "model", "--model": "model",
115
119
  "-t": "task", "--task": "task",
@@ -121,6 +125,7 @@ function parseArgs(argv) {
121
125
  "--push-uri": "push-uri",
122
126
  "--tar": "tar",
123
127
  "--report": "report",
128
+ "--project": "project",
124
129
  "--bench-root": "bench-root",
125
130
  "-C": "cwd", "--cwd": "cwd",
126
131
  "-o": "output", "--output": "output",
@@ -137,11 +142,17 @@ function parseArgs(argv) {
137
142
  "-h": "help", "--help": "help",
138
143
  };
139
144
  const flags = new Set(["quiet", "yolo", "help", "sessions", "tar", "push", "report"]);
140
- 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++) {
141
151
  const key = alias[argv[i]];
142
152
  if (!key) { console.error(`Unknown option: ${argv[i]}`); process.exit(2); }
143
153
  if (flags.has(key)) { args[key] = true; continue; }
144
154
  args[key] = argv[++i];
155
+ args._provided.add(key);
145
156
  }
146
157
  return args;
147
158
  }
@@ -183,11 +194,28 @@ async function main() {
183
194
  resumeDoc = loadSession(args.resume);
184
195
  }
185
196
 
186
- // 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.
187
198
  // Bench mode uses the autonomous engine; agent mode uses the interactive one.
188
199
  let task = args.task;
189
200
  if (args["task-file"]) task = fs.readFileSync(args["task-file"], "utf8");
190
- 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);
191
219
  const mode = wantsBench
192
220
  ? "autonomous"
193
221
  : resumeDoc?.info?.mode === "autonomous"
@@ -236,6 +264,8 @@ async function main() {
236
264
  let reasoning = args.reasoning || "";
237
265
 
238
266
  if (mode === "autonomous") {
267
+ if (project && !modelId) modelId = project.bench.models.join(",");
268
+ if (project && !reasoning) reasoning = project.bench.reasoning.join(",");
239
269
  if (!modelId) {
240
270
  console.error("\x1b[31m[astra] --model is required for bench mode.\x1b[0m\n");
241
271
  console.log(HELP);
@@ -254,7 +284,7 @@ async function main() {
254
284
  const isMatrix = models.length > 1 || reasonings.length > 1 || repeat > 1;
255
285
 
256
286
  if (isMatrix && !resumeDoc) {
257
- await runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, quiet: !!args.quiet });
287
+ await runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, project, quiet: !!args.quiet });
258
288
  return; // runBenchMatrix exits the process
259
289
  }
260
290
  } else {
@@ -310,6 +340,7 @@ async function main() {
310
340
  task = seeded.taskText || task || "";
311
341
  // Persist the task text alongside the run for reproducibility.
312
342
  fs.writeFileSync(path.join(benchRun.dir, "task.md"), task || "");
343
+ writeProjectMetadata(benchRun.dir, project?.metadata);
313
344
  }
314
345
 
315
346
  const cmdCwd = benchRun ? benchRun.workspace : cwd;
@@ -510,7 +541,7 @@ function archiveAndPush(args, root, links) {
510
541
  * Run a multi-model / multi-reasoning / repeated bench matrix, print a
511
542
  * leaderboard, optionally tar + upload the bench/ folder, then exit.
512
543
  */
513
- async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, quiet }) {
544
+ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, project, quiet }) {
514
545
  const root = args["bench-root"];
515
546
  const total = models.length * reasonings.length * repeat;
516
547
  console.error(
@@ -528,6 +559,7 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
528
559
  task,
529
560
  taskPath: args.path,
530
561
  taskFile: args["task-file"],
562
+ projectMetadata: project?.metadata,
531
563
  root,
532
564
  steps: Number(args.steps),
533
565
  wall: Number(args.wall),
@@ -540,8 +572,8 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
540
572
  }
541
573
  },
542
574
  onDone: (r) => {
543
- const mark = r.resolved ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
544
- 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;
545
577
  console.error(
546
578
  ` ${mark} ${String(r.model).padEnd(20)} ${String(r.reasoning).padEnd(8)} ` +
547
579
  `${String(status).padEnd(16)} ${String(r.steps).padStart(3)} steps · ` +
@@ -570,8 +602,8 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
570
602
  // Tar + optional S3 push of the whole bench/ folder.
571
603
  archiveAndPush(args, root, links);
572
604
 
573
- const solved = rows.filter((r) => r.resolved).length;
574
- 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`);
575
607
 
576
608
  // Print the important URLs / paths, aligned, at the very end.
577
609
  const w = Math.max(...links.map(([k]) => k.length));
@@ -580,23 +612,23 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
580
612
  console.error(`\x1b[1m ${k.padEnd(w)}\x1b[0m ${v}`);
581
613
  }
582
614
 
583
- process.exit(solved === rows.length ? 0 : 1);
615
+ process.exit(completed === rows.length ? 0 : 1);
584
616
  }
585
617
 
586
618
  /** Print the per-(model,reasoning) leaderboard table. */
587
619
  function printLeaderboard(board) {
588
- 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);
589
621
  console.error(
590
- `\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)} ` +
591
623
  `${"steps".padStart(6)} ${"tokens".padStart(9)} ${"cost".padStart(9)} source\x1b[0m`
592
624
  );
593
625
  for (const g of board) {
594
- const solved = `${g.solved}/${g.runs}`;
595
- const rate = `${Math.round(g.solved_rate * 100)}%`;
626
+ const completed = `${g.completed}/${g.runs}`;
627
+ const rate = `${Math.round(g.completion_rate * 100)}%`;
596
628
  const cost = g.cost_usd == null ? "n/a" : fmtUsd(g.cost_usd);
597
629
  console.error(
598
630
  `${String(g.model).padEnd(20)} ${String(g.reasoning).padEnd(8)} ` +
599
- `${(solved + " " + rate).padEnd(8)} ${g.avg_steps.toFixed(1).padStart(6)} ` +
631
+ `${(completed + " " + rate).padEnd(8)} ${g.avg_steps.toFixed(1).padStart(6)} ` +
600
632
  `${fmt(Math.round(g.avg_tokens)).padStart(9)} ${cost.padStart(9)} ${g.cost_source}`
601
633
  );
602
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
@@ -477,7 +477,7 @@
477
477
  var cells = tasks.map(function (t) {
478
478
  var m = cell[slug + "\u0000" + t];
479
479
  if (!m) return "<td>&mdash;</td>";
480
- 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>";
481
481
  }).join("");
482
482
  return "<tr><td class=\"mono\">" + esc(slug) + "</td>" + cells + "</tr>";
483
483
  }).join("") + "</tbody>";
@@ -500,15 +500,15 @@
500
500
  }
501
501
  uniq(function (r) { return r.slug; }).forEach(function (v) { fModel.appendChild(el("option", { value: v }, esc(v))); });
502
502
  uniq(function (r) { return r.task_id; }).forEach(function (v) { fTask.appendChild(el("option", { value: v }, esc(v))); });
503
- 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))); });
504
504
 
505
505
  var columns = [
506
506
  { key: "slug", label: "Model", render: function (r) { return '<span class="mono">' + esc(r.slug) + "</span>"; } },
507
507
  { key: "task_id", label: "Task", render: function (r) { return esc(r.task_id); } },
508
508
  { key: "run_id", label: "Run", render: function (r) { return esc(r.run_id); } },
509
- { key: "exit_status", label: "Outcome", render: function (r) {
510
- var cls = r.resolved ? "ok" : (r.exit_status === "Error" ? "bad" : "warn");
511
- 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>";
512
512
  } },
513
513
  { key: "steps", label: "Steps", render: function (r) { return fmt(r.steps); } },
514
514
  { key: "elapsed_seconds", label: "Time", render: function (r) { return fmt(r.elapsed_seconds) + "s"; } },
@@ -541,7 +541,7 @@
541
541
  var filtered = runs.filter(function (r) {
542
542
  if (fModel.value && r.slug !== fModel.value) return false;
543
543
  if (fTask.value && r.task_id !== fTask.value) return false;
544
- if (fOutcome.value && r.exit_status !== fOutcome.value) return false;
544
+ if (fOutcome.value && r.status !== fOutcome.value) return false;
545
545
  return true;
546
546
  });
547
547
  sortableTable(container, columns, filtered, {
package/src/report.js CHANGED
@@ -91,14 +91,14 @@ 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
104
  sum_steps: sum(rs, (r) => r.steps),
@@ -120,10 +120,10 @@ export function buildSummary(runs) {
120
120
  avg_n_retries: avg(rs, (r) => r.n_retries),
121
121
  };
122
122
  });
123
- 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);
124
124
 
125
125
  const matrix = groupBy(runs, (r) => `${r.slug}\u0000${r.task_id}`).map(([, rs]) => {
126
- const passed = rs.filter((r) => r.resolved).length;
126
+ const completed = rs.filter((r) => r.completed).length;
127
127
  const costRuns = rs.filter((r) => r.cost_source);
128
128
  return {
129
129
  model: rs[0].model,
@@ -132,8 +132,8 @@ export function buildSummary(runs) {
132
132
  task_id: rs[0].task_id,
133
133
  task_title: rs[0].task_title,
134
134
  k: rs.length,
135
- passed,
136
- pass_at_k: rs.length ? passed / rs.length : 0,
135
+ completed,
136
+ completion_rate: rs.length ? completed / rs.length : 0,
137
137
  avg_steps: avg(rs, (r) => r.steps),
138
138
  avg_tokens: avg(rs, (r) => r.tokens.total),
139
139
  avg_elapsed_seconds: avg(rs, (r) => r.elapsed_seconds),
@@ -192,7 +192,7 @@ export function buildSummary(runs) {
192
192
  .sort((a, b) => a.slug.localeCompare(b.slug) || a.step - b.step);
193
193
 
194
194
  const costRuns = runs.filter((r) => r.cost_source);
195
- const solved = runs.filter((r) => r.resolved).length;
195
+ const completed = runs.filter((r) => r.completed).length;
196
196
  const sources = new Set(costRuns.map((r) => r.cost_source));
197
197
 
198
198
  return {
@@ -202,8 +202,8 @@ export function buildSummary(runs) {
202
202
  models: new Set(runs.map((r) => r.slug)).size,
203
203
  tasks: new Set(runs.map((r) => r.task_id)).size,
204
204
  runs: runs.length,
205
- solved,
206
- solved_rate: runs.length ? solved / runs.length : 0,
205
+ completed,
206
+ completion_rate: runs.length ? completed / runs.length : 0,
207
207
  cost_usd: costRuns.length ? round(sum(costRuns, (r) => r.cost_usd), 6) : null,
208
208
  cost_source: sources.size === 0 ? "unknown" : sources.size === 1 ? [...sources][0] : "mixed",
209
209
  tokens: sum(runs, (r) => r.tokens.total),
@@ -234,6 +234,7 @@ function readRunDir({ rootDir, slug, runId, dir }) {
234
234
  const trajPath = path.join(dir, "trajectory.json");
235
235
  const metricsPath = path.join(dir, "metrics.csv");
236
236
  const taskPath = path.join(dir, "task.md");
237
+ const projectPath = path.join(dir, "project.json");
237
238
  if (!fs.existsSync(trajPath) && !fs.existsSync(metricsPath)) return null;
238
239
 
239
240
  let traj = null;
@@ -245,6 +246,7 @@ function readRunDir({ rootDir, slug, runId, dir }) {
245
246
  }
246
247
  }
247
248
  const metricsRow = fs.existsSync(metricsPath) ? parseCsv(fs.readFileSync(metricsPath, "utf8"))[0] : null;
249
+ const project = readJson(projectPath);
248
250
  const taskMd = fs.existsSync(taskPath) ? fs.readFileSync(taskPath, "utf8") : traj?.info?.task || "";
249
251
  const parsed = parseSlug(slug);
250
252
  const model = metricsRow?.model || traj?.info?.model || parsed.model;
@@ -261,11 +263,11 @@ function readRunDir({ rootDir, slug, runId, dir }) {
261
263
  last_context: num(metricsRow?.last_context_tokens ?? traj?.info?.tokens?.last_context),
262
264
  };
263
265
  const costSource = metricsRow?.cost_source || traj?.info?.cost?.source || "";
264
- const resolvedRaw = metricsRow?.resolved;
265
- const resolved =
266
- resolvedRaw == null
267
- ? traj?.info?.exit_status === "Submitted"
268
- : 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");
269
271
 
270
272
  return {
271
273
  id: `${slug}/${runId}`,
@@ -273,12 +275,16 @@ function readRunDir({ rootDir, slug, runId, dir }) {
273
275
  model,
274
276
  reasoning,
275
277
  slug,
276
- task_id: inferTaskId({ taskMd }),
278
+ task_id: typeof project?.id === "string" && project.id ? project.id : inferTaskId({ taskMd }),
277
279
  task_title: inferTaskTitle(taskMd),
280
+ project: project && typeof project.id === "string"
281
+ ? { id: project.id, version: project.version }
282
+ : null,
278
283
  run: runNum,
279
284
  run_id: runId,
280
285
  exit_status: metricsRow?.exit_status || traj?.info?.exit_status || "",
281
- resolved: !!resolved,
286
+ status,
287
+ completed: status === "completed",
282
288
  error: null,
283
289
  steps: num(metricsRow?.steps ?? traj?.info?.n_steps),
284
290
  n_calls: num(metricsRow?.n_calls ?? traj?.info?.n_calls),
@@ -301,6 +307,15 @@ function readRunDir({ rootDir, slug, runId, dir }) {
301
307
  };
302
308
  }
303
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
+
304
319
  export function extractTimeline(doc) {
305
320
  const messages = doc?.messages || [];
306
321
  const steps = [];