@fede0089/skill-eval 1.0.0 → 1.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
@@ -33,20 +33,33 @@ For each eval prompt, skill-eval spins up parallel agent processes — some with
33
33
 
34
34
  > The `trigger` command only runs with-skill trials and checks whether the skill dispatch tool was actually invoked — no judge or baseline needed.
35
35
 
36
- ## Quick Start
36
+ ## Installation
37
+
38
+ **Requirements:** Node.js, and the agent CLI you want to evaluate (e.g. `gemini`) installed and on `$PATH`.
39
+
40
+ ### Run without installing
41
+
42
+ ```sh
43
+ npx @fede0089/skill-eval --help
44
+ ```
45
+
46
+ ### Install globally
37
47
 
38
48
  ```sh
39
- git clone <this-repo>
49
+ npm install -g @fede0089/skill-eval
50
+ skill-eval --help
51
+ ```
52
+
53
+ ### From source
54
+
55
+ ```sh
56
+ git clone https://github.com/fede0089/skill-eval.git
40
57
  cd skill-eval
41
58
  npm install
42
59
  npm run build
43
- npm link
60
+ npm link # makes `skill-eval` available globally
44
61
  ```
45
62
 
46
- This makes `skill-eval` available globally in your terminal.
47
-
48
- **Requirements:** Node.js, and the agent CLI you want to evaluate (e.g. `gemini`) installed and on `$PATH`.
49
-
50
63
  ## Commands
51
64
 
52
65
  ```sh
@@ -67,6 +80,7 @@ skill-eval functional --workspace <path> --skill <path> [options] [agent]
67
80
  | `--trials <number>` | no | `3` | Trials per task (for pass@k) |
68
81
  | `--timeout <seconds>` | no | none | Kill the agent after this many seconds |
69
82
  | `--eval-id <id>` | no | all | Run only the eval with this numeric ID |
83
+ | `--compare-ref [refs...]` | no | — | Git references to compare against |
70
84
  | `-v, --debug` | no | `false` | Enable verbose debug logging |
71
85
  | `[agent]` | no | `gemini-cli` | Agent backend to use |
72
86
 
@@ -168,3 +182,15 @@ The factory, preflight check, and CLI all pick it up automatically.
168
182
  1. Create `src/reporters/<format>-reporter.ts` implementing `Reporter`.
169
183
  2. Export it and add a case in `createReporter()` in `src/reporters/index.ts`.
170
184
  3. Add the format string to `ReportFormat` in `src/types/index.ts`.
185
+ er, binary: '<cli-binary-name>' },
186
+ ```
187
+
188
+ The factory, preflight check, and CLI all pick it up automatically.
189
+
190
+ > Implement `applyRunnerConfig(evalConfigBaseDir, worktreePath)` to copy `evalConfigBaseDir/<your-agent>/` into the appropriate config directory in the worktree (e.g. `.claude/` for a Claude runner). No-op silently if the directory doesn't exist.
191
+
192
+ ### Adding a new report format
193
+
194
+ 1. Create `src/reporters/<format>-reporter.ts` implementing `Reporter`.
195
+ 2. Export it and add a case in `createReporter()` in `src/reporters/index.ts`.
196
+ 3. Add the format string to `ReportFormat` in `src/types/index.ts`.
@@ -11,7 +11,9 @@ import { preflight } from '../core/preflight.js';
11
11
  import { withRetry } from '../core/trial-utils.js';
12
12
  import { renderFunctionalTable, renderRunHeader } from '../utils/table-renderer.js';
13
13
  import { JsonReporter } from '../reporters/index.js';
14
- export async function functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId) {
14
+ import chalk from 'chalk';
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 = []) {
15
17
  if (!injectedSuite)
16
18
  preflight(agent, workspace, skillPath);
17
19
  const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath);
@@ -35,39 +37,45 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
35
37
  const timestamp = startTime.toISOString().replace(/[:.]/g, '-');
36
38
  const runDir = path.resolve(workspace, '.project-skill-evals', 'runs', timestamp);
37
39
  fs.mkdirSync(runDir, { recursive: true });
40
+ const refPathBase = path.resolve(workspace, '.project-skill-evals', 'skill-refs');
41
+ const variantRunners = new Map();
42
+ // 1. Local Runner
43
+ variantRunners.set('local', new EvalRunner({
44
+ agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: false, debug, timeoutMs
45
+ }));
46
+ // 2. Historical Runners
47
+ for (const ref of compareRefs) {
48
+ const refDir = path.join(refPathBase, ref);
49
+ Logger.write(` Extracting ref '${ref}'... `);
50
+ git.extractSkillRef(skillPath, ref, refDir);
51
+ Logger.write(chalk.green('Done\n'));
52
+ variantRunners.set(`ref:${ref}`, new EvalRunner({
53
+ agent,
54
+ workspace: refDir, // Run inside extracted repo
55
+ skillPath: path.join(refDir, path.relative(workspace, skillPath)), // Same relative path
56
+ skillName: skill_name,
57
+ runDir,
58
+ isBaseline: false,
59
+ debug,
60
+ timeoutMs
61
+ }));
62
+ }
63
+ // 3. Baseline Runner
64
+ const withoutSkillRunner = new EvalRunner({
65
+ agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: true, debug, timeoutMs
66
+ });
38
67
  const taskResults = [];
39
68
  let withSkillTasksAllPassedCount = 0;
40
69
  let baselineTasksAllPassedCount = 0;
41
- // Per-task trial storage, indexed by task.id
42
- const withoutSkillTrialsByTask = new Map();
43
- const withoutSkillRunner = new EvalRunner({
44
- agent,
45
- workspace,
46
- skillPath,
47
- skillName: skill_name,
48
- runDir,
49
- isBaseline: true,
50
- debug,
51
- timeoutMs
52
- });
53
- const withSkillRunner = new EvalRunner({
54
- agent,
55
- workspace,
56
- skillPath,
57
- skillName: skill_name,
58
- runDir,
59
- isBaseline: false,
60
- debug,
61
- timeoutMs
62
- });
63
70
  const pool = new AgentPool(maxAgents);
64
71
  const ui = new ListrEvalUI();
65
72
  // Barrier chain: each prompt waits until the previous prompt's trials have all acquired slots.
66
73
  let barrier = Promise.resolve();
67
- // Subtask labels: Without Skill 1..N then With Skill 1..N for each prompt
74
+ // Subtask labels
75
+ const skillVersions = Array.from(variantRunners.keys());
68
76
  const subtaskLabels = [
69
77
  ...Array.from({ length: numTrials }, (_, i) => `Without Skill ${i + 1}`),
70
- ...Array.from({ length: numTrials }, (_, i) => `With Skill ${i + 1}`)
78
+ ...skillVersions.flatMap(v => Array.from({ length: numTrials }, (_, i) => `${v} ${i + 1}`))
71
79
  ];
72
80
  try {
73
81
  renderRunHeader({ command: 'functional', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId });
@@ -85,7 +93,7 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
85
93
  subtaskLabels,
86
94
  task: async (uiCtx, multi) => {
87
95
  await thisBarrier;
88
- // ── Without Skill trials (subtask IDs 1..numTrials) ──────────────
96
+ // 1. Without Skill trials
89
97
  const baselineTrialPromises = [];
90
98
  for (let idx = 0; idx < numTrials; idx++) {
91
99
  const trialId = idx + 1;
@@ -112,60 +120,74 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
112
120
  }).finally(release);
113
121
  baselineTrialPromises.push(p);
114
122
  }
115
- // ── With Skill trials (subtask IDs numTrials+1..2*numTrials) ─────
116
- const withSkillTrialPromises = [];
117
- for (let idx = 0; idx < numTrials; idx++) {
118
- const subtaskId = numTrials + idx + 1; // Listr subtask position
119
- const runnerTrialId = idx + 1; // 1-based trial number within WI pass
120
- const trialCtx = multi?.getTrialCtx(subtaskId) ?? uiCtx;
121
- const release = await pool.acquire();
122
- const p = withRetry((attempt) => withSkillRunner.runFunctionalTask(task, i, runnerTrialId, trialCtx, attempt)
123
- .catch((error) => ({
124
- id: runnerTrialId,
125
- transcript: { error: error instanceof Error ? error.message : String(error) },
126
- assertionResults: [{ assertion: 'With Skill Execution', passed: false, reason: String(error) }],
127
- trialPassed: false,
128
- isError: true
129
- })), 2, 1000, (nextAttempt, lastTrial) => {
130
- const reason = lastTrial.assertionResults[0]?.reason ?? 'infrastructure error';
131
- trialCtx.updateLog(`Retry ${nextAttempt}/2 — ${reason.substring(0, 50)}`);
132
- }).then(trial => {
133
- if (multi) {
134
- const reason = trial.assertionResults.find(r => !r.passed)?.reason;
135
- const passedCount = trial.assertionResults.filter(r => r.passed).length;
136
- const totalCount = trial.assertionResults.length;
137
- multi.markTrialComplete(subtaskId, trial.trialPassed, reason, trial.isError, passedCount, totalCount);
138
- }
139
- return trial;
140
- }).finally(release);
141
- withSkillTrialPromises.push(p);
123
+ // 2. Skill variants trials
124
+ const variantTrialsPromises = {};
125
+ let currentSubtaskIdx = numTrials;
126
+ for (const [version, runner] of variantRunners.entries()) {
127
+ variantTrialsPromises[version] = [];
128
+ for (let idx = 0; idx < numTrials; idx++) {
129
+ currentSubtaskIdx++;
130
+ const subtaskId = currentSubtaskIdx;
131
+ const runnerTrialId = idx + 1;
132
+ const trialCtx = multi?.getTrialCtx(subtaskId) ?? uiCtx;
133
+ const release = await pool.acquire();
134
+ const p = withRetry((attempt) => runner.runFunctionalTask(task, i, runnerTrialId, trialCtx, attempt)
135
+ .catch((error) => ({
136
+ id: runnerTrialId,
137
+ transcript: { error: error instanceof Error ? error.message : String(error) },
138
+ assertionResults: [{ assertion: `${version} Execution`, passed: false, reason: String(error) }],
139
+ trialPassed: false,
140
+ isError: true
141
+ })), 2, 1000, (nextAttempt, lastTrial) => {
142
+ const reason = lastTrial.assertionResults[0]?.reason ?? 'infrastructure error';
143
+ trialCtx.updateLog(`Retry ${nextAttempt}/2 ${reason.substring(0, 50)}`);
144
+ }).then(trial => {
145
+ if (multi) {
146
+ const reason = trial.assertionResults.find(r => !r.passed)?.reason;
147
+ const passedCount = trial.assertionResults.filter(r => r.passed).length;
148
+ const totalCount = trial.assertionResults.length;
149
+ multi.markTrialComplete(subtaskId, trial.trialPassed, reason, trial.isError, passedCount, totalCount);
150
+ }
151
+ return trial;
152
+ }).finally(release);
153
+ variantTrialsPromises[version].push(p);
154
+ }
142
155
  }
143
- // All WO + WI slots for this prompt have been acquired — signal the next prompt.
156
+ // All slots for this prompt have been acquired — signal the next prompt.
144
157
  resolveBarrier();
145
- const [woTrials, wiTrials] = await Promise.all([
158
+ const [woTrials, ...versionsTrials] = await Promise.all([
146
159
  Promise.all(baselineTrialPromises),
147
- Promise.all(withSkillTrialPromises)
160
+ ...Object.values(variantTrialsPromises).map(ps => Promise.all(ps))
148
161
  ]);
149
- withoutSkillTrialsByTask.set(task.id, woTrials);
150
162
  const woPassedCount = woTrials.filter(t => t.trialPassed).length;
151
163
  if (woPassedCount === woTrials.length)
152
164
  baselineTasksAllPassedCount++;
153
- const wiPassedCount = wiTrials.filter(t => t.trialPassed).length;
154
- const score = wiTrials.length > 0 ? wiPassedCount / wiTrials.length : 0;
155
- const withoutSkillTrials = withoutSkillTrialsByTask.get(task.id) ?? [];
165
+ const taskSkillTrials = {};
166
+ const variantNames = Object.keys(variantTrialsPromises);
167
+ let localAllPassed = true;
168
+ for (let vIdx = 0; vIdx < variantNames.length; vIdx++) {
169
+ const vName = variantNames[vIdx];
170
+ const vTrials = versionsTrials[vIdx];
171
+ taskSkillTrials[vName] = vTrials.map(t => ({ ...t, transcript: undefined }));
172
+ if (vName === 'local') {
173
+ const passedCount = vTrials.filter(t => t.trialPassed).length;
174
+ if (passedCount === vTrials.length) {
175
+ withSkillTasksAllPassedCount++;
176
+ }
177
+ else {
178
+ localAllPassed = false;
179
+ }
180
+ }
181
+ }
156
182
  const taskResult = {
157
183
  taskId: task.id,
158
184
  prompt: task.prompt,
159
- score,
160
- trials: wiTrials.map(t => ({ ...t, transcript: undefined })),
161
- withoutSkillTrials: withoutSkillTrials.map(t => ({ ...t, transcript: undefined }))
185
+ baselineTrials: woTrials.map(t => ({ ...t, transcript: undefined })),
186
+ skillTrials: taskSkillTrials
162
187
  };
163
188
  taskResults.push(taskResult);
164
- if (wiPassedCount === wiTrials.length) {
165
- withSkillTasksAllPassedCount++;
166
- }
167
- else if (!multi) {
168
- const failureReason = wiTrials.find(t => !t.trialPassed)?.assertionResults.find(r => !r.passed)?.reason || 'With Skill failed';
189
+ if (!localAllPassed && !multi) {
190
+ const failureReason = taskSkillTrials['local'].find(t => !t.trialPassed)?.assertionResults.find(r => !r.passed)?.reason || 'With Skill failed';
169
191
  throw new Error(failureReason);
170
192
  }
171
193
  }
@@ -173,46 +195,60 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
173
195
  }
174
196
  await ui.run(tasks.length);
175
197
  // ==== REPORTING ====
176
- const { passAtK } = aggregatePassAtK(taskResults, numTrials, r => r.trials);
177
- const { passAtK: withoutSkillPassAtK } = aggregatePassAtK(taskResults, numTrials, r => r.withoutSkillTrials ?? []);
178
- const assertionPassRate = aggregateAssertionPassRate(taskResults, r => r.trials);
179
- const withoutSkillAssertionPassRate = aggregateAssertionPassRate(taskResults, r => r.withoutSkillTrials ?? []);
180
- const withSkillPercentage = Math.round(assertionPassRate * 100);
181
- const withoutSkillPercentage = Math.round(withoutSkillAssertionPassRate * 100);
182
- const skillUplift = withSkillPercentage - withoutSkillPercentage;
183
- const withSkillTokenStats = aggregateTokenStats(taskResults.flatMap(r => r.trials)) ?? undefined;
184
- const withoutSkillTokenStats = aggregateTokenStats(taskResults.flatMap(r => r.withoutSkillTrials ?? [])) ?? undefined;
185
- const withSkillDurationStats = aggregateDurationStats(taskResults.flatMap(r => r.trials)) ?? undefined;
186
- const withoutSkillDurationStats = aggregateDurationStats(taskResults.flatMap(r => r.withoutSkillTrials ?? [])) ?? undefined;
198
+ const allSkillVersions = taskResults.length > 0 ? Object.keys(taskResults[0].skillTrials) : ['local'];
199
+ const allVersions = ['baseline', ...allSkillVersions];
200
+ const scores = {};
201
+ const passAtK = {};
202
+ const assertionPassRate = {};
203
+ const tokenStats = {};
204
+ const durationStats = {};
205
+ // 1. Baseline Metrics
206
+ const { passAtK: woPassAtK } = aggregatePassAtK(taskResults, numTrials, r => r.baselineTrials);
207
+ const woAssertionPassRate = aggregateAssertionPassRate(taskResults, r => r.baselineTrials);
208
+ const woPercentage = Math.round(woAssertionPassRate * 100);
209
+ scores['baseline'] = `${woPercentage}%`;
210
+ passAtK['baseline'] = Math.round(woPassAtK * 1000) / 1000;
211
+ assertionPassRate['baseline'] = Math.round(woAssertionPassRate * 1000) / 1000;
212
+ const woTokens = aggregateTokenStats(taskResults.flatMap(r => r.baselineTrials));
213
+ if (woTokens)
214
+ tokenStats['baseline'] = woTokens;
215
+ const woDuration = aggregateDurationStats(taskResults.flatMap(r => r.baselineTrials));
216
+ if (woDuration)
217
+ durationStats['baseline'] = woDuration;
218
+ // 2. Skill Variants Metrics
219
+ for (const version of allSkillVersions) {
220
+ const { passAtK: wiPassAtK } = aggregatePassAtK(taskResults, numTrials, r => r.skillTrials[version] || []);
221
+ const wiAssertionPassRate = aggregateAssertionPassRate(taskResults, r => r.skillTrials[version] || []);
222
+ const wiPercentage = Math.round(wiAssertionPassRate * 100);
223
+ scores[version] = `${wiPercentage}%`;
224
+ passAtK[version] = Math.round(wiPassAtK * 1000) / 1000;
225
+ assertionPassRate[version] = Math.round(wiAssertionPassRate * 1000) / 1000;
226
+ const wiTokens = aggregateTokenStats(taskResults.flatMap(r => r.skillTrials[version] || []));
227
+ if (wiTokens)
228
+ tokenStats[version] = wiTokens;
229
+ const wiDuration = aggregateDurationStats(taskResults.flatMap(r => r.skillTrials[version] || []));
230
+ if (wiDuration)
231
+ durationStats[version] = wiDuration;
232
+ }
187
233
  const report = {
188
234
  timestamp: startTime.toISOString(),
189
235
  skill_name,
190
236
  agent,
191
237
  metrics: {
192
- withSkillScore: `${withSkillPercentage}%`,
193
- withoutSkillScore: `${withoutSkillPercentage}%`,
194
- skillUplift: `${skillUplift > 0 ? '+' : ''}${skillUplift}%`,
195
238
  passedCount: withSkillTasksAllPassedCount,
196
239
  totalCount: tasks.length,
197
240
  numTrials,
198
- passAtK: Math.round(passAtK * 1000) / 1000,
199
- withoutSkillPassAtK: Math.round(withoutSkillPassAtK * 1000) / 1000,
200
- assertionPassRate: Math.round(assertionPassRate * 1000) / 1000,
201
- withoutSkillAssertionPassRate: Math.round(withoutSkillAssertionPassRate * 1000) / 1000,
202
- tokenStats: (withSkillTokenStats || withoutSkillTokenStats)
203
- ? { withSkill: withSkillTokenStats, withoutSkill: withoutSkillTokenStats }
204
- : undefined,
205
- durationStats: (withSkillDurationStats || withoutSkillDurationStats)
206
- ? { withSkill: withSkillDurationStats, withoutSkill: withoutSkillDurationStats }
207
- : undefined
241
+ scores,
242
+ passAtK,
243
+ assertionPassRate,
244
+ tokenStats,
245
+ durationStats
208
246
  },
209
247
  results: taskResults
210
248
  };
211
249
  Logger.write(`\nEVALUATION SUMMARY\n`);
212
250
  Logger.write(`──────────────────────────────────────────────────\n`);
213
251
  renderFunctionalTable(report);
214
- const upliftSign = skillUplift > 0 ? '+' : '';
215
- Logger.write(`\n Skill Uplift: ${upliftSign}${skillUplift}%\n`);
216
252
  Logger.write('\n');
217
253
  new JsonReporter().generate(report, runDir);
218
254
  reporter.generate(report, runDir);
@@ -6,12 +6,14 @@ import * as evalLoader from '../utils/eval-loader.js';
6
6
  import { ListrEvalUI } from '../utils/ui.js';
7
7
  import { EvalRunner } from '../core/eval-runner.js';
8
8
  import { AgentPool } from '../core/agent-pool.js';
9
- import { aggregatePassAtK, aggregateTokenStats, aggregateDurationStats } from '../core/statistics.js';
9
+ import { aggregatePassAtK, aggregateAssertionPassRate, aggregateTokenStats, aggregateDurationStats } from '../core/statistics.js';
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
13
  import { JsonReporter } from '../reporters/index.js';
14
- export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId) {
14
+ import chalk from 'chalk';
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 = []) {
15
17
  if (!injectedSuite)
16
18
  preflight(agent, workspace, skillPath);
17
19
  const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath);
@@ -36,21 +38,38 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
36
38
  const timestamp = startTime.toISOString().replace(/[:.]/g, '-');
37
39
  const runDir = path.resolve(workspace, '.project-skill-evals', 'runs', timestamp);
38
40
  fs.mkdirSync(runDir, { recursive: true });
41
+ const refPathBase = path.resolve(workspace, '.project-skill-evals', 'skill-refs');
42
+ const variantRunners = new Map();
43
+ // 1. Local Runner
44
+ variantRunners.set('local', new EvalRunner({
45
+ agent, workspace, skillPath, skillName: skill_name, runDir, isBaseline: false, debug, timeoutMs
46
+ }));
47
+ // 2. Historical Runners
48
+ for (const ref of compareRefs) {
49
+ const refDir = path.join(refPathBase, ref);
50
+ Logger.write(` Extracting ref '${ref}'... `);
51
+ git.extractSkillRef(skillPath, ref, refDir);
52
+ Logger.write(chalk.green('Done\n'));
53
+ variantRunners.set(`ref:${ref}`, new EvalRunner({
54
+ agent,
55
+ workspace: refDir,
56
+ skillPath: path.join(refDir, path.relative(workspace, skillPath)),
57
+ skillName: skill_name,
58
+ runDir,
59
+ isBaseline: false,
60
+ debug,
61
+ timeoutMs
62
+ }));
63
+ }
39
64
  const taskResults = [];
40
65
  let tasksPassedCount = 0;
41
- const runner = new EvalRunner({
42
- agent,
43
- workspace,
44
- skillPath,
45
- skillName: skill_name,
46
- runDir,
47
- debug,
48
- timeoutMs
49
- });
50
66
  const pool = new AgentPool(maxAgents);
51
67
  const ui = new ListrEvalUI();
52
68
  // Barrier chain: each prompt waits until the previous prompt's trials have all acquired slots.
53
69
  let barrier = Promise.resolve();
70
+ // Subtask labels
71
+ const skillVersions = Array.from(variantRunners.keys());
72
+ const subtaskLabels = skillVersions.flatMap(v => Array.from({ length: numTrials }, (_, i) => `${v} ${i + 1}`));
54
73
  try {
55
74
  renderRunHeader({ command: 'trigger', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId });
56
75
  Logger.write(`--- Trigger Pass ---\n`);
@@ -65,54 +84,74 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
65
84
  ui.addTask({
66
85
  id: task.id,
67
86
  title: taskLabel,
68
- numTrials,
87
+ subtaskLabels,
69
88
  task: async (uiCtx, multi) => {
70
89
  await thisBarrier;
71
- const trialPromises = [];
72
- for (let idx = 0; idx < numTrials; idx++) {
73
- const trialId = idx + 1;
74
- const trialCtx = multi?.getTrialCtx(trialId) ?? uiCtx;
75
- const release = await pool.acquire();
76
- const p = withRetry((attempt) => runner.runTriggerTask(task, i, trialId, trialCtx, attempt)
77
- .catch((error) => ({
78
- id: trialId,
79
- transcript: { error: error instanceof Error ? error.message : String(error) },
80
- assertionResults: [{
81
- assertion: 'Runner Execution',
82
- passed: false,
83
- reason: error instanceof Error ? error.message : String(error)
84
- }],
85
- trialPassed: false,
86
- isError: true
87
- })), 2, 1000, (nextAttempt, lastTrial) => {
88
- const reason = lastTrial.assertionResults[0]?.reason ?? 'infrastructure error';
89
- trialCtx.updateLog(`Retry ${nextAttempt}/2 — ${reason.substring(0, 50)}`);
90
- }).then(trial => {
91
- if (multi) {
92
- const reason = trial.assertionResults.find(r => !r.passed)?.reason;
93
- multi.markTrialComplete(trialId, trial.trialPassed, reason, trial.isError);
94
- }
95
- return trial;
96
- }).finally(release);
97
- trialPromises.push(p);
90
+ const variantTrialsPromises = {};
91
+ let currentSubtaskIdx = 0;
92
+ for (const [version, runner] of variantRunners.entries()) {
93
+ variantTrialsPromises[version] = [];
94
+ for (let idx = 0; idx < numTrials; idx++) {
95
+ currentSubtaskIdx++;
96
+ const subtaskId = currentSubtaskIdx;
97
+ const runnerTrialId = idx + 1;
98
+ const trialCtx = multi?.getTrialCtx(subtaskId) ?? uiCtx;
99
+ const release = await pool.acquire();
100
+ const p = withRetry((attempt) => runner.runTriggerTask(task, i, runnerTrialId, trialCtx, attempt)
101
+ .catch((error) => ({
102
+ id: runnerTrialId,
103
+ transcript: { error: error instanceof Error ? error.message : String(error) },
104
+ assertionResults: [{
105
+ assertion: `${version} Execution`,
106
+ passed: false,
107
+ reason: error instanceof Error ? error.message : String(error)
108
+ }],
109
+ trialPassed: false,
110
+ isError: true
111
+ })), 2, 1000, (nextAttempt, lastTrial) => {
112
+ const reason = lastTrial.assertionResults[0]?.reason ?? 'infrastructure error';
113
+ trialCtx.updateLog(`Retry ${nextAttempt}/2 — ${reason.substring(0, 50)}`);
114
+ }).then(trial => {
115
+ if (multi) {
116
+ const reason = trial.assertionResults.find(r => !r.passed)?.reason;
117
+ const passedCount = trial.assertionResults.filter(r => r.passed).length;
118
+ const totalCount = trial.assertionResults.length;
119
+ multi.markTrialComplete(subtaskId, trial.trialPassed, reason, trial.isError, passedCount, totalCount);
120
+ }
121
+ return trial;
122
+ }).finally(release);
123
+ variantTrialsPromises[version].push(p);
124
+ }
98
125
  }
99
- // All trials for this prompt have acquired a slot — signal the next prompt.
126
+ // All slots for this prompt have acquired a slot — signal the next prompt.
100
127
  resolveBarrier();
101
- const trials = await Promise.all(trialPromises);
102
- const passedCount = trials.filter(t => t.trialPassed).length;
103
- const score = trials.length > 0 ? passedCount / trials.length : 0;
128
+ const versionsTrials = await Promise.all(Object.values(variantTrialsPromises).map(ps => Promise.all(ps)));
129
+ const taskSkillTrials = {};
130
+ const variantNames = Object.keys(variantTrialsPromises);
131
+ let localAllPassed = true;
132
+ for (let vIdx = 0; vIdx < variantNames.length; vIdx++) {
133
+ const vName = variantNames[vIdx];
134
+ const vTrials = versionsTrials[vIdx];
135
+ taskSkillTrials[vName] = vTrials.map(t => ({ ...t, transcript: undefined }));
136
+ if (vName === 'local') {
137
+ const passedCount = vTrials.filter(t => t.trialPassed).length;
138
+ if (passedCount === vTrials.length) {
139
+ tasksPassedCount++;
140
+ }
141
+ else {
142
+ localAllPassed = false;
143
+ }
144
+ }
145
+ }
104
146
  const taskResult = {
105
147
  taskId: task.id,
106
148
  prompt: task.prompt,
107
- score,
108
- trials: trials.map(t => ({ ...t, transcript: undefined }))
149
+ baselineTrials: [],
150
+ skillTrials: taskSkillTrials
109
151
  };
110
152
  taskResults.push(taskResult);
111
- if (passedCount === trials.length) {
112
- tasksPassedCount++;
113
- }
114
- else if (!multi) {
115
- const failureReason = trials.find(t => !t.trialPassed)?.assertionResults.find(r => !r.passed)?.reason || 'Task failed evaluation';
153
+ if (!localAllPassed && !multi) {
154
+ const failureReason = taskSkillTrials['local'].find(t => t.trialPassed === false)?.assertionResults.find(r => !r.passed)?.reason || 'Task failed evaluation';
116
155
  throw new Error(failureReason);
117
156
  }
118
157
  }
@@ -120,22 +159,39 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
120
159
  }
121
160
  await ui.run(tasks.length);
122
161
  // Compute aggregate metrics and build report
123
- const { passAtK } = aggregatePassAtK(taskResults, numTrials, r => r.trials);
124
- const percentage = Math.round(passAtK * 100);
125
- const withSkillTokenStats = aggregateTokenStats(taskResults.flatMap(r => r.trials)) ?? undefined;
126
- const withSkillDurationStats = aggregateDurationStats(taskResults.flatMap(r => r.trials)) ?? undefined;
162
+ const allSkillVersions = taskResults.length > 0 ? Object.keys(taskResults[0].skillTrials) : ['local'];
163
+ const scores = {};
164
+ const passAtK = {};
165
+ const assertionPassRate = {};
166
+ const tokenStats = {};
167
+ const durationStats = {};
168
+ for (const version of allSkillVersions) {
169
+ const { passAtK: vPassAtK } = aggregatePassAtK(taskResults, numTrials, r => r.skillTrials[version] || []);
170
+ const percentage = Math.round(vPassAtK * 100);
171
+ scores[version] = `${percentage}%`;
172
+ passAtK[version] = Math.round(vPassAtK * 1000) / 1000;
173
+ const vAssertionRate = aggregateAssertionPassRate(taskResults, r => r.skillTrials[version] || []);
174
+ assertionPassRate[version] = Math.round(vAssertionRate * 1000) / 1000;
175
+ const vTokens = aggregateTokenStats(taskResults.flatMap(r => r.skillTrials[version] || []));
176
+ if (vTokens)
177
+ tokenStats[version] = vTokens;
178
+ const vDuration = aggregateDurationStats(taskResults.flatMap(r => r.skillTrials[version] || []));
179
+ if (vDuration)
180
+ durationStats[version] = vDuration;
181
+ }
127
182
  const report = {
128
183
  timestamp: startTime.toISOString(),
129
184
  skill_name,
130
185
  agent,
131
186
  metrics: {
132
- withSkillScore: `${percentage}%`,
133
187
  passedCount: tasksPassedCount,
134
188
  totalCount: tasks.length,
135
189
  numTrials,
136
- passAtK: Math.round(passAtK * 1000) / 1000,
137
- tokenStats: withSkillTokenStats ? { withSkill: withSkillTokenStats } : undefined,
138
- durationStats: withSkillDurationStats ? { withSkill: withSkillDurationStats } : undefined
190
+ scores,
191
+ passAtK,
192
+ assertionPassRate,
193
+ tokenStats,
194
+ durationStats
139
195
  },
140
196
  results: taskResults
141
197
  };
@@ -11,13 +11,25 @@ export class EvalEnvironment {
11
11
  async setup() {
12
12
  }
13
13
  async teardown() {
14
- const worktreesDir = path.resolve(this.workspace, '.project-skill-evals', 'worktrees');
15
- if (!fs.existsSync(worktreesDir))
16
- return;
17
- for (const entry of fs.readdirSync(worktreesDir)) {
18
- this.removeWorktree(path.join(worktreesDir, entry));
14
+ const evalsDir = path.resolve(this.workspace, '.project-skill-evals');
15
+ // 1. Cleanup Worktrees
16
+ const worktreesDir = path.join(evalsDir, 'worktrees');
17
+ if (fs.existsSync(worktreesDir)) {
18
+ for (const entry of fs.readdirSync(worktreesDir)) {
19
+ this.removeWorktree(path.join(worktreesDir, entry));
20
+ }
21
+ executor.spawnSync('git', ['worktree', 'prune'], { stdio: 'ignore', cwd: this.workspace });
22
+ }
23
+ // 2. Cleanup Skill Refs
24
+ const skillRefsDir = path.join(evalsDir, 'skill-refs');
25
+ if (fs.existsSync(skillRefsDir)) {
26
+ try {
27
+ fs.rmSync(skillRefsDir, { recursive: true, force: true });
28
+ }
29
+ catch (err) {
30
+ Logger.warn(`Failed to remove skill-refs directory at ${skillRefsDir}. Manual cleanup may be required.`);
31
+ }
19
32
  }
20
- executor.spawnSync('git', ['worktree', 'prune'], { stdio: 'ignore', cwd: this.workspace });
21
33
  }
22
34
  /**
23
35
  * Creates a temporary git worktree for a specific evaluation.