@fede0089/skill-eval 1.2.2 → 1.4.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,11 +6,11 @@ A CLI tool for evaluating Agent Skills locally. Tests whether your skill trigger
6
6
 
7
7
  Skills are instructions that change how the agent behaves. But a single successful run isn't enough to trust one — agents are non-deterministic, and a good isolated result can be exactly that: an isolated case.
8
8
 
9
- Skill evals let you turn that intuition into evidence: run several comparable tasks with and without the skill, measure them against the same criteria, and validate whether the agent improves consistently against a baseline.
9
+ Skill evals let you turn that intuition into evidence: run several comparable tasks with the skill, measure them against the same criteria, and optionally compare against a baseline or historical skill branches.
10
10
 
11
11
  ## How it works
12
12
 
13
- For each eval prompt, skill-eval spins up parallel agent processes — some with the skill installed, others without (the baseline). Each agent runs headlessly and produces a transcript. An LLM judge then grades each transcript against your expectations. Results are aggregated into **pass@k** metrics, giving you a clear measure of how much your skill actually improves the agent's behavior versus the unassisted baseline.
13
+ For each eval prompt, skill-eval spins up parallel agent processes with the current skill installed by default. You can optionally add the no-skill baseline or historical skill branches for side-by-side comparison. Each agent runs headlessly and produces a transcript. An LLM judge then grades each transcript against your expectations. Results are aggregated into **pass@k** metrics, giving you a clear view of how the skill behaves in isolation or relative to comparison targets.
14
14
 
15
15
  ```
16
16
  eval prompt
@@ -66,7 +66,7 @@ npm link # makes `skill-eval` available globally
66
66
  # Checks that the skill is triggered (invoked) for each prompt
67
67
  skill-eval trigger --workspace <path> --skill <path> [options] [agent]
68
68
 
69
- # Checks that the skill produces correct output, measured against a baseline
69
+ # Checks that the skill produces correct output (skill-only by default)
70
70
  skill-eval functional --workspace <path> --skill <path> [options] [agent]
71
71
  ```
72
72
 
@@ -81,6 +81,7 @@ skill-eval functional --workspace <path> --skill <path> [options] [agent]
81
81
  | `--timeout <seconds>` | no | none | Kill the agent after this many seconds |
82
82
  | `--eval-id <id>` | no | all | Run only the eval with this numeric ID |
83
83
  | `--compare-ref [refs...]` | no | — | Git references to compare against |
84
+ | `--compare-baseline` | no | `false` | Also run the no-skill baseline alongside the skill |
84
85
  | `-v, --debug` | no | `false` | Enable verbose debug logging |
85
86
  | `[agent]` | no | `gemini-cli` | Agent backend to use |
86
87
 
@@ -13,7 +13,7 @@ import { renderFunctionalTable, renderRunHeader } from '../utils/table-renderer.
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 functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId, compareRefs = []) {
16
+ export async function functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId, compareRefs = [], compareBaseline = false) {
17
17
  if (!injectedSuite)
18
18
  preflight(agent, workspace, skillPath);
19
19
  const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath);
@@ -63,10 +63,10 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
63
63
  }));
64
64
  }
65
65
  // 3. Baseline Runner
66
- const withoutSkillRunner = new EvalRunner({
66
+ const withoutSkillRunner = compareBaseline ? new EvalRunner({
67
67
  agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: true, debug, timeoutMs,
68
68
  variant: 'baseline'
69
- });
69
+ }) : undefined;
70
70
  const taskResults = [];
71
71
  let withSkillTasksAllPassedCount = 0;
72
72
  let baselineTasksAllPassedCount = 0;
@@ -76,10 +76,12 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
76
76
  let barrier = Promise.resolve();
77
77
  // Subtask labels
78
78
  const skillVersions = Array.from(variantRunners.keys());
79
- const subtaskLabels = [
80
- ...Array.from({ length: numTrials }, (_, i) => `Without Skill ${i + 1}`),
81
- ...skillVersions.flatMap(v => Array.from({ length: numTrials }, (_, i) => `${v} ${i + 1}`))
82
- ];
79
+ const subtaskLabels = compareBaseline
80
+ ? [
81
+ ...Array.from({ length: numTrials }, (_, i) => `Without Skill ${i + 1}`),
82
+ ...skillVersions.flatMap(v => Array.from({ length: numTrials }, (_, i) => `${v} ${i + 1}`))
83
+ ]
84
+ : skillVersions.flatMap(v => Array.from({ length: numTrials }, (_, i) => `${v} ${i + 1}`));
83
85
  try {
84
86
  renderRunHeader({ command: 'functional', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId });
85
87
  Logger.write(`──────────────────────────────────────────────────\n`);
@@ -98,34 +100,36 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
98
100
  await thisBarrier;
99
101
  // 1. Without Skill trials
100
102
  const baselineTrialPromises = [];
101
- for (let idx = 0; idx < numTrials; idx++) {
102
- const trialId = idx + 1;
103
- const trialCtx = multi?.getTrialCtx(trialId) ?? uiCtx;
104
- const release = await pool.acquire();
105
- const p = withRetry((attempt) => withoutSkillRunner.runFunctionalTask(task, i, trialId, trialCtx, attempt)
106
- .catch((error) => ({
107
- id: trialId,
108
- transcript: { error: error instanceof Error ? error.message : String(error) },
109
- assertionResults: [{ assertion: 'Without Skill Execution', passed: false, reason: String(error) }],
110
- trialPassed: false,
111
- isError: true
112
- })), 2, 1000, (nextAttempt, lastTrial) => {
113
- const reason = lastTrial.assertionResults[0]?.reason ?? 'infrastructure error';
114
- trialCtx.updateLog(`Retry ${nextAttempt}/2 ${reason.substring(0, 50)}`);
115
- }).then(trial => {
116
- if (multi) {
117
- const reason = trial.assertionResults.find(r => !r.passed)?.reason;
118
- const passedCount = trial.assertionResults.filter(r => r.passed).length;
119
- const totalCount = trial.assertionResults.length;
120
- multi.markTrialComplete(trialId, trial.trialPassed, reason, trial.isError, passedCount, totalCount);
121
- }
122
- return trial;
123
- }).finally(release);
124
- baselineTrialPromises.push(p);
103
+ if (withoutSkillRunner) {
104
+ for (let idx = 0; idx < numTrials; idx++) {
105
+ const trialId = idx + 1;
106
+ const trialCtx = multi?.getTrialCtx(trialId) ?? uiCtx;
107
+ const release = await pool.acquire();
108
+ const p = withRetry((attempt) => withoutSkillRunner.runFunctionalTask(task, i, trialId, trialCtx, attempt)
109
+ .catch((error) => ({
110
+ id: trialId,
111
+ transcript: { error: error instanceof Error ? error.message : String(error) },
112
+ assertionResults: [{ assertion: 'Without Skill Execution', passed: false, reason: String(error) }],
113
+ trialPassed: false,
114
+ isError: true
115
+ })), 2, 1000, (nextAttempt, lastTrial) => {
116
+ const reason = lastTrial.assertionResults[0]?.reason ?? 'infrastructure error';
117
+ trialCtx.updateLog(`Retry ${nextAttempt}/2 — ${reason.substring(0, 50)}`);
118
+ }).then(trial => {
119
+ if (multi) {
120
+ const reason = trial.assertionResults.find(r => !r.passed)?.reason;
121
+ const passedCount = trial.assertionResults.filter(r => r.passed).length;
122
+ const totalCount = trial.assertionResults.length;
123
+ multi.markTrialComplete(trialId, trial.trialPassed, reason, trial.isError, passedCount, totalCount);
124
+ }
125
+ return trial;
126
+ }).finally(release);
127
+ baselineTrialPromises.push(p);
128
+ }
125
129
  }
126
130
  // 2. Skill variants trials
127
131
  const variantTrialsPromises = {};
128
- let currentSubtaskIdx = numTrials;
132
+ let currentSubtaskIdx = compareBaseline ? numTrials : 0;
129
133
  for (const [version, runner] of variantRunners.entries()) {
130
134
  variantTrialsPromises[version] = [];
131
135
  for (let idx = 0; idx < numTrials; idx++) {
@@ -158,19 +162,17 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
158
162
  }
159
163
  // All slots for this prompt have been acquired — signal the next prompt.
160
164
  resolveBarrier();
161
- const [woTrials, ...versionsTrials] = await Promise.all([
162
- Promise.all(baselineTrialPromises),
163
- ...Object.values(variantTrialsPromises).map(ps => Promise.all(ps))
164
- ]);
165
+ const variantsTrials = await Promise.all(Object.values(variantTrialsPromises).map(ps => Promise.all(ps)));
166
+ const woTrials = withoutSkillRunner ? await Promise.all(baselineTrialPromises) : [];
165
167
  const woPassedCount = woTrials.filter(t => t.trialPassed).length;
166
- if (woPassedCount === woTrials.length)
168
+ if (withoutSkillRunner && woPassedCount === woTrials.length)
167
169
  baselineTasksAllPassedCount++;
168
170
  const taskSkillTrials = {};
169
171
  const variantNames = Object.keys(variantTrialsPromises);
170
172
  let localAllPassed = true;
171
173
  for (let vIdx = 0; vIdx < variantNames.length; vIdx++) {
172
174
  const vName = variantNames[vIdx];
173
- const vTrials = versionsTrials[vIdx];
175
+ const vTrials = variantsTrials[vIdx];
174
176
  taskSkillTrials[vName] = vTrials.map(t => ({ ...t, transcript: undefined }));
175
177
  if (vName === 'local') {
176
178
  const passedCount = vTrials.filter(t => t.trialPassed).length;
@@ -185,7 +187,7 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
185
187
  const taskResult = {
186
188
  taskId: task.id,
187
189
  prompt: task.prompt,
188
- baselineTrials: woTrials.map(t => ({ ...t, transcript: undefined })),
190
+ baselineTrials: withoutSkillRunner ? woTrials.map(t => ({ ...t, transcript: undefined })) : [],
189
191
  skillTrials: taskSkillTrials
190
192
  };
191
193
  taskResults.push(taskResult);
@@ -199,25 +201,28 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
199
201
  await ui.run(tasks.length);
200
202
  // ==== REPORTING ====
201
203
  const allSkillVersions = taskResults.length > 0 ? Object.keys(taskResults[0].skillTrials) : ['local'];
202
- const allVersions = ['baseline', ...allSkillVersions];
204
+ const hasBaseline = compareBaseline;
205
+ const allVersions = hasBaseline ? ['baseline', ...allSkillVersions] : allSkillVersions;
203
206
  const scores = {};
204
207
  const passAtK = {};
205
208
  const assertionPassRate = {};
206
209
  const tokenStats = {};
207
210
  const durationStats = {};
208
211
  // 1. Baseline Metrics
209
- const { passAtK: woPassAtK } = aggregatePassAtK(taskResults, numTrials, r => r.baselineTrials);
210
- const woAssertionPassRate = aggregateAssertionPassRate(taskResults, r => r.baselineTrials);
211
- const woPercentage = Math.round(woAssertionPassRate * 100);
212
- scores['baseline'] = `${woPercentage}%`;
213
- passAtK['baseline'] = Math.round(woPassAtK * 1000) / 1000;
214
- assertionPassRate['baseline'] = Math.round(woAssertionPassRate * 1000) / 1000;
215
- const woTokens = aggregateTokenStats(taskResults.flatMap(r => r.baselineTrials));
216
- if (woTokens)
217
- tokenStats['baseline'] = woTokens;
218
- const woDuration = aggregateDurationStats(taskResults.flatMap(r => r.baselineTrials));
219
- if (woDuration)
220
- durationStats['baseline'] = woDuration;
212
+ if (hasBaseline) {
213
+ const { passAtK: woPassAtK } = aggregatePassAtK(taskResults, numTrials, r => r.baselineTrials);
214
+ const woAssertionPassRate = aggregateAssertionPassRate(taskResults, r => r.baselineTrials);
215
+ const woPercentage = Math.round(woAssertionPassRate * 100);
216
+ scores['baseline'] = `${woPercentage}%`;
217
+ passAtK['baseline'] = Math.round(woPassAtK * 1000) / 1000;
218
+ assertionPassRate['baseline'] = Math.round(woAssertionPassRate * 1000) / 1000;
219
+ const woTokens = aggregateTokenStats(taskResults.flatMap(r => r.baselineTrials));
220
+ if (woTokens)
221
+ tokenStats['baseline'] = woTokens;
222
+ const woDuration = aggregateDurationStats(taskResults.flatMap(r => r.baselineTrials));
223
+ if (woDuration)
224
+ durationStats['baseline'] = woDuration;
225
+ }
221
226
  // 2. Skill Variants Metrics
222
227
  for (const version of allSkillVersions) {
223
228
  const { passAtK: wiPassAtK } = aggregatePassAtK(taskResults, numTrials, r => r.skillTrials[version] || []);
@@ -235,6 +240,7 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
235
240
  }
236
241
  const report = {
237
242
  timestamp: startTime.toISOString(),
243
+ command: 'functional',
238
244
  skill_name,
239
245
  agent,
240
246
  metrics: {
@@ -183,6 +183,7 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
183
183
  }
184
184
  const report = {
185
185
  timestamp: startTime.toISOString(),
186
+ command: 'trigger',
186
187
  skill_name,
187
188
  agent,
188
189
  metrics: {
package/dist/index.js CHANGED
@@ -61,6 +61,7 @@ program
61
61
  .option('--timeout <seconds>', 'Agent timeout in seconds')
62
62
  .option('--eval-id <id>', 'Run only the eval with this ID (numeric)')
63
63
  .option('--compare-ref [refs...]', 'Compare against historical git references')
64
+ .option('--compare-baseline', 'Also run the no-skill baseline alongside the skill')
64
65
  .action((agent, options) => {
65
66
  const workspace = path.resolve(options.workspace);
66
67
  const selectedAgent = agent || DEFAULT_AGENT;
@@ -69,7 +70,8 @@ program
69
70
  const timeoutMs = options.timeout ? parseInt(options.timeout, 10) * 1000 : undefined;
70
71
  const evalId = options.evalId !== undefined ? parseInt(options.evalId, 10) : undefined;
71
72
  const compareRefs = options.compareRef || [];
72
- functionalCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs).catch(errorHandler);
73
+ const compareBaseline = !!options.compareBaseline;
74
+ functionalCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs, compareBaseline).catch(errorHandler);
73
75
  });
74
76
  const isMain = process.argv[1] && (() => {
75
77
  try {
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { Logger } from '../utils/logger.js';
4
- import { formatTokens, formatDuration } from '../utils/table-renderer.js';
4
+ import { formatTokens, formatDuration, hasFunctionalBaseline } from '../utils/table-renderer.js';
5
5
  import { computeAssertionPassRate } from '../core/statistics.js';
6
6
  export class HtmlReporter {
7
7
  generate(report, runDir) {
@@ -32,7 +32,7 @@ function passColorClass(val) {
32
32
  return 'red';
33
33
  }
34
34
  function isFunctional(report) {
35
- return report.metrics.assertionPassRate['baseline'] !== undefined;
35
+ return report.command === 'functional' || hasFunctionalBaseline(report);
36
36
  }
37
37
  // ---------------------------------------------------------------------------
38
38
  // Metrics Grid
@@ -50,7 +50,7 @@ function renderMetricsGrid(report) {
50
50
  const { metrics, results } = report;
51
51
  const functional = isFunctional(report);
52
52
  const skillVersions = results.length > 0 ? Object.keys(results[0].skillTrials) : ['local'];
53
- const allVersions = functional ? ['baseline', ...skillVersions] : skillVersions;
53
+ const allVersions = functional && hasFunctionalBaseline(report) ? ['baseline', ...skillVersions] : skillVersions;
54
54
  const headerCells = allVersions.map(v => `<th>${v}</th>`).join('');
55
55
  const successRows = `<tr>
56
56
  <td>Success Rate</td>
@@ -87,38 +87,64 @@ function renderMetricsGrid(report) {
87
87
  </div>`;
88
88
  }
89
89
  // ---------------------------------------------------------------------------
90
- // Trial details
90
+ // Expectations × Variants table (per-task drill-down)
91
91
  // ---------------------------------------------------------------------------
92
- function renderAssertions(assertions) {
93
- if (assertions.length === 0)
94
- return '<p class="muted">No assertions recorded.</p>';
95
- return assertions.map(a => {
96
- const icon = a.passed ? '✓' : '✗';
97
- const cls = a.passed ? 'assert-pass' : 'assert-fail';
98
- return `<div class="assertion ${cls}">
99
- <span class="assert-icon">${icon}</span>
100
- <div class="assert-body">
101
- <div class="assert-text">${escapeHtml(a.assertion)}</div>
102
- ${a.reason ? `<div class="assert-reason">${escapeHtml(a.reason)}</div>` : ''}
103
- </div>
104
- </div>`;
92
+ function renderExpectationCell(trials, expIdx, variant, taskId, clickable, totalCols) {
93
+ const passed = trials.filter(t => t.assertionResults[expIdx]?.passed).length;
94
+ const total = trials.length;
95
+ const rate = total > 0 ? passed / total : 0;
96
+ const colorCls = passColorClass(rate);
97
+ const ratePct = formatPercent(rate);
98
+ const frac = `${passed} / ${total}`;
99
+ const detailId = `exp-detail-${taskId}-${expIdx}-${variant}`;
100
+ const dataAttr = clickable ? ` data-detail="${escapeHtml(detailId)}"` : '';
101
+ const cell = `<td class="pass-cell ${colorCls}"${dataAttr}><span class="rate">${ratePct}</span><span class="frac">${frac}</span></td>`;
102
+ if (!clickable)
103
+ return { cell, detail: '' };
104
+ const lines = trials.map(t => {
105
+ const r = t.assertionResults[expIdx];
106
+ const ok = !!r?.passed;
107
+ const reason = r?.reason ?? '';
108
+ const icon = ok ? '✓' : '✗';
109
+ const iconCls = ok ? 'pass' : 'fail';
110
+ return `<div class="exp-trial-line"><div class="exp-trial-icon ${iconCls}">${icon}</div><div class="exp-trial-body"><span class="trial-label">Trial ${t.id}</span><span class="trial-reason">${escapeHtml(reason)}</span></div></div>`;
105
111
  }).join('');
112
+ const detail = `<tr class="exp-detail-row" id="${escapeHtml(detailId)}"><td colspan="${totalCols}"><div class="exp-detail-inner"><div class="exp-detail-header">Judge per trial · <span class="variant-pill">${escapeHtml(variant)}</span></div>${lines}</div></td></tr>`;
113
+ return { cell, detail };
106
114
  }
107
- function renderTrial(trial) {
108
- const passedCount = trial.assertionResults.filter(r => r.passed).length;
109
- const totalCount = trial.assertionResults.length;
110
- const isPartial = !trial.trialPassed && !trial.isError && passedCount > 0;
111
- const cls = trial.isError ? 'trial-error' : trial.trialPassed ? 'trial-pass' : isPartial ? 'trial-partial' : 'trial-fail';
112
- const badge = trial.isError
113
- ? '<span class="pill amber">! ERROR</span>'
114
- : trial.trialPassed
115
- ? '<span class="pill green">✓ PASS</span>'
116
- : isPartial
117
- ? `<span class="pill amber">~ PARTIAL ${passedCount}/${totalCount}</span>`
118
- : '<span class="pill red">✗ NOT PASSED</span>';
119
- return `<div class="trial ${cls}">
120
- <div class="trial-header">Trial ${trial.id} ${badge}</div>
121
- <div class="trial-assertions">${renderAssertions(trial.assertionResults)}</div>
115
+ function renderExpectationsTable(result, isFunctionalEval) {
116
+ const skillVersions = Object.keys(result.skillTrials);
117
+ const allVersions = isFunctionalEval && (result.baselineTrials?.length ?? 0) > 0 ? ['baseline', ...skillVersions] : skillVersions;
118
+ let canonical = [];
119
+ for (const v of allVersions) {
120
+ const trials = v === 'baseline' ? result.baselineTrials : result.skillTrials[v];
121
+ if (trials && trials.length > 0 && trials[0].assertionResults.length > 0) {
122
+ canonical = trials[0].assertionResults;
123
+ break;
124
+ }
125
+ }
126
+ if (canonical.length === 0) {
127
+ return '<p class="muted">No assertions recorded.</p>';
128
+ }
129
+ const totalCols = 1 + allVersions.length;
130
+ const headerCells = allVersions.map(v => `<th class="variant-col">${escapeHtml(v)}</th>`).join('');
131
+ const rows = canonical.map((a, expIdx) => {
132
+ const cells = [];
133
+ const details = [];
134
+ for (const v of allVersions) {
135
+ const trials = v === 'baseline' ? result.baselineTrials : result.skillTrials[v];
136
+ const { cell, detail } = renderExpectationCell(trials, expIdx, v, result.taskId, isFunctionalEval, totalCols);
137
+ cells.push(cell);
138
+ if (detail)
139
+ details.push(detail);
140
+ }
141
+ return `<tr class="exp-row"><td class="exp-text">${escapeHtml(a.assertion)}</td>${cells.join('')}</tr>${details.join('')}`;
142
+ }).join('');
143
+ return `<div class="expectations-table-wrap">
144
+ <table class="expectations-table">
145
+ <thead><tr><th class="exp-col">Expectation</th>${headerCells}</tr></thead>
146
+ <tbody>${rows}</tbody>
147
+ </table>
122
148
  </div>`;
123
149
  }
124
150
  function avgTrialTokens(trials) {
@@ -135,7 +161,7 @@ function avgTrialDuration(trials) {
135
161
  }
136
162
  function renderTaskMiniGrid(result, isFunctionalEval) {
137
163
  const skillVersions = Object.keys(result.skillTrials);
138
- const allVersions = isFunctionalEval ? ['baseline', ...skillVersions] : skillVersions;
164
+ const allVersions = isFunctionalEval && (result.baselineTrials?.length ?? 0) > 0 ? ['baseline', ...skillVersions] : skillVersions;
139
165
  const headerCells = allVersions.map(v => `<th>${v}</th>`).join('');
140
166
  const successRows = `<tr>
141
167
  <td>Success Rate</td>
@@ -177,14 +203,7 @@ function renderTaskMiniGrid(result, isFunctionalEval) {
177
203
  function renderTaskDetails(result, isFunctionalEval) {
178
204
  const sections = [];
179
205
  sections.push(renderTaskMiniGrid(result, isFunctionalEval));
180
- if (isFunctionalEval && result.baselineTrials && result.baselineTrials.length > 0) {
181
- sections.push('<div class="trial-group-label">Baseline</div>');
182
- sections.push(...result.baselineTrials.map(t => renderTrial(t)));
183
- }
184
- for (const version of Object.keys(result.skillTrials)) {
185
- sections.push(`<div class="trial-group-label">${version}</div>`);
186
- sections.push(...result.skillTrials[version].map(t => renderTrial(t)));
187
- }
206
+ sections.push(renderExpectationsTable(result, isFunctionalEval));
188
207
  return `<div class="task-details" id="details-${result.taskId}">${sections.join('')}</div>`;
189
208
  }
190
209
  // ---------------------------------------------------------------------------
@@ -281,31 +300,42 @@ tr:last-child td { border-bottom: none; }
281
300
  .details-row > td { padding: 0; background: #f8fafc; }
282
301
  .task-details { display: none; padding: 12px 16px; }
283
302
  .task-details.visible { display: block; }
284
- .trial-group-label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.05em; margin: 8px 0 4px; }
285
303
 
286
- /* Trials */
287
- .trial { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px; overflow: hidden; }
288
- .trial-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-weight: 500; font-size: 13px; background: #f8fafc; }
289
- .trial-pass .trial-header { border-left: 3px solid #22c55e; }
290
- .trial-partial .trial-header { border-left: 3px solid #f59e0b; }
291
- .trial-fail .trial-header { border-left: 3px solid #ef4444; }
292
- .trial-error .trial-header { border-left: 3px solid #f59e0b; }
293
- .trial-assertions { padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; }
304
+ /* Expectations × Variants table */
305
+ .expectations-table-wrap { border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden; background: #fff; }
306
+ .expectations-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
307
+ .expectations-table thead th { background: #f1f5f9; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #475569; padding: 10px 14px; text-align: left; border-bottom: 1px solid #e2e8f0; }
308
+ .expectations-table thead th.exp-col { width: auto; }
309
+ .expectations-table thead th.variant-col { width: 110px; text-align: center; }
310
+ .expectations-table tbody td { padding: 10px 14px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; font-size: 13px; }
311
+ .expectations-table tbody tr.exp-row:last-of-type > td { border-bottom: none; }
312
+ .exp-text { color: #1e293b; line-height: 1.4; word-break: break-word; }
294
313
 
295
- /* Pills */
296
- .pill { display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 99px; letter-spacing: 0.05em; }
297
- .pill.green { background: #dcfce7; color: #15803d; }
298
- .pill.red { background: #fee2e2; color: #b91c1c; }
299
- .pill.amber { background: #fef3c7; color: #92400e; }
314
+ .pass-cell { text-align: center; border-left: 1px solid #f1f5f9; }
315
+ .pass-cell[data-detail] { cursor: pointer; user-select: none; transition: background 0.12s; }
316
+ .pass-cell[data-detail]:hover { background: #f1f5f9; }
317
+ .pass-cell.open { background: #e0f2fe; }
318
+ .pass-cell .rate { display: block; font-size: 13px; font-weight: 700; line-height: 1.1; }
319
+ .pass-cell .frac { display: block; font-size: 10px; font-weight: 600; color: #94a3b8; margin-top: 2px; letter-spacing: 0.03em; }
320
+ .pass-cell.green .rate { color: #16a34a; }
321
+ .pass-cell.amber .rate { color: #d97706; }
322
+ .pass-cell.red .rate { color: #dc2626; }
323
+
324
+ .exp-detail-row { display: none; }
325
+ .exp-detail-row.visible { display: table-row; }
326
+ .exp-detail-row > td { padding: 0; background: #f8fafc; border-bottom: 1px solid #f1f5f9; }
327
+ .exp-detail-inner { padding: 12px 14px 14px; border-left: 3px solid #3b82f6; }
328
+ .exp-detail-header { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; margin-bottom: 8px; }
329
+ .exp-detail-header .variant-pill { display: inline-block; background: #e0f2fe; color: #0369a1; padding: 1px 7px; border-radius: 4px; margin-left: 4px; font-weight: 700; }
330
+ .exp-trial-line { display: flex; gap: 8px; padding: 6px 0; border-top: 1px dashed #e2e8f0; }
331
+ .exp-trial-line:first-of-type { border-top: none; }
332
+ .exp-trial-icon { flex-shrink: 0; font-size: 13px; font-weight: 700; line-height: 1.4; width: 16px; }
333
+ .exp-trial-icon.pass { color: #16a34a; }
334
+ .exp-trial-icon.fail { color: #dc2626; }
335
+ .exp-trial-body { flex: 1; min-width: 0; font-size: 12px; color: #334155; line-height: 1.45; }
336
+ .exp-trial-body .trial-label { font-weight: 700; color: #475569; margin-right: 4px; }
337
+ .exp-trial-body .trial-reason { color: #64748b; }
300
338
 
301
- /* Assertions */
302
- .assertion { display: flex; gap: 8px; }
303
- .assert-icon { flex-shrink: 0; font-size: 14px; margin-top: 1px; }
304
- .assert-pass .assert-icon { color: #16a34a; }
305
- .assert-fail .assert-icon { color: #dc2626; }
306
- .assert-body { flex: 1; min-width: 0; }
307
- .assert-text { font-size: 13px; color: #1e293b; word-break: break-word; }
308
- .assert-reason { font-size: 12px; color: #64748b; margin-top: 2px; word-break: break-word; }
309
339
  .muted { color: #94a3b8; font-size: 13px; }
310
340
  </style>
311
341
  </head>
@@ -345,6 +375,16 @@ tr:last-child td { border-bottom: none; }
345
375
  btn.textContent = isOpen ? '▶' : '▼';
346
376
  });
347
377
  });
378
+
379
+ document.querySelectorAll('.pass-cell[data-detail]').forEach(function (cell) {
380
+ cell.addEventListener('click', function () {
381
+ const detailRow = document.getElementById(cell.getAttribute('data-detail'));
382
+ if (!detailRow) return;
383
+ const isOpen = detailRow.classList.contains('visible');
384
+ detailRow.classList.toggle('visible', !isOpen);
385
+ cell.classList.toggle('open', !isOpen);
386
+ });
387
+ });
348
388
  }());
349
389
  </script>
350
390
  </body>
@@ -51,6 +51,12 @@ export function formatTokens(n) {
51
51
  return `${Math.round(n / 1_000)}K`;
52
52
  return `${n}`;
53
53
  }
54
+ export function hasFunctionalBaseline(report) {
55
+ if (report.results.some(result => (result.baselineTrials?.length ?? 0) > 0)) {
56
+ return true;
57
+ }
58
+ return report.metrics.passAtK['baseline'] !== undefined || report.metrics.assertionPassRate['baseline'] !== undefined;
59
+ }
54
60
  function formatTokenStatsLine(stats) {
55
61
  const total = formatTokens(stats.avgTotal);
56
62
  const input = formatTokens(stats.avgInput);
@@ -189,7 +195,8 @@ export function renderFunctionalTable(report) {
189
195
  const { results, metrics } = report;
190
196
  // Identify all versions present (baseline + skill versions)
191
197
  const skillVersions = results.length > 0 ? Object.keys(results[0].skillTrials) : ['local'];
192
- const allVersions = ['baseline', ...skillVersions];
198
+ const hasBaseline = hasFunctionalBaseline(report);
199
+ const allVersions = hasBaseline ? ['baseline', ...skillVersions] : skillVersions;
193
200
  const header = ['ID', 'Prompt'];
194
201
  for (const version of allVersions) {
195
202
  header.push(version);
@@ -201,9 +208,11 @@ export function renderFunctionalTable(report) {
201
208
  const row = [result.taskId.toString(), promptSnippet];
202
209
  // Baseline
203
210
  const woTrials = result.baselineTrials || [];
204
- if (woTrials.some(t => t.isError) && !woTrials.every(t => t.isError))
205
- hasPartialErrors = true;
206
- row.push(formatAssertionRate(woTrials));
211
+ if (hasBaseline) {
212
+ if (woTrials.some(t => t.isError) && !woTrials.every(t => t.isError))
213
+ hasPartialErrors = true;
214
+ row.push(formatAssertionRate(woTrials));
215
+ }
207
216
  // Skills
208
217
  for (const version of skillVersions) {
209
218
  const wiTrials = result.skillTrials[version] || [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fede0089/skill-eval",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
4
  "description": "CLI to evaluate agent skills triggering",
5
5
  "main": "dist/index.js",
6
6
  "bin": {