firegoat 0.30.0 → 0.32.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 +1 -1
  2. package/dist/cli.js +453 -208
  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
 
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.30.0",
6950
+ version: "0.32.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
@@ -9206,23 +9287,42 @@ Runs without a session file are not uploaded. FIREGOAT_BASE_URL points elsewhere
9206
9287
  Results block: without a terminal (an agent, a pipe, CI) and without --json, eval
9207
9288
  ends with the terminal's final timeline as one plain block (no color, at most 100
9208
9289
  columns): the logo header, a row per run (status, a ramp of one block per agent turn
9209
- ending ▲ pass, ╳ fail or ╌ not graded, checks passed, cost, turns, time), its failed
9210
- checks and trace URL, the totals and the tier ("GOATED · tier S" when all pass). It
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
9211
9294
  sits between two lines of ═, after a "Results (paste the block ...)" line; the last
9212
9295
  line says where it is saved, results.txt. Show it to your user as-is in a code block.
9213
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
+
9214
9307
  Output: .firegoat/runs/<runId>/
9215
9308
  out A symlink to $FIREGOAT_OUT,
9216
9309
  ~/.firegoat/runs/<hash>/<random>/out
9217
9310
  summary.json firegoatVersion, runId, runsDir, dir (where eval
9218
9311
  ran), git {branch, commit, dirty} (no git outside a
9219
9312
  repo), harnesses, upload {origin, email} (null when
9220
- not uploading), runs (every run.json), then the
9221
- 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
9222
9320
  results.txt The results block, also with --json or in a terminal
9223
9321
  ../index.jsonl One line per eval: runId, startedAt, dir, branch,
9224
9322
  commit, harnesses, passed, failed, notGraded, cost (null when
9225
- 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
9226
9326
  <test>/<n>/run.json One run: status and reason (from the checks), harness,
9227
9327
  model (asked for, or null) and reportedModel (what
9228
9328
  the harness reported running, or null), harnessArgs
@@ -9237,7 +9337,11 @@ Output: .firegoat/runs/<runId>/
9237
9337
  own file, else <test>/<n>/session.jsonl made from the
9238
9338
  stream) and sessionId (its id, the trace's), stderr,
9239
9339
  checksLog, cleanupLog; turns and cost (null when the
9240
- 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",
9241
9345
  url} | {status: "failed", error} | {status:
9242
9346
  "skipped", reason}, null when not uploading)
9243
9347
  <test>/<n>/transcript.txt What the agent did, below
@@ -9415,6 +9519,46 @@ async function stopLeftovers(opts) {
9415
9519
  return leftover;
9416
9520
  }
9417
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
+
9418
9562
  // src/session.ts
9419
9563
  import { randomUUID } from "node:crypto";
9420
9564
  import { writeFileSync as writeFileSync3 } from "node:fs";
@@ -10230,16 +10374,56 @@ function ramp(r) {
10230
10374
  const end = r.state === "pass" ? "▲" : r.state === "fail" ? "╳" : r.state === "error" ? "╌" : r.state === "running" ? "●" : "";
10231
10375
  return (blocks + end).padEnd(8);
10232
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
+ }
10233
10386
  function usageParts(r) {
10234
10387
  const parts = [];
10235
10388
  if (typeof r.cost === "number")
10236
10389
  parts.push(`$${r.cost.toFixed(2)}`);
10237
10390
  if (typeof r.reportedTurns === "number")
10238
- 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`);
10239
10394
  if (r.seconds !== undefined)
10240
10395
  parts.push(`${r.seconds}s`);
10241
10396
  return parts;
10242
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
+ }
10243
10427
  function usage(r) {
10244
10428
  return usageParts(r).join(" ");
10245
10429
  }
@@ -10413,12 +10597,16 @@ function timeline(t, p, w) {
10413
10597
  }
10414
10598
  const tr = tier(rows);
10415
10599
  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;
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;
10418
10602
  const right = goated ? `GOATED · tier ${tr}` : tr ? `Tier ${tr}` : "";
10419
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}`);
10420
10604
  const turns = totalTurns(rows);
10421
- out.push(p(C.gray, ` Total ${totalCost(rows)}${turns === null ? "" : ` · ${turns} turns`} · ${duration(finished.seconds)}`));
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)));
10422
10610
  out.push(p(C.gray, ` Runs ${shortPath(finished.runsDir)}/`));
10423
10611
  out.push(` Checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
10424
10612
  return out;
@@ -10528,6 +10716,30 @@ function runModel(r) {
10528
10716
  reported: label(r.reportedModel, "Reported model")
10529
10717
  };
10530
10718
  }
10719
+ var MAX_INT = 2147483647;
10720
+ function count(v, what) {
10721
+ if (v === null)
10722
+ return null;
10723
+ if (!Number.isInteger(v) || v < 0 || v > MAX_INT)
10724
+ throw new Error(`${what} is ${v}, the upload takes an integer from 0 to ${MAX_INT}`);
10725
+ return v;
10726
+ }
10727
+ function runTokens(t) {
10728
+ return {
10729
+ input: count(t.input, "Input tokens"),
10730
+ output: count(t.output, "Output tokens"),
10731
+ cacheRead: count(t.cacheRead, "Cache read tokens"),
10732
+ cacheWrite: count(t.cacheWrite, "Cache write tokens"),
10733
+ total: count(t.total, "Total tokens")
10734
+ };
10735
+ }
10736
+ function runPoints(r) {
10737
+ const rewardPoints = count(r.rewardPoints, "Reward points");
10738
+ const rewardPointsMax = count(r.rewardPointsMax, "Reward points max");
10739
+ if (rewardPoints !== null && rewardPointsMax !== null && rewardPoints > rewardPointsMax)
10740
+ throw new Error(`Reward points ${rewardPoints} are over the run's max ${rewardPointsMax}`);
10741
+ return { rewardPoints, rewardPointsMax };
10742
+ }
10531
10743
  function runGit(g) {
10532
10744
  if (g.commit !== null && !COMMIT.test(g.commit))
10533
10745
  throw new Error(`Git commit ${printable(g.commit, 80)} is not 4-64 lowercase hex characters`);
@@ -10592,9 +10804,13 @@ async function uploadRun(t, r) {
10592
10804
  let project;
10593
10805
  let model;
10594
10806
  let git;
10807
+ let tokens;
10808
+ let points;
10595
10809
  try {
10596
10810
  project = projectName(t.project);
10597
10811
  model = runModel(r);
10812
+ tokens = runTokens(r.tokens);
10813
+ points = runPoints(r);
10598
10814
  git = t.git ? runGit(t.git) : undefined;
10599
10815
  } catch (e) {
10600
10816
  return { status: "failed", error: e.message };
@@ -10618,6 +10834,8 @@ async function uploadRun(t, r) {
10618
10834
  duration_ms: r.durationMs,
10619
10835
  harness: r.harness,
10620
10836
  model,
10837
+ tokens,
10838
+ ...points,
10621
10839
  ...git && { git }
10622
10840
  },
10623
10841
  files: Object.fromEntries(FILES.map((f) => [f, bytes[f].byteLength]))
@@ -10906,6 +11124,9 @@ async function runOne(opts) {
10906
11124
  leftover: [],
10907
11125
  turns: null,
10908
11126
  cost: null,
11127
+ rewardPoints: 0,
11128
+ rewardPointsMax: test.rewardPoints,
11129
+ tokens: tokens(),
10909
11130
  durationMs: 0,
10910
11131
  startedAt: new Date(started).toISOString(),
10911
11132
  upload: null
@@ -10937,6 +11158,7 @@ async function runOne(opts) {
10937
11158
  r.leftover = await stopLeftovers({ group: agent.group, workdir });
10938
11159
  r.status = status;
10939
11160
  r.reason = reason;
11161
+ r.rewardPoints = earned(status, r.rewardPointsMax);
10940
11162
  r.durationMs = Date.now() - started;
10941
11163
  writeRunJson(r);
10942
11164
  return r;
@@ -11027,6 +11249,7 @@ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile, agent) {
11027
11249
  });
11028
11250
  r.turns = outcome.turns;
11029
11251
  r.cost = outcome.cost;
11252
+ r.tokens = outcome.tokens;
11030
11253
  if (!outcome.ok)
11031
11254
  return ["error", `not graded: ${outcome.reason}`];
11032
11255
  if (!r.session)
@@ -11249,8 +11472,12 @@ async function evalCommand(args, root) {
11249
11472
  const rows = plan.map((p) => ({
11250
11473
  ...p,
11251
11474
  harness: showHarness ? p.harness : null,
11475
+ harnessName: p.harness,
11252
11476
  state: "queued",
11253
- turns: 0
11477
+ turns: 0,
11478
+ rewardPoints: 0,
11479
+ rewardPointsMax: tests.find((t) => t.name === p.test).rewardPoints,
11480
+ tokens: tokens()
11254
11481
  }));
11255
11482
  if (config.build.length > 0) {
11256
11483
  const buildStarted = Date.now();
@@ -11308,6 +11535,8 @@ async function evalCommand(args, root) {
11308
11535
  reason: r.reason,
11309
11536
  checks: r.checks,
11310
11537
  cost: r.cost,
11538
+ rewardPoints: r.rewardPoints,
11539
+ tokens: r.tokens,
11311
11540
  seconds: Math.round(r.durationMs / 1000),
11312
11541
  command: undefined
11313
11542
  });
@@ -11345,6 +11574,7 @@ async function evalCommand(args, root) {
11345
11574
  fail: EXIT.fail,
11346
11575
  pass: EXIT.pass
11347
11576
  }[status];
11577
+ const reward = tally(results);
11348
11578
  const doc = {
11349
11579
  firegoatVersion: package_default.version,
11350
11580
  runId,
@@ -11354,6 +11584,15 @@ async function evalCommand(args, root) {
11354
11584
  harnesses: harnessNames,
11355
11585
  upload: upload && { origin: upload.origin, email: upload.email },
11356
11586
  runs: results,
11587
+ ...reward,
11588
+ groups: byGroup(results).map((g) => ({
11589
+ harness: g.harness,
11590
+ model: g.model,
11591
+ runs: g.runs.length,
11592
+ ...tally(g.runs),
11593
+ turns: g.runs.every((r) => r.turns !== null) ? g.runs.reduce((s, r) => s + r.turns, 0) : null,
11594
+ durationMs: g.runs.reduce((s, r) => s + r.durationMs, 0)
11595
+ })),
11357
11596
  totals,
11358
11597
  tier: tier(rows),
11359
11598
  status,
@@ -11372,6 +11611,9 @@ async function evalCommand(args, root) {
11372
11611
  failed: totals.fail,
11373
11612
  notGraded: totals.error,
11374
11613
  cost: results.every((r) => r.cost !== null) ? results.reduce((sum, r) => sum + r.cost, 0) : null,
11614
+ rewardPoints: reward.rewardPoints,
11615
+ rewardPointsMax: reward.rewardPointsMax,
11616
+ tokens: reward.tokens.total,
11375
11617
  uploaded,
11376
11618
  uploadFailed: uploadsFailed
11377
11619
  })}
@@ -12086,6 +12328,9 @@ tests:
12086
12328
  grep -Eiq '^agent: .*(you.*goat|goat.*you)' "$FIREGOAT_TRANSCRIPT"
12087
12329
  # Seconds before the agent run is stopped.
12088
12330
  timeout: 600
12331
+ # What a run earns when its checks pass (default 1): give harder end goals
12332
+ # more. eval prints tokens per point earned, the value per token.
12333
+ # reward_points: 3
12089
12334
  `;
12090
12335
  }
12091
12336
  var AGENT_PROMPT = "Scan my project and setup 2-3 agent experience prompts to test general usage of the app. Use firegoat skills";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"