@fede0089/skill-eval 1.0.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/dist/commands/functional.js +225 -0
  4. package/dist/commands/rate.js +20 -0
  5. package/dist/commands/show.js +43 -0
  6. package/dist/commands/trigger.js +154 -0
  7. package/dist/commands/view.js +30 -0
  8. package/dist/core/agent-pool.js +40 -0
  9. package/dist/core/config.js +58 -0
  10. package/dist/core/environment.js +83 -0
  11. package/dist/core/errors.js +29 -0
  12. package/dist/core/eval-runner.js +306 -0
  13. package/dist/core/evaluator.js +242 -0
  14. package/dist/core/preflight.js +36 -0
  15. package/dist/core/reporters/html-reporter.js +354 -0
  16. package/dist/core/reporters/index.js +9 -0
  17. package/dist/core/reporters/json-reporter.js +7 -0
  18. package/dist/core/reporters/reporter.js +1 -0
  19. package/dist/core/runner.js +75 -0
  20. package/dist/core/runners/factory.js +18 -0
  21. package/dist/core/runners/gemini-cli.runner.js +138 -0
  22. package/dist/core/runners/index.js +3 -0
  23. package/dist/core/runners/runner.interface.js +1 -0
  24. package/dist/core/statistics.js +79 -0
  25. package/dist/core/trial-utils.js +58 -0
  26. package/dist/index.js +80 -0
  27. package/dist/reporters/html-reporter.js +384 -0
  28. package/dist/reporters/index.js +2 -0
  29. package/dist/reporters/json-reporter.js +10 -0
  30. package/dist/reporters/reporter.js +1 -0
  31. package/dist/runners/gemini-cli/index.js +1 -0
  32. package/dist/runners/gemini-cli/runner.js +231 -0
  33. package/dist/runners/index.js +2 -0
  34. package/dist/runners/registry.js +16 -0
  35. package/dist/runners/runner.interface.js +1 -0
  36. package/dist/types/index.js +1 -0
  37. package/dist/utils/eval-loader.js +66 -0
  38. package/dist/utils/exec.js +9 -0
  39. package/dist/utils/logger.js +80 -0
  40. package/dist/utils/ndjson.js +85 -0
  41. package/dist/utils/table-renderer.js +229 -0
  42. package/dist/utils/ui.js +166 -0
  43. package/package.json +49 -0
@@ -0,0 +1,384 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { Logger } from '../utils/logger.js';
4
+ import { formatTokens, formatDuration } from '../utils/table-renderer.js';
5
+ import { computeAssertionPassRate } from '../core/statistics.js';
6
+ export class HtmlReporter {
7
+ generate(report, runDir) {
8
+ const htmlPath = path.join(runDir, 'report.html');
9
+ fs.writeFileSync(htmlPath, generateHtml(report), 'utf-8');
10
+ Logger.write(`\n Report: file://${htmlPath}\n`);
11
+ }
12
+ }
13
+ // ---------------------------------------------------------------------------
14
+ // HTML generation (module-private)
15
+ // ---------------------------------------------------------------------------
16
+ function escapeHtml(s) {
17
+ return s
18
+ .replace(/&/g, '&')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;')
22
+ .replace(/'/g, '&#39;');
23
+ }
24
+ function formatPercent(val) {
25
+ return `${Math.round(val * 100)}%`;
26
+ }
27
+ function passColorClass(val) {
28
+ if (val >= 0.8)
29
+ return 'green';
30
+ if (val >= 0.5)
31
+ return 'amber';
32
+ return 'red';
33
+ }
34
+ function isFunctional(report) {
35
+ return report.metrics.withoutSkillScore !== undefined;
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Metrics Grid
39
+ // ---------------------------------------------------------------------------
40
+ function renderDeltaCell(base, target, format, cellClass) {
41
+ if (base <= 0)
42
+ return `<span class="metric-val muted">—</span>`;
43
+ const delta = target - base;
44
+ const sign = delta >= 0 ? '+' : '';
45
+ const pct = Math.round((delta / base) * 100);
46
+ const cls = delta > 0 ? 'amber' : delta < 0 ? 'green' : '';
47
+ return `<span class="${cellClass} ${cls}">${sign}${pct}%</span>`;
48
+ }
49
+ function renderMetricsGrid(report) {
50
+ const { metrics } = report;
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">
62
+ <table>
63
+ <thead>
64
+ <tr><th></th><th>Without Skill</th><th>With Skill</th><th>Delta</th></tr>
65
+ </thead>
66
+ <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>
85
+ </tbody>
86
+ </table>
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
+ }
116
+ // ---------------------------------------------------------------------------
117
+ // Trial details
118
+ // ---------------------------------------------------------------------------
119
+ function renderAssertions(assertions) {
120
+ if (assertions.length === 0)
121
+ return '<p class="muted">No assertions recorded.</p>';
122
+ return assertions.map(a => {
123
+ const icon = a.passed ? '✓' : '✗';
124
+ const cls = a.passed ? 'assert-pass' : 'assert-fail';
125
+ return `<div class="assertion ${cls}">
126
+ <span class="assert-icon">${icon}</span>
127
+ <div class="assert-body">
128
+ <div class="assert-text">${escapeHtml(a.assertion)}</div>
129
+ ${a.reason ? `<div class="assert-reason">${escapeHtml(a.reason)}</div>` : ''}
130
+ </div>
131
+ </div>`;
132
+ }).join('');
133
+ }
134
+ function renderTrial(trial) {
135
+ const passedCount = trial.assertionResults.filter(r => r.passed).length;
136
+ const totalCount = trial.assertionResults.length;
137
+ const isPartial = !trial.trialPassed && !trial.isError && passedCount > 0;
138
+ const cls = trial.isError ? 'trial-error' : trial.trialPassed ? 'trial-pass' : isPartial ? 'trial-partial' : 'trial-fail';
139
+ const badge = trial.isError
140
+ ? '<span class="pill amber">! ERROR</span>'
141
+ : trial.trialPassed
142
+ ? '<span class="pill green">✓ PASS</span>'
143
+ : isPartial
144
+ ? `<span class="pill amber">~ PARTIAL ${passedCount}/${totalCount}</span>`
145
+ : '<span class="pill red">✗ NOT PASSED</span>';
146
+ return `<div class="trial ${cls}">
147
+ <div class="trial-header">Trial ${trial.id} ${badge}</div>
148
+ <div class="trial-assertions">${renderAssertions(trial.assertionResults)}</div>
149
+ </div>`;
150
+ }
151
+ function avgTrialTokens(trials) {
152
+ const withStats = trials.filter(t => t.tokenStats != null);
153
+ if (withStats.length === 0)
154
+ return null;
155
+ return Math.round(withStats.reduce((s, t) => s + t.tokenStats.totalTokens, 0) / withStats.length);
156
+ }
157
+ function avgTrialDuration(trials) {
158
+ const withDuration = trials.filter(t => t.durationMs != null);
159
+ if (withDuration.length === 0)
160
+ return null;
161
+ return Math.round(withDuration.reduce((s, t) => s + t.durationMs, 0) / withDuration.length);
162
+ }
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>';
181
+ return `<div class="metrics-grid-sm">
182
+ <table>
183
+ <thead>
184
+ <tr><th></th><th>Without Skill</th><th>With Skill</th><th>Delta</th></tr>
185
+ </thead>
186
+ <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>
205
+ </tbody>
206
+ </table>
207
+ </div>`;
208
+ }
209
+ function renderTaskDetails(result, isFunctionalEval) {
210
+ const sections = [];
211
+ if (isFunctionalEval) {
212
+ sections.push(renderTaskMiniGrid(result));
213
+ }
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>');
218
+ }
219
+ sections.push(...result.trials.map(t => renderTrial(t)));
220
+ return `<div class="task-details" id="details-${result.taskId}">${sections.join('')}</div>`;
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Eval results table
224
+ // ---------------------------------------------------------------------------
225
+ function renderTaskTable(report) {
226
+ const { results } = report;
227
+ const functional = isFunctional(report);
228
+ const headerRow = `<tr><th>#</th><th>Prompt</th><th>Details</th></tr>`;
229
+ const rows = results.map(result => {
230
+ const prompt = escapeHtml(result.prompt);
231
+ const detailsBtn = `<button class="details-btn" data-target="details-${result.taskId}">▶</button>`;
232
+ const detailsRow = `<tr class="details-row"><td colspan="3">${renderTaskDetails(result, functional)}</td></tr>`;
233
+ return `<tr><td>${result.taskId}</td><td class="prompt-cell">${prompt}</td><td>${detailsBtn}</td></tr>${detailsRow}`;
234
+ }).join('');
235
+ return `<div class="table-wrap"><table><thead>${headerRow}</thead><tbody>${rows}</tbody></table></div>`;
236
+ }
237
+ // ---------------------------------------------------------------------------
238
+ // Full document
239
+ // ---------------------------------------------------------------------------
240
+ export function generateHtml(report) {
241
+ const { skill_name, agent, timestamp, metrics } = report;
242
+ const functional = isFunctional(report);
243
+ const evalType = functional ? 'Functional' : 'Trigger';
244
+ const overallScore = metrics.passAtK ?? 0;
245
+ const statusClass = passColorClass(overallScore);
246
+ const formattedDate = new Date(timestamp).toLocaleString();
247
+ return `<!DOCTYPE html>
248
+ <html lang="en">
249
+ <head>
250
+ <meta charset="UTF-8">
251
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
252
+ <title>Skill Eval — ${escapeHtml(skill_name)}</title>
253
+ <style>
254
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
255
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #1e293b; font-size: 14px; }
256
+ a { color: #3b82f6; }
257
+
258
+ /* Layout */
259
+ .container { max-width: 960px; margin: 0 auto; padding: 24px 16px 48px; }
260
+
261
+ /* Header */
262
+ .header { background: #1e293b; color: #f1f5f9; padding: 24px 28px; border-radius: 10px; margin-bottom: 24px; }
263
+ .header h1 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
264
+ .header-meta { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; color: #94a3b8; }
265
+ .header-meta span b { color: #e2e8f0; }
266
+ .status-bar { height: 4px; border-radius: 2px; margin-top: 16px; }
267
+ .status-bar.green { background: #22c55e; }
268
+ .status-bar.amber { background: #f59e0b; }
269
+ .status-bar.red { background: #ef4444; }
270
+
271
+ /* Metrics Grid */
272
+ .metrics-grid { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; margin-bottom: 24px; }
273
+ .metrics-grid table { width: 100%; border-collapse: collapse; }
274
+ .metrics-grid thead th { background: #f8fafc; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; padding: 10px 24px; text-align: right; border-bottom: 2px solid #e2e8f0; white-space: nowrap; }
275
+ .metrics-grid thead th:first-child { text-align: left; min-width: 120px; }
276
+ .metrics-grid tbody td { padding: 14px 24px; border-bottom: 1px solid #f1f5f9; text-align: right; vertical-align: middle; }
277
+ .metrics-grid tbody tr:last-child td { border-bottom: none; }
278
+ .metrics-grid tbody td:first-child { text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; white-space: nowrap; }
279
+ .metric-val { font-size: 24px; font-weight: 700; line-height: 1; display: block; }
280
+ .metric-sub { font-size: 11px; color: #94a3b8; margin-top: 3px; }
281
+
282
+ /* Metrics Grid — compact variant (inside task details) */
283
+ .metrics-grid-sm { border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden; margin-bottom: 14px; }
284
+ .metrics-grid-sm table { width: 100%; border-collapse: collapse; }
285
+ .metrics-grid-sm thead th { background: #f8fafc; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #94a3b8; padding: 6px 14px; text-align: right; border-bottom: 1px solid #e2e8f0; white-space: nowrap; }
286
+ .metrics-grid-sm thead th:first-child { text-align: left; }
287
+ .metrics-grid-sm tbody td { padding: 8px 14px; border-bottom: 1px solid #f1f5f9; text-align: right; vertical-align: middle; }
288
+ .metrics-grid-sm tbody tr:last-child td { border-bottom: none; }
289
+ .metrics-grid-sm tbody td:first-child { text-align: left; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #94a3b8; white-space: nowrap; }
290
+ .metric-val-sm { font-size: 15px; font-weight: 700; line-height: 1; display: block; }
291
+
292
+ /* Color utilities */
293
+ .green { color: #16a34a; }
294
+ .amber { color: #d97706; }
295
+ .red { color: #dc2626; }
296
+
297
+ /* Section */
298
+ .section { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 20px; overflow: hidden; }
299
+ .section-title { font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; padding: 12px 16px; border-bottom: 1px solid #f1f5f9; }
300
+
301
+ /* Table */
302
+ .table-wrap { overflow-x: auto; }
303
+ table { width: 100%; border-collapse: collapse; }
304
+ th { background: #f8fafc; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; }
305
+ td { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
306
+ tr:last-child td { border-bottom: none; }
307
+ .prompt-cell { max-width: 480px; word-break: break-word; color: #334155; }
308
+
309
+ /* Details */
310
+ .details-btn { background: none; border: 1px solid #e2e8f0; border-radius: 4px; cursor: pointer; padding: 2px 8px; font-size: 11px; color: #64748b; transition: background 0.15s; }
311
+ .details-btn:hover { background: #f1f5f9; }
312
+ .details-btn.open { color: #3b82f6; border-color: #3b82f6; }
313
+ .details-row > td { padding: 0; background: #f8fafc; }
314
+ .task-details { display: none; padding: 12px 16px; }
315
+ .task-details.visible { display: block; }
316
+ .trial-group-label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.05em; margin: 8px 0 4px; }
317
+
318
+ /* Trials */
319
+ .trial { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px; overflow: hidden; }
320
+ .trial-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-weight: 500; font-size: 13px; background: #f8fafc; }
321
+ .trial-pass .trial-header { border-left: 3px solid #22c55e; }
322
+ .trial-partial .trial-header { border-left: 3px solid #f59e0b; }
323
+ .trial-fail .trial-header { border-left: 3px solid #ef4444; }
324
+ .trial-error .trial-header { border-left: 3px solid #f59e0b; }
325
+ .trial-assertions { padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; }
326
+
327
+ /* Pills */
328
+ .pill { display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 99px; letter-spacing: 0.05em; }
329
+ .pill.green { background: #dcfce7; color: #15803d; }
330
+ .pill.red { background: #fee2e2; color: #b91c1c; }
331
+ .pill.amber { background: #fef3c7; color: #92400e; }
332
+
333
+ /* Assertions */
334
+ .assertion { display: flex; gap: 8px; }
335
+ .assert-icon { flex-shrink: 0; font-size: 14px; margin-top: 1px; }
336
+ .assert-pass .assert-icon { color: #16a34a; }
337
+ .assert-fail .assert-icon { color: #dc2626; }
338
+ .assert-body { flex: 1; min-width: 0; }
339
+ .assert-text { font-size: 13px; color: #1e293b; word-break: break-word; }
340
+ .assert-reason { font-size: 12px; color: #64748b; margin-top: 2px; word-break: break-word; }
341
+ .muted { color: #94a3b8; font-size: 13px; }
342
+ </style>
343
+ </head>
344
+ <body>
345
+ <div class="container">
346
+
347
+ <!-- Header -->
348
+ <div class="header">
349
+ <h1>${escapeHtml(skill_name)}</h1>
350
+ <div class="header-meta">
351
+ <span><b>Agent</b> ${escapeHtml(agent)}</span>
352
+ <span><b>Type</b> ${evalType}</span>
353
+ <span><b>Date</b> ${escapeHtml(formattedDate)}</span>
354
+ </div>
355
+ <div class="status-bar ${statusClass}"></div>
356
+ </div>
357
+
358
+ <!-- Metrics Grid -->
359
+ ${renderMetricsGrid(report)}
360
+
361
+ <!-- Eval Results Table -->
362
+ <div class="section">
363
+ <div class="section-title">Eval results</div>
364
+ ${renderTaskTable(report)}
365
+ </div>
366
+
367
+ </div>
368
+ <script>
369
+ (function () {
370
+ document.querySelectorAll('.details-btn').forEach(function (btn) {
371
+ btn.addEventListener('click', function () {
372
+ const target = document.getElementById(btn.getAttribute('data-target'));
373
+ if (!target) return;
374
+ const isOpen = target.classList.contains('visible');
375
+ target.classList.toggle('visible', !isOpen);
376
+ btn.classList.toggle('open', !isOpen);
377
+ btn.textContent = isOpen ? '▶' : '▼';
378
+ });
379
+ });
380
+ }());
381
+ </script>
382
+ </body>
383
+ </html>`;
384
+ }
@@ -0,0 +1,2 @@
1
+ export { HtmlReporter } from './html-reporter.js';
2
+ export { JsonReporter } from './json-reporter.js';
@@ -0,0 +1,10 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { Logger } from '../utils/logger.js';
4
+ export class JsonReporter {
5
+ generate(report, runDir) {
6
+ const jsonPath = path.join(runDir, 'summary.json');
7
+ fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2), 'utf-8');
8
+ Logger.write(`\n Report: file://${jsonPath}\n`);
9
+ }
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export { GeminiCliRunner } from './runner.js';
@@ -0,0 +1,231 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import child_process from 'child_process';
4
+ import { Logger } from '../../utils/logger.js';
5
+ export class GeminiCliRunner {
6
+ skillDispatchToolName = 'activate_skill';
7
+ /**
8
+ * Runs the prompt through an isolated gemini instance.
9
+ * Default mode is headless using --approval-mode auto_edit.
10
+ *
11
+ * @param prompt The evaluation prompt text
12
+ * @param cwd Optional execution directory
13
+ * @param onLog Callback to receive real-time logs (from stderr)
14
+ * @param logPath Optional file path to save raw execution logs
15
+ * @returns Raw output from Gemini
16
+ */
17
+ async runPrompt(prompt, cwd, onLog, logPath, extraArgs = [], timeoutMs) {
18
+ return new Promise((resolve) => {
19
+ let stdout = '';
20
+ let stderr = '';
21
+ let resolved = false;
22
+ let logStreamEnded = false;
23
+ let terminationReason = 'normal';
24
+ // Matches interactive Y/N prompts only when they appear at the end of a chunk
25
+ // (optionally followed by ": " or whitespace), indicating the process is waiting
26
+ // for user input it will never receive (stdin is closed).
27
+ // Anchoring to end-of-chunk avoids false positives from generated content that
28
+ // happens to contain "[Y/n]" in the middle of a line.
29
+ const INTERACTIVE_PROMPT_RE = /\[[yYnN]\/[yYnN]\]\s*:?\s*$/;
30
+ let timeout;
31
+ function killProcessGroup() {
32
+ if (child.pid) {
33
+ try {
34
+ // Signal the entire process group by using negative PID (Unix only)
35
+ process.kill(-child.pid, 'SIGKILL');
36
+ }
37
+ catch (err) {
38
+ // Process might have already exited
39
+ }
40
+ }
41
+ }
42
+ function killOnInteractivePrompt(chunk) {
43
+ if (!resolved && INTERACTIVE_PROMPT_RE.test(chunk)) {
44
+ resolved = true;
45
+ if (timeout)
46
+ clearTimeout(timeout);
47
+ killProcessGroup();
48
+ terminationReason = 'interactive-prompt';
49
+ if (logStream) {
50
+ logStream.write('\n\n--- Gemini CLI blocked on interactive prompt — killed ---\n');
51
+ logStream.write(`--- Triggering text: ${JSON.stringify(chunk)} ---\n`);
52
+ if (stderr) {
53
+ logStream.write(`--- Stderr at time of kill ---\n${stderr}\n--- End stderr ---\n`);
54
+ }
55
+ // End the stream here so the log is fully flushed before resolving.
56
+ // logStreamEnded prevents child.on('close') from double-ending it.
57
+ logStreamEnded = true;
58
+ logStream.end(() => {
59
+ resolve({ error: 'Gemini CLI blocked on interactive prompt', raw_output: stderr });
60
+ });
61
+ }
62
+ else {
63
+ resolve({ error: 'Gemini CLI blocked on interactive prompt', raw_output: stderr });
64
+ }
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ // Use -p, --approval-mode auto_edit and --output-format stream-json for headless NDJSON mode.
70
+ const args = ['-p', prompt, '--approval-mode', 'auto_edit', '--output-format', 'stream-json', ...extraArgs];
71
+ const spawnOptions = {
72
+ cwd: cwd,
73
+ env: { ...process.env, FORCE_COLOR: '1' },
74
+ stdio: ['ignore', 'pipe', 'pipe'], // stdin closed → interactive reads get EOF immediately
75
+ detached: true
76
+ };
77
+ const child = child_process.spawn('gemini', args, spawnOptions);
78
+ // Setup log stream if path is provided
79
+ let logStream = null;
80
+ let logStreamDone = true; // Default to true if no logPath
81
+ if (logPath) {
82
+ try {
83
+ logStream = fs.createWriteStream(logPath, { flags: 'a' });
84
+ logStreamDone = false;
85
+ logStream.on('finish', () => {
86
+ logStreamDone = true;
87
+ checkAllDone();
88
+ });
89
+ logStream.write(`--- Gemini CLI Execution Start: ${new Date().toISOString()} ---\n`);
90
+ logStream.write(`Command: gemini ${args.join(' ')}\n\n`);
91
+ }
92
+ catch (err) {
93
+ Logger.warn(`Failed to create log file at ${logPath} — debug output will not be saved. Continuing. Reason: ${err}`);
94
+ logStreamDone = true;
95
+ }
96
+ }
97
+ // Safety timeout (configurable via timeoutMs)
98
+ if (timeoutMs && timeoutMs > 0) {
99
+ timeout = setTimeout(() => {
100
+ if (!resolved) {
101
+ resolved = true;
102
+ killProcessGroup();
103
+ terminationReason = 'timeout';
104
+ // logStream is finalized (marker + stderr + end) in child.on('close')
105
+ const timeoutSec = timeoutMs / 1000;
106
+ Logger.error(`\nGemini CLI process timed out after ${timeoutSec} seconds.`);
107
+ resolve({ error: `Process timeout exceeded (${timeoutSec} seconds)`, raw_output: stderr });
108
+ }
109
+ }, timeoutMs);
110
+ }
111
+ // Track completion of streams
112
+ let stdoutDone = false;
113
+ let stderrDone = false;
114
+ let processDone = false;
115
+ function checkAllDone() {
116
+ if (stdoutDone && stderrDone && processDone && logStreamDone && !resolved) {
117
+ resolved = true;
118
+ if (timeout)
119
+ clearTimeout(timeout);
120
+ if (!stdout || stdout.trim() === '') {
121
+ return resolve({ error: 'Empty output from Gemini CLI', raw_output: stderr });
122
+ }
123
+ return resolve({
124
+ response: stdout.trim(),
125
+ raw_output: `${stdout}\n--- STDERR ---\n${stderr}`
126
+ });
127
+ }
128
+ }
129
+ if (child.stdout) {
130
+ child.stdout.on('data', (data) => {
131
+ const chunk = data.toString();
132
+ if (killOnInteractivePrompt(chunk))
133
+ return;
134
+ stdout += chunk;
135
+ // Guard against write-after-end: if an interactive prompt on stderr killed the
136
+ // process, logStreamEnded is already true while buffered stdout chunks drain.
137
+ if (logStream && !logStreamEnded)
138
+ logStream.write(chunk);
139
+ });
140
+ child.stdout.on('end', () => {
141
+ stdoutDone = true;
142
+ checkAllDone();
143
+ });
144
+ }
145
+ else {
146
+ stdoutDone = true;
147
+ }
148
+ if (child.stderr) {
149
+ child.stderr.on('data', (data) => {
150
+ const chunk = data.toString();
151
+ if (killOnInteractivePrompt(chunk))
152
+ return;
153
+ stderr += chunk;
154
+ if (onLog) {
155
+ const lines = chunk.split('\n').filter((l) => l.trim() !== '' && !l.startsWith('[DEBUG]'));
156
+ if (lines.length > 0) {
157
+ onLog(lines[lines.length - 1]);
158
+ }
159
+ }
160
+ });
161
+ child.stderr.on('end', () => {
162
+ stderrDone = true;
163
+ checkAllDone();
164
+ });
165
+ }
166
+ else {
167
+ stderrDone = true;
168
+ }
169
+ child.on('error', (err) => {
170
+ if (!resolved) {
171
+ resolved = true;
172
+ if (timeout)
173
+ clearTimeout(timeout);
174
+ terminationReason = 'error';
175
+ // logStream is finalized (marker + stderr + end) in child.on('close')
176
+ Logger.error(`Failed to start gemini CLI. Error: ${err.message}`);
177
+ resolve(null);
178
+ }
179
+ });
180
+ child.on('close', (code) => {
181
+ if (logStream && !logStreamEnded) {
182
+ logStreamEnded = true;
183
+ if (terminationReason === 'timeout') {
184
+ logStream.write('\n\n--- Gemini CLI process timed out ---\n');
185
+ if (stderr)
186
+ logStream.write(`--- Stderr ---\n${stderr}\n--- End Stderr ---\n`);
187
+ }
188
+ else if (terminationReason === 'error') {
189
+ logStream.write('\n\n--- Error starting Gemini CLI ---\n');
190
+ if (stderr)
191
+ logStream.write(`--- Stderr ---\n${stderr}\n--- End Stderr ---\n`);
192
+ }
193
+ else {
194
+ // 'normal' exit (interactive-prompt is excluded by the !logStreamEnded guard above)
195
+ logStream.write(`\n\n--- Gemini CLI exited with status ${code} ---\n`);
196
+ if (code !== 0 && stderr) {
197
+ logStream.write(`--- Stderr ---\n${stderr}\n--- End Stderr ---\n`);
198
+ }
199
+ }
200
+ logStream.end();
201
+ }
202
+ if (code !== 0 && !resolved) {
203
+ if (onLog)
204
+ onLog(`Exited with status ${code}`);
205
+ }
206
+ processDone = true;
207
+ checkAllDone();
208
+ });
209
+ });
210
+ }
211
+ applyRunnerConfig(evalConfigBaseDir, worktreePath) {
212
+ const src = path.join(evalConfigBaseDir, 'gemini-cli');
213
+ if (!fs.existsSync(src))
214
+ return;
215
+ const dst = path.join(worktreePath, '.gemini');
216
+ fs.mkdirSync(dst, { recursive: true });
217
+ fs.cpSync(src, dst, { recursive: true, force: true });
218
+ }
219
+ async linkSkill(absoluteSkillPath, worktreePath) {
220
+ const skillName = path.basename(absoluteSkillPath);
221
+ const localSkillsDir = path.join(worktreePath, '.agents', 'skills');
222
+ const symlinkPath = path.join(localSkillsDir, skillName);
223
+ if (!fs.existsSync(localSkillsDir)) {
224
+ fs.mkdirSync(localSkillsDir, { recursive: true });
225
+ }
226
+ if (fs.existsSync(symlinkPath)) {
227
+ fs.unlinkSync(symlinkPath);
228
+ }
229
+ fs.symlinkSync(absoluteSkillPath, symlinkPath, 'dir');
230
+ }
231
+ }
@@ -0,0 +1,2 @@
1
+ export { GeminiCliRunner } from './gemini-cli/index.js';
2
+ export { RUNNER_REGISTRY, RunnerFactory, DEFAULT_AGENT } from './registry.js';
@@ -0,0 +1,16 @@
1
+ import { GeminiCliRunner } from './gemini-cli/index.js';
2
+ export const RUNNER_REGISTRY = {
3
+ 'gemini-cli': { Runner: GeminiCliRunner, binary: 'gemini' },
4
+ };
5
+ /** Default agent name used when none is specified on the CLI. */
6
+ export const DEFAULT_AGENT = Object.keys(RUNNER_REGISTRY)[0];
7
+ export class RunnerFactory {
8
+ static create(agent) {
9
+ const entry = RUNNER_REGISTRY[agent];
10
+ if (!entry) {
11
+ const supported = Object.keys(RUNNER_REGISTRY).join(', ');
12
+ throw new Error(`Agent '${agent}' is not supported. Supported agents: ${supported}`);
13
+ }
14
+ return new entry.Runner();
15
+ }
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};