@fede0089/skill-eval 3.0.1 → 3.1.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
 
@@ -128,6 +128,22 @@ All `.json` files in `evals/` are loaded and merged into a single suite — you
128
128
  }
129
129
  ```
130
130
 
131
+ **Negative trigger eval** — add `should_trigger: false` to assert the skill must *not* fire:
132
+ ```json
133
+ {
134
+ "skill_name": "my-skill",
135
+ "evals": [
136
+ { "id": 2, "prompt": "Something my skill has no business handling", "should_trigger": false }
137
+ ]
138
+ }
139
+ ```
140
+
141
+ 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.
142
+
143
+ 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.
144
+
145
+ > 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`.
146
+
131
147
  **Functional eval** — add `expectations` for the LLM judge to evaluate:
132
148
  ```json
133
149
  {
@@ -205,7 +221,7 @@ Refer to your runner's documentation for the full set of settings and policy key
205
221
 
206
222
  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.
207
223
 
208
- 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:
224
+ 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:
209
225
 
210
226
  ```sh
211
227
  skill-eval functional --workspace . --skill mock-skill --trials 2 --compare-baseline --debug claude-code
@@ -224,7 +240,7 @@ Without `--debug` these files are not written, so reach for the flag when you ne
224
240
 
225
241
  ## Try it out
226
242
 
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:
243
+ 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
244
 
229
245
  ```sh
230
246
  npm run test:unit # run the unit test suite
@@ -8,6 +8,7 @@ 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';
@@ -17,6 +18,16 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
17
18
  if (!injectedSuite)
18
19
  preflight(agent, workspace, skillPath);
19
20
  const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath);
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
+ Logger.write(chalk.dim(` Skipping ${triggerOnly.length} trigger-only eval(s)\n`));
30
+ }
20
31
  if (evalId !== undefined) {
21
32
  suite.tasks = suite.tasks.filter(t => t.id === evalId);
22
33
  if (suite.tasks.length === 0) {
@@ -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 || '';
@@ -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') ||
@@ -44,12 +44,16 @@ export function loadEvalSuite(skillPath) {
44
44
  if (e.id === undefined || typeof e.id !== 'number') {
45
45
  throw new ConfigError(`Invalid task ID in ${file}. ID must be a number.`);
46
46
  }
47
+ if (e.should_trigger !== undefined && typeof e.should_trigger !== 'boolean') {
48
+ throw new ConfigError(`Invalid 'should_trigger' for eval ${e.id} in ${file}. Must be a boolean.`);
49
+ }
47
50
  return {
48
51
  id: e.id,
49
52
  prompt: e.prompt,
50
53
  expected_output: e.expected_output,
51
54
  assertions: e.expectations || e.assertions,
52
- files: e.files
55
+ files: e.files,
56
+ should_trigger: e.should_trigger
53
57
  };
54
58
  });
55
59
  mergedTasks.push(...mappedTasks);
@@ -134,7 +134,11 @@ export function renderTriggerTable(report) {
134
134
  const numTrials = metrics.numTrials || 1;
135
135
  // Identify all skill versions present in the results
136
136
  const skillVersions = results.length > 0 ? Object.keys(results[0].skillTrials) : ['local'];
137
+ // Only surface the polarity column when the suite actually contains negative evals.
138
+ const hasNegativeEvals = results.some(r => r.shouldTrigger === false);
137
139
  const header = ['ID', 'Prompt'];
140
+ if (hasNegativeEvals)
141
+ header.push('Expect');
138
142
  if (numTrials > 1) {
139
143
  for (const version of skillVersions) {
140
144
  header.push(`${version} Trials`, `${version} Rate`);
@@ -150,6 +154,9 @@ export function renderTriggerTable(report) {
150
154
  for (const result of results) {
151
155
  const promptSnippet = result.prompt.substring(0, 40) + (result.prompt.length > 40 ? '...' : '');
152
156
  const row = [result.taskId.toString(), promptSnippet];
157
+ if (hasNegativeEvals) {
158
+ row.push(result.shouldTrigger === false ? chalk.cyan('no-trigger') : chalk.dim('trigger'));
159
+ }
153
160
  for (const version of skillVersions) {
154
161
  const trials = result.skillTrials[version] || [];
155
162
  const p1Cell = formatPassAt1(trials);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fede0089/skill-eval",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "CLI to evaluate agent skills triggering and functionality",
5
5
  "main": "dist/index.js",
6
6
  "bin": {