snapeval 2.0.0 → 2.1.1

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.
Files changed (46) hide show
  1. package/README.md +144 -104
  2. package/bin/snapeval.ts +39 -1
  3. package/dist/bin/snapeval.js +33 -0
  4. package/dist/bin/snapeval.js.map +1 -1
  5. package/dist/src/adapters/copilot-sdk-client.js +3 -1
  6. package/dist/src/adapters/copilot-sdk-client.js.map +1 -1
  7. package/dist/src/adapters/harness/copilot-sdk.d.ts +11 -0
  8. package/dist/src/adapters/harness/copilot-sdk.js +101 -0
  9. package/dist/src/adapters/harness/copilot-sdk.js.map +1 -0
  10. package/dist/src/adapters/harness/resolve.js +10 -2
  11. package/dist/src/adapters/harness/resolve.js.map +1 -1
  12. package/dist/src/adapters/inference/copilot-sdk.js +4 -1
  13. package/dist/src/adapters/inference/copilot-sdk.js.map +1 -1
  14. package/dist/src/adapters/report/terminal.js +89 -9
  15. package/dist/src/adapters/report/terminal.js.map +1 -1
  16. package/dist/src/commands/eval.d.ts +3 -0
  17. package/dist/src/commands/eval.js +146 -17
  18. package/dist/src/commands/eval.js.map +1 -1
  19. package/dist/src/commands/review.d.ts +1 -0
  20. package/dist/src/commands/review.js.map +1 -1
  21. package/dist/src/config.js +2 -1
  22. package/dist/src/config.js.map +1 -1
  23. package/dist/src/engine/grader.js +67 -9
  24. package/dist/src/engine/grader.js.map +1 -1
  25. package/dist/src/engine/runner.d.ts +1 -0
  26. package/dist/src/engine/runner.js +15 -12
  27. package/dist/src/engine/runner.js.map +1 -1
  28. package/dist/src/errors.d.ts +6 -0
  29. package/dist/src/errors.js +21 -3
  30. package/dist/src/errors.js.map +1 -1
  31. package/dist/src/types.d.ts +3 -0
  32. package/package.json +4 -1
  33. package/plugin.json +1 -1
  34. package/skills/snapeval/SKILL.md +132 -39
  35. package/src/adapters/copilot-sdk-client.ts +3 -1
  36. package/src/adapters/harness/copilot-sdk.ts +126 -0
  37. package/src/adapters/harness/resolve.ts +13 -2
  38. package/src/adapters/inference/copilot-sdk.ts +5 -1
  39. package/src/adapters/report/terminal.ts +99 -10
  40. package/src/commands/eval.ts +183 -31
  41. package/src/commands/review.ts +1 -1
  42. package/src/config.ts +2 -1
  43. package/src/engine/grader.ts +59 -8
  44. package/src/engine/runner.ts +16 -13
  45. package/src/errors.ts +24 -3
  46. package/src/types.ts +3 -0
@@ -0,0 +1,126 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { Harness, HarnessRunResult } from '../../types.js';
4
+ import { getClient, isSDKInstalled } from '../copilot-sdk-client.js';
5
+
6
+ export class CopilotSDKHarness implements Harness {
7
+ readonly name = 'copilot-sdk';
8
+
9
+ async run(options: {
10
+ skillPath?: string;
11
+ prompt: string;
12
+ files?: string[];
13
+ outputDir: string;
14
+ }): Promise<HarnessRunResult> {
15
+ const startMs = Date.now();
16
+ const client = await getClient();
17
+
18
+ fs.mkdirSync(options.outputDir, { recursive: true });
19
+
20
+ // Dynamically import SDK for approveAll
21
+ // @ts-ignore — module may not be installed (optional dep)
22
+ const { approveAll } = await import('@github/copilot-sdk');
23
+
24
+ // Build session config
25
+ const sessionConfig: Record<string, unknown> = {
26
+ model: 'gpt-4.1',
27
+ onPermissionRequest: approveAll,
28
+ workingDirectory: options.outputDir,
29
+ infiniteSessions: { enabled: false },
30
+ };
31
+
32
+ // Native skill loading: point skillDirectories at the skill's parent
33
+ if (options.skillPath) {
34
+ sessionConfig.skillDirectories = [options.skillPath];
35
+ }
36
+
37
+ const session = await client.createSession(sessionConfig);
38
+
39
+ try {
40
+ // Attach input files if provided
41
+ const attachments: Array<{ type: string; path: string; displayName?: string }> = [];
42
+ if (options.files) {
43
+ for (const file of options.files) {
44
+ // Copy to outputDir for script assertions, and attach for the model
45
+ const dest = path.join(options.outputDir, path.basename(file));
46
+ fs.copyFileSync(file, dest);
47
+ attachments.push({ type: 'file', path: dest, displayName: path.basename(file) });
48
+ }
49
+ }
50
+
51
+ const response = await session.sendAndWait(
52
+ {
53
+ prompt: options.prompt,
54
+ ...(attachments.length > 0 ? { attachments } : {}),
55
+ },
56
+ 300_000, // 5 min timeout — calibrated for complex eval prompts
57
+ );
58
+
59
+ const raw = response?.data?.content ?? '';
60
+
61
+ // Collect full transcript from session events
62
+ const events = await session.getMessages();
63
+ const transcript = buildTranscript(events);
64
+
65
+ // Extract token count from events if available
66
+ const totalTokens = extractTokenCount(events);
67
+
68
+ const durationMs = Date.now() - startMs;
69
+
70
+ return {
71
+ raw: raw.trim(),
72
+ transcript,
73
+ files: [],
74
+ total_tokens: totalTokens,
75
+ duration_ms: durationMs,
76
+ };
77
+ } finally {
78
+ await session.disconnect();
79
+ }
80
+ }
81
+
82
+ async isAvailable(): Promise<boolean> {
83
+ return isSDKInstalled();
84
+ }
85
+ }
86
+
87
+ function buildTranscript(events: any[]): string {
88
+ const lines: string[] = [];
89
+ for (const event of events) {
90
+ switch (event.type) {
91
+ case 'user.message':
92
+ lines.push(`[user] ${event.data?.content ?? ''}`);
93
+ break;
94
+ case 'assistant.message':
95
+ lines.push(`[assistant] ${event.data?.content ?? ''}`);
96
+ break;
97
+ case 'tool.execution_start':
98
+ lines.push(`[tool:start] ${event.data?.toolName ?? 'unknown'}(${JSON.stringify(event.data?.arguments ?? {})})`);
99
+ break;
100
+ case 'tool.execution_complete':
101
+ lines.push(`[tool:done] ${event.data?.toolName ?? 'unknown'} → ${truncate(event.data?.result ?? '', 200)}`);
102
+ break;
103
+ case 'skill.invoked':
104
+ lines.push(`[skill] ${event.data?.name ?? 'unknown'} (${event.data?.path ?? ''})`);
105
+ break;
106
+ case 'session.error':
107
+ lines.push(`[error] ${event.data?.message ?? ''}`);
108
+ break;
109
+ }
110
+ }
111
+ return lines.join('\n');
112
+ }
113
+
114
+ function extractTokenCount(events: any[]): number {
115
+ let total = 0;
116
+ for (const event of events) {
117
+ if (event.type === 'assistant.usage') {
118
+ total += (event.data?.inputTokens ?? 0) + (event.data?.outputTokens ?? 0);
119
+ }
120
+ }
121
+ return total;
122
+ }
123
+
124
+ function truncate(str: string, max: number): string {
125
+ return str.length > max ? str.slice(0, max) + '...' : str;
126
+ }
@@ -1,10 +1,21 @@
1
1
  import type { Harness } from '../../types.js';
2
2
  import { CopilotCLIHarness } from './copilot-cli.js';
3
- import { SnapevalError } from '../../errors.js';
3
+ import { CopilotSDKHarness } from './copilot-sdk.js';
4
+ import { AdapterNotAvailableError, SnapevalError } from '../../errors.js';
5
+ import { isSDKInstalled } from '../copilot-sdk-client.js';
4
6
 
5
7
  export function resolveHarness(name: string): Harness {
8
+ if (name === 'copilot-sdk') {
9
+ if (!isSDKInstalled()) {
10
+ throw new AdapterNotAvailableError(
11
+ 'copilot-sdk',
12
+ '@github/copilot-sdk is not installed. Install with: npm install @github/copilot-sdk'
13
+ );
14
+ }
15
+ return new CopilotSDKHarness();
16
+ }
6
17
  if (name === 'copilot-cli') {
7
18
  return new CopilotCLIHarness();
8
19
  }
9
- throw new SnapevalError(`Unknown harness "${name}". Built-in options: copilot-cli.`);
20
+ throw new SnapevalError(`Unknown harness "${name}". Built-in options: copilot-sdk, copilot-cli.`);
10
21
  }
@@ -7,6 +7,9 @@ export class CopilotSDKInference implements InferenceAdapter {
7
7
  async chat(messages: Message[], _options?: ChatOptions): Promise<string> {
8
8
  const client = await getClient();
9
9
 
10
+ // @ts-ignore — module may not be installed (optional dep)
11
+ const { approveAll } = await import('@github/copilot-sdk');
12
+
10
13
  const systemMessages = messages.filter((m) => m.role === 'system');
11
14
  const nonSystemMessages = messages.filter((m) => m.role !== 'system');
12
15
  const systemContent = systemMessages.map((m) => m.content).join('\n');
@@ -17,7 +20,8 @@ export class CopilotSDKInference implements InferenceAdapter {
17
20
  ...(systemContent
18
21
  ? { systemMessage: { content: systemContent } }
19
22
  : {}),
20
- onPermissionRequest: async () => ({ kind: 'approved' }),
23
+ onPermissionRequest: approveAll,
24
+ infiniteSessions: { enabled: false },
21
25
  });
22
26
 
23
27
  try {
@@ -1,5 +1,44 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
1
3
  import chalk from 'chalk';
2
- import type { ReportAdapter, EvalResults } from '../../types.js';
4
+ import type { ReportAdapter, EvalResults, BenchmarkData, GradingResult } from '../../types.js';
5
+
6
+ interface PreviousIteration {
7
+ benchmark: BenchmarkData;
8
+ gradings: Map<string, { withSkill?: GradingResult; withoutSkill?: GradingResult }>;
9
+ }
10
+
11
+ function loadPreviousIteration(iterationDir: string): PreviousIteration | null {
12
+ const workspaceDir = path.dirname(iterationDir);
13
+ const currentName = path.basename(iterationDir);
14
+ const currentNum = parseInt(currentName.replace('iteration-', ''), 10);
15
+ if (isNaN(currentNum) || currentNum <= 1) return null;
16
+ const prevDir = path.join(workspaceDir, `iteration-${currentNum - 1}`);
17
+ const prevBenchmarkPath = path.join(prevDir, 'benchmark.json');
18
+ if (!fs.existsSync(prevBenchmarkPath)) return null;
19
+ try {
20
+ const benchmark = JSON.parse(fs.readFileSync(prevBenchmarkPath, 'utf-8'));
21
+ const gradings = new Map<string, { withSkill?: GradingResult; withoutSkill?: GradingResult }>();
22
+ const evalDirs = fs.readdirSync(prevDir).filter(d => d.startsWith('eval-'));
23
+ for (const evalDir of evalDirs) {
24
+ const wsPath = path.join(prevDir, evalDir, 'with_skill', 'grading.json');
25
+ const wosPath = path.join(prevDir, evalDir, 'without_skill', 'grading.json');
26
+ const ws = fs.existsSync(wsPath) ? JSON.parse(fs.readFileSync(wsPath, 'utf-8')) : undefined;
27
+ const wos = fs.existsSync(wosPath) ? JSON.parse(fs.readFileSync(wosPath, 'utf-8')) : undefined;
28
+ gradings.set(evalDir, { withSkill: ws, withoutSkill: wos });
29
+ }
30
+ return { benchmark, gradings };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function evalLabel(run: { evalId: number; slug: string; label?: string; prompt: string }): string {
37
+ if (run.label) return run.label;
38
+ if (run.slug && run.slug !== `${run.evalId}`) return run.slug;
39
+ const firstLine = run.prompt.split('\n')[0].slice(0, 60);
40
+ return firstLine;
41
+ }
3
42
 
4
43
  export class TerminalReporter implements ReportAdapter {
5
44
  readonly name = 'terminal';
@@ -8,24 +47,74 @@ export class TerminalReporter implements ReportAdapter {
8
47
  const { skillName, evalRuns, benchmark } = results;
9
48
 
10
49
  console.log(chalk.bold(`\nsnapeval — ${skillName}`));
11
- console.log(chalk.dim('─'.repeat(50)));
50
+ console.log(chalk.dim(`Baseline = without SKILL.md (raw AI response)`));
51
+ console.log(chalk.dim('─'.repeat(60)));
52
+
53
+ const prev = loadPreviousIteration(results.iterationDir);
12
54
 
13
55
  for (const run of evalRuns) {
14
- const wsRate = run.withSkill.grading?.summary.pass_rate;
56
+ const wsGrading = run.withSkill.grading;
57
+ const wsRate = wsGrading?.summary.pass_rate;
15
58
  const wosRate = run.withoutSkill.grading?.summary.pass_rate;
16
59
  const wsLabel = wsRate !== undefined ? `${(wsRate * 100).toFixed(0)}%` : 'n/a';
17
60
  const wosLabel = wosRate !== undefined ? `${(wosRate * 100).toFixed(0)}%` : 'n/a';
18
- const tokens = run.withSkill.output.total_tokens;
19
- const durationS = (run.withSkill.output.duration_ms / 1000).toFixed(2);
20
- console.log(` ${chalk.cyan(`#${run.evalId}`)} ${run.prompt.slice(0, 60)}`);
21
- console.log(` with_skill: ${wsLabel} | without_skill: ${wosLabel} | ${tokens} tokens, ${durationS}s`);
61
+ const wsColor = wsRate === 1 ? chalk.green : wsRate === 0 ? chalk.red : chalk.yellow;
62
+ const durationS = (run.withSkill.output.duration_ms / 1000).toFixed(1);
63
+
64
+ // Show per-eval delta from previous iteration
65
+ let perEvalDelta = '';
66
+ if (prev) {
67
+ const prevGrading = prev.gradings.get(`eval-${run.slug}`);
68
+ const prevRate = prevGrading?.withSkill?.summary.pass_rate;
69
+ if (prevRate !== undefined && wsRate !== undefined) {
70
+ const change = wsRate - prevRate;
71
+ if (change !== 0) {
72
+ const arrow = change > 0 ? chalk.green('↑') : chalk.red('↓');
73
+ perEvalDelta = ` ${arrow} was ${(prevRate * 100).toFixed(0)}%`;
74
+ }
75
+ }
76
+ }
77
+
78
+ console.log(` ${chalk.cyan(`#${run.evalId}`)} ${evalLabel(run)}`);
79
+ console.log(` Skill: ${wsColor(wsLabel)}${perEvalDelta} | Baseline: ${wosLabel} | ${durationS}s`);
80
+
81
+ // Show failed assertions inline
82
+ if (wsGrading) {
83
+ const failed = wsGrading.assertion_results.filter((a) => !a.passed);
84
+ for (const f of failed) {
85
+ console.log(chalk.red(` FAIL: ${f.text}`));
86
+ if (f.evidence) {
87
+ console.log(chalk.dim(` ${f.evidence.slice(0, 100)}`));
88
+ }
89
+ }
90
+ }
22
91
  }
23
92
 
24
- console.log(chalk.dim('─'.repeat(50)));
93
+ console.log(chalk.dim('─'.repeat(60)));
25
94
 
95
+ const ws = benchmark.run_summary.with_skill;
96
+ const wos = benchmark.run_summary.without_skill;
26
97
  const delta = benchmark.run_summary.delta;
27
98
  const deltaColor = delta.pass_rate > 0 ? chalk.green : delta.pass_rate < 0 ? chalk.red : chalk.dim;
28
- console.log(`Delta: ${deltaColor(`${(delta.pass_rate * 100).toFixed(1)}% pass rate`)} | ${delta.time_seconds.toFixed(1)}s time | ${delta.tokens.toFixed(0)} tokens`);
29
- console.log(chalk.dim(`with_skill avg: ${(benchmark.run_summary.with_skill.pass_rate.mean * 100).toFixed(1)}% | without_skill avg: ${(benchmark.run_summary.without_skill.pass_rate.mean * 100).toFixed(1)}%`));
99
+
100
+ console.log(chalk.bold('Summary:'));
101
+ console.log(` Skill pass rate: ${(ws.pass_rate.mean * 100).toFixed(1)}%`);
102
+ console.log(` Baseline pass rate: ${(wos.pass_rate.mean * 100).toFixed(1)}%`);
103
+ console.log(` Improvement: ${deltaColor(`${delta.pass_rate > 0 ? '+' : ''}${(delta.pass_rate * 100).toFixed(1)}%`)}`);
104
+
105
+ if (prev) {
106
+ const prevRate = prev.benchmark.run_summary.with_skill.pass_rate.mean;
107
+ const currRate = ws.pass_rate.mean;
108
+ const change = currRate - prevRate;
109
+ const changeColor = change > 0 ? chalk.green : change < 0 ? chalk.red : chalk.dim;
110
+ console.log(` vs previous: ${changeColor(`${change > 0 ? '+' : ''}${(change * 100).toFixed(1)}%`)} (was ${(prevRate * 100).toFixed(1)}%)`);
111
+
112
+ // Note if eval set size changed
113
+ const prevEvalCount = prev.gradings.size;
114
+ const currEvalCount = evalRuns.length;
115
+ if (prevEvalCount !== currEvalCount) {
116
+ console.log(chalk.dim(` Note: eval set changed (${prevEvalCount} → ${currEvalCount} evals)`));
117
+ }
118
+ }
30
119
  }
31
120
  }
@@ -6,82 +6,234 @@ import type {
6
6
  EvalsFile,
7
7
  EvalResults,
8
8
  EvalRunResult,
9
+ GradingResult,
9
10
  } from '../types.js';
10
11
  import { WorkspaceManager } from '../engine/workspace.js';
11
12
  import { runEval } from '../engine/runner.js';
12
13
  import { gradeAssertions } from '../engine/grader.js';
13
14
  import { computeBenchmark } from '../engine/aggregator.js';
14
- import { SnapevalError } from '../errors.js';
15
+ import { SnapevalError, FileNotFoundError, ThresholdError } from '../errors.js';
16
+
17
+ async function runWithConcurrency<T>(
18
+ tasks: (() => Promise<T>)[],
19
+ limit: number,
20
+ ): Promise<T[]> {
21
+ const results: T[] = new Array(tasks.length);
22
+ let index = 0;
23
+ async function worker() {
24
+ while (index < tasks.length) {
25
+ const i = index++;
26
+ results[i] = await tasks[i]();
27
+ }
28
+ }
29
+ await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
30
+ return results;
31
+ }
32
+
33
+ const MAX_CONCURRENCY = 10;
34
+
35
+ /**
36
+ * Average pass rates across multiple grading runs.
37
+ * Uses the last run's assertion_results for display, but averages the
38
+ * pass_rate across all runs so --runs N provides statistical significance.
39
+ */
40
+ function averageGradings(gradings: (GradingResult | null)[]): GradingResult | undefined {
41
+ const valid = gradings.filter((g): g is GradingResult => g !== null);
42
+ if (valid.length === 0) return undefined;
43
+ if (valid.length === 1) return valid[0];
44
+
45
+ const avgPassRate = valid.reduce((sum, g) => sum + g.summary.pass_rate, 0) / valid.length;
46
+ const avgPassed = valid.reduce((sum, g) => sum + g.summary.passed, 0) / valid.length;
47
+ const avgFailed = valid.reduce((sum, g) => sum + g.summary.failed, 0) / valid.length;
48
+ const last = valid[valid.length - 1];
49
+
50
+ return {
51
+ assertion_results: last.assertion_results,
52
+ summary: {
53
+ passed: Math.round(avgPassed),
54
+ failed: Math.round(avgFailed),
55
+ total: last.summary.total,
56
+ pass_rate: avgPassRate,
57
+ },
58
+ };
59
+ }
60
+
61
+ function validateEvalsFile(evalsFile: EvalsFile, evalsPath: string): void {
62
+ if (!evalsFile.skill_name || typeof evalsFile.skill_name !== 'string') {
63
+ throw new SnapevalError(`Invalid evals.json at ${evalsPath}: missing or invalid "skill_name" field.`);
64
+ }
65
+ if (!Array.isArray(evalsFile.evals)) {
66
+ throw new SnapevalError(`Invalid evals.json at ${evalsPath}: "evals" must be an array.`);
67
+ }
68
+ for (const [i, evalCase] of evalsFile.evals.entries()) {
69
+ const prefix = `Invalid evals.json at ${evalsPath}: evals[${i}]`;
70
+ if (typeof evalCase.id !== 'number') {
71
+ throw new SnapevalError(`${prefix} missing or invalid "id" (must be a number).`);
72
+ }
73
+ if (typeof evalCase.prompt !== 'string') {
74
+ throw new SnapevalError(`${prefix} (id:${evalCase.id}) missing "prompt" field.`);
75
+ }
76
+ if (typeof evalCase.expected_output !== 'string') {
77
+ throw new SnapevalError(`${prefix} (id:${evalCase.id}) missing "expected_output" field.`);
78
+ }
79
+ if (evalCase.assertions !== undefined && !Array.isArray(evalCase.assertions)) {
80
+ throw new SnapevalError(`${prefix} (id:${evalCase.id}) "assertions" must be an array of strings.`);
81
+ }
82
+ }
83
+ }
15
84
 
16
85
  export async function evalCommand(
17
86
  skillPath: string,
18
87
  harness: Harness,
19
88
  inference: InferenceAdapter,
20
- options: { workspace?: string; runs?: number; oldSkill?: string }
89
+ options: { workspace?: string; runs?: number; oldSkill?: string; concurrency?: number; only?: number[]; threshold?: number }
21
90
  ): Promise<EvalResults> {
22
91
  const evalsPath = path.join(skillPath, 'evals', 'evals.json');
23
92
  if (!fs.existsSync(evalsPath)) {
24
- throw new SnapevalError(`No evals.json found at ${evalsPath}. Create evals/evals.json with test scenarios first.`);
93
+ throw new FileNotFoundError(evalsPath, 'Create evals/evals.json with test scenarios first');
94
+ }
95
+
96
+ let evalsFile: EvalsFile;
97
+ try {
98
+ evalsFile = JSON.parse(fs.readFileSync(evalsPath, 'utf-8'));
99
+ } catch {
100
+ throw new SnapevalError(`Invalid JSON in ${evalsPath}. Check for syntax errors (missing commas, trailing commas, etc).`);
101
+ }
102
+ validateEvalsFile(evalsFile, evalsPath);
103
+
104
+ // Filter to specific eval IDs if --only is provided
105
+ if (options.only && options.only.length > 0) {
106
+ const ids = new Set(options.only);
107
+ const filtered = evalsFile.evals.filter((e) => ids.has(e.id));
108
+ if (filtered.length === 0) {
109
+ throw new SnapevalError(`No eval cases match --only ${options.only.join(',')}. Available IDs: ${evalsFile.evals.map((e) => e.id).join(', ')}`);
110
+ }
111
+ evalsFile = { ...evalsFile, evals: filtered };
112
+ }
113
+
114
+ if (options.threshold !== undefined && (options.threshold < 0 || options.threshold > 1)) {
115
+ throw new SnapevalError(`Threshold must be between 0 and 1 (e.g., 0.8 for 80%). Got: ${options.threshold}`);
25
116
  }
26
117
 
27
- const evalsFile: EvalsFile = JSON.parse(fs.readFileSync(evalsPath, 'utf-8'));
28
118
  const ws = new WorkspaceManager(skillPath, options.workspace);
29
119
  const iterationDir = ws.createIteration();
120
+
121
+ // Track which SKILL.md was used for this iteration
122
+ const skillMdPath = path.join(skillPath, 'SKILL.md');
123
+ if (fs.existsSync(skillMdPath)) {
124
+ fs.copyFileSync(skillMdPath, path.join(iterationDir, 'SKILL.md.snapshot'));
125
+ }
30
126
  const runs = options.runs ?? 1;
127
+ const concurrency = Math.min(Math.max(options.concurrency ?? 1, 1), MAX_CONCURRENCY);
31
128
  const baselineVariant = options.oldSkill ? 'old_skill' : 'without_skill';
32
129
  const scriptsDir = path.join(skillPath, 'evals', 'scripts');
33
130
 
34
- const evalRuns: EvalRunResult[] = [];
35
-
36
- for (const evalCase of evalsFile.evals) {
131
+ // Pre-create eval directories sequentially (filesystem setup)
132
+ const evalDirs = evalsFile.evals.map((evalCase) => {
37
133
  const slug = WorkspaceManager.getEvalSlug(evalCase).replace('eval-', '');
38
- const evalDir = ws.createEvalDir(iterationDir, slug, baselineVariant);
134
+ return { evalCase, slug, evalDir: ws.createEvalDir(iterationDir, slug, baselineVariant) };
135
+ });
39
136
 
137
+ const tasks = evalDirs.map(({ evalCase, slug, evalDir }) => async (): Promise<EvalRunResult> => {
138
+ const assertions = evalCase.assertions ?? [];
139
+ const allGradings: { withSkill: GradingResult | null; withoutSkill: GradingResult | null }[] = [];
40
140
  let lastRun: Awaited<ReturnType<typeof runEval>> | null = null;
141
+
41
142
  for (let i = 0; i < runs; i++) {
42
143
  lastRun = await runEval(evalCase, skillPath, evalDir, harness, options.oldSkill);
144
+
145
+ // Grade every run, not just the last
146
+ const [wsGrading, wosGrading] = await Promise.all([
147
+ gradeAssertions(
148
+ assertions,
149
+ lastRun.withSkill.output,
150
+ path.join(evalDir, 'with_skill'),
151
+ inference,
152
+ fs.existsSync(scriptsDir) ? scriptsDir : undefined,
153
+ ),
154
+ gradeAssertions(
155
+ assertions,
156
+ lastRun.withoutSkill.output,
157
+ path.join(evalDir, baselineVariant),
158
+ inference,
159
+ fs.existsSync(scriptsDir) ? scriptsDir : undefined,
160
+ ),
161
+ ]);
162
+ allGradings.push({ withSkill: wsGrading, withoutSkill: wosGrading });
43
163
  }
44
164
 
45
- if (!lastRun) continue;
165
+ if (!lastRun) {
166
+ throw new SnapevalError(`No runs completed for eval ${evalCase.id}`);
167
+ }
46
168
 
47
- const assertions = evalCase.assertions ?? [];
48
- const withSkillGrading = await gradeAssertions(
49
- assertions,
50
- lastRun.withSkill.output,
51
- path.join(evalDir, 'with_skill'),
52
- inference,
53
- fs.existsSync(scriptsDir) ? scriptsDir : undefined,
54
- );
55
- const withoutSkillGrading = await gradeAssertions(
56
- assertions,
57
- lastRun.withoutSkill.output,
58
- path.join(evalDir, baselineVariant),
59
- inference,
60
- fs.existsSync(scriptsDir) ? scriptsDir : undefined,
61
- );
62
-
63
- evalRuns.push({
169
+ // Average pass rates across all runs for statistical significance
170
+ const withSkillGrading = averageGradings(allGradings.map(g => g.withSkill));
171
+ const withoutSkillGrading = averageGradings(allGradings.map(g => g.withoutSkill));
172
+
173
+ // When runs > 1, overwrite grading.json with averaged results so
174
+ // artifacts match the benchmark (not just the last run's raw data)
175
+ if (runs > 1) {
176
+ if (withSkillGrading) {
177
+ fs.writeFileSync(
178
+ path.join(evalDir, 'with_skill', 'grading.json'),
179
+ JSON.stringify(withSkillGrading, null, 2),
180
+ );
181
+ }
182
+ if (withoutSkillGrading) {
183
+ fs.writeFileSync(
184
+ path.join(evalDir, baselineVariant, 'grading.json'),
185
+ JSON.stringify(withoutSkillGrading, null, 2),
186
+ );
187
+ }
188
+ }
189
+
190
+ return {
64
191
  evalId: evalCase.id,
65
192
  slug,
193
+ label: evalCase.label,
66
194
  prompt: evalCase.prompt,
67
195
  withSkill: {
68
196
  output: lastRun.withSkill.output,
69
- grading: withSkillGrading ?? undefined,
197
+ grading: withSkillGrading,
70
198
  },
71
199
  withoutSkill: {
72
200
  output: lastRun.withoutSkill.output,
73
- grading: withoutSkillGrading ?? undefined,
201
+ grading: withoutSkillGrading,
74
202
  },
75
- });
76
- }
203
+ };
204
+ });
77
205
 
206
+ const evalRuns = await runWithConcurrency(tasks, concurrency);
78
207
  const benchmark = computeBenchmark(evalRuns);
79
208
 
209
+ // Add iteration metadata for cross-iteration comparison
210
+ const benchmarkWithMeta = {
211
+ ...benchmark,
212
+ metadata: {
213
+ eval_count: evalRuns.length,
214
+ eval_ids: evalRuns.map((r) => r.evalId),
215
+ skill_name: evalsFile.skill_name,
216
+ runs_per_eval: runs,
217
+ timestamp: new Date().toISOString(),
218
+ },
219
+ };
220
+
80
221
  fs.writeFileSync(
81
222
  path.join(iterationDir, 'benchmark.json'),
82
- JSON.stringify(benchmark, null, 2)
223
+ JSON.stringify(benchmarkWithMeta, (_key, value) =>
224
+ typeof value === 'number' ? Math.round(value * 10000) / 10000 : value, 2)
83
225
  );
84
226
 
227
+ // Check threshold if set (for CI gating)
228
+ if (options.threshold !== undefined) {
229
+ const passRate = benchmark.run_summary.with_skill.pass_rate.mean;
230
+ if (passRate < options.threshold) {
231
+ // Still return results so the reporter can display them before the error
232
+ const results = { skillName: evalsFile.skill_name, evalRuns, benchmark, iterationDir };
233
+ throw Object.assign(new ThresholdError(passRate, options.threshold), { results });
234
+ }
235
+ }
236
+
85
237
  return {
86
238
  skillName: evalsFile.skill_name,
87
239
  evalRuns,
@@ -10,7 +10,7 @@ export async function reviewCommand(
10
10
  skillPath: string,
11
11
  harness: Harness,
12
12
  inference: InferenceAdapter,
13
- options: { workspace?: string; runs?: number; oldSkill?: string; noOpen?: boolean }
13
+ options: { workspace?: string; runs?: number; oldSkill?: string; noOpen?: boolean; concurrency?: number }
14
14
  ): Promise<void> {
15
15
  const results = await evalCommand(skillPath, harness, inference, options);
16
16
 
package/src/config.ts CHANGED
@@ -3,10 +3,11 @@ import * as path from 'node:path';
3
3
  import type { SnapevalConfig } from './types.js';
4
4
 
5
5
  export const DEFAULT_CONFIG: SnapevalConfig = {
6
- harness: 'copilot-cli',
6
+ harness: 'copilot-sdk',
7
7
  inference: 'auto',
8
8
  workspace: '../{skill_name}-workspace',
9
9
  runs: 1,
10
+ concurrency: 1,
10
11
  };
11
12
 
12
13
  function loadConfigFile(dirPath: string): Partial<SnapevalConfig> | null {