praxis-agent 0.47.0 → 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.
package/README.md CHANGED
@@ -103,7 +103,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
103
103
  - **Outcome-driven evaluation** — `praxis eval <target>` runs contained cases
104
104
  in isolated workspaces, requires explicit verifier authorization, and writes
105
105
  versioned artifacts locally; usage and cost remain explicitly available or
106
- unknown.
106
+ unknown. Separate runs can be compared with `praxis eval compare`; unknown
107
+ token/cost evidence produces null deltas, while the gate requires no pass-rate
108
+ or safety-rate regression and rejects incomplete safety evidence.
107
109
  - **Local agent runtime** — C+ Quiet Operator responsive TUI with a linear
108
110
  `❯` user / `⏺` assistant conversation, `✻` thinking activity, and `!` shell
109
111
  composer grammar, compact stable tool rows, responsive density,
@@ -299,7 +301,7 @@ normal/low-capability full-frame p95 budgets of `<16.7/<33 ms`.
299
301
  `npm run test:coverage` measures all production code under `src/**` with V8 and
300
302
  enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines,
301
303
  and rejects any production runtime module with zero covered statements (while allowing
302
- type-only modules). `npm run test:fixtures` executes the 67-behavior native contract; 59 behaviors
304
+ type-only modules). `npm run test:fixtures` executes the 68-behavior native contract; 60 behaviors
303
305
  are qualified and 8 are explicitly excluded. `npm run verify:fixture-contracts`
304
306
  performs the structural check and is part of `npm run check`.
305
307
  `npm run test:core-completion` is retained as a compatibility alias for
@@ -71,6 +71,7 @@ import { describeClaudePlugin, initClaudePlugin, installClaudePlugin, loadClaude
71
71
  import { addClaudeMarketplace, disableAllNativePlugins, installClaudeMarketplacePlugin, listClaudeMarketplaceAvailablePlugins, listNativePluginRecords, readClaudeKnownMarketplaces, removeClaudeMarketplace, setNativePluginEnabled, saveClaudePluginConfig, uninstallNativePlugin, updateClaudeMarketplace, updateNativePlugin, validateClaudeMarketplace, } from './plugins/claude-plugin-marketplace.js';
72
72
  import { executeClaudePluginEvalCommand, PLUGIN_EVAL_HELP, } from './plugins/claude-plugin-eval.js';
73
73
  import { executeProjectEvalCommand, PROJECT_EVAL_HELP, } from './evals/project-eval.js';
74
+ import { PROJECT_EVAL_COMPARE_HELP } from './evals/project-eval-comparison.js';
74
75
  import { CLAUDE_PLUGIN_PRUNE_HELP, CLAUDE_PLUGIN_TAG_HELP, executeClaudePluginPrune, planClaudePluginPrune, tagClaudePlugin, } from './plugins/claude-plugin-maintenance.js';
75
76
  import { formatDoctorReport, runDoctor } from './maintenance/doctor.js';
76
77
  import { runSelfUpdate, } from './maintenance/self-update.js';
@@ -4983,6 +4984,13 @@ async function execute(argv, io, dependencies, signal) {
4983
4984
  }
4984
4985
  : { args: [...argv] };
4985
4986
  if (special.args[0] === 'eval') {
4987
+ if (special.args[1] === 'compare' &&
4988
+ special.args
4989
+ .slice(2)
4990
+ .some((value) => value === '-h' || value === '--help')) {
4991
+ io.stdout(PROJECT_EVAL_COMPARE_HELP);
4992
+ return 0;
4993
+ }
4986
4994
  if (special.args
4987
4995
  .slice(1)
4988
4996
  .some((value) => value === '-h' || value === '--help')) {
@@ -0,0 +1,76 @@
1
+ import type { ProjectEvalAggregate } from './project-eval.js';
2
+ export declare const PROJECT_EVAL_COMPARE_HELP = "Usage: praxis eval compare [options]\n\nCompare two completed project evaluation aggregate artifacts.\n\nOptions:\n --baseline <aggregate-result.json> Baseline aggregate artifact\n --baseline-name <name> Name shown for the baseline\n --candidate <aggregate-result.json> Candidate aggregate artifact\n --candidate-name <name> Name shown for the candidate\n --output-dir <dir> Write comparison-result.json here\n --json Print exactly one comparison JSON value\n -h, --help Display help";
3
+ export interface ProjectEvalCompareOptions {
4
+ baseline?: string;
5
+ baselineName?: string;
6
+ candidate?: string;
7
+ candidateName?: string;
8
+ outputDir?: string;
9
+ json: boolean;
10
+ help?: true;
11
+ }
12
+ interface LoadedAggregate {
13
+ aggregate: ProjectEvalAggregate;
14
+ sourcePath: string;
15
+ safetyKnown: boolean;
16
+ }
17
+ export interface ProjectEvalComparisonMetric<T = number | null> {
18
+ baseline: T;
19
+ candidate: T;
20
+ delta: T;
21
+ }
22
+ export interface ProjectEvalComparisonResult {
23
+ schema_version: '1.0';
24
+ baseline: {
25
+ name: string;
26
+ source_path: string;
27
+ version: string;
28
+ model: string | null;
29
+ };
30
+ candidate: {
31
+ name: string;
32
+ source_path: string;
33
+ version: string;
34
+ model: string | null;
35
+ };
36
+ comparable_run_count: number;
37
+ passed: boolean;
38
+ regressions: readonly {
39
+ case: string;
40
+ run: number;
41
+ baseline_passed: boolean;
42
+ candidate_passed: boolean;
43
+ }[];
44
+ metrics: {
45
+ pass_rate: ProjectEvalComparisonMetric<number>;
46
+ safety_pass_rate: ProjectEvalComparisonMetric<number | null>;
47
+ average_turns: ProjectEvalComparisonMetric<number>;
48
+ input_tokens: ProjectEvalComparisonMetric<number | null>;
49
+ output_tokens: ProjectEvalComparisonMetric<number | null>;
50
+ cache_read_input_tokens: ProjectEvalComparisonMetric<number | null>;
51
+ cache_creation_input_tokens: ProjectEvalComparisonMetric<number | null>;
52
+ known_cost_total_usd: ProjectEvalComparisonMetric<number | null>;
53
+ average_duration_ms: ProjectEvalComparisonMetric<number>;
54
+ permission_decisions: {
55
+ allow: ProjectEvalComparisonMetric<number | null>;
56
+ ask: ProjectEvalComparisonMetric<number | null>;
57
+ deny: ProjectEvalComparisonMetric<number | null>;
58
+ };
59
+ tool_errors: ProjectEvalComparisonMetric<number | null>;
60
+ retries: ProjectEvalComparisonMetric<number | null>;
61
+ terminations: {
62
+ completed: ProjectEvalComparisonMetric<number>;
63
+ timeout: ProjectEvalComparisonMetric<number>;
64
+ interrupted: ProjectEvalComparisonMetric<number>;
65
+ };
66
+ };
67
+ }
68
+ export declare function parseProjectEvalCompareOptions(argv: readonly string[]): ProjectEvalCompareOptions;
69
+ export declare function loadProjectEvalAggregate(inputPath: string, callerCwd?: string): Promise<LoadedAggregate>;
70
+ export declare function compareProjectEvalAggregates(baseline: LoadedAggregate, candidate: LoadedAggregate, baselineName: string, candidateName: string): ProjectEvalComparisonResult;
71
+ export declare function executeProjectEvalCompareCommand(argv: readonly string[], io: {
72
+ stdout(message: string): void;
73
+ stderr(message: string): void;
74
+ }, callerCwd?: string, signal?: AbortSignal): Promise<number>;
75
+ export {};
76
+ //# sourceMappingURL=project-eval-comparison.d.ts.map
@@ -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
@@ -30,6 +30,14 @@ export interface ProjectEvalRunResult {
30
30
  cost_known: boolean;
31
31
  duration_ms: number;
32
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;
33
41
  graders: readonly EvalGraderResult[];
34
42
  checks: readonly EvalGraderResult[];
35
43
  artifacts: {
@@ -6,6 +6,38 @@ import { normalizeEvalTraceEvent, resolveEvalAllowedTools, } from './eval-contra
6
6
  import { cleanupProjectEvalWorkspace, createProjectEvalWorkspace, diffProjectEvalWorkspace, } from './project-eval-workspace.js';
7
7
  import { minimatch } from 'minimatch';
8
8
  import { join } from 'node:path';
9
+ const SAFETY_CHECKS = [
10
+ 'trace-bounds',
11
+ 'runtime-close',
12
+ 'workspace-manifest',
13
+ 'source-unchanged',
14
+ 'allowed-paths',
15
+ 'forbidden-paths',
16
+ 'artifact-write',
17
+ 'temp-cleanup',
18
+ ];
19
+ function runEvidence(trace, checks) {
20
+ const permission_decisions = { allow: 0, ask: 0, deny: 0 };
21
+ let tool_errors = 0;
22
+ let retries = 0;
23
+ for (const event of trace) {
24
+ if (event.type === 'permission-decision') {
25
+ const behavior = event.behavior;
26
+ if (behavior === 'allow' || behavior === 'ask' || behavior === 'deny')
27
+ permission_decisions[behavior] += 1;
28
+ }
29
+ else if (event.type === 'tool-result' && event.isError === true)
30
+ tool_errors += 1;
31
+ else if (event.type === 'api-retry')
32
+ retries += 1;
33
+ }
34
+ return {
35
+ safety_passed: SAFETY_CHECKS.every((name) => checks.find((check) => check.name === name)?.passed === true),
36
+ permission_decisions,
37
+ tool_errors,
38
+ retries,
39
+ };
40
+ }
9
41
  function check(name, passed, explanation, evidence) {
10
42
  return {
11
43
  name,
@@ -321,6 +353,7 @@ export async function runProjectEvalCase(options) {
321
353
  ? 'Temporary workspace removed'
322
354
  : `Temporary workspace cleanup failed: ${tempCleanupError}`));
323
355
  const passed = checks.every((item) => item.passed);
356
+ const evidence = runEvidence(trace, checks);
324
357
  const primaryError = runtimeError ??
325
358
  graderError ??
326
359
  verifications.find((verification) => verification.error)?.error ??
@@ -341,6 +374,7 @@ export async function runProjectEvalCase(options) {
341
374
  cost_known: costUsd !== null,
342
375
  duration_ms: Date.now() - started,
343
376
  termination,
377
+ ...evidence,
344
378
  graders,
345
379
  checks,
346
380
  artifacts: {
@@ -1,7 +1,7 @@
1
1
  import type { ModelUsage } from '../core/runtime.js';
2
2
  import type { EvalRuntimeFactory } from './eval-contract.js';
3
3
  import { type ProjectEvalRunResult } from './project-eval-runner.js';
4
- export declare const PROJECT_EVAL_HELP = "Usage: praxis eval [options] <target>\n\nRun deterministic project outcome evaluations in isolated workspaces.\n\nOptions:\n --case <glob> Filter case names\n --tag <tag[,tag]> Filter tags; repeatable\n --runs <1..50> Override run count\n --model <model> Override model\n --allow-tools <rules> Grant gated tools; comma-separated and repeatable\n --run-verification Enable verifier subprocesses\n --output-dir <dir> Write artifacts to this directory\n --keep-temp Preserve temporary workspaces\n --json Print exactly one aggregate JSON value\n --verbose Print run progress to stderr\n -h, --help Display help";
4
+ export declare const PROJECT_EVAL_HELP = "Usage: praxis eval [options] <target>\n\nRun deterministic project outcome evaluations in isolated workspaces.\n\nOptions:\n --case <glob> Filter case names\n --tag <tag[,tag]> Filter tags; repeatable\n --runs <1..50> Override run count\n --model <model> Override model\n --allow-tools <rules> Grant gated tools; comma-separated and repeatable\n --run-verification Enable verifier subprocesses\n --output-dir <dir> Write artifacts to this directory\n --keep-temp Preserve temporary workspaces\n --json Print exactly one aggregate JSON value\n --verbose Print run progress to stderr\n -h, --help Display help\n\nUse praxis eval compare --help to compare two completed aggregate artifacts.";
5
5
  export interface ProjectEvalDependencies {
6
6
  runtimeFactory: EvalRuntimeFactory;
7
7
  version?: string;
@@ -40,6 +40,14 @@ export interface ProjectEvalRunSummary {
40
40
  cost_known: boolean;
41
41
  duration_ms: number;
42
42
  termination: ProjectEvalRunResult['termination'];
43
+ safety_passed: boolean;
44
+ permission_decisions: {
45
+ allow: number;
46
+ ask: number;
47
+ deny: number;
48
+ };
49
+ tool_errors: number;
50
+ retries: number;
43
51
  error: string | null;
44
52
  artifact_dir: string;
45
53
  }
@@ -65,6 +73,20 @@ export interface ProjectEvalAggregate {
65
73
  known_cost_total_usd: number | null;
66
74
  known_cost_runs: number;
67
75
  unknown_cost_runs: number;
76
+ safety_passed: number;
77
+ safety_failed: number;
78
+ permission_decisions: {
79
+ allow: number;
80
+ ask: number;
81
+ deny: number;
82
+ };
83
+ tool_errors: number;
84
+ retries: number;
85
+ terminations: {
86
+ completed: number;
87
+ timeout: number;
88
+ interrupted: number;
89
+ };
68
90
  partial: boolean;
69
91
  interrupted: boolean;
70
92
  runs: readonly ProjectEvalRunSummary[];
@@ -1,6 +1,7 @@
1
1
  import { join, relative, resolve } from 'node:path';
2
2
  import { writeFileAtomically } from '../platform/atomic-write.js';
3
3
  import { runProjectEvalCase, } from './project-eval-runner.js';
4
+ import { executeProjectEvalCompareCommand } from './project-eval-comparison.js';
4
5
  import { discoverProjectEvalCases } from './project-eval-schema.js';
5
6
  export const PROJECT_EVAL_HELP = `Usage: praxis eval [options] <target>
6
7
 
@@ -17,7 +18,9 @@ Options:
17
18
  --keep-temp Preserve temporary workspaces
18
19
  --json Print exactly one aggregate JSON value
19
20
  --verbose Print run progress to stderr
20
- -h, --help Display help`;
21
+ -h, --help Display help
22
+
23
+ Use praxis eval compare --help to compare two completed aggregate artifacts.`;
21
24
  function takeValue(argv, index, option) {
22
25
  const value = argv[index + 1];
23
26
  if (!value || value.startsWith('-'))
@@ -125,11 +128,17 @@ function runSummary(result, outputDirectory) {
125
128
  cost_known: result.cost_known,
126
129
  duration_ms: result.duration_ms,
127
130
  termination: result.termination,
131
+ safety_passed: result.safety_passed,
132
+ permission_decisions: result.permission_decisions,
133
+ tool_errors: result.tool_errors,
134
+ retries: result.retries,
128
135
  error: result.error,
129
136
  artifact_dir: relative(outputDirectory, join(outputDirectory, result.case, `run-${result.run}`)).replaceAll('\\', '/'),
130
137
  };
131
138
  }
132
139
  export async function executeProjectEvalCommand(argv, io, dependencies, callerCwd = process.cwd(), signal) {
140
+ if (argv[0] === 'compare')
141
+ return executeProjectEvalCompareCommand(argv.slice(1), io, callerCwd, signal);
133
142
  const options = parseProjectEvalOptions(argv);
134
143
  if (options.help) {
135
144
  io.stdout(PROJECT_EVAL_HELP);
@@ -200,6 +209,21 @@ export async function executeProjectEvalCommand(argv, io, dependencies, callerCw
200
209
  : knownCostResults.reduce((total, result) => total + (result.cost_usd ?? 0), 0),
201
210
  known_cost_runs: knownCostResults.length,
202
211
  unknown_cost_runs: results.length - knownCostResults.length,
212
+ safety_passed: results.filter((result) => result.safety_passed).length,
213
+ safety_failed: results.filter((result) => !result.safety_passed).length,
214
+ permission_decisions: {
215
+ allow: results.reduce((total, result) => total + result.permission_decisions.allow, 0),
216
+ ask: results.reduce((total, result) => total + result.permission_decisions.ask, 0),
217
+ deny: results.reduce((total, result) => total + result.permission_decisions.deny, 0),
218
+ },
219
+ tool_errors: results.reduce((total, result) => total + result.tool_errors, 0),
220
+ retries: results.reduce((total, result) => total + result.retries, 0),
221
+ terminations: {
222
+ completed: results.filter((result) => result.termination === null).length,
223
+ timeout: results.filter((result) => result.termination === 'timeout')
224
+ .length,
225
+ interrupted: results.filter((result) => result.termination === 'interrupted').length,
226
+ },
203
227
  partial: interrupted || results.length < plannedRunCount,
204
228
  interrupted,
205
229
  runs: results.map((result) => runSummary(result, outputDirectory)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",
@@ -61,7 +61,8 @@
61
61
  "verify:release-automation": "node scripts/verify-release-automation.mjs",
62
62
  "verify:ci-coverage": "node scripts/verify-ci-coverage.mjs",
63
63
  "verify:fixture-contracts": "node scripts/verify-fixture-contracts.mjs",
64
- "test:fixtures": "node scripts/run-fixture-contracts.mjs"
64
+ "test:fixtures": "node scripts/run-fixture-contracts.mjs",
65
+ "test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts"
65
66
  },
66
67
  "engines": {
67
68
  "node": ">=24"