firegoat 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +18 -10
  2. package/dist/cli.js +93 -46
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,7 +12,7 @@ npx firegoat@latest init # Creates firegoat.yaml and .firegoat/, installs th
12
12
  npx firegoat@latest eval # Runs your claude on each test, in .firegoat/runs/<runId>/<test>/<n>/work
13
13
  ```
14
14
 
15
- That's it: two commands. Use `npx firegoat@latest`: plain `npx firegoat` can keep running an old cached version. `eval` prints its version first (`firegoat 0.18.0`), and `summary.json` records it as `firegoatVersion`. `claude` must be installed and logged in (run `claude` once).
15
+ That's it: two commands. Use `npx firegoat@latest`: plain `npx firegoat` can keep running an old cached version. `eval` prints its version first (`firegoat X.Y.Z`), and `summary.json` records it as `firegoatVersion`. `claude` must be installed and logged in (run `claude` once).
16
16
 
17
17
  `init` installs the firegoat-onboarding skill globally, into `~/.claude/skills/` (in a terminal it asks: global, this repo, or none). `--local-skills` puts it in this repo's `.claude/skills/` instead (commit it so teammates and CI get it); `--no-skills` skips it.
18
18
 
@@ -160,16 +160,24 @@ tests:
160
160
 
161
161
  ### Testing an MCP server
162
162
 
163
+ Commit the server's config as `fixtures/mcp.json`:
164
+
165
+ ```json
166
+ {"mcpServers": {"acme": {"command": "./node_modules/.bin/acme-mcp",
167
+ "env": {"HOME": "${FIREGOAT_HOME}"}}}}
168
+ ```
169
+
170
+ Copy it in `build:` and pass it with `claude_args:`:
171
+
163
172
  ```yaml
164
- install: |
165
- cat > .mcp.json <<EOF
166
- {"mcpServers": {"acme": {"command": "npx", "args": ["-y", "acme-mcp"],
167
- "env": {"HOME": "$FIREGOAT_HOME"}}}}
168
- EOF
169
- claude_args: [--mcp-config, .mcp.json]
173
+ build:
174
+ - npm pack --pack-destination "$FIREGOAT_OUT"
175
+ - cp -R fixtures/. "$FIREGOAT_OUT"
176
+ install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
177
+ claude_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
170
178
  ```
171
179
 
172
- Write the MCP config per run in `install:` (or copy a committed one to `$FIREGOAT_OUT` in `build:` and pass `"$FIREGOAT_OUT/mcp.json"`, but then it can't hold the run's home), and pass it with `claude_args: [--mcp-config, .mcp.json]`: relative paths are from the run's folder, claude's cwd. `--strict-mcp-config` is always on, so only the configs you pass load. MCP servers that claude starts inherit claude's own env, which keeps your real `HOME` (claude needs it for its login): set `"env": {"HOME": "<the run's $FIREGOAT_HOME>"}` on the server, as above, or it writes to your real home.
180
+ claude gets `FIREGOAT_HOME`, `FIREGOAT_WORKDIR` and `FIREGOAT_OUT` in its own env, set per run, and expands `${VAR}` in an MCP config's `command`, `args`, `env`, `url` and `headers` from its env (an unset var stays the literal text). Relative paths in the config are from the run's folder, claude's cwd: `./node_modules/.bin/acme-mcp` is the build under test, installed by `install:`. MCP servers that claude starts inherit claude's own env, which keeps your real `HOME` (claude needs it for its login): set `"env": {"HOME": "${FIREGOAT_HOME}"}` on the server, as above, or it writes to your real home. Per-run state the server keeps elsewhere goes the same way, e.g. `"ACME_DB": "${FIREGOAT_WORKDIR}/acme.db"`. `--strict-mcp-config` is always on, so only the configs you pass load. To check that the agent used a tool, grep the transcript: `grep -Eq '^\[[0-9]+\] mcp__acme__search' "$FIREGOAT_TRANSCRIPT"`.
173
181
 
174
182
  With `--chrome`, claude points the Claude in Chrome extension's native host (`~/Library/Application Support/<browser>/NativeMessagingHosts/com.anthropic.claude_code_browser_extension.json`) at `.firegoat/.claude`. If you delete `.firegoat/`, run a normal `claude --chrome` once to point it back.
175
183
 
@@ -211,9 +219,9 @@ Limits:
211
219
  ## What eval does
212
220
 
213
221
  - claude runs on its own login (or `ANTHROPIC_API_KEY`, if set) with `CLAUDE_CONFIG_DIR=.firegoat/.claude` and `--setting-sources "" --strict-mcp-config --disable-slash-commands`: none of your settings, CLAUDE.md, plugins, skills or MCP servers (add flags with `claude_args:`, see above). claude saves its session there, in its own format.
214
- - No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. `.firegoat/` holds local run output and ignores itself in git. Keep test fixtures (scripts, sample data) outside it, e.g. in `fixtures/` next to `firegoat.yaml`, and commit them.
222
+ - No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. `.firegoat/` holds local run output and ignores itself in git. Keep test fixtures (scripts, sample data) in `fixtures/` next to `firegoat.yaml`, committed.
215
223
  - In a linked git worktree (`git worktree add`), `.firegoat/runs/` and `.firegoat/.claude/` are in the main checkout, so runs (and their session files) survive `git worktree remove`; `firegoat.yaml`, fixtures and `build:` stay in the worktree. Each `summary.json` records `dir` (where eval ran) and `git: {branch, commit, dirty}`, and `.firegoat/runs/index.jsonl` gets one line per eval (`runId, startedAt, dir, branch, commit, passed, failed, notGraded, cost, uploaded, uploadFailed`) to tell runs from different branches apart.
216
- - The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, build, install, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it. Every `FIREGOAT_*` var firegoat was started with (`FIREGOAT_API_KEY` above all) is removed too, and so are the vars a parent Claude Code session sets (when firegoat runs from claude, or its agent runs firegoat), so the claude under test doesn't act as a nested session: `CLAUDECODE`, `CLAUDE_CODE_*`, `CLAUDE_PID`, `CLAUDE_ENV_FILE`, `CLAUDE_SESSION_ID`, `CLAUDE_PROJECT_DIR`, `CLAUDE_EFFORT`, `CLAUDE_AGENT_SDK_*`, `CLAUDE_PLUGIN_ROOT`, `CLAUDE_PLUGIN_DATA`, `CLAUDE_SKILL_DIR`. Login and provider vars stay: `ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_OAUTH_REFRESH_TOKEN`, `CLAUDE_CODE_USE_*` (Bedrock, Vertex, ...), `CLAUDE_CODE_SKIP_*_AUTH`, `CLAUDE_CODE_CLIENT_CERT`, `CLAUDE_CODE_CLIENT_KEY`, `CLAUDE_CODE_CLIENT_KEY_PASSPHRASE`.
224
+ - The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, build, install, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it. Every `FIREGOAT_*` var firegoat was started with (`FIREGOAT_API_KEY` above all) is removed too (claude then gets the run's own `FIREGOAT_OUT`, `FIREGOAT_WORKDIR` and `FIREGOAT_HOME`, for MCP configs; the agent's commands see them too), and so are the vars a parent Claude Code session sets (when firegoat runs from claude, or its agent runs firegoat), so the claude under test doesn't act as a nested session: `CLAUDECODE`, `CLAUDE_CODE_*`, `CLAUDE_PID`, `CLAUDE_ENV_FILE`, `CLAUDE_SESSION_ID`, `CLAUDE_PROJECT_DIR`, `CLAUDE_EFFORT`, `CLAUDE_AGENT_SDK_*`, `CLAUDE_PLUGIN_ROOT`, `CLAUDE_PLUGIN_DATA`, `CLAUDE_SKILL_DIR`. Login and provider vars stay: `ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_OAUTH_REFRESH_TOKEN`, `CLAUDE_CODE_USE_*` (Bedrock, Vertex, ...), `CLAUDE_CODE_SKIP_*_AUTH`, `CLAUDE_CODE_CLIENT_CERT`, `CLAUDE_CODE_CLIENT_KEY`, `CLAUDE_CODE_CLIENT_KEY_PASSPHRASE`.
217
225
  - Runs (tests × models × `runs`) go 4 at a time, each in its own folder with its own session and HOME; `--parallel N` changes that, `--parallel 1` runs them one after another. `summary.json` lists them in `firegoat.yaml` order.
218
226
  - Script checks run in the agent's folder afterwards and set the exit code. `end_goal` is not graded by firegoat: it's for you, or your coding agent, to judge from `run.json`, the session and the folder.
219
227
  - Output per run, in `.firegoat/runs/<runId>/<test>/<n>/`: `transcript.txt` (each tool call with its command and short output, the final answer, files made; checks get it as `$FIREGOAT_TRANSCRIPT`; greppable line by line: every line of a shell call starts with `[n] $ `, every line of the agent's text and final answer with `agent: `, output is indented, so `grep -E '^\[[0-9]+\] \$ .*npm install'` matches only commands, also inside heredocs and multi-line scripts), `run.json` (status, prompt, end_goal, check results, paths), `work/`, `home/` and `home.env` (the run's HOME), logs. Read the transcript to judge `end_goal`; claude's full session file is ~50x larger. `summary.json` covers the whole eval; `--json` prints it.
package/dist/cli.js CHANGED
@@ -6947,7 +6947,7 @@ var require_public_api = __commonJS(function(exports) {
6947
6947
  // package.json
6948
6948
  var package_default = {
6949
6949
  name: "firegoat",
6950
- version: "0.20.0",
6950
+ version: "0.21.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -7448,8 +7448,15 @@ async function runClaude(opts) {
7448
7448
  reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
7449
7449
  };
7450
7450
  }
7451
- const sessionId = String(init.session_id);
7452
7451
  const claudeModel = typeof init.model === "string" ? init.model : null;
7452
+ if (typeof init.session_id !== "string" || init.session_id === "")
7453
+ return {
7454
+ events,
7455
+ claudeModel,
7456
+ ok: false,
7457
+ reason: "claude's init line has no session_id"
7458
+ };
7459
+ const sessionId = init.session_id;
7453
7460
  const result = events.findLast((e) => e?.type === "result");
7454
7461
  if (exited.timedOut)
7455
7462
  return {
@@ -7646,7 +7653,7 @@ async function send(opts) {
7646
7653
  function replyError(step, r) {
7647
7654
  const j = r.json ?? {};
7648
7655
  const error = typeof j.error === "string" ? j.error : "no error message";
7649
- const more = j.error_description ?? j.details ?? j.message;
7656
+ const more = j.error_description ?? j.details;
7650
7657
  const details = more === undefined ? "" : ` ${typeof more === "string" ? more : JSON.stringify(more)}`;
7651
7658
  return new ApiError(printable(`${step} ${r.status}: ${error}${details}`), r.status);
7652
7659
  }
@@ -7816,7 +7823,12 @@ var GITIGNORE = `*
7816
7823
  function ensureFiregoatDir(root) {
7817
7824
  const dir = join2(root, ".firegoat");
7818
7825
  mkdirSync2(dir, { recursive: true });
7819
- writeFileSync(join2(dir, ".gitignore"), GITIGNORE);
7826
+ try {
7827
+ writeFileSync(join2(dir, ".gitignore"), GITIGNORE, { flag: "wx" });
7828
+ } catch (e) {
7829
+ if (e.code !== "EEXIST")
7830
+ throw e;
7831
+ }
7820
7832
  return dir;
7821
7833
  }
7822
7834
 
@@ -7918,8 +7930,8 @@ firegoat-onboarding skill globally, into ~/.claude/skills/ (npx skills add
7918
7930
  firegoat-ai/skills -g; in a terminal it asks: global, this repo, or none), offers
7919
7931
  firegoat login (in a terminal only; otherwise prints how), then prints a prompt to
7920
7932
  paste into your coding agent. .firegoat/ holds local run output and ignores itself
7921
- in git. Keep test fixtures (helper scripts, sample data) outside it, e.g. in
7922
- fixtures/ next to firegoat.yaml, and commit them.
7933
+ in git. Keep test fixtures (helper scripts, sample data) in fixtures/ next to
7934
+ firegoat.yaml, committed.
7923
7935
 
7924
7936
  --force Replace an existing firegoat.yaml
7925
7937
  --local-skills Install the skill into this repo's .claude/skills/ instead
@@ -8008,19 +8020,23 @@ firegoat.yaml reference:
8008
8020
  (npm_config_*, npm_lifecycle_*, npm_package_*, npm_execpath, npm_node_execpath,
8009
8021
  npm_command, INIT_CWD; e.g. npm_config_yes=true from npx -y) are removed from
8010
8022
  every env firegoat starts, unless env: sets them. So is every FIREGOAT_* var
8011
- (FIREGOAT_API_KEY above all) firegoat itself was started with.
8012
-
8013
- Testing an MCP server: write its config per run in install: and pass it with
8014
- claude_args (relative paths are from the run's folder, claude's cwd). Only the
8015
- configs you pass load: --strict-mcp-config is always on. An MCP server inherits
8016
- claude's env, which keeps your real HOME (claude's login is there), so set the
8017
- server's HOME to the run's home, or it writes to yours:
8018
- install: |
8019
- cat > .mcp.json <<EOF
8020
- {"mcpServers": {"acme": {"command": "npx", "args": ["-y", "acme-mcp"],
8021
- "env": {"HOME": "$FIREGOAT_HOME"}}}}
8022
- EOF
8023
- claude_args: [--mcp-config, .mcp.json]
8023
+ (FIREGOAT_API_KEY above all) firegoat itself was started with. claude itself
8024
+ gets $FIREGOAT_OUT, $FIREGOAT_WORKDIR and $FIREGOAT_HOME (for MCP configs, below),
8025
+ so the agent's commands see them too.
8026
+
8027
+ Testing an MCP server: commit its config as fixtures/mcp.json, copy it in build:
8028
+ and pass it with claude_args. Only the configs you pass load: --strict-mcp-config
8029
+ is always on. An MCP server inherits claude's env, which keeps your real HOME
8030
+ (claude's login is there), so set the server's HOME to the run's home, or it
8031
+ writes to yours. claude gets $FIREGOAT_HOME, $FIREGOAT_WORKDIR and $FIREGOAT_OUT
8032
+ in its env and expands \${VAR} in the config (an unset one stays literal);
8033
+ relative paths in it are from the run's folder, claude's cwd:
8034
+ fixtures/mcp.json:
8035
+ {"mcpServers": {"acme": {"command": "./node_modules/.bin/acme-mcp",
8036
+ "env": {"HOME": "\${FIREGOAT_HOME}"}}}}
8037
+ firegoat.yaml:
8038
+ build: cp -R fixtures/. "$FIREGOAT_OUT"
8039
+ claude_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
8024
8040
 
8025
8041
  Example:
8026
8042
  build: npm pack --pack-destination "$FIREGOAT_OUT"
@@ -8136,8 +8152,7 @@ fixtures and build: stay in the current folder.
8136
8152
  --json The summary as JSON on stdout (or {error, exitCode} if eval
8137
8153
  stops early); progress goes to stderr
8138
8154
  --no-upload Keep runs local even when logged in to firegoat.ai
8139
- --claude-bin PATH Path to claude (env FIREGOAT_CLAUDE_BIN; default: claude on
8140
- PATH)
8155
+ --claude-bin PATH Path to claude (default: claude on PATH)
8141
8156
 
8142
8157
  Upload: after firegoat login (or with FIREGOAT_API_KEY), each run's claude session,
8143
8158
  checks.txt and stderr.txt go to firegoat.ai as a private trace, and its URL is
@@ -8154,8 +8169,8 @@ Output: .firegoat/runs/<runId>/
8154
8169
  git {branch, commit, dirty} (no git outside a repo)
8155
8170
  and upload {origin, email} (null when not uploading)
8156
8171
  ../index.jsonl One line per eval: runId, startedAt, dir, branch,
8157
- commit, passed, failed, notGraded, cost, uploaded,
8158
- uploadFailed
8172
+ commit, passed, failed, notGraded, cost (null when
8173
+ any run's cost is unknown), uploaded, uploadFailed
8159
8174
  <test>/<n>/run.json One run: status and reason (from the checks), model
8160
8175
  (asked for, or null) and claudeModel (what claude's
8161
8176
  init line reported), claudeArgs (claude_args
@@ -8306,6 +8321,13 @@ function describeCall(step, name, input) {
8306
8321
  return `[${step}] ${name} ${input[key]}`;
8307
8322
  return `[${step}] ${name} ${truncate(JSON.stringify(input), 1, 300)}`;
8308
8323
  }
8324
+ function describeToolUse(step, b) {
8325
+ if (typeof b.name !== "string" || b.name === "")
8326
+ return `[${step}] (tool call without a name)`;
8327
+ if (typeof b.input !== "object" || b.input === null)
8328
+ return `[${step}] ${b.name} (no input)`;
8329
+ return describeCall(step, b.name, b.input);
8330
+ }
8309
8331
  function resultText(content) {
8310
8332
  if (typeof content === "string")
8311
8333
  return content;
@@ -8341,7 +8363,7 @@ function writeTranscript(opts) {
8341
8363
  if (e?.type === "assistant")
8342
8364
  for (const b of content) {
8343
8365
  if (b.type === "tool_use")
8344
- out.push(describeCall(++step, String(b.name), b.input ?? {}));
8366
+ out.push(describeToolUse(++step, b));
8345
8367
  else if (b.type === "text" && typeof b.text === "string") {
8346
8368
  lastText = out.length;
8347
8369
  out.push(prose(truncate(b.text, 30, TEXT_CHARS)));
@@ -8418,23 +8440,38 @@ function ramp(r) {
8418
8440
  const end = r.state === "pass" ? "▲" : r.state === "fail" ? "╳" : r.state === "error" ? "╌" : r.state === "running" ? "●" : "";
8419
8441
  return (blocks + end).padEnd(8);
8420
8442
  }
8421
- function usage(r) {
8443
+ function usageParts(r) {
8422
8444
  const parts = [];
8423
- if (r.cost !== null && r.cost !== undefined)
8445
+ if (typeof r.cost === "number")
8424
8446
  parts.push(`$${r.cost.toFixed(2)}`);
8425
- parts.push(`${r.turns} turns`);
8447
+ if (typeof r.reportedTurns === "number")
8448
+ parts.push(`${r.reportedTurns} turns`);
8426
8449
  if (r.seconds !== undefined)
8427
8450
  parts.push(`${r.seconds}s`);
8428
- return parts.join(" ");
8451
+ return parts;
8452
+ }
8453
+ function usage(r) {
8454
+ return usageParts(r).join(" ");
8429
8455
  }
8430
8456
  function checksPassed(r) {
8431
8457
  const all = r.checks ?? [];
8432
8458
  return `${all.filter((c) => c.code === 0).length}/${all.length} checks`;
8433
8459
  }
8434
- function total(rows) {
8435
- const cost = rows.reduce((s, r) => s + (r.cost ?? 0), 0);
8436
- const turns = rows.reduce((s, r) => s + r.turns, 0);
8437
- return { cost, turns };
8460
+ function totalCost(rows) {
8461
+ const done = rows.filter((r) => r.state !== "queued" && r.state !== "running");
8462
+ const unknown = done.filter((r) => typeof r.cost !== "number").length;
8463
+ if (unknown > 0)
8464
+ return `cost unknown for ${unknown} run${unknown === 1 ? "" : "s"}`;
8465
+ return `$${done.reduce((s, r) => s + r.cost, 0).toFixed(2)}`;
8466
+ }
8467
+ function totalTurns(rows) {
8468
+ let sum = 0;
8469
+ for (const r of rows) {
8470
+ if (typeof r.reportedTurns !== "number")
8471
+ return null;
8472
+ sum += r.reportedTurns;
8473
+ }
8474
+ return sum;
8438
8475
  }
8439
8476
  function duration(seconds) {
8440
8477
  return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
@@ -8476,9 +8513,9 @@ function plainUI(write) {
8476
8513
  continue;
8477
8514
  printed.add(r);
8478
8515
  const label = r.state === "error" ? "ERROR" : r.state.toUpperCase();
8479
- const u = `$${(r.cost ?? 0).toFixed(2)}, ${r.turns} turns, ${r.seconds ?? 0}s`;
8516
+ const u = usageParts(r).join(", ");
8480
8517
  const model = r.model === null ? "" : ` [${r.model}]`;
8481
- write(`${label} ${r.test}#${r.n}${model} ${r.reason} (${u})`);
8518
+ write(`${label} ${r.test}#${r.n}${model} ${r.reason}${u ? ` (${u})` : ""}`);
8482
8519
  }
8483
8520
  for (const r of rows) {
8484
8521
  if (uploadPrinted.has(r) || !r.upload || r.upload === "uploading")
@@ -8489,10 +8526,9 @@ function plainUI(write) {
8489
8526
  },
8490
8527
  done: ({ runsDir, rows: all }) => {
8491
8528
  const c = counts(all);
8492
- const t = total(all);
8493
8529
  const tr = tier(all);
8494
8530
  write("");
8495
- write(`${c.pass} passed, ${c.fail} failed, ${c.error} not graded (${all.length} runs, $${t.cost.toFixed(2)})${tr ? `, tier ${tr}` : ""}`);
8531
+ write(`${c.pass} passed, ${c.fail} failed, ${c.error} not graded (${all.length} runs, ${totalCost(all)})${tr ? `, tier ${tr}` : ""}`);
8496
8532
  write(`Runs: ${runsDir} (end_goal is for you to judge: read <test>/<n>/transcript.txt)`);
8497
8533
  }
8498
8534
  };
@@ -8585,12 +8621,11 @@ function ttyUI(out, opts) {
8585
8621
  out.push(p(C.amber, ` │ ${cut(r.upload.status === "failed" ? `↑ Upload failed: ${r.upload.error}` : `↑ Not uploaded: ${r.upload.reason}`, w - 12)}`));
8586
8622
  });
8587
8623
  out.push(rule);
8588
- const t = total(rows);
8589
8624
  if (!finished) {
8590
8625
  const track = Math.max(10, w - 34);
8591
8626
  const at = rows.length ? Math.round(doneCount / rows.length * (track - 1)) : 0;
8592
8627
  const progress = `${"·".repeat(at)}▶${"·".repeat(Math.max(0, track - at - 1))}`;
8593
- out.push(` ${p(C.ember, progress)} ${p(C.gray, `${doneCount}/${rows.length} · $${t.cost.toFixed(2)} so far`)}`);
8628
+ out.push(` ${p(C.ember, progress)} ${p(C.gray, `${doneCount}/${rows.length} · ${totalCost(rows)} so far`)}`);
8594
8629
  return out;
8595
8630
  }
8596
8631
  const tr = tier(rows);
@@ -8599,7 +8634,8 @@ function ttyUI(out, opts) {
8599
8634
  const left = goated ? `${rows.length}/${rows.length} runs passed their checks` : tally;
8600
8635
  const right = goated ? `GOATED · tier ${tr}` : tr ? `Tier ${tr}` : "";
8601
8636
  out.push(` ${goated ? p(C.green, left) : left}${" ".repeat(Math.max(2, w - left.length - right.length - 3))}${goated ? p(C.ember, right) : right}`);
8602
- out.push(p(C.gray, ` Total $${t.cost.toFixed(2)} · ${t.turns} turns · ${duration(finished.seconds)}`));
8637
+ const turns = totalTurns(rows);
8638
+ out.push(p(C.gray, ` Total ${totalCost(rows)}${turns === null ? "" : ` · ${turns} turns`} · ${duration(finished.seconds)}`));
8603
8639
  out.push(p(C.gray, ` Runs ${shortPath(finished.runsDir)}/`));
8604
8640
  out.push(` Checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
8605
8641
  return out;
@@ -8666,6 +8702,12 @@ function label(v, what) {
8666
8702
  throw new Error(`${what} is ${v.length} characters, the upload takes at most ${MAX_LABEL}`);
8667
8703
  return v;
8668
8704
  }
8705
+ function projectName(v) {
8706
+ const name = label(v, "Project name");
8707
+ if (name === null)
8708
+ throw new Error("Project name is empty");
8709
+ return name;
8710
+ }
8669
8711
  function runModel(r) {
8670
8712
  return {
8671
8713
  requested: label(r.model, "Model"),
@@ -8731,9 +8773,11 @@ async function uploadRun(t, r) {
8731
8773
  };
8732
8774
  bytes[name] = b;
8733
8775
  }
8776
+ let project;
8734
8777
  let model;
8735
8778
  let git;
8736
8779
  try {
8780
+ project = projectName(t.project);
8737
8781
  model = runModel(r);
8738
8782
  git = t.git ? runGit(t.git) : undefined;
8739
8783
  } catch (e) {
@@ -8742,7 +8786,7 @@ async function uploadRun(t, r) {
8742
8786
  const body = {
8743
8787
  sessionId,
8744
8788
  runGroup: t.runGroup,
8745
- project: t.project,
8789
+ project,
8746
8790
  timestamp: r.startedAt,
8747
8791
  run: {
8748
8792
  test: r.test,
@@ -8836,7 +8880,7 @@ function parseArgs(args) {
8836
8880
  json: false,
8837
8881
  noUpload: false,
8838
8882
  vars: {},
8839
- claudeBin: process.env.FIREGOAT_CLAUDE_BIN || "claude"
8883
+ claudeBin: "claude"
8840
8884
  };
8841
8885
  for (let i = 0;i < args.length; i++) {
8842
8886
  const a = args[i];
@@ -9031,7 +9075,10 @@ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
9031
9075
  cwd: workdir,
9032
9076
  env: {
9033
9077
  ...claudeEnv(opts.configDir, env, homeEnvFile),
9034
- PATH: opts.stepEnv.PATH
9078
+ PATH: opts.stepEnv.PATH,
9079
+ FIREGOAT_OUT: opts.out,
9080
+ FIREGOAT_WORKDIR: workdir,
9081
+ FIREGOAT_HOME: r.home
9035
9082
  },
9036
9083
  stderr,
9037
9084
  timeoutMs: test.timeout * 1000,
@@ -9161,7 +9208,7 @@ async function evalCommand(args, root) {
9161
9208
  return fatal(EXIT.usage, `No value for ${missing.map((k) => `{{vars.${k}}}`).join(", ")}: pass --var ${missing[0]}=... or set it under vars: in firegoat.yaml`);
9162
9209
  const claudePath = resolveBin(opts.claudeBin);
9163
9210
  if (!claudePath)
9164
- return fatal(EXIT.notGraded, `Not graded: claude binary ${JSON.stringify(opts.claudeBin)} not found. Install Claude Code (https://claude.com/claude-code) or pass --claude-bin PATH / FIREGOAT_CLAUDE_BIN.`);
9211
+ return fatal(EXIT.notGraded, `Not graded: claude binary ${JSON.stringify(opts.claudeBin)} not found. Install Claude Code (https://claude.com/claude-code) or pass --claude-bin PATH.`);
9165
9212
  let repo;
9166
9213
  try {
9167
9214
  repo = gitRepo(root);
@@ -9183,7 +9230,7 @@ async function evalCommand(args, root) {
9183
9230
  const runId = newRunId();
9184
9231
  let upload;
9185
9232
  try {
9186
- const pre = await uploadPreflight(opts, ui, basename3(repo?.mainCheckout ?? repo?.toplevel ?? root).slice(0, 256), runId, repo?.info ?? null);
9233
+ const pre = await uploadPreflight(opts, ui, basename3(repo?.mainCheckout ?? repo?.toplevel ?? root), runId, repo?.info ?? null);
9187
9234
  if (pre && "exitCode" in pre)
9188
9235
  return fatal(pre.exitCode, pre.error);
9189
9236
  upload = pre;
@@ -9266,7 +9313,7 @@ async function evalCommand(args, root) {
9266
9313
  results[i] = r;
9267
9314
  Object.assign(row, {
9268
9315
  state: r.status,
9269
- turns: r.num_turns ?? row.turns,
9316
+ reportedTurns: r.num_turns,
9270
9317
  reason: r.reason,
9271
9318
  checks: r.checks,
9272
9319
  cost: r.cost,
@@ -9314,7 +9361,7 @@ async function evalCommand(args, root) {
9314
9361
  passed: totals.pass,
9315
9362
  failed: totals.fail,
9316
9363
  notGraded: totals.error,
9317
- cost: results.reduce((sum, r) => sum + (r.cost ?? 0), 0),
9364
+ cost: results.every((r) => r.cost !== null) ? results.reduce((sum, r) => sum + r.cost, 0) : null,
9318
9365
  uploaded: results.filter((r) => r.upload?.status === "uploaded").length,
9319
9366
  uploadFailed: uploadsFailed
9320
9367
  })}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"