@fede0089/skill-eval 3.2.0 → 3.3.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.
package/README.md CHANGED
@@ -92,7 +92,7 @@ skill-eval functional --workspace <path> --skill <path> [options] [agent]
92
92
  | `--eval-file <name>` | no | all | Run only the evals from this file in `evals/` (e.g. `edge-cases.json`) |
93
93
  | `--compare-ref [refs...]` | no | — | Git references to compare against (variadic — put `[agent]` before it, not after) |
94
94
  | `--compare-baseline` | no | `false` | Also run the no-skill baseline alongside the skill |
95
- | `-v, --debug` | no | `false` | Enable verbose debug logging |
95
+ | `-v, --debug` | no | `false` | Print verbose logs to the console (trial transcripts are always saved) |
96
96
  | `[agent]` | no | `gemini-cli` | Agent backend to use |
97
97
 
98
98
  Supported runners:
@@ -222,24 +222,55 @@ Refer to your runner's documentation for the full set of settings and policy key
222
222
 
223
223
  ## Reports
224
224
 
225
- Each run writes to `.project-skill-evals/runs/<timestamp>/` and includes per-trial logs, the raw eval JSON, and a self-contained HTML report you can open in any browser. The report shows pass@k aggregates per eval, lets you expand each trial, and color-codes triggering vs. functional outcomes.
225
+ Each run writes to `.project-skill-evals/runs/<timestamp>/`: one log per trial and a self-contained HTML report you can open in any browser.
226
+
227
+ Expanding an eval gives you three sections:
228
+
229
+ - **Summary** — success rate, average tokens and average time, per variant.
230
+ - **Trials** — one row per trial, with its score, anomaly flags, cost and an exclude control. Expanding a row shows the agent's final output, its stats and a link to the full transcript.
231
+ - **Expectations** — one row per expectation, with per-variant pass rates. Clicking a cell shows the judge's verdict for every trial.
232
+
233
+ The report carries the full run data and computes every figure in the browser, so excluding a trial updates all of them at once.
226
234
 
227
235
  A published sample report is available at [fede0089.github.io/skill-eval/sample-report.html](https://fede0089.github.io/skill-eval/sample-report.html), generated from this project root with:
228
236
 
229
237
  ```sh
230
- skill-eval functional --workspace . --skill mock-skill --trials 2 --compare-baseline --debug claude-code
238
+ skill-eval functional --workspace . --skill mock-skill --trials 2 --compare-baseline claude-code
231
239
  ```
232
240
 
233
241
  ![Sample HTML report](docs/sample-report.png)
234
242
 
235
- ### Debug logs
243
+ ### Trial transcripts
236
244
 
237
- When a trial misbehaves, pass `-v` / `--debug` to capture the full transcripts to disk. Each trial writes a `task_<id>_<variant>_trial_<n>.log` file inside the run directory with two sections appended in order:
245
+ Every trial writes a `task_<id>_<variant>_trial_<n>.log` file inside the run directory, with two sections appended in order:
238
246
 
239
247
  - `# SECTION: <MODE> AGENT RUN` — the initial prompt sent to the agent and its raw streamed response.
240
248
  - `# SECTION: <MODE> JUDGE RUN` — the prompt sent to the LLM judge and its verdict (only present for `functional` runs; `trigger` is graded programmatically and produces no judge section).
241
249
 
242
- Without `--debug` these files are not written, so reach for the flag when you need to see exactly what the agent — or the judge — saw.
250
+ These are always written, and the report links to each one from its trial row, so you can go from a suspicious number to exactly what the agent — or the judge — saw. `-v` / `--debug` is unrelated: it only makes the console output verbose.
251
+
252
+ ## Excluding trials
253
+
254
+ Agents are non-deterministic, and a trial sometimes fails for reasons that have nothing to do with the skill: the model degenerates into a single word, stops before answering, or trips over the environment. Those trials drag the score down and, worse, compress the very difference an A/B run exists to measure.
255
+
256
+ The report flags the likely ones and lets you drop them. Each flag compares a trial against its own cohort — the sibling trials of the same eval and variant — rather than against a fixed threshold:
257
+
258
+ | Flag | Meaning |
259
+ |------|---------|
260
+ | `degenerate-output` | The final output is a fraction of the cohort's median length |
261
+ | `zero-assertions` | Nothing passed, while the cohort median is well above zero |
262
+ | `premature-stop` | The run ended without a success status, or produced no output |
263
+ | `resource-outlier` | Token spend far above the cohort median |
264
+
265
+ Flags never exclude anything on their own — no threshold can reliably separate "the skill failed" from "the model went off the rails this time", so the call is yours. Open a trial row, read what the agent actually produced, and press **Exclude**; the reason is pre-filled from the strongest flag and can be changed, along with a free-text note.
266
+
267
+ Excluding recomputes every figure in the report, under a few rules that keep the result honest:
268
+
269
+ - **The raw number stays visible.** Every adjusted rate is shown next to the unadjusted one and the effective sample size (`raw 36% · n=3/5`). Below three usable trials the figure is marked low-confidence.
270
+ - **Unbalanced exclusions raise a warning.** Dropping more trials from one variant than another moves the delta you are measuring, so the report says so.
271
+ - **The exclusion rate is itself a result.** If four of ten trials degenerated, that is a finding about the model or the prompt, not noise to sweep away — so it is shown at the top.
272
+
273
+ Exclusions are kept in the browser for that run. **Download reviewed copy** writes a `report-reviewed.html` with them baked in, for sharing or committing alongside the run.
243
274
 
244
275
  ## Try it out
245
276
 
@@ -11,10 +11,10 @@ import { preflight } from '../core/preflight.js';
11
11
  import { ConfigError } from '../core/errors.js';
12
12
  import { withRetry } from '../core/trial-utils.js';
13
13
  import { renderFunctionalTable, renderRunHeader } from '../utils/table-renderer.js';
14
- import { JsonReporter } from '../reporters/index.js';
14
+ import { HtmlReporter } from '../reporters/index.js';
15
15
  import chalk from 'chalk';
16
16
  import { git } from '../utils/git.js';
17
- export async function functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId, compareRefs = [], compareBaseline = false, evalFile) {
17
+ export async function functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new HtmlReporter(), timeoutMs, evalId, compareRefs = [], compareBaseline = false, evalFile) {
18
18
  if (!injectedSuite)
19
19
  preflight(agent, workspace, skillPath);
20
20
  const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath, evalFile);
@@ -45,8 +45,7 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
45
45
  const cleanup = () => { env.teardown().finally(() => process.exit(1)); };
46
46
  process.once('SIGINT', cleanup);
47
47
  process.once('SIGTERM', cleanup);
48
- // Setup Artifacts Directory (Always create, even if not in debug mode, for 'show' command)
49
- const debug = !!process.env.DEBUG;
48
+ // Every run gets its own directory: trial logs and the report always land here.
50
49
  const startTime = new Date();
51
50
  const timestamp = startTime.toISOString().replace(/[:.]/g, '-');
52
51
  const runDir = path.resolve(workspace, '.project-skill-evals', 'runs', timestamp);
@@ -55,7 +54,7 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
55
54
  const variantRunners = new Map();
56
55
  // 1. Local Runner
57
56
  variantRunners.set('local', new EvalRunner({
58
- agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: false, debug, timeoutMs,
57
+ agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: false, timeoutMs,
59
58
  variant: 'local'
60
59
  }));
61
60
  // 2. Historical Runners
@@ -71,14 +70,13 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
71
70
  skillName: skill_name,
72
71
  runDir,
73
72
  isBaseline: false,
74
- debug,
75
73
  timeoutMs,
76
74
  variant: `ref:${ref}`
77
75
  }));
78
76
  }
79
77
  // 3. Baseline Runner
80
78
  const withoutSkillRunner = compareBaseline ? new EvalRunner({
81
- agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: true, debug, timeoutMs,
79
+ agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: true, timeoutMs,
82
80
  variant: 'baseline'
83
81
  }) : undefined;
84
82
  const taskResults = [];
@@ -273,7 +271,6 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
273
271
  Logger.write(`──────────────────────────────────────────────────\n`);
274
272
  renderFunctionalTable(report);
275
273
  Logger.write('\n');
276
- new JsonReporter().generate(report, runDir);
277
274
  reporter.generate(report, runDir);
278
275
  }
279
276
  finally {
@@ -10,10 +10,10 @@ import { aggregatePassAtK, aggregateAssertionPassRate, aggregateTokenStats, aggr
10
10
  import { preflight } from '../core/preflight.js';
11
11
  import { withRetry } from '../core/trial-utils.js';
12
12
  import { renderTriggerTable, renderRunHeader } from '../utils/table-renderer.js';
13
- import { JsonReporter } from '../reporters/index.js';
13
+ import { HtmlReporter } from '../reporters/index.js';
14
14
  import chalk from 'chalk';
15
15
  import { git } from '../utils/git.js';
16
- export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId, compareRefs = [], evalFile) {
16
+ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new HtmlReporter(), timeoutMs, evalId, compareRefs = [], evalFile) {
17
17
  if (!injectedSuite)
18
18
  preflight(agent, workspace, skillPath);
19
19
  const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath, evalFile);
@@ -32,8 +32,7 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
32
32
  const cleanup = () => { env.teardown().finally(() => process.exit(1)); };
33
33
  process.once('SIGINT', cleanup);
34
34
  process.once('SIGTERM', cleanup);
35
- // Setup Artifacts Directory (Always create, even if not in debug mode, for 'show' command)
36
- const debug = !!process.env.DEBUG;
35
+ // Every run gets its own directory: trial logs and the report always land here.
37
36
  const startTime = new Date();
38
37
  const timestamp = startTime.toISOString().replace(/[:.]/g, '-');
39
38
  const runDir = path.resolve(workspace, '.project-skill-evals', 'runs', timestamp);
@@ -42,7 +41,7 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
42
41
  const variantRunners = new Map();
43
42
  // 1. Local Runner
44
43
  variantRunners.set('local', new EvalRunner({
45
- agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: false, debug, timeoutMs,
44
+ agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: false, timeoutMs,
46
45
  variant: 'local'
47
46
  }));
48
47
  // 2. Historical Runners
@@ -58,7 +57,6 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
58
57
  skillName: skill_name,
59
58
  runDir,
60
59
  isBaseline: false,
61
- debug,
62
60
  timeoutMs,
63
61
  variant: `ref:${ref}`
64
62
  }));
@@ -203,7 +201,6 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
203
201
  Logger.write(`──────────────────────────────────────────────────\n`);
204
202
  renderTriggerTable(report);
205
203
  Logger.write('\n\n');
206
- new JsonReporter().generate(report, runDir);
207
204
  reporter.generate(report, runDir);
208
205
  }
209
206
  finally {
@@ -0,0 +1,63 @@
1
+ /** Fraction of the cohort's median output length below which text reads as degenerate. */
2
+ const DEGENERATE_OUTPUT_RATIO = 0.15;
3
+ /** Multiple of the cohort's median token spend above which a trial is an outlier. */
4
+ const RESOURCE_OUTLIER_RATIO = 2.5;
5
+ function median(values) {
6
+ if (values.length === 0)
7
+ return 0;
8
+ const sorted = [...values].sort((a, b) => a - b);
9
+ const mid = Math.floor(sorted.length / 2);
10
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
11
+ }
12
+ function passedAssertions(trial) {
13
+ return trial.assertionResults.filter(r => r.passed).length;
14
+ }
15
+ /**
16
+ * Flags a trial against its cohort — the sibling trials of the same eval and
17
+ * variant. Comparisons are relative to the cohort median rather than absolute
18
+ * thresholds, so the signals hold whether an eval has 3 assertions or 30 and
19
+ * whether a task normally takes 20K tokens or 2M.
20
+ *
21
+ * Infrastructure errors return no anomalies: they are already excluded from
22
+ * every metric, and flagging them again would only pad the review queue.
23
+ *
24
+ * @param trial The trial to inspect.
25
+ * @param cohort All trials of the same eval and variant, including `trial`.
26
+ */
27
+ export function detectAnomalies(trial, cohort) {
28
+ if (trial.isError || !trial.summary)
29
+ return [];
30
+ // Errored siblings produced no output; letting them into the baseline would
31
+ // drag the median down and mask the degenerate trials this exists to catch.
32
+ const peers = cohort.filter(t => !t.isError && t.summary);
33
+ const anomalies = [];
34
+ const medianOutputLen = median(peers.map(t => t.summary.outputLen));
35
+ if (medianOutputLen > 0 && trial.summary.outputLen < medianOutputLen * DEGENERATE_OUTPUT_RATIO) {
36
+ anomalies.push({
37
+ tag: 'degenerate-output',
38
+ reason: `Final output is ${trial.summary.outputLen} characters; the cohort median is ${Math.round(medianOutputLen)}.`,
39
+ });
40
+ }
41
+ const medianPassed = median(peers.map(passedAssertions));
42
+ if (trial.assertionResults.length > 0 && passedAssertions(trial) === 0 && medianPassed > 0) {
43
+ anomalies.push({
44
+ tag: 'zero-assertions',
45
+ reason: `Passed 0 of ${trial.assertionResults.length} assertions; the cohort median is ${medianPassed}.`,
46
+ });
47
+ }
48
+ if (trial.summary.outputLen === 0 || trial.summary.stopStatus !== 'success') {
49
+ anomalies.push({
50
+ tag: 'premature-stop',
51
+ reason: `Run ended with status "${trial.summary.stopStatus ?? 'unknown'}" and ${trial.summary.outputLen} characters of output.`,
52
+ });
53
+ }
54
+ const medianTokens = median(peers.filter(t => t.tokenStats).map(t => t.tokenStats.totalTokens));
55
+ if (trial.tokenStats && medianTokens > 0 &&
56
+ trial.tokenStats.totalTokens > medianTokens * RESOURCE_OUTLIER_RATIO) {
57
+ anomalies.push({
58
+ tag: 'resource-outlier',
59
+ reason: `Spent ${trial.tokenStats.totalTokens.toLocaleString('en-US')} tokens against a cohort median of ${Math.round(medianTokens).toLocaleString('en-US')}.`,
60
+ });
61
+ }
62
+ return anomalies;
63
+ }
@@ -4,6 +4,7 @@ import { executor } from '../utils/exec.js';
4
4
  import { EvalEnvironment } from './environment.js';
5
5
  import { RunnerFactory } from '../runners/index.js';
6
6
  import { TriggerGrader, ModelBasedGrader } from './evaluator.js';
7
+ import { buildTrialSummary } from './trial-utils.js';
7
8
  import { parseStreamResult, parseTokenStats } from '../utils/ndjson.js';
8
9
  // Replace filesystem-unsafe characters so variants like 'ref:main' or 'feature/x' can be used in filenames.
9
10
  function slugifyVariant(v) {
@@ -26,7 +27,7 @@ export class EvalRunner {
26
27
  async runTriggerTask(task, index, trialId, uiCtx, attempt = 0) {
27
28
  const variantSlug = slugifyVariant(this.options.variant ?? 'local');
28
29
  const logFileName = `task_${task.id}_${variantSlug}_trial_${trialId}.log`;
29
- const logPath = this.options.debug ? path.join(this.options.runDir, logFileName) : undefined;
30
+ const logPath = path.join(this.options.runDir, logFileName);
30
31
  let worktreePath;
31
32
  let transcript = null;
32
33
  let durationMs;
@@ -65,6 +66,8 @@ export class EvalRunner {
65
66
  const tokenStats = transcript
66
67
  ? parseTokenStats(transcript.response || '') ?? undefined
67
68
  : undefined;
69
+ // Compact evidence for the report. The full stream stays in the trial log.
70
+ const summary = buildTrialSummary(transcript?.response || '', logFileName);
68
71
  // Negative evals (should_trigger: false) assert the opposite: the skill must NOT activate.
69
72
  const shouldTrigger = task.should_trigger !== false;
70
73
  const assertionLabel = shouldTrigger ? 'Skill was triggered' : 'Skill was not triggered';
@@ -99,7 +102,8 @@ export class EvalRunner {
99
102
  trialPassed: false,
100
103
  isError: true,
101
104
  tokenStats,
102
- durationMs
105
+ durationMs,
106
+ summary
103
107
  };
104
108
  }
105
109
  else {
@@ -129,7 +133,8 @@ export class EvalRunner {
129
133
  trialPassed: false,
130
134
  isError: true,
131
135
  tokenStats,
132
- durationMs
136
+ durationMs,
137
+ summary
133
138
  };
134
139
  }
135
140
  return {
@@ -138,7 +143,8 @@ export class EvalRunner {
138
143
  assertionResults: assertionResults,
139
144
  trialPassed,
140
145
  tokenStats,
141
- durationMs
146
+ durationMs,
147
+ summary
142
148
  };
143
149
  }
144
150
  async runFunctionalTask(task, index, trialId, uiCtx, attempt = 0) {
@@ -149,7 +155,7 @@ export class EvalRunner {
149
155
  : `${task.prompt}\n\nIMPORTANT: You must use the '${this.options.skillName}' skill/tool to solve this task.`;
150
156
  const variantSlug = slugifyVariant(this.options.variant ?? (skillDisabled ? 'baseline' : 'local'));
151
157
  const logFileName = `task_${task.id}_${variantSlug}_trial_${trialId}.log`;
152
- const logPath = this.options.debug ? path.join(this.options.runDir, logFileName) : undefined;
158
+ const logPath = path.join(this.options.runDir, logFileName);
153
159
  let worktreePath;
154
160
  let assertionResults = [];
155
161
  let trialPassed = false;
@@ -188,6 +194,8 @@ export class EvalRunner {
188
194
  const tokenStats = transcript
189
195
  ? parseTokenStats(transcript.response || '') ?? undefined
190
196
  : undefined;
197
+ // Compact evidence for the report. The full stream stays in the trial log.
198
+ const summary = buildTrialSummary(transcript?.response || '', logFileName);
191
199
  if (transcript && !transcript.error) {
192
200
  if (skillDisabled && this.triggerGrader.detectSkillAttempt(transcript)) {
193
201
  return {
@@ -201,7 +209,8 @@ export class EvalRunner {
201
209
  }],
202
210
  trialPassed: false,
203
211
  tokenStats,
204
- durationMs
212
+ durationMs,
213
+ summary
205
214
  };
206
215
  }
207
216
  if (!skillDisabled && !this.triggerGrader.gradeTrigger(transcript)) {
@@ -216,7 +225,8 @@ export class EvalRunner {
216
225
  }],
217
226
  trialPassed: false,
218
227
  tokenStats,
219
- durationMs
228
+ durationMs,
229
+ summary
220
230
  };
221
231
  }
222
232
  let context = 'No changes detected or git not available.';
@@ -273,7 +283,8 @@ export class EvalRunner {
273
283
  })),
274
284
  trialPassed: false,
275
285
  tokenStats,
276
- durationMs
286
+ durationMs,
287
+ summary
277
288
  };
278
289
  }
279
290
  trialPassed = assertionResults.every(r => r.passed);
@@ -287,7 +298,8 @@ export class EvalRunner {
287
298
  assertionResults: assertionResults,
288
299
  trialPassed,
289
300
  tokenStats,
290
- durationMs
301
+ durationMs,
302
+ summary
291
303
  };
292
304
  }
293
305
  else {
@@ -308,7 +320,8 @@ export class EvalRunner {
308
320
  trialPassed: false,
309
321
  isError: true,
310
322
  tokenStats,
311
- durationMs
323
+ durationMs,
324
+ summary
312
325
  };
313
326
  }
314
327
  }
@@ -322,13 +335,16 @@ export class EvalRunner {
322
335
  graderType: 'model-based'
323
336
  }));
324
337
  }
325
- // isError return — finally still runs cleanup
338
+ // isError return — finally still runs cleanup.
339
+ // The agent crashed before a summary could be built, but the log may hold
340
+ // a partial stream, so keep the pointer to it.
326
341
  return {
327
342
  id: trialId,
328
343
  transcript: { error: errorMsg },
329
344
  assertionResults,
330
345
  trialPassed: false,
331
- isError: true
346
+ isError: true,
347
+ summary: buildTrialSummary('', logFileName)
332
348
  };
333
349
  }
334
350
  finally {
@@ -1,9 +1,10 @@
1
+ import { isCounted } from './trial-utils.js';
1
2
  /**
2
- * Computes the fraction of individual assertions that passed across all non-error trials.
3
- * Returns 0 if there are no non-error trials or no assertions.
3
+ * Computes the fraction of individual assertions that passed across all counted trials.
4
+ * Returns 0 if there are no counted trials or no assertions.
4
5
  */
5
6
  export function computeAssertionPassRate(trials) {
6
- const relevant = trials.filter(t => !t.isError);
7
+ const relevant = trials.filter(isCounted);
7
8
  if (relevant.length === 0)
8
9
  return 0;
9
10
  const total = relevant.reduce((s, t) => s + t.assertionResults.length, 0);
@@ -21,14 +22,16 @@ export function aggregateAssertionPassRate(results, trialSelector) {
21
22
  return results.reduce((sum, r) => sum + computeAssertionPassRate(trialSelector(r)), 0) / results.length;
22
23
  }
23
24
  /**
24
- * Computes the pass rate (pass@1) for a set of trials.
25
- * Returns the fraction of trials that passed: c / n.
25
+ * Computes the pass rate (pass@1) for a set of trials: c / n over counted trials.
26
+ * Infrastructure errors leave the denominator rather than counting as failures,
27
+ * matching computeAssertionPassRate — see isCounted().
28
+ * Returns 0 when no trial counted.
26
29
  */
27
30
  export function computePassAtK(trials, _k = 1) {
28
- const n = trials.length;
29
- if (n === 0)
31
+ const relevant = trials.filter(isCounted);
32
+ if (relevant.length === 0)
30
33
  return 0;
31
- return trials.filter(t => t.trialPassed).length / n;
34
+ return relevant.filter(t => t.trialPassed).length / relevant.length;
32
35
  }
33
36
  /**
34
37
  * Aggregates pass@1 across all task results.
@@ -1,3 +1,28 @@
1
+ import { parseStreamResult, parseStreamStats } from '../utils/ndjson.js';
2
+ /**
3
+ * Cap on the agent text carried into the report. The full text always stays in
4
+ * the trial log, which the report links to; this bound keeps a run with dozens
5
+ * of trials from producing a multi-megabyte HTML file.
6
+ */
7
+ export const MAX_SUMMARY_OUTPUT = 4000;
8
+ /**
9
+ * Builds the compact record the report shows for a trial.
10
+ * `rawStream` is the agent's raw NDJSON stdout; an empty or unparsable stream
11
+ * yields an empty output, which is itself the signal that the agent produced
12
+ * nothing usable.
13
+ */
14
+ export function buildTrialSummary(rawStream, logFile) {
15
+ const parsed = parseStreamResult(rawStream);
16
+ const text = parsed && 'response' in parsed ? parsed.response : '';
17
+ const { toolCalls, status } = parseStreamStats(rawStream);
18
+ return {
19
+ output: text.slice(0, MAX_SUMMARY_OUTPUT),
20
+ outputLen: text.length,
21
+ toolCalls,
22
+ stopStatus: status,
23
+ logFile,
24
+ };
25
+ }
1
26
  /**
2
27
  * Returns true when a trial represents an infrastructure failure (timeout, blocked
3
28
  * interactive prompt, runner crash, etc.) rather than a legitimate judge verdict.
@@ -6,6 +31,21 @@
6
31
  export function isTrialError(trial) {
7
32
  return trial.isError === true;
8
33
  }
34
+ /**
35
+ * Whether a trial contributes to metrics.
36
+ *
37
+ * Infrastructure errors (timeout, blocked interactive prompt, runner crash)
38
+ * never reached a verdict, so they leave the denominator entirely instead of
39
+ * counting as failures: a harness timeout says nothing about the skill. The
40
+ * report shows the effective n alongside every rate so an incomplete run is
41
+ * still visible as one.
42
+ *
43
+ * The report applies the same rule to trials a reviewer excluded. Exclusions
44
+ * are made after the run, against the report, so they are not visible here.
45
+ */
46
+ export function isCounted(trial) {
47
+ return !trial.isError;
48
+ }
9
49
  /**
10
50
  * Runs fn(), retrying up to maxRetries additional times with exponential backoff
11
51
  * whenever the result is an infrastructure-error trial (isTrialError returns true).
@@ -33,7 +73,11 @@ export async function withRetry(fn, maxRetries = 2, baseDelayMs = 1000, onRetry)
33
73
  }
34
74
  /**
35
75
  * Pads the trials array up to targetCount when a trial loop aborts early.
36
- * Ensures that pass@k calculations always reflect the full requested trial count.
76
+ *
77
+ * The padding records that N trials were requested but never ran, so the report
78
+ * can show "n=2/5" instead of silently presenting a two-trial measurement as if
79
+ * it were the full run. It does not push the rates down: padded entries are
80
+ * infrastructure errors, and isCounted() keeps those out of every denominator.
37
81
  *
38
82
  * @param trials Trials collected so far (may be shorter than targetCount).
39
83
  * @param targetCount The requested number of trials (numTrials).
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ program
41
41
  .name('skill-eval')
42
42
  .description('CLI to evaluate agent skills triggering and functionality')
43
43
  .version(pkg.version)
44
- .option('-v, --debug', 'Enable debug logging', false);
44
+ .option('-v, --debug', 'Print verbose logs to the console (trial transcripts are always saved)', false);
45
45
  program.on('option:debug', () => {
46
46
  process.env.DEBUG = 'true';
47
47
  });