@fede0089/skill-eval 3.0.2 → 3.2.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
@@ -6,7 +6,7 @@ A CLI tool for evaluating Agent Skills locally. Tests whether your skill trigger
6
6
 
7
7
  skill-eval ships two commands, each targeting a different failure mode:
8
8
 
9
- - **Triggering** (`skill-eval trigger`) — checks whether the agent actually decides to invoke the skill in the right context. A skill that never gets triggered cannot help, no matter how good its instructions are.
9
+ - **Triggering** (`skill-eval trigger`) — checks whether the agent actually decides to invoke the skill in the right context, and leaves it alone in the wrong one. A skill that never gets triggered cannot help, no matter how good its instructions are; one that triggers everywhere gets in the way.
10
10
  - **Functional correctness** (`skill-eval functional`) — checks whether the actions the agent takes while the skill is active match your expectations. An LLM judge grades each transcript against the expectation list you provide.
11
11
 
12
12
  ## Why run skill evals
@@ -38,7 +38,7 @@ For each eval prompt, skill-eval spins up parallel agent processes with the curr
38
38
  pass@k
39
39
  ```
40
40
 
41
- > The `trigger` command only runs with-skill trials and checks whether the skill dispatch tool was actually invoked — no judge or baseline needed.
41
+ > The `trigger` command only runs with-skill trials and checks whether the skill dispatch tool was actually invoked — no judge or baseline needed. Evals marked `should_trigger: false` assert the opposite: that it was *not* invoked.
42
42
  >
43
43
  > The baseline branch is opt-in: enable it with `--compare-baseline` (no-skill control) or `--compare-ref <ref>` (historical skill versions).
44
44
 
@@ -89,7 +89,8 @@ skill-eval functional --workspace <path> --skill <path> [options] [agent]
89
89
  | `--trials <number>` | no | `3` | Trials per task (for pass@k) |
90
90
  | `--timeout <seconds>` | no | none | Kill the agent after this many seconds |
91
91
  | `--eval-id <id>` | no | all | Run only the eval with this numeric ID |
92
- | `--compare-ref [refs...]` | no | | Git references to compare against |
92
+ | `--eval-file <name>` | no | all | Run only the evals from this file in `evals/` (e.g. `edge-cases.json`) |
93
+ | `--compare-ref [refs...]` | no | — | Git references to compare against (variadic — put `[agent]` before it, not after) |
93
94
  | `--compare-baseline` | no | `false` | Also run the no-skill baseline alongside the skill |
94
95
  | `-v, --debug` | no | `false` | Enable verbose debug logging |
95
96
  | `[agent]` | no | `gemini-cli` | Agent backend to use |
@@ -118,6 +119,8 @@ my-skill/
118
119
 
119
120
  All `.json` files in `evals/` are loaded and merged into a single suite — you can split them by feature or regression category.
120
121
 
122
+ Use `--eval-file <name>` to run just one of them while iterating (the `.json` extension is optional), and combine it with `--eval-id <id>` to narrow down to a single eval inside that file.
123
+
121
124
  **Trigger eval** — `id` must be a unique integer across all eval files:
122
125
  ```json
123
126
  {
@@ -128,6 +131,22 @@ All `.json` files in `evals/` are loaded and merged into a single suite — you
128
131
  }
129
132
  ```
130
133
 
134
+ **Negative trigger eval** — add `should_trigger: false` to assert the skill must *not* fire:
135
+ ```json
136
+ {
137
+ "skill_name": "my-skill",
138
+ "evals": [
139
+ { "id": 2, "prompt": "Something my skill has no business handling", "should_trigger": false }
140
+ ]
141
+ }
142
+ ```
143
+
144
+ Positive evals measure **under-triggering** (the skill never fires when it should). Negative evals measure the opposite failure, **over-triggering** — the one you introduce when you widen a skill's `description` to catch more cases and silently lose precision. Both kinds live in the same suite and feed a single success rate, so a change that improves one at the expense of the other shows up immediately.
145
+
146
+ A negative eval fails if the agent *attempts* to activate the skill at all, even if the activation itself errors out. These evals are trigger-only: `skill-eval functional` skips them, since that command instructs the agent to use the skill.
147
+
148
+ > Trigger detection for Codex is heuristic (it infers activations from the event stream), so negative evals are most reliable on `claude-code` and `gemini-cli`.
149
+
131
150
  **Functional eval** — add `expectations` for the LLM judge to evaluate:
132
151
  ```json
133
152
  {
@@ -224,7 +243,7 @@ Without `--debug` these files are not written, so reach for the flag when you ne
224
243
 
225
244
  ## Try it out
226
245
 
227
- This repo includes a `mock-skill/` directory — a complete, working example of a license-generator skill with trigger and functional evals. Run it directly with:
246
+ This repo includes a `mock-skill/` directory — a complete, working example of a license-generator skill with positive trigger, negative trigger, and functional evals. Run it directly with:
228
247
 
229
248
  ```sh
230
249
  npm run test:unit # run the unit test suite
@@ -234,6 +253,9 @@ npm run test:trigger -- codex # run trigger evals with Codex
234
253
  npm run test:functional -- codex # run functional evals with Codex
235
254
  npm run test:trigger -- claude-code # run trigger evals with Claude Code
236
255
  npm run test:functional -- claude-code # run functional evals with Claude Code
256
+
257
+ npm run test:trigger -- --eval-file negative-triggers.json # only the negative-trigger evals
258
+ npm run test:trigger -- --eval-file edge-cases --eval-id 3 # a single eval inside one file
237
259
  ```
238
260
 
239
261
  ## Extending
@@ -8,15 +8,29 @@ import { EvalRunner } from '../core/eval-runner.js';
8
8
  import { AgentPool } from '../core/agent-pool.js';
9
9
  import { aggregatePassAtK, aggregateAssertionPassRate, aggregateTokenStats, aggregateDurationStats } from '../core/statistics.js';
10
10
  import { preflight } from '../core/preflight.js';
11
+ import { ConfigError } from '../core/errors.js';
11
12
  import { withRetry } from '../core/trial-utils.js';
12
13
  import { renderFunctionalTable, renderRunHeader } from '../utils/table-renderer.js';
13
14
  import { JsonReporter } from '../reporters/index.js';
14
15
  import chalk from 'chalk';
15
16
  import { git } from '../utils/git.js';
16
- export async function functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId, compareRefs = [], compareBaseline = false) {
17
+ export async function functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId, compareRefs = [], compareBaseline = false, evalFile) {
17
18
  if (!injectedSuite)
18
19
  preflight(agent, workspace, skillPath);
19
- const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath);
20
+ const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath, evalFile);
21
+ // Negative evals (should_trigger: false) only make sense for the trigger command:
22
+ // this pass instructs the agent that it MUST use the skill, which contradicts them.
23
+ const triggerOnly = suite.tasks.filter(t => t.should_trigger === false);
24
+ if (triggerOnly.length > 0) {
25
+ if (evalId !== undefined && triggerOnly.some(t => t.id === evalId)) {
26
+ throw new ConfigError(`Eval #${evalId} is trigger-only (should_trigger: false) and cannot run under 'functional'.`);
27
+ }
28
+ suite.tasks = suite.tasks.filter(t => t.should_trigger !== false);
29
+ if (suite.tasks.length === 0) {
30
+ throw new ConfigError(`No evals left to run: all ${triggerOnly.length} eval(s) in scope are trigger-only (should_trigger: false).`);
31
+ }
32
+ Logger.write(chalk.dim(` Skipping ${triggerOnly.length} trigger-only eval(s)\n`));
33
+ }
20
34
  if (evalId !== undefined) {
21
35
  suite.tasks = suite.tasks.filter(t => t.id === evalId);
22
36
  if (suite.tasks.length === 0) {
@@ -83,7 +97,7 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
83
97
  ]
84
98
  : skillVersions.flatMap(v => Array.from({ length: numTrials }, (_, i) => `${v} ${i + 1}`));
85
99
  try {
86
- renderRunHeader({ command: 'functional', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId });
100
+ renderRunHeader({ command: 'functional', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId, evalFile });
87
101
  Logger.write(`──────────────────────────────────────────────────\n`);
88
102
  for (let i = 0; i < tasks.length; i++) {
89
103
  const task = tasks[i];
@@ -13,10 +13,10 @@ import { renderTriggerTable, renderRunHeader } from '../utils/table-renderer.js'
13
13
  import { JsonReporter } 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 = []) {
16
+ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId, compareRefs = [], evalFile) {
17
17
  if (!injectedSuite)
18
18
  preflight(agent, workspace, skillPath);
19
- const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath);
19
+ const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath, evalFile);
20
20
  if (evalId !== undefined) {
21
21
  suite.tasks = suite.tasks.filter(t => t.id === evalId);
22
22
  if (suite.tasks.length === 0) {
@@ -73,7 +73,7 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
73
73
  const skillVersions = Array.from(variantRunners.keys());
74
74
  const subtaskLabels = skillVersions.flatMap(v => Array.from({ length: numTrials }, (_, i) => `${v} ${i + 1}`));
75
75
  try {
76
- renderRunHeader({ command: 'trigger', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId });
76
+ renderRunHeader({ command: 'trigger', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId, evalFile });
77
77
  Logger.write(`--- Trigger Pass ---\n`);
78
78
  Logger.write(`──────────────────────────────────────────────────\n`);
79
79
  for (let i = 0; i < tasks.length; i++) {
@@ -149,7 +149,8 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
149
149
  taskId: task.id,
150
150
  prompt: task.prompt,
151
151
  baselineTrials: [],
152
- skillTrials: taskSkillTrials
152
+ skillTrials: taskSkillTrials,
153
+ shouldTrigger: task.should_trigger !== false
153
154
  };
154
155
  taskResults.push(taskResult);
155
156
  if (!localAllPassed && !multi) {
@@ -65,22 +65,59 @@ export class EvalRunner {
65
65
  const tokenStats = transcript
66
66
  ? parseTokenStats(transcript.response || '') ?? undefined
67
67
  : undefined;
68
- let triggered = false;
68
+ // Negative evals (should_trigger: false) assert the opposite: the skill must NOT activate.
69
+ const shouldTrigger = task.should_trigger !== false;
70
+ const assertionLabel = shouldTrigger ? 'Skill was triggered' : 'Skill was not triggered';
71
+ let trialPassed = false;
69
72
  const assertionResults = [];
70
73
  if (transcript && !transcript.error) {
71
74
  uiCtx.updateLog('Grading…');
72
- triggered = this.triggerGrader.gradeTrigger(transcript);
73
- assertionResults.push({
74
- assertion: 'Skill was triggered',
75
- passed: triggered,
76
- reason: triggered ? 'Detected skill activation in transcript' : 'No skill activation detected in transcript',
77
- graderType: 'programmatic'
78
- });
75
+ if (shouldTrigger) {
76
+ const triggered = this.triggerGrader.gradeTrigger(transcript);
77
+ trialPassed = triggered;
78
+ assertionResults.push({
79
+ assertion: assertionLabel,
80
+ passed: triggered,
81
+ reason: triggered ? 'Detected skill activation in transcript' : 'No skill activation detected in transcript',
82
+ graderType: 'programmatic'
83
+ });
84
+ }
85
+ else if (!this.triggerGrader.hasParsableEvents(transcript)) {
86
+ // Without any parsable events the absence of an activation proves nothing —
87
+ // report it as an infrastructure error instead of a vacuous pass.
88
+ const reason = 'No parsable agent events — cannot verify that the skill did not trigger';
89
+ assertionResults.push({
90
+ assertion: assertionLabel,
91
+ passed: false,
92
+ reason,
93
+ graderType: 'programmatic'
94
+ });
95
+ return {
96
+ id: trialId,
97
+ transcript,
98
+ assertionResults,
99
+ trialPassed: false,
100
+ isError: true,
101
+ tokenStats,
102
+ durationMs
103
+ };
104
+ }
105
+ else {
106
+ // Any activation attempt counts, even one that failed: the skill still fired.
107
+ const triggered = this.triggerGrader.detectSkillAttempt(transcript);
108
+ trialPassed = !triggered;
109
+ assertionResults.push({
110
+ assertion: assertionLabel,
111
+ passed: !triggered,
112
+ reason: triggered ? 'Skill activation detected in transcript' : 'No skill activation detected in transcript',
113
+ graderType: 'programmatic'
114
+ });
115
+ }
79
116
  }
80
117
  else {
81
118
  const errorMsg = transcript?.error || 'Error: No transcript was produced';
82
119
  assertionResults.push({
83
- assertion: 'Skill was triggered',
120
+ assertion: assertionLabel,
84
121
  passed: false,
85
122
  reason: errorMsg,
86
123
  graderType: 'programmatic'
@@ -99,7 +136,7 @@ export class EvalRunner {
99
136
  id: trialId,
100
137
  transcript: transcript || { error: 'No transcript produced' },
101
138
  assertionResults: assertionResults,
102
- trialPassed: triggered,
139
+ trialPassed,
103
140
  tokenStats,
104
141
  durationMs
105
142
  };
@@ -89,10 +89,19 @@ export class TriggerGrader {
89
89
  }
90
90
  return false;
91
91
  }
92
+ /**
93
+ * Whether the transcript produced any parsable NDJSON events.
94
+ * Negative trigger evals rely on the absence of an activation event, so a transcript
95
+ * with no events at all cannot prove anything and must not be graded as a pass.
96
+ */
97
+ hasParsableEvents(transcript) {
98
+ return parseNdjsonEvents(transcript.raw_output || '').length > 0;
99
+ }
92
100
  /**
93
101
  * Checks whether the skill was attempted (any tool_use event for activate_skill matching the skill name).
94
102
  * Unlike gradeTrigger, does NOT require a successful tool_result — any attempt counts.
95
- * Used to detect invalid baseline runs where the agent tried to invoke the restricted skill.
103
+ * Used to detect invalid baseline runs where the agent tried to invoke the restricted skill,
104
+ * and to grade negative trigger evals (an attempted activation still counts as a trigger).
96
105
  */
97
106
  detectSkillAttempt(transcript) {
98
107
  const rawOutput = transcript.raw_output || '';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { Command } from 'commander';
3
3
  import { triggerCommand } from './commands/trigger.js';
4
4
  import { functionalCommand } from './commands/functional.js';
5
5
  import { Logger } from './utils/logger.js';
6
- import { AppError } from './core/errors.js';
6
+ import { AppError, ConfigError } from './core/errors.js';
7
7
  import { HtmlReporter } from './reporters/index.js';
8
8
  import { DEFAULT_AGENT } from './runners/registry.js';
9
9
  import * as path from 'path';
@@ -25,6 +25,18 @@ const errorHandler = (err) => {
25
25
  }
26
26
  process.exit(1);
27
27
  };
28
+ /**
29
+ * A variadic option used without values (`--compare-ref` on its own) is parsed as
30
+ * `true` by commander; reject it instead of iterating over a boolean.
31
+ */
32
+ const parseCompareRefs = (value) => {
33
+ if (value === undefined)
34
+ return [];
35
+ if (Array.isArray(value))
36
+ return value;
37
+ errorHandler(new ConfigError('--compare-ref requires at least one git reference (e.g. --compare-ref HEAD~1).'));
38
+ return [];
39
+ };
28
40
  program
29
41
  .name('skill-eval')
30
42
  .description('CLI to evaluate agent skills triggering and functionality')
@@ -42,6 +54,7 @@ program
42
54
  .option('--trials <number>', 'Number of trials per task for pass@k calculation', '3')
43
55
  .option('--timeout <seconds>', 'Agent timeout in seconds')
44
56
  .option('--eval-id <id>', 'Run only the eval with this ID (numeric)')
57
+ .option('--eval-file <name>', 'Run only the evals from this file in evals/ (e.g. edge-cases.json)')
45
58
  .option('--compare-ref [refs...]', 'Compare against historical git references')
46
59
  .action((agent, options) => {
47
60
  const workspace = path.resolve(options.workspace);
@@ -50,8 +63,8 @@ program
50
63
  const numTrials = parseInt(options.trials, 10);
51
64
  const timeoutMs = options.timeout ? parseInt(options.timeout, 10) * 1000 : undefined;
52
65
  const evalId = options.evalId !== undefined ? parseInt(options.evalId, 10) : undefined;
53
- const compareRefs = options.compareRef || [];
54
- triggerCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs).catch(errorHandler);
66
+ const compareRefs = parseCompareRefs(options.compareRef);
67
+ triggerCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs, options.evalFile).catch(errorHandler);
55
68
  });
56
69
  program
57
70
  .command('functional [agent]')
@@ -62,6 +75,7 @@ program
62
75
  .option('--trials <number>', 'Number of trials per task for pass@k calculation', '3')
63
76
  .option('--timeout <seconds>', 'Agent timeout in seconds')
64
77
  .option('--eval-id <id>', 'Run only the eval with this ID (numeric)')
78
+ .option('--eval-file <name>', 'Run only the evals from this file in evals/ (e.g. edge-cases.json)')
65
79
  .option('--compare-ref [refs...]', 'Compare against historical git references')
66
80
  .option('--compare-baseline', 'Also run the no-skill baseline alongside the skill')
67
81
  .action((agent, options) => {
@@ -71,9 +85,9 @@ program
71
85
  const numTrials = parseInt(options.trials, 10);
72
86
  const timeoutMs = options.timeout ? parseInt(options.timeout, 10) * 1000 : undefined;
73
87
  const evalId = options.evalId !== undefined ? parseInt(options.evalId, 10) : undefined;
74
- const compareRefs = options.compareRef || [];
88
+ const compareRefs = parseCompareRefs(options.compareRef);
75
89
  const compareBaseline = !!options.compareBaseline;
76
- functionalCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs, compareBaseline).catch(errorHandler);
90
+ functionalCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs, compareBaseline, options.evalFile).catch(errorHandler);
77
91
  });
78
92
  const isMain = process.argv[1] && (() => {
79
93
  try {
@@ -215,9 +215,11 @@ function renderTaskTable(report) {
215
215
  const headerRow = `<tr><th>#</th><th>Prompt</th><th>Details</th></tr>`;
216
216
  const rows = results.map(result => {
217
217
  const prompt = escapeHtml(result.prompt);
218
+ // Negative trigger evals assert the opposite outcome — flag them so the row is not misread.
219
+ const badge = result.shouldTrigger === false ? '<span class="badge-neg">no-trigger</span>' : '';
218
220
  const detailsBtn = `<button class="details-btn" data-target="details-${result.taskId}">▶</button>`;
219
221
  const detailsRow = `<tr class="details-row"><td colspan="3">${renderTaskDetails(result, functional)}</td></tr>`;
220
- return `<tr><td>${result.taskId}</td><td class="prompt-cell">${prompt}</td><td>${detailsBtn}</td></tr>${detailsRow}`;
222
+ return `<tr><td>${result.taskId}</td><td class="prompt-cell">${badge}${prompt}</td><td>${detailsBtn}</td></tr>${detailsRow}`;
221
223
  }).join('');
222
224
  return `<div class="table-wrap"><table><thead>${headerRow}</thead><tbody>${rows}</tbody></table></div>`;
223
225
  }
@@ -292,6 +294,7 @@ th { background: #f8fafc; font-size: 11px; font-weight: 600; text-transform: upp
292
294
  td { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
293
295
  tr:last-child td { border-bottom: none; }
294
296
  .prompt-cell { max-width: 480px; word-break: break-word; color: #334155; }
297
+ .badge-neg { display: inline-block; margin-right: 8px; padding: 1px 7px; border-radius: 10px; background: #e0f2fe; color: #0369a1; font-size: 11px; font-weight: 600; vertical-align: 1px; }
295
298
 
296
299
  /* Details */
297
300
  .details-btn { background: none; border: 1px solid #e2e8f0; border-radius: 4px; cursor: pointer; padding: 2px 8px; font-size: 11px; color: #64748b; transition: background 0.15s; }
@@ -57,6 +57,10 @@ function eventSignalsSkill(event, skillName) {
57
57
  const eventType = String(event.type ?? '').toLowerCase();
58
58
  const item = getEventItem(event);
59
59
  const itemType = String(item?.type ?? '').toLowerCase();
60
+ // Assistant prose is not a tool call: an agent merely naming the skill (often to say it is
61
+ // not needed) must not be reported as an activation. Real activations arrive as skill items.
62
+ if (['agent_message', 'assistant_message', 'message'].includes(itemType))
63
+ return false;
60
64
  return (serialized.includes(normalizedSkill) &&
61
65
  (serialized.includes('skill') ||
62
66
  eventType.includes('skill') ||
@@ -1,20 +1,36 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { ConfigError } from '../core/errors.js';
4
+ /**
5
+ * Normalizes an --eval-file value to the bare file name used inside the evals directory.
6
+ * Accepts 'edge-cases', 'edge-cases.json' and 'path/to/evals/edge-cases.json' alike.
7
+ */
8
+ function normalizeEvalFileName(evalFile) {
9
+ const base = path.basename(evalFile.trim());
10
+ return base.endsWith('.json') ? base : `${base}.json`;
11
+ }
4
12
  /**
5
13
  * Loads and merges all JSON evaluation files from a skill's evals directory.
6
14
  * Aligned with Anthropic's recommendation to split evals by capability/regression.
7
15
  * Supports legacy 'tasks' and 'assertions' internally while maintaining 'evals' and 'expectations' in files.
16
+ * When 'evalFile' is given, only that file is loaded instead of the whole suite.
8
17
  */
9
- export function loadEvalSuite(skillPath) {
18
+ export function loadEvalSuite(skillPath, evalFile) {
10
19
  const evalsDir = path.resolve(skillPath, 'evals');
11
20
  if (!fs.existsSync(evalsDir)) {
12
21
  throw new ConfigError(`Could not find evals directory at ${evalsDir}`);
13
22
  }
14
- const files = fs.readdirSync(evalsDir).filter(file => file.endsWith('.json'));
23
+ let files = fs.readdirSync(evalsDir).filter(file => file.endsWith('.json'));
15
24
  if (files.length === 0) {
16
25
  throw new ConfigError(`No JSON evaluation files found in ${evalsDir}`);
17
26
  }
27
+ if (evalFile !== undefined) {
28
+ const wanted = normalizeEvalFileName(evalFile);
29
+ if (!files.includes(wanted)) {
30
+ throw new ConfigError(`Eval file '${wanted}' not found in ${evalsDir}. Available: ${[...files].sort().join(', ')}.`);
31
+ }
32
+ files = [wanted];
33
+ }
18
34
  let mergedSkillName = '';
19
35
  const mergedTasks = [];
20
36
  for (const file of files) {
@@ -44,18 +60,23 @@ export function loadEvalSuite(skillPath) {
44
60
  if (e.id === undefined || typeof e.id !== 'number') {
45
61
  throw new ConfigError(`Invalid task ID in ${file}. ID must be a number.`);
46
62
  }
63
+ if (e.should_trigger !== undefined && typeof e.should_trigger !== 'boolean') {
64
+ throw new ConfigError(`Invalid 'should_trigger' for eval ${e.id} in ${file}. Must be a boolean.`);
65
+ }
47
66
  return {
48
67
  id: e.id,
49
68
  prompt: e.prompt,
50
69
  expected_output: e.expected_output,
51
70
  assertions: e.expectations || e.assertions,
52
- files: e.files
71
+ files: e.files,
72
+ should_trigger: e.should_trigger
53
73
  };
54
74
  });
55
75
  mergedTasks.push(...mappedTasks);
56
76
  }
57
77
  if (mergedTasks.length === 0) {
58
- throw new ConfigError(`No evaluations found in any of the JSON files in ${evalsDir}`);
78
+ const scope = evalFile !== undefined ? `${files[0]} in ${evalsDir}` : `any of the JSON files in ${evalsDir}`;
79
+ throw new ConfigError(`No evaluations found in ${scope}`);
59
80
  }
60
81
  return {
61
82
  skill_name: mergedSkillName,
@@ -98,7 +98,7 @@ function boxLabel(key, value) {
98
98
  * Always shown regardless of debug mode.
99
99
  */
100
100
  export function renderRunHeader(config) {
101
- const { command, skillName, agent, workspace, tasks, trials, maxAgents, timeoutMs, runDir, evalId } = config;
101
+ const { command, skillName, agent, workspace, tasks, trials, maxAgents, timeoutMs, runDir, evalId, evalFile } = config;
102
102
  let timeoutStr = 'None';
103
103
  if (timeoutMs && timeoutMs > 0) {
104
104
  const timeoutSec = timeoutMs / 1000;
@@ -111,7 +111,12 @@ export function renderRunHeader(config) {
111
111
  const dashes = '─'.repeat(BOX_INNER - titleLabel.length);
112
112
  const top = chalk.gray('┌─ ') + chalk.bold(titleLabel) + ' ' + chalk.gray(dashes + '┐');
113
113
  const bottom = chalk.gray('└' + '─'.repeat(BOX_INNER + 2) + '┘');
114
- const commandPart = evalId !== undefined ? `${command} · eval #${evalId}` : command;
114
+ const filterParts = [];
115
+ if (evalFile !== undefined)
116
+ filterParts.push(evalFile);
117
+ if (evalId !== undefined)
118
+ filterParts.push(`eval #${evalId}`);
119
+ const commandPart = [command, ...filterParts].join(' · ');
115
120
  const title = chalk.bold.cyan(skillName) + chalk.gray(` · ${commandPart}`);
116
121
  const runLine = `${tasks} task${tasks !== 1 ? 's' : ''} · ${trials} trial${trials !== 1 ? 's' : ''} · agents ${maxAgents}`;
117
122
  process.stdout.write('\n');
@@ -134,7 +139,11 @@ export function renderTriggerTable(report) {
134
139
  const numTrials = metrics.numTrials || 1;
135
140
  // Identify all skill versions present in the results
136
141
  const skillVersions = results.length > 0 ? Object.keys(results[0].skillTrials) : ['local'];
142
+ // Only surface the polarity column when the suite actually contains negative evals.
143
+ const hasNegativeEvals = results.some(r => r.shouldTrigger === false);
137
144
  const header = ['ID', 'Prompt'];
145
+ if (hasNegativeEvals)
146
+ header.push('Expect');
138
147
  if (numTrials > 1) {
139
148
  for (const version of skillVersions) {
140
149
  header.push(`${version} Trials`, `${version} Rate`);
@@ -150,6 +159,9 @@ export function renderTriggerTable(report) {
150
159
  for (const result of results) {
151
160
  const promptSnippet = result.prompt.substring(0, 40) + (result.prompt.length > 40 ? '...' : '');
152
161
  const row = [result.taskId.toString(), promptSnippet];
162
+ if (hasNegativeEvals) {
163
+ row.push(result.shouldTrigger === false ? chalk.cyan('no-trigger') : chalk.dim('trigger'));
164
+ }
153
165
  for (const version of skillVersions) {
154
166
  const trials = result.skillTrials[version] || [];
155
167
  const p1Cell = formatPassAt1(trials);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fede0089/skill-eval",
3
- "version": "3.0.2",
3
+ "version": "3.2.0",
4
4
  "description": "CLI to evaluate agent skills triggering and functionality",
5
5
  "main": "dist/index.js",
6
6
  "bin": {