praxis-agent 0.46.5 → 0.48.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.
@@ -0,0 +1,513 @@
1
+ import { mkdir, lstat, readFile } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { writeFileAtomically } from '../platform/atomic-write.js';
4
+ const MAX_AGGREGATE_BYTES = 8 * 1024 * 1024;
5
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
6
+ export const PROJECT_EVAL_COMPARE_HELP = `Usage: praxis eval compare [options]
7
+
8
+ Compare two completed project evaluation aggregate artifacts.
9
+
10
+ Options:
11
+ --baseline <aggregate-result.json> Baseline aggregate artifact
12
+ --baseline-name <name> Name shown for the baseline
13
+ --candidate <aggregate-result.json> Candidate aggregate artifact
14
+ --candidate-name <name> Name shown for the candidate
15
+ --output-dir <dir> Write comparison-result.json here
16
+ --json Print exactly one comparison JSON value
17
+ -h, --help Display help`;
18
+ function valueAt(argv, index, option) {
19
+ const value = argv[index + 1];
20
+ if (!value || value.startsWith('-'))
21
+ throw new Error(`${option} requires a value`);
22
+ return value;
23
+ }
24
+ export function parseProjectEvalCompareOptions(argv) {
25
+ const options = { json: false };
26
+ for (let index = 0; index < argv.length; index += 1) {
27
+ const value = argv[index];
28
+ if (!value)
29
+ continue;
30
+ if (value === '-h' || value === '--help')
31
+ return { ...options, help: true };
32
+ if (value === '--json')
33
+ options.json = true;
34
+ else if (value === '--baseline' ||
35
+ value === '--baseline-name' ||
36
+ value === '--candidate' ||
37
+ value === '--candidate-name' ||
38
+ value === '--output-dir') {
39
+ const selected = valueAt(argv, index, value);
40
+ index += 1;
41
+ if (value === '--baseline')
42
+ options.baseline = selected;
43
+ else if (value === '--baseline-name')
44
+ options.baselineName = selected;
45
+ else if (value === '--candidate')
46
+ options.candidate = selected;
47
+ else if (value === '--candidate-name')
48
+ options.candidateName = selected;
49
+ else
50
+ options.outputDir = selected;
51
+ }
52
+ else if (value.startsWith('-')) {
53
+ throw new Error(`Unknown eval compare option: ${value}`);
54
+ }
55
+ else {
56
+ throw new Error('eval compare accepts no positional operands');
57
+ }
58
+ }
59
+ if (options.help)
60
+ return options;
61
+ for (const [option, selected] of [
62
+ ['--baseline', options.baseline],
63
+ ['--baseline-name', options.baselineName],
64
+ ['--candidate', options.candidate],
65
+ ['--candidate-name', options.candidateName],
66
+ ]) {
67
+ if (!selected)
68
+ throw new Error(`${option} is required`);
69
+ }
70
+ if (!IDENTIFIER.test(options.baselineName ?? ''))
71
+ throw new Error('--baseline-name is not a safe eval identifier');
72
+ if (!IDENTIFIER.test(options.candidateName ?? ''))
73
+ throw new Error('--candidate-name is not a safe eval identifier');
74
+ return options;
75
+ }
76
+ function fail(path, message) {
77
+ throw new Error(`Invalid aggregate ${path}: ${message}`);
78
+ }
79
+ function stringField(value, path, nullable = false) {
80
+ if (nullable && value === null)
81
+ return null;
82
+ if (typeof value !== 'string' || value.length === 0 || value.length > 4096)
83
+ fail(path, 'expected a bounded non-empty string');
84
+ return value;
85
+ }
86
+ function boolField(value, path) {
87
+ if (typeof value !== 'boolean')
88
+ fail(path, 'expected a boolean');
89
+ return value;
90
+ }
91
+ function numberField(value, path, integer = false) {
92
+ if (typeof value !== 'number' ||
93
+ !Number.isFinite(value) ||
94
+ value < 0 ||
95
+ (integer && !Number.isSafeInteger(value)))
96
+ fail(path, 'expected a finite nonnegative number');
97
+ return value;
98
+ }
99
+ function nullableNumber(value, path) {
100
+ if (value === null)
101
+ return null;
102
+ return numberField(value, path);
103
+ }
104
+ function objectField(value, path) {
105
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
106
+ fail(path, 'expected an object');
107
+ return value;
108
+ }
109
+ function optionalGroup(value, keys, path) {
110
+ const present = keys.filter((key) => value[key] !== undefined);
111
+ if (present.length !== 0 && present.length !== keys.length)
112
+ fail(path, `fields must be all present or all absent: ${keys.join(', ')}`);
113
+ return present.length === keys.length;
114
+ }
115
+ function usageField(value, path) {
116
+ if (value === null)
117
+ return null;
118
+ const usage = objectField(value, path);
119
+ for (const key of [
120
+ 'inputTokens',
121
+ 'outputTokens',
122
+ 'cacheReadInputTokens',
123
+ 'cacheCreationInputTokens',
124
+ 'webSearchRequests',
125
+ ]) {
126
+ if (usage[key] !== undefined)
127
+ numberField(usage[key], `${path}.${key}`, true);
128
+ }
129
+ if (usage.inputTokens === undefined || usage.outputTokens === undefined)
130
+ fail(path, 'inputTokens and outputTokens are required');
131
+ return usage;
132
+ }
133
+ function validateRun(value, index) {
134
+ const path = `runs[${index}]`;
135
+ const run = objectField(value, path);
136
+ stringField(run.case, `${path}.case`);
137
+ if (!IDENTIFIER.test(run.case))
138
+ fail(`${path}.case`, 'unsafe case name');
139
+ const runNumber = numberField(run.run, `${path}.run`, true);
140
+ if (runNumber < 1)
141
+ fail(`${path}.run`, 'must be positive');
142
+ stringField(run.model, `${path}.model`, true);
143
+ boolField(run.passed, `${path}.passed`);
144
+ if (run.score !== 0 && run.score !== 1)
145
+ fail(`${path}.score`, 'must be 0 or 1');
146
+ if (run.score !== (run.passed ? 1 : 0))
147
+ fail(`${path}.score`, 'does not match passed');
148
+ numberField(run.turns, `${path}.turns`, true);
149
+ usageField(run.usage, `${path}.usage`);
150
+ nullableNumber(run.cost_usd, `${path}.cost_usd`);
151
+ boolField(run.cost_known, `${path}.cost_known`);
152
+ if (run.cost_known !== (run.cost_usd !== null))
153
+ fail(`${path}.cost_known`, 'does not match cost_usd');
154
+ numberField(run.duration_ms, `${path}.duration_ms`);
155
+ if (run.termination !== null &&
156
+ run.termination !== 'timeout' &&
157
+ run.termination !== 'interrupted')
158
+ fail(`${path}.termination`, 'invalid termination');
159
+ stringField(run.error, `${path}.error`, true);
160
+ stringField(run.artifact_dir, `${path}.artifact_dir`);
161
+ const evidenceKnown = optionalGroup(run, ['safety_passed', 'permission_decisions', 'tool_errors', 'retries'], path);
162
+ if (evidenceKnown) {
163
+ boolField(run.safety_passed, `${path}.safety_passed`);
164
+ const permissions = objectField(run.permission_decisions, `${path}.permission_decisions`);
165
+ for (const key of ['allow', 'ask', 'deny'])
166
+ numberField(permissions[key], `${path}.permission_decisions.${key}`, true);
167
+ numberField(run.tool_errors, `${path}.tool_errors`, true);
168
+ numberField(run.retries, `${path}.retries`, true);
169
+ }
170
+ return run;
171
+ }
172
+ export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cwd()) {
173
+ const sourcePath = resolve(callerCwd, inputPath);
174
+ const info = await lstat(sourcePath);
175
+ if (info.isSymbolicLink())
176
+ throw new Error(`Aggregate path contains symlink: ${sourcePath}`);
177
+ if (!info.isFile())
178
+ throw new Error(`Aggregate path is not a regular file: ${sourcePath}`);
179
+ if (info.size > MAX_AGGREGATE_BYTES)
180
+ throw new Error(`Aggregate exceeds 8 MiB: ${sourcePath}`);
181
+ const content = await readFile(sourcePath);
182
+ if (content.byteLength > MAX_AGGREGATE_BYTES)
183
+ throw new Error(`Aggregate exceeds 8 MiB: ${sourcePath}`);
184
+ let value;
185
+ try {
186
+ value = JSON.parse(content.toString('utf8'));
187
+ }
188
+ catch {
189
+ throw new Error(`Invalid aggregate JSON: ${sourcePath}`);
190
+ }
191
+ const aggregate = objectField(value, 'root');
192
+ const data = aggregate;
193
+ if (aggregate.schema_version !== '1.0')
194
+ fail('schema_version', 'must be "1.0"');
195
+ stringField(aggregate.version, 'version');
196
+ stringField(aggregate.start, 'start');
197
+ numberField(aggregate.duration_ms, 'duration_ms');
198
+ stringField(aggregate.target, 'target');
199
+ stringField(aggregate.output_dir, 'output_dir');
200
+ stringField(aggregate.model, 'model', true);
201
+ for (const key of [
202
+ 'case_count',
203
+ 'planned_run_count',
204
+ 'completed_run_count',
205
+ 'run_count',
206
+ 'passed',
207
+ 'failed',
208
+ 'total_turns',
209
+ 'usage_known_runs',
210
+ 'usage_unknown_runs',
211
+ 'known_cost_runs',
212
+ 'unknown_cost_runs',
213
+ ])
214
+ numberField(aggregate[key], key, true);
215
+ numberField(aggregate.pass_rate, 'pass_rate');
216
+ if (data.pass_rate > 1)
217
+ fail('pass_rate', 'must be within [0,1]');
218
+ boolField(aggregate.partial, 'partial');
219
+ boolField(aggregate.interrupted, 'interrupted');
220
+ const usageTotals = objectField(aggregate.usage_totals, 'usage_totals');
221
+ for (const key of [
222
+ 'input_tokens',
223
+ 'output_tokens',
224
+ 'cache_read_input_tokens',
225
+ 'cache_creation_input_tokens',
226
+ 'web_search_requests',
227
+ ])
228
+ numberField(usageTotals[key], `usage_totals.${key}`, true);
229
+ nullableNumber(aggregate.known_cost_total_usd, 'known_cost_total_usd');
230
+ if (!Array.isArray(aggregate.runs) || aggregate.runs.length > 100000)
231
+ fail('runs', 'expected a bounded array');
232
+ const runs = aggregate.runs.map(validateRun);
233
+ if (data.run_count !== runs.length)
234
+ fail('run_count', 'does not match runs length');
235
+ if (data.completed_run_count > data.planned_run_count ||
236
+ data.completed_run_count !== data.run_count)
237
+ fail('completed_run_count', 'inconsistent with planned/run counts');
238
+ if (data.case_count > data.planned_run_count)
239
+ fail('case_count', 'cannot exceed planned_run_count');
240
+ if (data.passed + data.failed !== data.run_count)
241
+ fail('passed', 'passed + failed must equal run_count');
242
+ if (data.passed !== runs.filter((run) => run.passed).length)
243
+ fail('passed', 'does not match run outcomes');
244
+ if (data.pass_rate !== (data.run_count === 0 ? 0 : data.passed / data.run_count))
245
+ fail('pass_rate', 'inconsistent with passed/run_count');
246
+ if (data.usage_known_runs + data.usage_unknown_runs !== data.run_count)
247
+ fail('usage_known_runs', 'usage totals are inconsistent');
248
+ if (data.known_cost_runs + data.unknown_cost_runs !== data.run_count)
249
+ fail('known_cost_runs', 'cost totals are inconsistent');
250
+ if (data.known_cost_total_usd === null && data.known_cost_runs !== 0)
251
+ fail('known_cost_total_usd', 'must be present when cost is known');
252
+ if (data.known_cost_total_usd !== null && data.known_cost_runs === 0)
253
+ fail('known_cost_total_usd', 'must be null when no cost is known');
254
+ if (data.usage_known_runs !== runs.filter((run) => run.usage !== null).length)
255
+ fail('usage_known_runs', 'does not match run usage');
256
+ if (data.usage_unknown_runs !== runs.filter((run) => run.usage === null).length)
257
+ fail('usage_unknown_runs', 'does not match run usage');
258
+ if (data.known_cost_runs !== runs.filter((run) => run.cost_known).length)
259
+ fail('known_cost_runs', 'does not match run costs');
260
+ if (data.unknown_cost_runs !== runs.filter((run) => !run.cost_known).length)
261
+ fail('unknown_cost_runs', 'does not match run costs');
262
+ if (data.known_cost_total_usd !== null) {
263
+ const cost = runs.reduce((total, run) => total + (run.cost_usd ?? 0), 0);
264
+ if (Math.abs(cost - data.known_cost_total_usd) > 1e-9)
265
+ fail('known_cost_total_usd', 'does not match run costs');
266
+ }
267
+ if (data.total_turns !== runs.reduce((total, run) => total + run.turns, 0))
268
+ fail('total_turns', 'does not match run turns');
269
+ const expectedUsage = {
270
+ input_tokens: runs.reduce((total, run) => total + (run.usage?.inputTokens ?? 0), 0),
271
+ output_tokens: runs.reduce((total, run) => total + (run.usage?.outputTokens ?? 0), 0),
272
+ cache_read_input_tokens: runs.reduce((total, run) => total + (run.usage?.cacheReadInputTokens ?? 0), 0),
273
+ cache_creation_input_tokens: runs.reduce((total, run) => total + (run.usage?.cacheCreationInputTokens ?? 0), 0),
274
+ web_search_requests: runs.reduce((total, run) => total + (run.usage?.webSearchRequests ?? 0), 0),
275
+ };
276
+ for (const key of Object.keys(expectedUsage))
277
+ if (usageTotals[key] !== expectedUsage[key])
278
+ fail(`usage_totals.${key}`, 'does not match run usage');
279
+ const keys = new Set();
280
+ const caseNames = new Set();
281
+ for (const run of runs) {
282
+ const key = `${run.case}\u0000${run.run}`;
283
+ if (keys.has(key))
284
+ fail('runs', 'duplicate (case,run) key');
285
+ keys.add(key);
286
+ caseNames.add(run.case);
287
+ }
288
+ if (!data.partial && caseNames.size !== data.case_count)
289
+ fail('case_count', 'does not match completed run cases');
290
+ if (data.interrupted && !data.partial)
291
+ fail('interrupted', 'interrupted aggregate must be partial');
292
+ if (!data.partial && data.run_count !== data.planned_run_count)
293
+ fail('partial', 'complete aggregate is missing planned runs');
294
+ if (data.partial !==
295
+ (data.interrupted || data.run_count < data.planned_run_count))
296
+ fail('partial', 'does not match interrupted/completed run state');
297
+ const aggregateEvidenceKnown = optionalGroup(aggregate, [
298
+ 'safety_passed',
299
+ 'safety_failed',
300
+ 'permission_decisions',
301
+ 'tool_errors',
302
+ 'retries',
303
+ 'terminations',
304
+ ], 'root evidence');
305
+ const runEvidenceKnown = runs.every((run) => typeof run.safety_passed === 'boolean' &&
306
+ typeof run.tool_errors === 'number' &&
307
+ typeof run.retries === 'number' &&
308
+ run.permission_decisions !== undefined);
309
+ const runEvidenceAbsent = runs.every((run) => run.safety_passed === undefined &&
310
+ run.tool_errors === undefined &&
311
+ run.retries === undefined &&
312
+ run.permission_decisions === undefined);
313
+ if ((aggregateEvidenceKnown && !runEvidenceKnown) ||
314
+ (!aggregateEvidenceKnown && !runEvidenceAbsent))
315
+ fail('runs', 'run evidence must match aggregate evidence availability');
316
+ const safetyKnown = aggregateEvidenceKnown && runEvidenceKnown;
317
+ if (aggregateEvidenceKnown) {
318
+ numberField(aggregate.safety_passed, 'safety_passed', true);
319
+ numberField(aggregate.safety_failed, 'safety_failed', true);
320
+ const permissions = objectField(aggregate.permission_decisions, 'permission_decisions');
321
+ for (const key of ['allow', 'ask', 'deny'])
322
+ numberField(permissions[key], `permission_decisions.${key}`, true);
323
+ numberField(aggregate.tool_errors, 'tool_errors', true);
324
+ numberField(aggregate.retries, 'retries', true);
325
+ if (aggregate.safety_passed !==
326
+ runs.filter((run) => run.safety_passed).length ||
327
+ aggregate.safety_failed !==
328
+ runs.filter((run) => !run.safety_passed).length)
329
+ fail('safety_passed', 'does not match run safety evidence');
330
+ const expectedPermissions = {
331
+ allow: runs.reduce((total, run) => total + run.permission_decisions.allow, 0),
332
+ ask: runs.reduce((total, run) => total + run.permission_decisions.ask, 0),
333
+ deny: runs.reduce((total, run) => total + run.permission_decisions.deny, 0),
334
+ };
335
+ if (permissions.allow !== expectedPermissions.allow ||
336
+ permissions.ask !== expectedPermissions.ask ||
337
+ permissions.deny !== expectedPermissions.deny)
338
+ fail('permission_decisions', 'does not match run evidence');
339
+ if (aggregate.tool_errors !==
340
+ runs.reduce((total, run) => total + run.tool_errors, 0) ||
341
+ aggregate.retries !== runs.reduce((total, run) => total + run.retries, 0))
342
+ fail('tool_errors', 'does not match run evidence');
343
+ const terminations = objectField(aggregate.terminations, 'terminations');
344
+ for (const key of ['completed', 'timeout', 'interrupted'])
345
+ numberField(terminations[key], `terminations.${key}`, true);
346
+ const expectedTerminations = {
347
+ completed: runs.filter((run) => run.termination === null).length,
348
+ timeout: runs.filter((run) => run.termination === 'timeout').length,
349
+ interrupted: runs.filter((run) => run.termination === 'interrupted')
350
+ .length,
351
+ };
352
+ for (const key of Object.keys(expectedTerminations))
353
+ if (terminations[key] !== expectedTerminations[key])
354
+ fail(`terminations.${key}`, 'does not match runs');
355
+ }
356
+ return {
357
+ aggregate: { ...aggregate, runs },
358
+ sourcePath,
359
+ safetyKnown,
360
+ };
361
+ }
362
+ function metric(baseline, candidate) {
363
+ return { baseline, candidate, delta: candidate - baseline };
364
+ }
365
+ function nullableMetric(baseline, candidate) {
366
+ return baseline === null || candidate === null
367
+ ? { baseline, candidate, delta: null }
368
+ : { baseline, candidate, delta: candidate - baseline };
369
+ }
370
+ function avg(values) {
371
+ return values.length === 0
372
+ ? 0
373
+ : values.reduce((a, b) => a + b, 0) / values.length;
374
+ }
375
+ function compareRunIdentity(left, right) {
376
+ const leftKey = `${left.case}\u0000${left.run}`;
377
+ const rightKey = `${right.case}\u0000${right.run}`;
378
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
379
+ }
380
+ export function compareProjectEvalAggregates(baseline, candidate, baselineName, candidateName) {
381
+ const left = baseline.aggregate;
382
+ const right = candidate.aggregate;
383
+ if (left.partial || left.interrupted || right.partial || right.interrupted)
384
+ throw new Error('Comparison requires complete, uninterrupted aggregates');
385
+ if (left.completed_run_count !== left.planned_run_count ||
386
+ right.completed_run_count !== right.planned_run_count)
387
+ throw new Error('Comparison requires every planned run to be completed');
388
+ const leftRuns = [...left.runs].sort(compareRunIdentity);
389
+ const rightRuns = [...right.runs].sort(compareRunIdentity);
390
+ if (leftRuns.length !== rightRuns.length ||
391
+ leftRuns.some((run, index) => run.case !== rightRuns[index]?.case ||
392
+ run.run !== rightRuns[index]?.run))
393
+ throw new Error('Aggregates have different comparable run sets');
394
+ if (leftRuns.length === 0)
395
+ throw new Error('Comparison requires at least one completed run');
396
+ const regressions = rightRuns.flatMap((run, index) => leftRuns[index]?.passed && !run.passed
397
+ ? [
398
+ {
399
+ case: run.case,
400
+ run: run.run,
401
+ baseline_passed: true,
402
+ candidate_passed: false,
403
+ },
404
+ ]
405
+ : []);
406
+ const safetyKnown = baseline.safetyKnown && candidate.safetyKnown;
407
+ const leftSafety = safetyKnown
408
+ ? leftRuns.filter((run) => run.safety_passed).length / leftRuns.length
409
+ : null;
410
+ const rightSafety = safetyKnown
411
+ ? rightRuns.filter((run) => run.safety_passed).length / rightRuns.length
412
+ : null;
413
+ const terms = (runs, kind) => runs.filter((run) => kind === 'completed'
414
+ ? run.termination === null
415
+ : run.termination === kind).length;
416
+ const token = (field) => nullableMetric(left.usage_unknown_runs === 0 ? left.usage_totals[field] : null, right.usage_unknown_runs === 0 ? right.usage_totals[field] : null);
417
+ const permission = (name) => safetyKnown
418
+ ? metric(leftRuns.reduce((n, r) => n + r.permission_decisions[name], 0), rightRuns.reduce((n, r) => n + r.permission_decisions[name], 0))
419
+ : nullableMetric(null, null);
420
+ const result = {
421
+ schema_version: '1.0',
422
+ baseline: {
423
+ name: baselineName,
424
+ source_path: baseline.sourcePath,
425
+ version: left.version,
426
+ model: left.model,
427
+ },
428
+ candidate: {
429
+ name: candidateName,
430
+ source_path: candidate.sourcePath,
431
+ version: right.version,
432
+ model: right.model,
433
+ },
434
+ comparable_run_count: leftRuns.length,
435
+ passed: right.pass_rate >= left.pass_rate &&
436
+ safetyKnown &&
437
+ (rightSafety ?? 0) >= (leftSafety ?? 0),
438
+ regressions,
439
+ metrics: {
440
+ pass_rate: metric(left.pass_rate, right.pass_rate),
441
+ safety_pass_rate: nullableMetric(leftSafety, rightSafety),
442
+ average_turns: metric(avg(leftRuns.map((r) => r.turns)), avg(rightRuns.map((r) => r.turns))),
443
+ input_tokens: token('input_tokens'),
444
+ output_tokens: token('output_tokens'),
445
+ cache_read_input_tokens: token('cache_read_input_tokens'),
446
+ cache_creation_input_tokens: token('cache_creation_input_tokens'),
447
+ known_cost_total_usd: nullableMetric(left.known_cost_total_usd, right.known_cost_total_usd),
448
+ average_duration_ms: metric(avg(leftRuns.map((r) => r.duration_ms)), avg(rightRuns.map((r) => r.duration_ms))),
449
+ permission_decisions: {
450
+ allow: permission('allow'),
451
+ ask: permission('ask'),
452
+ deny: permission('deny'),
453
+ },
454
+ tool_errors: safetyKnown
455
+ ? metric(leftRuns.reduce((n, r) => n + r.tool_errors, 0), rightRuns.reduce((n, r) => n + r.tool_errors, 0))
456
+ : nullableMetric(null, null),
457
+ retries: safetyKnown
458
+ ? metric(leftRuns.reduce((n, r) => n + r.retries, 0), rightRuns.reduce((n, r) => n + r.retries, 0))
459
+ : nullableMetric(null, null),
460
+ terminations: {
461
+ completed: metric(terms(leftRuns, 'completed'), terms(rightRuns, 'completed')),
462
+ timeout: metric(terms(leftRuns, 'timeout'), terms(rightRuns, 'timeout')),
463
+ interrupted: metric(terms(leftRuns, 'interrupted'), terms(rightRuns, 'interrupted')),
464
+ },
465
+ },
466
+ };
467
+ return result;
468
+ }
469
+ export async function executeProjectEvalCompareCommand(argv, io, callerCwd = process.cwd(), signal) {
470
+ const options = parseProjectEvalCompareOptions(argv);
471
+ if (options.help) {
472
+ io.stdout(`${PROJECT_EVAL_COMPARE_HELP}\n`);
473
+ return 0;
474
+ }
475
+ if (signal?.aborted)
476
+ return 130;
477
+ const baseline = await loadProjectEvalAggregate(options.baseline ?? '', callerCwd);
478
+ if (signal?.aborted)
479
+ return 130;
480
+ const candidate = await loadProjectEvalAggregate(options.candidate ?? '', callerCwd);
481
+ if (signal?.aborted)
482
+ return 130;
483
+ const result = compareProjectEvalAggregates(baseline, candidate, options.baselineName ?? '', options.candidateName ?? '');
484
+ if (signal?.aborted)
485
+ return 130;
486
+ const outputDir = resolve(callerCwd, options.outputDir ?? dirname(candidate.sourcePath));
487
+ if (signal?.aborted)
488
+ return 130;
489
+ await mkdir(outputDir, { recursive: true });
490
+ if (signal?.aborted)
491
+ return 130;
492
+ const outputPath = resolve(outputDir, 'comparison-result.json');
493
+ try {
494
+ const existing = await lstat(outputPath);
495
+ if (existing.isSymbolicLink())
496
+ throw new Error(`Comparison output path contains symlink: ${outputPath}`);
497
+ if (!existing.isFile())
498
+ throw new Error(`Comparison output path is not a regular file: ${outputPath}`);
499
+ }
500
+ catch (error) {
501
+ if (error.code !== 'ENOENT')
502
+ throw error;
503
+ }
504
+ if (signal?.aborted)
505
+ return 130;
506
+ await writeFileAtomically(outputPath, `${JSON.stringify(result, null, 2)}\n`);
507
+ if (options.json)
508
+ io.stdout(`${JSON.stringify(result)}\n`);
509
+ else
510
+ io.stdout(`${result.passed ? 'passed' : 'failed'}: ${result.metrics.pass_rate.delta >= 0 ? '+' : ''}${result.metrics.pass_rate.delta.toFixed(3)} pass rate, ${result.metrics.safety_pass_rate.delta === null ? 'unknown' : `${result.metrics.safety_pass_rate.delta >= 0 ? '+' : ''}${result.metrics.safety_pass_rate.delta.toFixed(3)} safety rate`}\n`);
511
+ return result.passed ? 0 : 1;
512
+ }
513
+ //# sourceMappingURL=project-eval-comparison.js.map
@@ -0,0 +1,67 @@
1
+ import type { ModelUsage } from '../core/runtime.js';
2
+ import { type EvalGraderResult, type EvalRuntimeFactory } from './eval-contract.js';
3
+ import type { ProjectEvalCase } from './project-eval-schema.js';
4
+ export type ProjectEvalTermination = 'timeout' | 'interrupted' | null;
5
+ export interface ProjectEvalVerificationResult {
6
+ schema_version: '1.0';
7
+ name: string;
8
+ command: string;
9
+ argv: readonly string[];
10
+ exit_code: number | null;
11
+ timed_out: boolean;
12
+ stdout: string;
13
+ stderr: string;
14
+ truncated: boolean;
15
+ duration_ms: number;
16
+ passed: boolean;
17
+ error: string | null;
18
+ }
19
+ export interface ProjectEvalRunResult {
20
+ schema_version: '1.0';
21
+ case: string;
22
+ run: number;
23
+ version: string;
24
+ model: string | null;
25
+ passed: boolean;
26
+ score: 0 | 1;
27
+ turns: number;
28
+ usage: ModelUsage | null;
29
+ cost_usd: number | null;
30
+ cost_known: boolean;
31
+ duration_ms: number;
32
+ termination: ProjectEvalTermination;
33
+ safety_passed: boolean;
34
+ permission_decisions: {
35
+ allow: number;
36
+ ask: number;
37
+ deny: number;
38
+ };
39
+ tool_errors: number;
40
+ retries: number;
41
+ graders: readonly EvalGraderResult[];
42
+ checks: readonly EvalGraderResult[];
43
+ artifacts: {
44
+ trace: 'trace.jsonl';
45
+ workspace_diff: 'workspace-diff.json';
46
+ verification: 'verification.json';
47
+ result: 'result.json';
48
+ };
49
+ error: string | null;
50
+ cleanup_errors: readonly string[];
51
+ temp_root: string | null;
52
+ }
53
+ interface ProjectEvalRunOptions {
54
+ case: ProjectEvalCase;
55
+ factory: EvalRuntimeFactory;
56
+ run: number;
57
+ allowTools?: readonly string[];
58
+ model?: string;
59
+ keepTemp?: boolean;
60
+ runVerification?: boolean;
61
+ outputDir: string;
62
+ version: string;
63
+ signal?: AbortSignal;
64
+ }
65
+ export declare function runProjectEvalCase(options: ProjectEvalRunOptions): Promise<ProjectEvalRunResult>;
66
+ export {};
67
+ //# sourceMappingURL=project-eval-runner.d.ts.map