@fede0089/skill-eval 1.0.6 → 1.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
@@ -80,6 +80,7 @@ skill-eval functional --workspace <path> --skill <path> [options] [agent]
80
80
  | `--trials <number>` | no | `3` | Trials per task (for pass@k) |
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
+ | `--compare-ref [refs...]` | no | — | Git references to compare against |
83
84
  | `-v, --debug` | no | `false` | Enable verbose debug logging |
84
85
  | `[agent]` | no | `gemini-cli` | Agent backend to use |
85
86
 
@@ -181,3 +182,15 @@ The factory, preflight check, and CLI all pick it up automatically.
181
182
  1. Create `src/reporters/<format>-reporter.ts` implementing `Reporter`.
182
183
  2. Export it and add a case in `createReporter()` in `src/reporters/index.ts`.
183
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.
package/dist/index.js CHANGED
@@ -40,6 +40,7 @@ program
40
40
  .option('--trials <number>', 'Number of trials per task for pass@k calculation')
41
41
  .option('--timeout <seconds>', 'Agent timeout in seconds')
42
42
  .option('--eval-id <id>', 'Run only the eval with this ID (numeric)')
43
+ .option('--compare-ref [refs...]', 'Compare against historical git references')
43
44
  .action((agent, options) => {
44
45
  const workspace = path.resolve(options.workspace);
45
46
  const selectedAgent = agent || DEFAULT_AGENT;
@@ -47,7 +48,8 @@ program
47
48
  const numTrials = options.trials !== undefined ? (parseInt(options.trials, 10) || 3) : 3;
48
49
  const timeoutMs = options.timeout ? parseInt(options.timeout, 10) * 1000 : undefined;
49
50
  const evalId = options.evalId !== undefined ? parseInt(options.evalId, 10) : undefined;
50
- triggerCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId).catch(errorHandler);
51
+ const compareRefs = options.compareRef || [];
52
+ triggerCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs).catch(errorHandler);
51
53
  });
52
54
  program
53
55
  .command('functional [agent]')
@@ -58,6 +60,7 @@ program
58
60
  .option('--trials <number>', 'Number of trials per task for pass@k calculation')
59
61
  .option('--timeout <seconds>', 'Agent timeout in seconds')
60
62
  .option('--eval-id <id>', 'Run only the eval with this ID (numeric)')
63
+ .option('--compare-ref [refs...]', 'Compare against historical git references')
61
64
  .action((agent, options) => {
62
65
  const workspace = path.resolve(options.workspace);
63
66
  const selectedAgent = agent || DEFAULT_AGENT;
@@ -65,7 +68,8 @@ program
65
68
  const numTrials = options.trials !== undefined ? (parseInt(options.trials, 10) || 3) : 3;
66
69
  const timeoutMs = options.timeout ? parseInt(options.timeout, 10) * 1000 : undefined;
67
70
  const evalId = options.evalId !== undefined ? parseInt(options.evalId, 10) : undefined;
68
- functionalCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId).catch(errorHandler);
71
+ const compareRefs = options.compareRef || [];
72
+ functionalCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs).catch(errorHandler);
69
73
  });
70
74
  const isMain = process.argv[1] && (() => {
71
75
  try {
@@ -32,7 +32,7 @@ function passColorClass(val) {
32
32
  return 'red';
33
33
  }
34
34
  function isFunctional(report) {
35
- return report.metrics.withoutSkillScore !== undefined;
35
+ return report.metrics.assertionPassRate['baseline'] !== undefined;
36
36
  }
37
37
  // ---------------------------------------------------------------------------
38
38
  // Metrics Grid
@@ -47,71 +47,44 @@ function renderDeltaCell(base, target, format, cellClass) {
47
47
  return `<span class="${cellClass} ${cls}">${sign}${pct}%</span>`;
48
48
  }
49
49
  function renderMetricsGrid(report) {
50
- const { metrics } = report;
50
+ const { metrics, results } = report;
51
51
  const functional = isFunctional(report);
52
- if (functional) {
53
- const bk = metrics.withoutSkillAssertionPassRate ?? metrics.withoutSkillPassAtK ?? 0;
54
- const tk = metrics.assertionPassRate ?? metrics.passAtK ?? 0;
55
- const upliftRaw = parseInt(metrics.skillUplift ?? '0', 10);
56
- const upliftClass = upliftRaw > 0 ? 'green' : upliftRaw < 0 ? 'red' : '';
57
- const wo = metrics.tokenStats?.withoutSkill;
58
- const wi = metrics.tokenStats?.withSkill;
59
- const wod = metrics.durationStats?.withoutSkill;
60
- const wid = metrics.durationStats?.withSkill;
61
- return `<div class="metrics-grid">
52
+ const skillVersions = results.length > 0 ? Object.keys(results[0].skillTrials) : ['local'];
53
+ const allVersions = functional ? ['baseline', ...skillVersions] : skillVersions;
54
+ const headerCells = allVersions.map(v => `<th>${v}</th>`).join('');
55
+ const successRows = `<tr>
56
+ <td>Success Rate</td>
57
+ ${allVersions.map(v => {
58
+ const val = metrics.assertionPassRate[v] ?? metrics.passAtK[v] ?? 0;
59
+ return `<td><span class="metric-val ${passColorClass(val)}">${formatPercent(val)}</span></td>`;
60
+ }).join('')}
61
+ </tr>`;
62
+ const tokenRows = `<tr>
63
+ <td>Tokens (avg)</td>
64
+ ${allVersions.map(v => {
65
+ const stats = metrics.tokenStats?.[v];
66
+ return `<td>${stats ? `<span class="metric-val">${formatTokens(stats.avgTotal)}</span><div class="metric-sub">avg total</div>` : '<span class="metric-val muted">—</span>'}</td>`;
67
+ }).join('')}
68
+ </tr>`;
69
+ const timeRows = `<tr>
70
+ <td>Time (avg)</td>
71
+ ${allVersions.map(v => {
72
+ const stats = metrics.durationStats?.[v];
73
+ return `<td>${stats ? `<span class="metric-val">${formatDuration(stats.avgMs)}</span>` : '<span class="metric-val muted">—</span>'}</td>`;
74
+ }).join('')}
75
+ </tr>`;
76
+ return `<div class="metrics-grid">
62
77
  <table>
63
78
  <thead>
64
- <tr><th></th><th>Without Skill</th><th>With Skill</th><th>Delta</th></tr>
79
+ <tr><th></th>${headerCells}</tr>
65
80
  </thead>
66
81
  <tbody>
67
- <tr>
68
- <td>Success Rate</td>
69
- <td><span class="metric-val ${passColorClass(bk)}">${formatPercent(bk)}</span></td>
70
- <td><span class="metric-val ${passColorClass(tk)}">${formatPercent(tk)}</span></td>
71
- <td><span class="metric-val ${upliftClass}">${escapeHtml(metrics.skillUplift ?? '0%')}</span></td>
72
- </tr>
73
- <tr>
74
- <td>Tokens (avg)</td>
75
- <td>${wo ? `<span class="metric-val">${formatTokens(wo.avgTotal)}</span><div class="metric-sub">avg total</div>` : '<span class="metric-val muted">—</span>'}</td>
76
- <td>${wi ? `<span class="metric-val">${formatTokens(wi.avgTotal)}</span><div class="metric-sub">avg total</div>` : '<span class="metric-val muted">—</span>'}</td>
77
- <td>${wo && wi ? renderDeltaCell(wo.avgTotal, wi.avgTotal, formatTokens, 'metric-val') : '<span class="metric-val muted">—</span>'}</td>
78
- </tr>
79
- <tr>
80
- <td>Time (avg)</td>
81
- <td>${wod ? `<span class="metric-val">${formatDuration(wod.avgMs)}</span>` : '<span class="metric-val muted">—</span>'}</td>
82
- <td>${wid ? `<span class="metric-val">${formatDuration(wid.avgMs)}</span>` : '<span class="metric-val muted">—</span>'}</td>
83
- <td>${wod && wid ? renderDeltaCell(wod.avgMs, wid.avgMs, formatDuration, 'metric-val') : '<span class="metric-val muted">—</span>'}</td>
84
- </tr>
82
+ ${successRows}
83
+ ${tokenRows}
84
+ ${timeRows}
85
85
  </tbody>
86
86
  </table>
87
87
  </div>`;
88
- }
89
- else {
90
- const k = metrics.passAtK ?? 0;
91
- const wi = metrics.tokenStats?.withSkill;
92
- const wid = metrics.durationStats?.withSkill;
93
- return `<div class="metrics-grid">
94
- <table>
95
- <thead>
96
- <tr><th></th><th>Score</th></tr>
97
- </thead>
98
- <tbody>
99
- <tr>
100
- <td>Success Rate</td>
101
- <td><span class="metric-val ${passColorClass(k)}">${formatPercent(k)}</span></td>
102
- </tr>
103
- <tr>
104
- <td>Tokens (avg)</td>
105
- <td>${wi ? `<span class="metric-val">${formatTokens(wi.avgTotal)}</span><div class="metric-sub">avg total</div>` : '<span class="metric-val muted">—</span>'}</td>
106
- </tr>
107
- <tr>
108
- <td>Time (avg)</td>
109
- <td>${wid ? `<span class="metric-val">${formatDuration(wid.avgMs)}</span>` : '<span class="metric-val muted">—</span>'}</td>
110
- </tr>
111
- </tbody>
112
- </table>
113
- </div>`;
114
- }
115
88
  }
116
89
  // ---------------------------------------------------------------------------
117
90
  // Trial details
@@ -160,63 +133,58 @@ function avgTrialDuration(trials) {
160
133
  return null;
161
134
  return Math.round(withDuration.reduce((s, t) => s + t.durationMs, 0) / withDuration.length);
162
135
  }
163
- function renderTaskMiniGrid(result) {
164
- const woTrials = result.withoutSkillTrials ?? [];
165
- const wiTrials = result.trials;
166
- const bk = woTrials.length ? Math.round(computeAssertionPassRate(woTrials) * 100) : 0;
167
- const tk = wiTrials.length ? Math.round(computeAssertionPassRate(wiTrials) * 100) : 0;
168
- const rateDelta = tk - bk;
169
- const rateDeltaSign = rateDelta >= 0 ? '+' : '';
170
- const rateDeltaClass = rateDelta > 0 ? 'green' : rateDelta < 0 ? 'red' : '';
171
- const woTokens = avgTrialTokens(woTrials);
172
- const wiTokens = avgTrialTokens(wiTrials);
173
- const woMs = avgTrialDuration(woTrials);
174
- const wiMs = avgTrialDuration(wiTrials);
175
- const tokenDeltaCell = (woTokens && wiTokens)
176
- ? renderDeltaCell(woTokens, wiTokens, formatTokens, 'metric-val-sm')
177
- : '<span class="metric-val-sm muted">—</span>';
178
- const durationDeltaCell = (woMs && wiMs)
179
- ? renderDeltaCell(woMs, wiMs, formatDuration, 'metric-val-sm')
180
- : '<span class="metric-val-sm muted">—</span>';
136
+ function renderTaskMiniGrid(result, isFunctionalEval) {
137
+ const skillVersions = Object.keys(result.skillTrials);
138
+ const allVersions = isFunctionalEval ? ['baseline', ...skillVersions] : skillVersions;
139
+ const headerCells = allVersions.map(v => `<th>${v}</th>`).join('');
140
+ const successRows = `<tr>
141
+ <td>Success Rate</td>
142
+ ${allVersions.map(v => {
143
+ const trials = v === 'baseline' ? result.baselineTrials : result.skillTrials[v];
144
+ const rate = trials.length ? Math.round(computeAssertionPassRate(trials) * 100) : 0;
145
+ return `<td><span class="metric-val-sm ${passColorClass(rate / 100)}">${rate}%</span></td>`;
146
+ }).join('')}
147
+ </tr>`;
148
+ const tokenRows = `<tr>
149
+ <td>Tokens (avg)</td>
150
+ ${allVersions.map(v => {
151
+ const trials = v === 'baseline' ? result.baselineTrials : result.skillTrials[v];
152
+ const tokens = avgTrialTokens(trials);
153
+ return `<td>${tokens != null ? `<span class="metric-val-sm">${formatTokens(tokens)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>`;
154
+ }).join('')}
155
+ </tr>`;
156
+ const timeRows = `<tr>
157
+ <td>Time (avg)</td>
158
+ ${allVersions.map(v => {
159
+ const trials = v === 'baseline' ? result.baselineTrials : result.skillTrials[v];
160
+ const ms = avgTrialDuration(trials);
161
+ return `<td>${ms != null ? `<span class="metric-val-sm">${formatDuration(ms)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>`;
162
+ }).join('')}
163
+ </tr>`;
181
164
  return `<div class="metrics-grid-sm">
182
165
  <table>
183
166
  <thead>
184
- <tr><th></th><th>Without Skill</th><th>With Skill</th><th>Delta</th></tr>
167
+ <tr><th></th>${headerCells}</tr>
185
168
  </thead>
186
169
  <tbody>
187
- <tr>
188
- <td>Success Rate</td>
189
- <td><span class="metric-val-sm ${passColorClass(bk / 100)}">${bk}%</span></td>
190
- <td><span class="metric-val-sm ${passColorClass(tk / 100)}">${tk}%</span></td>
191
- <td><span class="metric-val-sm ${rateDeltaClass}">${rateDeltaSign}${rateDelta}%</span></td>
192
- </tr>
193
- <tr>
194
- <td>Tokens (avg)</td>
195
- <td>${woTokens != null ? `<span class="metric-val-sm">${formatTokens(woTokens)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>
196
- <td>${wiTokens != null ? `<span class="metric-val-sm">${formatTokens(wiTokens)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>
197
- <td>${tokenDeltaCell}</td>
198
- </tr>
199
- <tr>
200
- <td>Time (avg)</td>
201
- <td>${woMs != null ? `<span class="metric-val-sm">${formatDuration(woMs)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>
202
- <td>${wiMs != null ? `<span class="metric-val-sm">${formatDuration(wiMs)}</span>` : '<span class="metric-val-sm muted">—</span>'}</td>
203
- <td>${durationDeltaCell}</td>
204
- </tr>
170
+ ${successRows}
171
+ ${tokenRows}
172
+ ${timeRows}
205
173
  </tbody>
206
174
  </table>
207
175
  </div>`;
208
176
  }
209
177
  function renderTaskDetails(result, isFunctionalEval) {
210
178
  const sections = [];
211
- if (isFunctionalEval) {
212
- sections.push(renderTaskMiniGrid(result));
179
+ 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)));
213
183
  }
214
- if (isFunctionalEval && result.withoutSkillTrials && result.withoutSkillTrials.length > 0) {
215
- sections.push('<div class="trial-group-label">Without Skill</div>');
216
- sections.push(...result.withoutSkillTrials.map(t => renderTrial(t)));
217
- sections.push('<div class="trial-group-label">With Skill</div>');
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)));
218
187
  }
219
- sections.push(...result.trials.map(t => renderTrial(t)));
220
188
  return `<div class="task-details" id="details-${result.taskId}">${sections.join('')}</div>`;
221
189
  }
222
190
  // ---------------------------------------------------------------------------
@@ -241,7 +209,7 @@ export function generateHtml(report) {
241
209
  const { skill_name, agent, timestamp, metrics } = report;
242
210
  const functional = isFunctional(report);
243
211
  const evalType = functional ? 'Functional' : 'Trigger';
244
- const overallScore = metrics.passAtK ?? 0;
212
+ const overallScore = metrics.passAtK['local'] ?? 0;
245
213
  const statusClass = passColorClass(overallScore);
246
214
  const formattedDate = new Date(timestamp).toLocaleString();
247
215
  return `<!DOCTYPE html>
@@ -1,9 +1,16 @@
1
1
  import { execSync, spawnSync } from 'child_process';
2
2
  export const executor = {
3
3
  execSync(command, options) {
4
- return execSync(command, options);
4
+ return execSync(command, {
5
+ ...options,
6
+ shell: process.env.SHELL || '/bin/sh',
7
+ env: { ...process.env, ...options?.env }
8
+ });
5
9
  },
6
10
  spawnSync(command, args, options) {
7
- return spawnSync(command, args, options);
11
+ return spawnSync(command, args, {
12
+ ...options,
13
+ env: { ...process.env, ...options?.env }
14
+ });
8
15
  }
9
16
  };
@@ -0,0 +1,30 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { executor } from './exec.js';
4
+ import { ExecutionError } from '../core/errors.js';
5
+ export const git = {
6
+ extractSkillRef(skillPath, ref, targetDir) {
7
+ // 1. Identify repo root
8
+ let repoRoot;
9
+ try {
10
+ repoRoot = executor.execSync('git rev-parse --show-toplevel', { cwd: skillPath }).toString().trim();
11
+ }
12
+ catch (err) {
13
+ throw new ExecutionError(`Path is not inside a git repository: ${skillPath}`);
14
+ }
15
+ // 2. Resolve relative path of skill within repo
16
+ const relativeSkillPath = path.relative(repoRoot, path.resolve(skillPath));
17
+ // 3. Create target directory
18
+ fs.mkdirSync(targetDir, { recursive: true });
19
+ // 4. Extract via git archive
20
+ try {
21
+ const cmd = `git archive ${ref} | tar -x -C ${targetDir}`;
22
+ executor.execSync(cmd, { cwd: repoRoot });
23
+ }
24
+ catch (err) {
25
+ throw new ExecutionError(`Failed to extract git reference '${ref}': ${err instanceof Error ? err.message : String(err)}`);
26
+ }
27
+ }
28
+ };
29
+ // Also keep named export for backward compatibility if needed
30
+ export const extractSkillRef = git.extractSkillRef;
@@ -126,42 +126,60 @@ export function renderRunHeader(config) {
126
126
  export function renderTriggerTable(report) {
127
127
  const { results, metrics } = report;
128
128
  const numTrials = metrics.numTrials || 1;
129
- const tableData = numTrials > 1
130
- ? [['ID', 'Prompt', 'Trials', 'success rate']]
131
- : [['ID', 'Prompt', 'success rate']];
129
+ // Identify all skill versions present in the results
130
+ const skillVersions = results.length > 0 ? Object.keys(results[0].skillTrials) : ['local'];
131
+ const header = ['ID', 'Prompt'];
132
+ if (numTrials > 1) {
133
+ for (const version of skillVersions) {
134
+ header.push(`${version} Trials`, `${version} Rate`);
135
+ }
136
+ }
137
+ else {
138
+ for (const version of skillVersions) {
139
+ header.push(`${version} Rate`);
140
+ }
141
+ }
142
+ const tableData = [header];
132
143
  let hasPartialErrors = false;
133
144
  for (const result of results) {
134
145
  const promptSnippet = result.prompt.substring(0, 40) + (result.prompt.length > 40 ? '...' : '');
135
- const p1Cell = formatPassAt1(result.trials);
136
- const someError = result.trials.some(t => t.isError);
137
- const allError = result.trials.length > 0 && result.trials.every(t => t.isError);
138
- if (someError && !allError)
139
- hasPartialErrors = true;
140
- if (numTrials > 1) {
141
- const errorCount = result.trials.filter(t => t.isError).length;
142
- const passedCount = result.trials.filter(t => t.trialPassed).length;
143
- const trialsBase = `${passedCount}/${result.trials.length}`;
144
- const trialsStr = errorCount > 0 ? `${trialsBase} (${errorCount}!)` : trialsBase;
145
- const trials = result.score === 1.0 ? chalk.green(trialsStr) : errorCount > 0 ? chalk.yellow(trialsStr) : chalk.red(trialsStr);
146
- tableData.push([result.taskId.toString(), promptSnippet, trials, p1Cell]);
147
- }
148
- else {
149
- tableData.push([result.taskId.toString(), promptSnippet, p1Cell]);
146
+ const row = [result.taskId.toString(), promptSnippet];
147
+ for (const version of skillVersions) {
148
+ const trials = result.skillTrials[version] || [];
149
+ const p1Cell = formatPassAt1(trials);
150
+ const someError = trials.some(t => t.isError);
151
+ const allError = trials.length > 0 && trials.every(t => t.isError);
152
+ if (someError && !allError)
153
+ hasPartialErrors = true;
154
+ if (numTrials > 1) {
155
+ const errorCount = trials.filter(t => t.isError).length;
156
+ const passedCount = trials.filter(t => t.trialPassed).length;
157
+ const trialsBase = `${passedCount}/${trials.length}`;
158
+ const trialsStr = errorCount > 0 ? `${trialsBase} (${errorCount}!)` : trialsBase;
159
+ const trialsCell = passedCount === trials.length ? chalk.green(trialsStr) : errorCount > 0 ? chalk.yellow(trialsStr) : chalk.red(trialsStr);
160
+ row.push(trialsCell, p1Cell);
161
+ }
162
+ else {
163
+ row.push(p1Cell);
164
+ }
150
165
  }
166
+ tableData.push(row);
151
167
  }
152
168
  Logger.table(tableData);
153
169
  if (hasPartialErrors) {
154
170
  Logger.write(chalk.yellow('\n * Some trials did not complete due to infrastructure errors. success rate is computed over the trials that ran.'));
155
171
  }
156
- const percentage = Math.round((metrics.passAtK || 0) * 100);
157
- Logger.write(`\n Trigger Success Rate: ${percentage}%`);
158
- const withSkillTokenStats = metrics.tokenStats?.withSkill;
159
- if (withSkillTokenStats) {
160
- Logger.write(`\n Avg Tokens: ${formatTokenStatsLine(withSkillTokenStats)}`);
161
- }
162
- const withSkillDurationStats = metrics.durationStats?.withSkill;
163
- if (withSkillDurationStats) {
164
- Logger.write(`\n Avg Time: ${formatDuration(withSkillDurationStats.avgMs)}`);
172
+ for (const version of skillVersions) {
173
+ const percentage = Math.round((metrics.passAtK[version] || 0) * 100);
174
+ Logger.write(`\n ${version} Success Rate: ${percentage}%`);
175
+ const tokenStats = metrics.tokenStats?.[version];
176
+ if (tokenStats) {
177
+ Logger.write(`\n Avg Tokens (${version}): ${formatTokenStatsLine(tokenStats)}`);
178
+ }
179
+ const durationStats = metrics.durationStats?.[version];
180
+ if (durationStats) {
181
+ Logger.write(`\n Avg Time (${version}): ${formatDuration(durationStats.avgMs)}`);
182
+ }
165
183
  }
166
184
  }
167
185
  /**
@@ -169,61 +187,46 @@ export function renderTriggerTable(report) {
169
187
  */
170
188
  export function renderFunctionalTable(report) {
171
189
  const { results, metrics } = report;
172
- const tableData = [['ID', 'Prompt', 'Without Skill', 'With Skill']];
190
+ // Identify all versions present (baseline + skill versions)
191
+ const skillVersions = results.length > 0 ? Object.keys(results[0].skillTrials) : ['local'];
192
+ const allVersions = ['baseline', ...skillVersions];
193
+ const header = ['ID', 'Prompt'];
194
+ for (const version of allVersions) {
195
+ header.push(version);
196
+ }
197
+ const tableData = [header];
173
198
  let hasPartialErrors = false;
174
199
  for (const result of results) {
175
200
  const promptSnippet = result.prompt.substring(0, 40) + (result.prompt.length > 40 ? '...' : '');
176
- const withoutSkillTrials = result.withoutSkillTrials || [];
177
- const withSkillTrials = result.trials;
178
- const baselineSomeError = withoutSkillTrials.some(t => t.isError);
179
- const baselineAllError = withoutSkillTrials.length > 0 && withoutSkillTrials.every(t => t.isError);
180
- const withSkillSomeError = withSkillTrials.some(t => t.isError);
181
- const withSkillAllError = withSkillTrials.length > 0 && withSkillTrials.every(t => t.isError);
182
- if ((baselineSomeError && !baselineAllError) || (withSkillSomeError && !withSkillAllError))
201
+ const row = [result.taskId.toString(), promptSnippet];
202
+ // Baseline
203
+ const woTrials = result.baselineTrials || [];
204
+ if (woTrials.some(t => t.isError) && !woTrials.every(t => t.isError))
183
205
  hasPartialErrors = true;
184
- tableData.push([
185
- result.taskId.toString(),
186
- promptSnippet,
187
- formatAssertionRate(withoutSkillTrials),
188
- formatAssertionRate(withSkillTrials),
189
- ]);
206
+ row.push(formatAssertionRate(woTrials));
207
+ // Skills
208
+ for (const version of skillVersions) {
209
+ const wiTrials = result.skillTrials[version] || [];
210
+ if (wiTrials.some(t => t.isError) && !wiTrials.every(t => t.isError))
211
+ hasPartialErrors = true;
212
+ row.push(formatAssertionRate(wiTrials));
213
+ }
214
+ tableData.push(row);
190
215
  }
191
216
  Logger.table(tableData);
192
217
  if (hasPartialErrors) {
193
218
  Logger.write(chalk.yellow('\n * Some trials did not complete due to infrastructure errors. success rate is computed over the trials that ran.'));
194
219
  }
195
- const withoutSkillPercentage = Math.round(((metrics.withoutSkillAssertionPassRate ?? metrics.withoutSkillPassAtK) || 0) * 100);
196
- const withSkillPercentage = Math.round(((metrics.assertionPassRate ?? metrics.passAtK) || 0) * 100);
197
- Logger.write(`\n Without Skill Rate: ${withoutSkillPercentage}%`);
198
- Logger.write(`\n With Skill Rate: ${withSkillPercentage}%`);
199
- const baselineTokenStats = metrics.tokenStats?.withoutSkill;
200
- const withSkillTokenStats = metrics.tokenStats?.withSkill;
201
- if (baselineTokenStats || withSkillTokenStats) {
202
- if (baselineTokenStats)
203
- Logger.write(`\n Tokens (w/o skill): ${formatTokenStatsLine(baselineTokenStats)}`);
204
- if (withSkillTokenStats)
205
- Logger.write(`\n Tokens (w/ skill): ${formatTokenStatsLine(withSkillTokenStats)}`);
206
- if (baselineTokenStats && withSkillTokenStats && baselineTokenStats.avgTotal > 0) {
207
- const delta = withSkillTokenStats.avgTotal - baselineTokenStats.avgTotal;
208
- const deltaSign = delta >= 0 ? '+' : '';
209
- const deltaPct = Math.round((delta / baselineTokenStats.avgTotal) * 100);
210
- const deltaStr = `${deltaSign}${formatTokens(Math.abs(delta))} (${deltaSign}${deltaPct}%)`;
211
- Logger.write(`\n Token Delta: ${delta >= 0 ? chalk.yellow(deltaStr) : chalk.green(deltaStr)}`);
220
+ for (const version of allVersions) {
221
+ const rate = Math.round(((metrics.assertionPassRate[version] ?? metrics.passAtK[version]) || 0) * 100);
222
+ Logger.write(`\n ${version} Rate: ${rate}%`);
223
+ const stats = metrics.tokenStats?.[version];
224
+ if (stats) {
225
+ Logger.write(`\n Tokens (${version}): ${formatTokenStatsLine(stats)}`);
212
226
  }
213
- }
214
- const baselineDurationStats = metrics.durationStats?.withoutSkill;
215
- const withSkillDurationStats = metrics.durationStats?.withSkill;
216
- if (baselineDurationStats || withSkillDurationStats) {
217
- if (baselineDurationStats)
218
- Logger.write(`\n Time (w/o skill): ${formatDuration(baselineDurationStats.avgMs)} avg`);
219
- if (withSkillDurationStats)
220
- Logger.write(`\n Time (w/ skill): ${formatDuration(withSkillDurationStats.avgMs)} avg`);
221
- if (baselineDurationStats && withSkillDurationStats && baselineDurationStats.avgMs > 0) {
222
- const delta = withSkillDurationStats.avgMs - baselineDurationStats.avgMs;
223
- const deltaSign = delta >= 0 ? '+' : '';
224
- const deltaPct = Math.round((delta / baselineDurationStats.avgMs) * 100);
225
- const deltaStr = `${deltaSign}${formatDuration(Math.abs(delta))} (${deltaSign}${deltaPct}%)`;
226
- Logger.write(`\n Time Delta: ${delta >= 0 ? chalk.yellow(deltaStr) : chalk.green(deltaStr)}`);
227
+ const dStats = metrics.durationStats?.[version];
228
+ if (dStats) {
229
+ Logger.write(`\n Time (${version}): ${formatDuration(dStats.avgMs)} avg`);
227
230
  }
228
231
  }
229
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fede0089/skill-eval",
3
- "version": "1.0.6",
3
+ "version": "1.2.0",
4
4
  "description": "CLI to evaluate agent skills triggering",
5
5
  "main": "dist/index.js",
6
6
  "bin": {