firegoat 0.29.0 → 0.31.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 +3 -1
  2. package/dist/cli.js +562 -294
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,7 +8,7 @@ So far we've got the CLI. It lets anyone use a simplified version of our product
8
8
 
9
9
  ## What you get
10
10
 
11
- When you run `firegoat eval`, you get visibility into how agents use your product: whether they fail or succeed, and how much time they take doing it. You can point them at your local codebase or at your cloud product, and see how Claude acts on the simple tasks your product aims to do.
11
+ When you run `firegoat eval`, you get visibility into how agents use your product: whether they fail or succeed, and how much time and how many tokens they take doing it. You can point them at your local codebase or at your cloud product, and see how Claude acts on the simple tasks your product aims to do. Give harder tests more `reward_points` and eval tells you the tokens spent per point earned, per harness and model.
12
12
 
13
13
  ## Start
14
14
 
@@ -22,6 +22,8 @@ In a terminal it asks where to install the firegoat-onboarding skill (global by
22
22
  npx firegoat@latest eval
23
23
  ```
24
24
 
25
+ Run by an agent, eval ends with a results block to paste as-is (also in `.firegoat/runs/<runId>/results.txt`): ask it to show you the results table.
26
+
25
27
  ## Example
26
28
 
27
29
  ```yaml
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.29.0",
6950
+ version: "0.31.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -7039,6 +7039,230 @@ var $visitAsync = visit.visitAsync;
7039
7039
  import { spawnSync } from "node:child_process";
7040
7040
  import { existsSync, readdirSync } from "node:fs";
7041
7041
  import { join } from "node:path";
7042
+
7043
+ // src/process.ts
7044
+ import { execFileSync, spawn } from "node:child_process";
7045
+ var NPM_INJECTED = /^(npm_config_|npm_lifecycle_|npm_package_)|^(npm_execpath|npm_node_execpath|npm_command|INIT_CWD)$/;
7046
+ var CLAUDE_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)$/;
7047
+ var CLAUDE_AUTH = /^CLAUDE_CODE_(OAUTH_TOKEN|OAUTH_REFRESH_TOKEN|USE_[A-Z_]+|SKIP_[A-Z]+_AUTH|CLIENT_CERT|CLIENT_KEY|CLIENT_KEY_PASSPHRASE)$/;
7048
+ function hostEnv() {
7049
+ return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k) && !k.startsWith("FIREGOAT_") && (!CLAUDE_SESSION.test(k) || CLAUDE_AUTH.test(k))));
7050
+ }
7051
+ var GRACE_MS = 5000;
7052
+ var liveGroups = new Set;
7053
+ var signalsHooked = false;
7054
+ function hookSignals() {
7055
+ if (signalsHooked)
7056
+ return;
7057
+ signalsHooked = true;
7058
+ const onSignal = (signal) => {
7059
+ for (const g of liveGroups)
7060
+ try {
7061
+ process.kill(-g, "SIGKILL");
7062
+ } catch {}
7063
+ process.kill(process.pid, signal);
7064
+ };
7065
+ for (const s of ["SIGINT", "SIGTERM", "SIGHUP"])
7066
+ process.once(s, onSignal);
7067
+ }
7068
+ function forgetGroup(pgid) {
7069
+ liveGroups.delete(pgid);
7070
+ }
7071
+ function processTree(pid) {
7072
+ const children = new Map;
7073
+ const ps = execFileSync("ps", ["-A", "-o", "pid=,ppid="], {
7074
+ encoding: "utf8"
7075
+ });
7076
+ for (const line of ps.split(`
7077
+ `)) {
7078
+ const [child, parent] = line.trim().split(/\s+/).map(Number);
7079
+ if (!child || parent === undefined)
7080
+ continue;
7081
+ children.set(parent, [...children.get(parent) ?? [], child]);
7082
+ }
7083
+ const tree = [pid];
7084
+ for (let i = 0;i < tree.length; i++)
7085
+ tree.push(...children.get(tree[i]) ?? []);
7086
+ return tree;
7087
+ }
7088
+ function killTree(pid, group) {
7089
+ if (group)
7090
+ try {
7091
+ process.kill(-pid, "SIGKILL");
7092
+ } catch {}
7093
+ let tree = [pid];
7094
+ let problem;
7095
+ try {
7096
+ tree = processTree(pid);
7097
+ } catch (e) {
7098
+ problem = e.message;
7099
+ }
7100
+ for (const p of tree)
7101
+ try {
7102
+ process.kill(p, "SIGKILL");
7103
+ } catch {}
7104
+ return problem;
7105
+ }
7106
+ function run(opts) {
7107
+ return new Promise((resolve) => {
7108
+ const detached = opts.detached === true;
7109
+ if (detached)
7110
+ hookSignals();
7111
+ const child = spawn(opts.cmd, opts.args, {
7112
+ cwd: opts.cwd,
7113
+ env: opts.env,
7114
+ stdio: ["ignore", "pipe", "pipe"],
7115
+ detached
7116
+ });
7117
+ if (child.pid !== undefined) {
7118
+ if (detached)
7119
+ liveGroups.add(child.pid);
7120
+ opts.onSpawn?.(child.pid);
7121
+ }
7122
+ let timedOut = false;
7123
+ let settled = false;
7124
+ const timer = setTimeout(() => {
7125
+ timedOut = true;
7126
+ if (child.pid === undefined)
7127
+ return;
7128
+ const problem = killTree(child.pid, detached);
7129
+ if (problem)
7130
+ opts.onStderr(Buffer.from(`firegoat: timed out; could not list ${opts.cmd}'s child processes (${problem}), killed only ${opts.cmd}
7131
+ `));
7132
+ }, opts.timeoutMs);
7133
+ const done = (r) => {
7134
+ if (settled)
7135
+ return;
7136
+ settled = true;
7137
+ clearTimeout(timer);
7138
+ resolve(r);
7139
+ };
7140
+ child.stdout.on("data", opts.onStdout);
7141
+ child.stderr.on("data", opts.onStderr);
7142
+ opts.finalSeen?.then(() => setTimeout(() => done({ code: null, timedOut: false, ended: true }), GRACE_MS).unref());
7143
+ child.on("error", (error) => done({ code: null, timedOut, error }));
7144
+ child.on("close", (code) => done({ code, timedOut }));
7145
+ child.on("exit", (code) => setTimeout(() => {
7146
+ child.stdout.destroy();
7147
+ child.stderr.destroy();
7148
+ done({ code, timedOut });
7149
+ }, 2000).unref());
7150
+ });
7151
+ }
7152
+ async function runShell(opts) {
7153
+ opts.log.write(`$ ${opts.run}
7154
+ `);
7155
+ const r = await run({
7156
+ cmd: "/bin/sh",
7157
+ args: ["-c", opts.run],
7158
+ cwd: opts.cwd,
7159
+ env: opts.env,
7160
+ timeoutMs: opts.timeoutMs,
7161
+ onStdout: (c) => opts.log.write(c),
7162
+ onStderr: (c) => opts.log.write(c)
7163
+ });
7164
+ let error;
7165
+ if (r.error)
7166
+ error = `could not start /bin/sh: ${r.error.message}`;
7167
+ else if (r.timedOut)
7168
+ error = `timed out after ${opts.timeoutMs / 1000}s`;
7169
+ else if (r.code === null)
7170
+ error = "killed by a signal";
7171
+ opts.log.write(`[exit ${r.code ?? "none"}${error ? `: ${error}` : ""}]
7172
+
7173
+ `);
7174
+ return error ? { run: opts.run, code: r.code, error } : { run: opts.run, code: r.code };
7175
+ }
7176
+
7177
+ // src/harness.ts
7178
+ function tokens(parts = {
7179
+ input: null,
7180
+ output: null,
7181
+ cacheRead: null,
7182
+ cacheWrite: null
7183
+ }) {
7184
+ const { input, output, cacheRead, cacheWrite } = parts;
7185
+ const total = input === null || output === null || cacheRead === null || cacheWrite === null ? null : input + output + cacheRead + cacheWrite;
7186
+ return { input, output, cacheRead, cacheWrite, total };
7187
+ }
7188
+ function modelUsageTokens(modelUsage) {
7189
+ if (typeof modelUsage !== "object" || modelUsage === null)
7190
+ return tokens();
7191
+ const entries = Object.values(modelUsage);
7192
+ if (entries.length === 0)
7193
+ return tokens();
7194
+ const sum = (key) => {
7195
+ let n = 0;
7196
+ for (const e of entries) {
7197
+ const v = e?.[key];
7198
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 0)
7199
+ return null;
7200
+ n += v;
7201
+ }
7202
+ return n;
7203
+ };
7204
+ return tokens({
7205
+ input: sum("inputTokens"),
7206
+ output: sum("outputTokens"),
7207
+ cacheRead: sum("cacheReadInputTokens"),
7208
+ cacheWrite: sum("cacheCreationInputTokens")
7209
+ });
7210
+ }
7211
+ async function runAgent(harness, opts) {
7212
+ let stdout = "";
7213
+ let pending = "";
7214
+ let group;
7215
+ let markFinal = () => {};
7216
+ const finalSeen = new Promise((r) => {
7217
+ markFinal = r;
7218
+ });
7219
+ const exited = await run({
7220
+ cmd: opts.bin,
7221
+ args: harness.args(opts),
7222
+ cwd: opts.cwd,
7223
+ env: opts.env,
7224
+ timeoutMs: opts.timeoutMs,
7225
+ detached: true,
7226
+ finalSeen,
7227
+ onSpawn: (pid) => {
7228
+ group = pid;
7229
+ },
7230
+ onStdout: (c) => {
7231
+ const text = c.toString();
7232
+ stdout += text;
7233
+ pending += text;
7234
+ const lines = pending.split(`
7235
+ `);
7236
+ pending = lines.pop() ?? "";
7237
+ for (const line of lines) {
7238
+ if (harness.isFinal(line))
7239
+ markFinal();
7240
+ const p = opts.onProgress && harness.progress(line);
7241
+ if (p)
7242
+ opts.onProgress?.(p);
7243
+ }
7244
+ },
7245
+ onStderr: (c) => opts.stderr.write(c)
7246
+ });
7247
+ if (exited.error)
7248
+ return {
7249
+ group,
7250
+ outcome: {
7251
+ ok: false,
7252
+ reason: `could not start ${opts.bin}: ${exited.error.message}`,
7253
+ steps: [],
7254
+ final: "",
7255
+ reportedModel: null,
7256
+ version: null,
7257
+ turns: null,
7258
+ cost: null,
7259
+ tokens: tokens()
7260
+ }
7261
+ };
7262
+ return { group, outcome: harness.read(stdout, exited, opts.timeoutMs) };
7263
+ }
7264
+
7265
+ // src/harnesses/claude.ts
7042
7266
  var FLAGS = [
7043
7267
  "--output-format",
7044
7268
  "stream-json",
@@ -7050,6 +7274,17 @@ var FLAGS = [
7050
7274
  "--disable-slash-commands"
7051
7275
  ];
7052
7276
  var SET = "firegoat sets it for every run";
7277
+ function sumTurns(results) {
7278
+ if (results.length === 0)
7279
+ return null;
7280
+ let n = 0;
7281
+ for (const r of results) {
7282
+ if (typeof r.num_turns !== "number")
7283
+ return null;
7284
+ n += r.num_turns;
7285
+ }
7286
+ return n;
7287
+ }
7053
7288
  function parse(line) {
7054
7289
  try {
7055
7290
  const e = JSON.parse(line);
@@ -7105,12 +7340,14 @@ function read(stdout, exited, timeoutMs) {
7105
7340
  for (const e of events)
7106
7341
  if (e?.type === "result" && typeof e.result === "string")
7107
7342
  final = e.result;
7108
- const result = events.findLast((e) => e?.type === "result");
7343
+ const results = events.filter((e) => e?.type === "result");
7344
+ const result = results.at(-1);
7109
7345
  const trace = {
7110
7346
  steps: steps(events),
7111
7347
  final,
7112
- turns: result?.num_turns ?? null,
7113
- cost: result?.total_cost_usd ?? null
7348
+ turns: sumTurns(results),
7349
+ cost: result?.total_cost_usd ?? null,
7350
+ tokens: modelUsageTokens(result?.modelUsage)
7114
7351
  };
7115
7352
  const init = events.find((e) => e?.type === "system" && e.subtype === "init");
7116
7353
  if (!init) {
@@ -7269,142 +7506,6 @@ import { execFile } from "node:child_process";
7269
7506
  import { mkdirSync, writeFileSync } from "node:fs";
7270
7507
  import { join as join2 } from "node:path";
7271
7508
  import { promisify } from "node:util";
7272
-
7273
- // src/process.ts
7274
- import { execFileSync, spawn } from "node:child_process";
7275
- var NPM_INJECTED = /^(npm_config_|npm_lifecycle_|npm_package_)|^(npm_execpath|npm_node_execpath|npm_command|INIT_CWD)$/;
7276
- var CLAUDE_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)$/;
7277
- var CLAUDE_AUTH = /^CLAUDE_CODE_(OAUTH_TOKEN|OAUTH_REFRESH_TOKEN|USE_[A-Z_]+|SKIP_[A-Z]+_AUTH|CLIENT_CERT|CLIENT_KEY|CLIENT_KEY_PASSPHRASE)$/;
7278
- function hostEnv() {
7279
- return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k) && !k.startsWith("FIREGOAT_") && (!CLAUDE_SESSION.test(k) || CLAUDE_AUTH.test(k))));
7280
- }
7281
- var GRACE_MS = 5000;
7282
- var liveGroups = new Set;
7283
- var signalsHooked = false;
7284
- function hookSignals() {
7285
- if (signalsHooked)
7286
- return;
7287
- signalsHooked = true;
7288
- const onSignal = (signal) => {
7289
- for (const g of liveGroups)
7290
- try {
7291
- process.kill(-g, "SIGKILL");
7292
- } catch {}
7293
- process.kill(process.pid, signal);
7294
- };
7295
- for (const s of ["SIGINT", "SIGTERM", "SIGHUP"])
7296
- process.once(s, onSignal);
7297
- }
7298
- function forgetGroup(pgid) {
7299
- liveGroups.delete(pgid);
7300
- }
7301
- function processTree(pid) {
7302
- const children = new Map;
7303
- const ps = execFileSync("ps", ["-A", "-o", "pid=,ppid="], {
7304
- encoding: "utf8"
7305
- });
7306
- for (const line of ps.split(`
7307
- `)) {
7308
- const [child, parent] = line.trim().split(/\s+/).map(Number);
7309
- if (!child || parent === undefined)
7310
- continue;
7311
- children.set(parent, [...children.get(parent) ?? [], child]);
7312
- }
7313
- const tree = [pid];
7314
- for (let i = 0;i < tree.length; i++)
7315
- tree.push(...children.get(tree[i]) ?? []);
7316
- return tree;
7317
- }
7318
- function killTree(pid, group) {
7319
- if (group)
7320
- try {
7321
- process.kill(-pid, "SIGKILL");
7322
- } catch {}
7323
- let tree = [pid];
7324
- let problem;
7325
- try {
7326
- tree = processTree(pid);
7327
- } catch (e) {
7328
- problem = e.message;
7329
- }
7330
- for (const p of tree)
7331
- try {
7332
- process.kill(p, "SIGKILL");
7333
- } catch {}
7334
- return problem;
7335
- }
7336
- function run(opts) {
7337
- return new Promise((resolve) => {
7338
- const detached = opts.detached === true;
7339
- if (detached)
7340
- hookSignals();
7341
- const child = spawn(opts.cmd, opts.args, {
7342
- cwd: opts.cwd,
7343
- env: opts.env,
7344
- stdio: ["ignore", "pipe", "pipe"],
7345
- detached
7346
- });
7347
- if (child.pid !== undefined) {
7348
- if (detached)
7349
- liveGroups.add(child.pid);
7350
- opts.onSpawn?.(child.pid);
7351
- }
7352
- let timedOut = false;
7353
- let settled = false;
7354
- const timer = setTimeout(() => {
7355
- timedOut = true;
7356
- if (child.pid === undefined)
7357
- return;
7358
- const problem = killTree(child.pid, detached);
7359
- if (problem)
7360
- opts.onStderr(Buffer.from(`firegoat: timed out; could not list ${opts.cmd}'s child processes (${problem}), killed only ${opts.cmd}
7361
- `));
7362
- }, opts.timeoutMs);
7363
- const done = (r) => {
7364
- if (settled)
7365
- return;
7366
- settled = true;
7367
- clearTimeout(timer);
7368
- resolve(r);
7369
- };
7370
- child.stdout.on("data", opts.onStdout);
7371
- child.stderr.on("data", opts.onStderr);
7372
- opts.finalSeen?.then(() => setTimeout(() => done({ code: null, timedOut: false, ended: true }), GRACE_MS).unref());
7373
- child.on("error", (error) => done({ code: null, timedOut, error }));
7374
- child.on("close", (code) => done({ code, timedOut }));
7375
- child.on("exit", (code) => setTimeout(() => {
7376
- child.stdout.destroy();
7377
- child.stderr.destroy();
7378
- done({ code, timedOut });
7379
- }, 2000).unref());
7380
- });
7381
- }
7382
- async function runShell(opts) {
7383
- opts.log.write(`$ ${opts.run}
7384
- `);
7385
- const r = await run({
7386
- cmd: "/bin/sh",
7387
- args: ["-c", opts.run],
7388
- cwd: opts.cwd,
7389
- env: opts.env,
7390
- timeoutMs: opts.timeoutMs,
7391
- onStdout: (c) => opts.log.write(c),
7392
- onStderr: (c) => opts.log.write(c)
7393
- });
7394
- let error;
7395
- if (r.error)
7396
- error = `could not start /bin/sh: ${r.error.message}`;
7397
- else if (r.timedOut)
7398
- error = `timed out after ${opts.timeoutMs / 1000}s`;
7399
- else if (r.code === null)
7400
- error = "killed by a signal";
7401
- opts.log.write(`[exit ${r.code ?? "none"}${error ? `: ${error}` : ""}]
7402
-
7403
- `);
7404
- return error ? { run: opts.run, code: r.code, error } : { run: opts.run, code: r.code };
7405
- }
7406
-
7407
- // src/home.ts
7408
7509
  function homeEnv(home) {
7409
7510
  return {
7410
7511
  HOME: home,
@@ -7551,6 +7652,33 @@ function errorMessage(raw) {
7551
7652
  } catch {}
7552
7653
  return raw;
7553
7654
  }
7655
+ function usageTokens(events) {
7656
+ const usages = events.filter((e) => e?.type === "turn.completed").map((e) => e?.usage ?? {});
7657
+ if (usages.length === 0)
7658
+ return tokens();
7659
+ const sum = (key) => {
7660
+ let n = 0;
7661
+ for (const u of usages) {
7662
+ const v = u[key];
7663
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 0)
7664
+ return null;
7665
+ n += v;
7666
+ }
7667
+ return n;
7668
+ };
7669
+ const input = sum("input_tokens");
7670
+ const cacheRead = sum("cached_input_tokens");
7671
+ const cacheWrite = sum("cache_write_input_tokens");
7672
+ const uncached = input === null || cacheRead === null || cacheWrite === null ? null : input - cacheRead - cacheWrite;
7673
+ if (uncached !== null && uncached < 0)
7674
+ throw new Error(`codex reported input_tokens ${input} below cached_input_tokens ${cacheRead} + cache_write_input_tokens ${cacheWrite}: its usage is no longer what firegoat reads (src/harnesses/codex.ts usageTokens)`);
7675
+ return tokens({
7676
+ input: uncached,
7677
+ output: sum("output_tokens"),
7678
+ cacheRead,
7679
+ cacheWrite
7680
+ });
7681
+ }
7554
7682
  function read2(stdout, exited, timeoutMs) {
7555
7683
  const events = stdout.split(`
7556
7684
  `).filter((l) => l.trim() !== "").map(parse2);
@@ -7560,7 +7688,8 @@ function read2(stdout, exited, timeoutMs) {
7560
7688
  reportedModel: null,
7561
7689
  version: null,
7562
7690
  turns: null,
7563
- cost: null
7691
+ cost: null,
7692
+ tokens: usageTokens(events)
7564
7693
  };
7565
7694
  const thread = events.find((e) => e?.type === "thread.started");
7566
7695
  if (!thread) {
@@ -7833,7 +7962,8 @@ function read3(stdout, exited, timeoutMs) {
7833
7962
  steps: steps3(events),
7834
7963
  final: typeof result?.result === "string" ? result.result : "",
7835
7964
  turns: result?.num_turns ?? null,
7836
- cost: result?.total_cost_usd ?? null
7965
+ cost: result?.total_cost_usd ?? null,
7966
+ tokens: modelUsageTokens(result?.modelUsage)
7837
7967
  };
7838
7968
  const init = events.find((e) => e?.type === "system" && e.subtype === "init");
7839
7969
  const reportedModel = typeof init?.model === "string" && init.model !== "unknown" ? init.model : null;
@@ -8110,6 +8240,7 @@ function parseConfig(text) {
8110
8240
  "end_goal",
8111
8241
  "checks",
8112
8242
  "timeout",
8243
+ "reward_points",
8113
8244
  "env",
8114
8245
  "install",
8115
8246
  "cleanup",
@@ -8140,6 +8271,7 @@ function parseConfig(text) {
8140
8271
  end_goal: str2(t.end_goal, `${p}.end_goal`),
8141
8272
  checks,
8142
8273
  timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT),
8274
+ rewardPoints: posInt(t.reward_points, `${p}.reward_points`, 1),
8143
8275
  env: { ...env, ...parseEnv(t.env, `${p}.env`) },
8144
8276
  install: commands(t.install, `${p}.install`, RUN_PLACEHOLDERS),
8145
8277
  cleanup: commands(t.cleanup, `${p}.cleanup`, RUN_PLACEHOLDERS),
@@ -8743,60 +8875,6 @@ function gitRepo(dir) {
8743
8875
  };
8744
8876
  }
8745
8877
 
8746
- // src/harness.ts
8747
- async function runAgent(harness, opts) {
8748
- let stdout = "";
8749
- let pending = "";
8750
- let group;
8751
- let markFinal = () => {};
8752
- const finalSeen = new Promise((r) => {
8753
- markFinal = r;
8754
- });
8755
- const exited = await run({
8756
- cmd: opts.bin,
8757
- args: harness.args(opts),
8758
- cwd: opts.cwd,
8759
- env: opts.env,
8760
- timeoutMs: opts.timeoutMs,
8761
- detached: true,
8762
- finalSeen,
8763
- onSpawn: (pid) => {
8764
- group = pid;
8765
- },
8766
- onStdout: (c) => {
8767
- const text = c.toString();
8768
- stdout += text;
8769
- pending += text;
8770
- const lines = pending.split(`
8771
- `);
8772
- pending = lines.pop() ?? "";
8773
- for (const line of lines) {
8774
- if (harness.isFinal(line))
8775
- markFinal();
8776
- const p = opts.onProgress && harness.progress(line);
8777
- if (p)
8778
- opts.onProgress?.(p);
8779
- }
8780
- },
8781
- onStderr: (c) => opts.stderr.write(c)
8782
- });
8783
- if (exited.error)
8784
- return {
8785
- group,
8786
- outcome: {
8787
- ok: false,
8788
- reason: `could not start ${opts.bin}: ${exited.error.message}`,
8789
- steps: [],
8790
- final: "",
8791
- reportedModel: null,
8792
- version: null,
8793
- turns: null,
8794
- cost: null
8795
- }
8796
- };
8797
- return { group, outcome: harness.read(stdout, exited, opts.timeoutMs) };
8798
- }
8799
-
8800
8878
  // src/help.ts
8801
8879
  var EXIT_CODES = `Exit codes:
8802
8880
  0 Every run's script checks passed
@@ -8944,6 +9022,8 @@ firegoat.yaml reference:
8944
9022
  checks: Optional shell commands, run in the agent's folder
8945
9023
  - run: ... Afterwards; exit 0 = pass; they set eval's exit code
8946
9024
  timeout: 600 Optional, seconds
9025
+ reward_points: 3 Optional positive integer (default 1): what a run
9026
+ earns when its checks pass; harder end goals get more
8947
9027
  env: {NAME: value} Optional, on top of the top-level env:
8948
9028
  install: <sh> | [...] Optional, runs after the top-level install
8949
9029
  cleanup: <sh> | [...] Optional, runs before the top-level cleanup
@@ -9171,7 +9251,8 @@ firegoat.yaml, fixtures and build: stay in the current folder.
9171
9251
  cleanup); env for build, install, checks and cleanup
9172
9252
  --json The summary as JSON on stdout (or {error, exitCode} if eval
9173
9253
  stops early); progress goes to stderr. The verdict comes
9174
- last, after runs: totals {pass, fail, error}, tier
9254
+ last, after runs and the reward points (Reward, below):
9255
+ totals {pass, fail, error}, tier
9175
9256
  (S/A/B/C, as printed; null when no run was graded), status
9176
9257
  ("pass", "fail" or "error" = not graded) and exitCode
9177
9258
  --no-upload Keep runs local even when logged in to firegoat.ai
@@ -9203,17 +9284,45 @@ says "N uploads failed" on stderr. A file over 20 MiB fails that run's upload.
9203
9284
  Runs without a session file are not uploaded. FIREGOAT_BASE_URL points elsewhere
9204
9285
  (default https://firegoat.ai/evals). See firegoat login --help.
9205
9286
 
9287
+ Results block: without a terminal (an agent, a pipe, CI) and without --json, eval
9288
+ ends with the terminal's final timeline as one plain block (no color, at most 100
9289
+ columns): the logo header, a row per run (status, a ramp of one block per agent turn
9290
+ ending ▲ pass, ╳ fail or ╌ not graded, checks passed, cost, turns, tokens, time), its
9291
+ failed checks and trace URL, the totals, the tier ("GOATED · tier S" when all pass),
9292
+ "Reward points 7/9 · 12k tokens per point", and with more than one harness × model a
9293
+ table of each one's points, tokens per point, turns and time. It
9294
+ sits between two lines of ═, after a "Results (paste the block ...)" line; the last
9295
+ line says where it is saved, results.txt. Show it to your user as-is in a code block.
9296
+
9297
+ Reward: value per token. A run earns its test's reward_points when its checks pass,
9298
+ else 0; not graded runs earn 0 and still count in the max. tokens = input (uncached)
9299
+ + output + cacheRead + cacheWrite, as the harness reports them, never estimated:
9300
+ claude-code and grok-cli from the result line's modelUsage (all models, subagents
9301
+ too), codex-cli from turn.completed usage (its cached and cache-written tokens are
9302
+ taken out of input_tokens). Tokens per reward point = the tokens of every run,
9303
+ passed or not, / the points earned; null when no points were earned or any run's
9304
+ tokens are unknown. Tokenizers differ (claude, gpt, grok), so token counts across
9305
+ harnesses are approximate.
9306
+
9206
9307
  Output: .firegoat/runs/<runId>/
9207
9308
  out A symlink to $FIREGOAT_OUT,
9208
9309
  ~/.firegoat/runs/<hash>/<random>/out
9209
9310
  summary.json firegoatVersion, runId, runsDir, dir (where eval
9210
9311
  ran), git {branch, commit, dirty} (no git outside a
9211
9312
  repo), harnesses, upload {origin, email} (null when
9212
- not uploading), runs (every run.json), then the
9213
- verdict: totals, tier, status and exitCode
9313
+ not uploading), runs (every run.json),
9314
+ rewardPoints, rewardPointsMax, tokens (each part
9315
+ summed; null when any run's is), tokensPerRewardPoint
9316
+ (null with 0 points or unknown tokens), groups (per
9317
+ harness × model: harness, model, runs, the same four,
9318
+ turns, durationMs), then the verdict: totals, tier,
9319
+ status and exitCode
9320
+ results.txt The results block, also with --json or in a terminal
9214
9321
  ../index.jsonl One line per eval: runId, startedAt, dir, branch,
9215
9322
  commit, harnesses, passed, failed, notGraded, cost (null when
9216
- any run's cost is unknown), uploaded, uploadFailed
9323
+ any run's cost is unknown), rewardPoints,
9324
+ rewardPointsMax, tokens (the total, or null),
9325
+ uploaded, uploadFailed
9217
9326
  <test>/<n>/run.json One run: status and reason (from the checks), harness,
9218
9327
  model (asked for, or null) and reportedModel (what
9219
9328
  the harness reported running, or null), harnessArgs
@@ -9228,7 +9337,11 @@ Output: .firegoat/runs/<runId>/
9228
9337
  own file, else <test>/<n>/session.jsonl made from the
9229
9338
  stream) and sessionId (its id, the trace's), stderr,
9230
9339
  checksLog, cleanupLog; turns and cost (null when the
9231
- harness reports none), durationMs, startedAt; upload ({status: "uploaded",
9340
+ harness reports none), rewardPoints (earned),
9341
+ rewardPointsMax (the test's reward_points), tokens
9342
+ {input, output, cacheRead, cacheWrite, total} (null
9343
+ parts when the harness reports none), durationMs,
9344
+ startedAt; upload ({status: "uploaded",
9232
9345
  url} | {status: "failed", error} | {status:
9233
9346
  "skipped", reason}, null when not uploading)
9234
9347
  <test>/<n>/transcript.txt What the agent did, below
@@ -9406,6 +9519,46 @@ async function stopLeftovers(opts) {
9406
9519
  return leftover;
9407
9520
  }
9408
9521
 
9522
+ // src/reward.ts
9523
+ function earned(status, rewardPointsMax) {
9524
+ return status === "pass" ? rewardPointsMax : 0;
9525
+ }
9526
+ function sum(values) {
9527
+ let n = 0;
9528
+ for (const v of values) {
9529
+ if (v === null)
9530
+ return null;
9531
+ n += v;
9532
+ }
9533
+ return n;
9534
+ }
9535
+ function tally(runs) {
9536
+ const rewardPoints = runs.reduce((s, r) => s + r.rewardPoints, 0);
9537
+ const t = tokens({
9538
+ input: sum(runs.map((r) => r.tokens.input)),
9539
+ output: sum(runs.map((r) => r.tokens.output)),
9540
+ cacheRead: sum(runs.map((r) => r.tokens.cacheRead)),
9541
+ cacheWrite: sum(runs.map((r) => r.tokens.cacheWrite))
9542
+ });
9543
+ return {
9544
+ rewardPoints,
9545
+ rewardPointsMax: runs.reduce((s, r) => s + r.rewardPointsMax, 0),
9546
+ tokens: t,
9547
+ tokensPerRewardPoint: rewardPoints === 0 || t.total === null ? null : Math.round(t.total / rewardPoints)
9548
+ };
9549
+ }
9550
+ function byGroup(runs) {
9551
+ const groups = [];
9552
+ for (const r of runs) {
9553
+ const g = groups.find((x) => x.harness === r.harness && x.model === r.model);
9554
+ if (g)
9555
+ g.runs.push(r);
9556
+ else
9557
+ groups.push({ harness: r.harness, model: r.model, runs: [r] });
9558
+ }
9559
+ return groups;
9560
+ }
9561
+
9409
9562
  // src/session.ts
9410
9563
  import { randomUUID } from "node:crypto";
9411
9564
  import { writeFileSync as writeFileSync3 } from "node:fs";
@@ -10221,16 +10374,56 @@ function ramp(r) {
10221
10374
  const end = r.state === "pass" ? "▲" : r.state === "fail" ? "╳" : r.state === "error" ? "╌" : r.state === "running" ? "●" : "";
10222
10375
  return (blocks + end).padEnd(8);
10223
10376
  }
10377
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
10378
+ function compactTokens(n) {
10379
+ const unit = (v, suffix) => `${v < 9.95 ? (Math.round(v * 10) / 10).toString() : Math.round(v)}${suffix}`;
10380
+ if (n < 1000)
10381
+ return String(n);
10382
+ if (n < 999500)
10383
+ return unit(n / 1000, "k");
10384
+ return unit(n / 1e6, "M");
10385
+ }
10224
10386
  function usageParts(r) {
10225
10387
  const parts = [];
10226
10388
  if (typeof r.cost === "number")
10227
10389
  parts.push(`$${r.cost.toFixed(2)}`);
10228
10390
  if (typeof r.reportedTurns === "number")
10229
- parts.push(`${r.reportedTurns} turns`);
10391
+ parts.push(plural(r.reportedTurns, "turn"));
10392
+ if (r.tokens.total !== null)
10393
+ parts.push(`${compactTokens(r.tokens.total)} tokens`);
10230
10394
  if (r.seconds !== undefined)
10231
10395
  parts.push(`${r.seconds}s`);
10232
10396
  return parts;
10233
10397
  }
10398
+ function rewardLine(t, rows) {
10399
+ const points = `Reward points ${t.rewardPoints}/${t.rewardPointsMax}`;
10400
+ const unknown = rows.filter((r) => r.tokens.total === null).length;
10401
+ if (unknown > 0)
10402
+ return `${points} · tokens unknown for ${plural(unknown, "run")}`;
10403
+ if (t.tokensPerRewardPoint === null)
10404
+ return `${points} · no points, so no tokens per point`;
10405
+ return `${points} · ${compactTokens(t.tokensPerRewardPoint)} tokens per point`;
10406
+ }
10407
+ function comparison(rows) {
10408
+ const groups = byGroup(rows.map((r) => ({ ...r, harness: r.harnessName })));
10409
+ const label = (g) => `${g.harness} ${g.model ?? "(default model)"}`;
10410
+ const width = Math.min(40, Math.max(5, ...groups.map((g) => label(g).length)));
10411
+ const line = (cells) => ` ${cells[0].padEnd(width)} ${cells[1].padEnd(8)} ${cells[2].padEnd(10)} ${cells[3].padEnd(6)} ${cells[4]}`;
10412
+ return [
10413
+ line(["Group", "Points", "Tokens/pt", "Turns", "Time"]),
10414
+ ...groups.map((g) => {
10415
+ const t = tally(g.runs);
10416
+ const turns = totalTurns(g.runs);
10417
+ return line([
10418
+ cut(label(g), width),
10419
+ `${t.rewardPoints}/${t.rewardPointsMax}`,
10420
+ g.runs.some((r) => r.tokens.total === null) ? "unknown" : t.tokensPerRewardPoint === null ? "no points" : compactTokens(t.tokensPerRewardPoint),
10421
+ turns === null ? "unknown" : String(turns),
10422
+ duration(g.runs.reduce((s, r) => s + (r.seconds ?? 0), 0))
10423
+ ]);
10424
+ })
10425
+ ];
10426
+ }
10234
10427
  function usage(r) {
10235
10428
  return usageParts(r).join(" ");
10236
10429
  }
@@ -10313,17 +10506,9 @@ function plainUI(write) {
10313
10506
  }
10314
10507
  };
10315
10508
  }
10316
- function ttyUI(out, opts) {
10317
- const p = painter(opts.color);
10318
- let rows = [];
10319
- let build = null;
10320
- let authText = "";
10321
- const notes = [];
10322
- let frame = 0;
10323
- let drawn = 0;
10324
- let finished = null;
10325
- const width = () => Math.min(out.columns || 80, 100);
10326
- const cut = (text, max) => text.length > max ? `${text.slice(0, max - 1)}…` : text;
10509
+ var cut = (text, max) => text.length > max ? `${text.slice(0, max - 1)}…` : text;
10510
+ function timeline(t, p, w) {
10511
+ const { rows, build, finished, frame } = t;
10327
10512
  const statusWord = (r) => {
10328
10513
  switch (r.state) {
10329
10514
  case "pass":
@@ -10352,77 +10537,113 @@ function ttyUI(out, opts) {
10352
10537
  return p(C.gray, "○");
10353
10538
  }
10354
10539
  };
10355
- const lines = () => {
10356
- const w = width();
10357
- const rule = p(C.gray, ` ${"─".repeat(w - 2)}`);
10358
- const c = counts(rows);
10359
- const doneCount = c.pass + c.fail + c.error;
10360
- const badge = finished ? `DONE · RUN ${finished.runId.slice(-4)}` : `RUNNING · ${doneCount}/${rows.length}`;
10361
- const out = [
10362
- ...logoHeader(p, `firegoat ${opts.version} eval · ${opts.project}`, p(finished ? C.green : C.ember, badge), w),
10363
- rule
10364
- ];
10365
- if (authText)
10366
- out.push(p(C.gray, ` Auth ${authText}`));
10367
- for (const n of notes)
10368
- out.push(p(C.gray, ` ${cut(n, w - 2)}`));
10369
- if (build) {
10370
- const mark = build.state === "ok" ? p(C.green, "✓") : build.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
10371
- out.push(` ${build.state === "running" ? p(C.ember, "◐") : p(C.green, "●")} Build${" ".repeat(Math.max(1, w - 20))}${mark}${build.seconds !== undefined ? ` ${build.seconds}s` : ""}`);
10372
- }
10373
- const column = (width) => Math.min(24, Math.max(0, ...rows.map(width)));
10374
- const harnessCols = column((r) => r.harness?.length ?? 0);
10375
- const modelCols = column((r) => r.model?.length ?? 0);
10376
- rows.forEach((r, i) => {
10377
- out.push(p(C.gray, " │"));
10378
- const num = String(i + 1).padStart(2, "0");
10379
- const name = cut(`${r.test}${r.n > 1 ? `#${r.n}` : ""}`, 16).padEnd(16);
10380
- const harness = harnessCols > 0 ? ` ${p(C.cyan, cut(r.harness ?? "", harnessCols).padEnd(harnessCols))}` : "";
10381
- const model = modelCols > 0 ? ` ${p(C.cyan, cut(r.model ?? "", modelCols).padEnd(modelCols))}` : "";
10382
- let detail = "";
10383
- if (r.state === "running")
10384
- detail = p(C.gray, `Turn ${r.turns}`);
10385
- else if (r.state === "pass" || r.state === "fail")
10386
- detail = `${checksPassed(r).padEnd(11)} ${p(C.gray, usage(r))}`;
10387
- else if (r.state === "error")
10388
- detail = p(C.gray, usage(r));
10389
- out.push(` ${glyph(r)} ${p(C.gray, num)} ${p(C.bold, name)}${harness}${model} ${p(C.ember, ramp(r))} ${statusWord(r)} ${detail}`);
10390
- if (r.state === "running" && r.command)
10391
- out.push(p(C.gray, ` ┊ └ $ ${cut(r.command, w - 16)}`));
10392
- if (r.state === "fail")
10393
- for (const ck of (r.checks ?? []).filter((x) => x.code !== 0)) {
10394
- out.push(` ${p(C.gray, "│")} ${p(C.red, "✗")} ${cut(ck.run, w - 14)}`);
10395
- out.push(p(C.gray, ` │ ${ck.error ?? `exit ${ck.code}`}`));
10396
- }
10397
- if (r.state === "error")
10398
- out.push(p(C.amber, ` ${cut(notGradedWhy(r.reason ?? ""), w - 12)}`));
10399
- if (r.upload === "uploading")
10400
- out.push(p(C.gray, " │ ↑ Uploading…"));
10401
- else if (r.upload?.status === "uploaded")
10402
- out.push(` ${p(C.gray, "│")} ${p(C.cyan, `↑ ${r.upload.url}`)}`);
10403
- else if (r.upload)
10404
- out.push(p(C.amber, ` │ ${cut(r.upload.status === "failed" ? `↑ Upload failed: ${r.upload.error}` : `↑ Not uploaded: ${r.upload.reason}`, w - 12)}`));
10405
- });
10406
- out.push(rule);
10407
- if (!finished) {
10408
- const track = Math.max(10, w - 34);
10409
- const at = rows.length ? Math.round(doneCount / rows.length * (track - 1)) : 0;
10410
- const progress = `${"·".repeat(at)}▶${"·".repeat(Math.max(0, track - at - 1))}`;
10411
- out.push(` ${p(C.ember, progress)} ${p(C.gray, `${doneCount}/${rows.length} · ${totalCost(rows)} so far`)}`);
10412
- return out;
10413
- }
10414
- const tr = tier(rows);
10415
- const goated = c.pass === rows.length && rows.length > 0;
10416
- const tally = `${c.pass} PASS · ${c.fail} FAIL · ${c.error} NOT GRADED`;
10417
- const left = goated ? `${rows.length}/${rows.length} runs passed their checks` : tally;
10418
- const right = goated ? `GOATED · tier ${tr}` : tr ? `Tier ${tr}` : "";
10419
- out.push(` ${goated ? p(C.green, left) : left}${" ".repeat(Math.max(2, w - left.length - right.length - 3))}${goated ? p(C.ember, right) : right}`);
10420
- const turns = totalTurns(rows);
10421
- out.push(p(C.gray, ` Total ${totalCost(rows)}${turns === null ? "" : ` · ${turns} turns`} · ${duration(finished.seconds)}`));
10422
- out.push(p(C.gray, ` Runs ${shortPath(finished.runsDir)}/`));
10423
- out.push(` Checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
10540
+ const rule = p(C.gray, ` ${"─".repeat(w - 2)}`);
10541
+ const c = counts(rows);
10542
+ const doneCount = c.pass + c.fail + c.error;
10543
+ const badge = finished ? `DONE · RUN ${finished.runId.slice(-4)}` : `RUNNING · ${doneCount}/${rows.length}`;
10544
+ const out = [
10545
+ ...logoHeader(p, `firegoat ${t.version} eval · ${t.project}`, p(finished ? C.green : C.ember, badge), w),
10546
+ rule
10547
+ ];
10548
+ if (t.authText)
10549
+ out.push(p(C.gray, ` Auth ${t.authText}`));
10550
+ for (const n of t.notes)
10551
+ out.push(p(C.gray, ` ${cut(n, w - 2)}`));
10552
+ if (build) {
10553
+ const mark = build.state === "ok" ? p(C.green, "✓") : build.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
10554
+ out.push(` ${build.state === "running" ? p(C.ember, "◐") : p(C.green, "●")} Build${" ".repeat(Math.max(1, w - 20))}${mark}${build.seconds !== undefined ? ` ${build.seconds}s` : ""}`);
10555
+ }
10556
+ const column = (width) => Math.min(24, Math.max(0, ...rows.map(width)));
10557
+ const harnessCols = column((r) => r.harness?.length ?? 0);
10558
+ const modelCols = column((r) => r.model?.length ?? 0);
10559
+ const detailCols = w - 1 - (47 + (harnessCols > 0 ? harnessCols + 1 : 0) + (modelCols > 0 ? modelCols + 1 : 0));
10560
+ rows.forEach((r, i) => {
10561
+ out.push(p(C.gray, " │"));
10562
+ const num = String(i + 1).padStart(2, "0");
10563
+ const name = cut(`${r.test}${r.n > 1 ? `#${r.n}` : ""}`, 16).padEnd(16);
10564
+ const harness = harnessCols > 0 ? ` ${p(C.cyan, cut(r.harness ?? "", harnessCols).padEnd(harnessCols))}` : "";
10565
+ const model = modelCols > 0 ? ` ${p(C.cyan, cut(r.model ?? "", modelCols).padEnd(modelCols))}` : "";
10566
+ let detail = "";
10567
+ if (r.state === "running")
10568
+ detail = p(C.gray, `Turn ${r.turns}`);
10569
+ else if (r.state === "pass" || r.state === "fail")
10570
+ detail = `${checksPassed(r).padEnd(11)} ${p(C.gray, cut(usage(r), Math.max(1, detailCols - 12)))}`;
10571
+ else if (r.state === "error")
10572
+ detail = p(C.gray, cut(usage(r), Math.max(1, detailCols)));
10573
+ out.push(` ${glyph(r)} ${p(C.gray, num)} ${p(C.bold, name)}${harness}${model} ${p(C.ember, ramp(r))} ${statusWord(r)} ${detail}`);
10574
+ if (r.state === "running" && r.command)
10575
+ out.push(p(C.gray, ` ┊ └ $ ${cut(r.command, w - 16)}`));
10576
+ if (r.state === "fail")
10577
+ for (const ck of (r.checks ?? []).filter((x) => x.code !== 0)) {
10578
+ out.push(` ${p(C.gray, "│")} ${p(C.red, "✗")} ${cut(ck.run, w - 14)}`);
10579
+ out.push(p(C.gray, ` │ ${cut(ck.error ?? `exit ${ck.code}`, w - 14)}`));
10580
+ }
10581
+ if (r.state === "error")
10582
+ out.push(p(C.amber, ` ${cut(notGradedWhy(r.reason ?? ""), w - 12)}`));
10583
+ if (r.upload === "uploading")
10584
+ out.push(p(C.gray, " │ ↑ Uploading…"));
10585
+ else if (r.upload?.status === "uploaded")
10586
+ out.push(` ${p(C.gray, "│")} ${p(C.cyan, `↑ ${r.upload.url}`)}`);
10587
+ else if (r.upload)
10588
+ out.push(p(C.amber, ` │ ${cut(r.upload.status === "failed" ? `↑ Upload failed: ${r.upload.error}` : `↑ Not uploaded: ${r.upload.reason}`, w - 12)}`));
10589
+ });
10590
+ out.push(rule);
10591
+ if (!finished) {
10592
+ const track = Math.max(10, w - 34);
10593
+ const at = rows.length ? Math.round(doneCount / rows.length * (track - 1)) : 0;
10594
+ const progress = `${"·".repeat(at)}▶${"·".repeat(Math.max(0, track - at - 1))}`;
10595
+ out.push(` ${p(C.ember, progress)} ${p(C.gray, `${doneCount}/${rows.length} · ${totalCost(rows)} so far`)}`);
10424
10596
  return out;
10425
- };
10597
+ }
10598
+ const tr = tier(rows);
10599
+ const goated = c.pass === rows.length && rows.length > 0;
10600
+ const passFail = `${c.pass} PASS · ${c.fail} FAIL · ${c.error} NOT GRADED`;
10601
+ const left = goated ? `${rows.length}/${rows.length} runs passed their checks` : passFail;
10602
+ const right = goated ? `GOATED · tier ${tr}` : tr ? `Tier ${tr}` : "";
10603
+ out.push(` ${goated ? p(C.green, left) : left}${" ".repeat(Math.max(2, w - left.length - right.length - 3))}${goated ? p(C.ember, right) : right}`);
10604
+ const turns = totalTurns(rows);
10605
+ const reward = tally(rows);
10606
+ out.push(p(C.gray, ` Total ${totalCost(rows)}${turns === null ? "" : ` · ${plural(turns, "turn")}`}${reward.tokens.total === null ? "" : ` · ${compactTokens(reward.tokens.total)} tokens`} · ${duration(finished.seconds)}`));
10607
+ out.push(` ${rewardLine(reward, rows)}`);
10608
+ if (byGroup(rows.map((r) => ({ ...r, harness: r.harnessName }))).length > 1)
10609
+ out.push(...comparison(rows).map((l) => p(C.gray, l)));
10610
+ out.push(p(C.gray, ` Runs ${shortPath(finished.runsDir)}/`));
10611
+ out.push(` Checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
10612
+ return out;
10613
+ }
10614
+ var RESULTS_WIDTH = 100;
10615
+ function resultsBlock(t) {
10616
+ return timeline({
10617
+ project: t.project,
10618
+ version: t.version,
10619
+ rows: t.rows,
10620
+ build: null,
10621
+ authText: "",
10622
+ notes: [],
10623
+ finished: { runId: t.runId, runsDir: t.runsDir, seconds: t.seconds },
10624
+ frame: 0
10625
+ }, painter(false), RESULTS_WIDTH);
10626
+ }
10627
+ function ttyUI(out, opts) {
10628
+ const p = painter(opts.color);
10629
+ let rows = [];
10630
+ let build = null;
10631
+ let authText = "";
10632
+ const notes = [];
10633
+ let frame = 0;
10634
+ let drawn = 0;
10635
+ let finished = null;
10636
+ const width = () => Math.min(out.columns || 80, 100);
10637
+ const lines = () => timeline({
10638
+ project: opts.project,
10639
+ version: opts.version,
10640
+ rows,
10641
+ build,
10642
+ authText,
10643
+ notes,
10644
+ finished,
10645
+ frame
10646
+ }, p, width());
10426
10647
  const rowsOf = (line) => {
10427
10648
  const visible = line.replace(ANSI, "");
10428
10649
  return Math.max(1, Math.ceil(visible.length / (out.columns || 80)));
@@ -10873,6 +11094,9 @@ async function runOne(opts) {
10873
11094
  leftover: [],
10874
11095
  turns: null,
10875
11096
  cost: null,
11097
+ rewardPoints: 0,
11098
+ rewardPointsMax: test.rewardPoints,
11099
+ tokens: tokens(),
10876
11100
  durationMs: 0,
10877
11101
  startedAt: new Date(started).toISOString(),
10878
11102
  upload: null
@@ -10904,6 +11128,7 @@ async function runOne(opts) {
10904
11128
  r.leftover = await stopLeftovers({ group: agent.group, workdir });
10905
11129
  r.status = status;
10906
11130
  r.reason = reason;
11131
+ r.rewardPoints = earned(status, r.rewardPointsMax);
10907
11132
  r.durationMs = Date.now() - started;
10908
11133
  writeRunJson(r);
10909
11134
  return r;
@@ -10994,6 +11219,7 @@ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile, agent) {
10994
11219
  });
10995
11220
  r.turns = outcome.turns;
10996
11221
  r.cost = outcome.cost;
11222
+ r.tokens = outcome.tokens;
10997
11223
  if (!outcome.ok)
10998
11224
  return ["error", `not graded: ${outcome.reason}`];
10999
11225
  if (!r.session)
@@ -11043,6 +11269,16 @@ function uploadedSummary(origin, results, runDir) {
11043
11269
  for (const u of urls)
11044
11270
  console.log(` ${u}`);
11045
11271
  }
11272
+ var RESULTS_EDGE = "═".repeat(RESULTS_WIDTH);
11273
+ function printResults(block, file) {
11274
+ console.log("");
11275
+ console.log("Results (paste the block between the ═ lines as-is, in a code block):");
11276
+ console.log(RESULTS_EDGE);
11277
+ for (const line of block)
11278
+ console.log(line);
11279
+ console.log(RESULTS_EDGE);
11280
+ console.log(`Results block saved to ${file}`);
11281
+ }
11046
11282
  async function evalCommand(args, root) {
11047
11283
  if (args.includes("--help") || args.includes("-h")) {
11048
11284
  console.log(EVAL_HELP);
@@ -11206,8 +11442,12 @@ async function evalCommand(args, root) {
11206
11442
  const rows = plan.map((p) => ({
11207
11443
  ...p,
11208
11444
  harness: showHarness ? p.harness : null,
11445
+ harnessName: p.harness,
11209
11446
  state: "queued",
11210
- turns: 0
11447
+ turns: 0,
11448
+ rewardPoints: 0,
11449
+ rewardPointsMax: tests.find((t) => t.name === p.test).rewardPoints,
11450
+ tokens: tokens()
11211
11451
  }));
11212
11452
  if (config.build.length > 0) {
11213
11453
  const buildStarted = Date.now();
@@ -11265,6 +11505,8 @@ async function evalCommand(args, root) {
11265
11505
  reason: r.reason,
11266
11506
  checks: r.checks,
11267
11507
  cost: r.cost,
11508
+ rewardPoints: r.rewardPoints,
11509
+ tokens: r.tokens,
11268
11510
  seconds: Math.round(r.durationMs / 1000),
11269
11511
  command: undefined
11270
11512
  });
@@ -11302,6 +11544,7 @@ async function evalCommand(args, root) {
11302
11544
  fail: EXIT.fail,
11303
11545
  pass: EXIT.pass
11304
11546
  }[status];
11547
+ const reward = tally(results);
11305
11548
  const doc = {
11306
11549
  firegoatVersion: package_default.version,
11307
11550
  runId,
@@ -11311,6 +11554,15 @@ async function evalCommand(args, root) {
11311
11554
  harnesses: harnessNames,
11312
11555
  upload: upload && { origin: upload.origin, email: upload.email },
11313
11556
  runs: results,
11557
+ ...reward,
11558
+ groups: byGroup(results).map((g) => ({
11559
+ harness: g.harness,
11560
+ model: g.model,
11561
+ runs: g.runs.length,
11562
+ ...tally(g.runs),
11563
+ turns: g.runs.every((r) => r.turns !== null) ? g.runs.reduce((s, r) => s + r.turns, 0) : null,
11564
+ durationMs: g.runs.reduce((s, r) => s + r.durationMs, 0)
11565
+ })),
11314
11566
  totals,
11315
11567
  tier: tier(rows),
11316
11568
  status,
@@ -11329,16 +11581,15 @@ async function evalCommand(args, root) {
11329
11581
  failed: totals.fail,
11330
11582
  notGraded: totals.error,
11331
11583
  cost: results.every((r) => r.cost !== null) ? results.reduce((sum, r) => sum + r.cost, 0) : null,
11584
+ rewardPoints: reward.rewardPoints,
11585
+ rewardPointsMax: reward.rewardPointsMax,
11586
+ tokens: reward.tokens.total,
11332
11587
  uploaded,
11333
11588
  uploadFailed: uploadsFailed
11334
11589
  })}
11335
11590
  `);
11336
- ui.done({
11337
- runId,
11338
- runsDir: runDir,
11339
- rows,
11340
- seconds: Math.round((Date.now() - evalStarted) / 1000)
11341
- });
11591
+ const seconds = Math.round((Date.now() - evalStarted) / 1000);
11592
+ ui.done({ runId, runsDir: runDir, rows, seconds });
11342
11593
  if (upload && !opts.json)
11343
11594
  uploadedSummary(upload.origin, results, runDir);
11344
11595
  for (const r of results)
@@ -11350,6 +11601,20 @@ async function evalCommand(args, root) {
11350
11601
  console.error(`firegoat eval: Warning: ${r.test}#${r.n} left ${r.leftover.length} process${r.leftover.length === 1 ? "" : "es"} running, stopped after cleanup: ${r.leftover.map((l) => `${l.pid} ${l.command}`).join("; ")} (run.json leftover)`);
11351
11602
  if (uploadsFailed > 0)
11352
11603
  console.error(`firegoat eval: ${uploadsFailed} upload${uploadsFailed === 1 ? "" : "s"} failed (each run's upload.error in summary.json)`);
11604
+ const block = resultsBlock({
11605
+ project: basename2(root),
11606
+ version: package_default.version,
11607
+ rows,
11608
+ runId,
11609
+ runsDir: runDir,
11610
+ seconds
11611
+ });
11612
+ const resultsFile = join10(runDir, "results.txt");
11613
+ writeFileSync5(resultsFile, `${block.join(`
11614
+ `)}
11615
+ `);
11616
+ if (!opts.json && !stream.isTTY)
11617
+ printResults(block, resultsFile);
11353
11618
  if (opts.json)
11354
11619
  console.log(JSON.stringify(doc, null, 2));
11355
11620
  if (status === "error")
@@ -12033,6 +12298,9 @@ tests:
12033
12298
  grep -Eiq '^agent: .*(you.*goat|goat.*you)' "$FIREGOAT_TRANSCRIPT"
12034
12299
  # Seconds before the agent run is stopped.
12035
12300
  timeout: 600
12301
+ # What a run earns when its checks pass (default 1): give harder end goals
12302
+ # more. eval prints tokens per point earned, the value per token.
12303
+ # reward_points: 3
12036
12304
  `;
12037
12305
  }
12038
12306
  var AGENT_PROMPT = "Scan my project and setup 2-3 agent experience prompts to test general usage of the app. Use firegoat skills";
@@ -12049,7 +12317,7 @@ function copyToClipboard(text) {
12049
12317
  });
12050
12318
  }
12051
12319
  var LOGIN_HINT = "Runs stay local: npx firegoat login uploads them to firegoat.ai";
12052
- function cut(s, n) {
12320
+ function cut2(s, n) {
12053
12321
  return s.length <= n ? s : `${s.slice(0, Math.max(0, n - 1))}~`;
12054
12322
  }
12055
12323
 
@@ -12064,8 +12332,8 @@ class Timeline {
12064
12332
  line(mark, n, label, status = "") {
12065
12333
  const head = ` ${mark} ${this.paint("90", String(n).padStart(2, "0"))} `;
12066
12334
  if (!status)
12067
- return head + cut(label, this.width - 9);
12068
- return `${head}${label.padEnd(28)}${this.paint("90", cut(status, this.width - 37))}`;
12335
+ return head + cut2(label, this.width - 9);
12336
+ return `${head}${label.padEnd(28)}${this.paint("90", cut2(status, this.width - 37))}`;
12069
12337
  }
12070
12338
  done(n, label, status) {
12071
12339
  return this.line(this.paint("32", "●"), n, label, status);
@@ -12088,7 +12356,7 @@ class Timeline {
12088
12356
  return select(block, options.length, (current) => [
12089
12357
  this.line(this.paint("38;5;208", "◉"), n, question),
12090
12358
  ...options.map(([label, note], i) => {
12091
- const text = cut(`${label.padEnd(cols)}${note}`, room);
12359
+ const text = cut2(`${label.padEnd(cols)}${note}`, room);
12092
12360
  return i === current ? `${Timeline.INDENT}${this.paint("38;5;208", `● ${text}`)}` : this.paint("90", `${Timeline.INDENT}○ ${text}`);
12093
12361
  }),
12094
12362
  this.paint("90", `${Timeline.INDENT}Up/down to move, enter to pick`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"