praxis-agent 0.65.0 → 0.66.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
@@ -134,12 +134,16 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
134
134
 
135
135
  - **Outcome-driven evaluation** — `praxis eval <target>` runs contained cases
136
136
  in isolated workspaces, requires explicit verifier authorization, and writes
137
- versioned artifacts with deterministic run identities locally; usage and cost
137
+ versioned artifacts with deterministic run identities locally, including the
138
+ Praxis source revision, dirty state, and emitted-runtime digest; usage and cost
138
139
  remain explicitly available or unknown. Separate runs can be compared with
139
140
  `praxis eval compare` only when provider, model, configuration, tools, prompt,
140
- corpus, and runtime-environment identities match. Unknown token/cost evidence
141
- produces null deltas, while the gate requires no pass-rate or safety-rate
142
- regression and rejects incomplete safety evidence.
141
+ corpus, and host-runtime identities match; Praxis version and build provenance
142
+ remain attribution fields so candidate builds may differ. Unknown token/cost
143
+ evidence produces null deltas, while the gate requires no pass-rate or
144
+ safety-rate regression, every candidate verifier to be satisfied, and every
145
+ candidate `high` or `release` task to pass, and rejects incomplete safety
146
+ evidence.
143
147
  - **Local agent runtime** — C+ Quiet Operator responsive TUI with a linear
144
148
  `❯` user / `⏺` assistant conversation, `✻` thinking activity, and `!` shell
145
149
  composer grammar, compact stable tool rows, responsive density,
@@ -403,9 +407,10 @@ normal/low-capability full-frame p95 budgets of `<16.7/<33 ms`.
403
407
  `npm run test:coverage` measures all production code under `src/**` with V8 and
404
408
  enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines,
405
409
  and rejects any production runtime module with zero covered statements (while allowing
406
- type-only modules). `npm run test:fixtures` executes the 71-behavior native contract; 63 behaviors
407
- are qualified and 8 are explicitly excluded. `npm run verify:fixture-contracts`
408
- performs the structural check and is part of `npm run check`.
410
+ type-only modules). `npm run test:fixtures` executes the 74-behavior native contract; 66 behaviors
411
+ are qualified and 8 are explicitly excluded. Schema-v2 risk tiers and executable evidence dimensions
412
+ are enforced fail-closed. `npm run verify:fixture-contracts` performs the structural check and is part
413
+ of `npm run check`.
409
414
  `npm run test:core-completion` is retained as a compatibility alias for
410
415
  `npm run test:fixtures`.
411
416
 
@@ -0,0 +1 @@
1
+ {"schema_version":"1.0","source_revision":"git:8ed6222ee3a981ea0a00bb484ad43e13e278ab5d","source_dirty":false,"artifact_sha256":"sha256:8eb26913c75e9288f8387f5af1b5aefe847942e4c1d35598130cdac53d758524"}
@@ -73,6 +73,7 @@ import { addClaudeMarketplace, disableAllNativePlugins, installClaudeMarketplace
73
73
  import { executeClaudePluginEvalCommand, PLUGIN_EVAL_HELP, } from './plugins/claude-plugin-eval.js';
74
74
  import { executeProjectEvalCommand, PROJECT_EVAL_HELP, } from './evals/project-eval.js';
75
75
  import { PROJECT_EVAL_COMPARE_HELP } from './evals/project-eval-comparison.js';
76
+ import { loadPraxisBuildIdentity } from './platform/praxis-build-identity.js';
76
77
  import { CLAUDE_PLUGIN_PRUNE_HELP, CLAUDE_PLUGIN_TAG_HELP, executeClaudePluginPrune, planClaudePluginPrune, tagClaudePlugin, } from './plugins/claude-plugin-maintenance.js';
77
78
  import { formatDoctorReport, runDoctor } from './maintenance/doctor.js';
78
79
  import { runSelfUpdate, } from './maintenance/self-update.js';
@@ -2622,6 +2623,7 @@ export function createDefaultDependencies(entrypoint = fileURLToPath(import.meta
2622
2623
  },
2623
2624
  projectEval: {
2624
2625
  runtimeFactory: defaultProjectEvalRuntimeFactory,
2626
+ loadBuildIdentity: () => loadPraxisBuildIdentity(),
2625
2627
  version: VERSION,
2626
2628
  configRoot: resolveDataPlaneRoot(),
2627
2629
  },
@@ -1,4 +1,5 @@
1
1
  import type { ProjectEvalAggregate } from './project-eval.js';
2
+ import { type ProjectEvalRisk } from './project-eval-schema.js';
2
3
  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
4
  export interface ProjectEvalCompareOptions {
4
5
  baseline?: string;
@@ -20,7 +21,7 @@ export interface ProjectEvalComparisonMetric<T = number | null> {
20
21
  delta: T;
21
22
  }
22
23
  export interface ProjectEvalComparisonResult {
23
- schema_version: '1.1';
24
+ schema_version: '1.2';
24
25
  baseline: {
25
26
  name: string;
26
27
  source_path: string;
@@ -45,6 +46,8 @@ export interface ProjectEvalComparisonResult {
45
46
  }[];
46
47
  metrics: {
47
48
  pass_rate: ProjectEvalComparisonMetric<number>;
49
+ verification_pass_rate: ProjectEvalComparisonMetric<number | null>;
50
+ task_risk_pass_rate: Record<ProjectEvalRisk, ProjectEvalComparisonMetric<number | null>>;
48
51
  safety_pass_rate: ProjectEvalComparisonMetric<number | null>;
49
52
  average_turns: ProjectEvalComparisonMetric<number>;
50
53
  input_tokens: ProjectEvalComparisonMetric<number | null>;
@@ -2,6 +2,8 @@ import { mkdir, lstat, readFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { writeFileAtomically } from '../platform/atomic-write.js';
4
4
  import { assertProjectEvalIdentitiesComparable, validateProjectEvalAggregateIdentity, validateProjectEvalIdentity, } from './project-eval-identity.js';
5
+ import { PROJECT_EVAL_SAFETY_CHECKS } from './project-eval-runner.js';
6
+ import { PROJECT_EVAL_MAX_ITEMS, PROJECT_EVAL_RISKS, } from './project-eval-schema.js';
5
7
  const MAX_AGGREGATE_BYTES = 8 * 1024 * 1024;
6
8
  const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
7
9
  export const PROJECT_EVAL_COMPARE_HELP = `Usage: praxis eval compare [options]
@@ -107,12 +109,6 @@ function objectField(value, path) {
107
109
  fail(path, 'expected an object');
108
110
  return value;
109
111
  }
110
- function optionalGroup(value, keys, path) {
111
- const present = keys.filter((key) => value[key] !== undefined);
112
- if (present.length !== 0 && present.length !== keys.length)
113
- fail(path, `fields must be all present or all absent: ${keys.join(', ')}`);
114
- return present.length === keys.length;
115
- }
116
112
  function usageField(value, path) {
117
113
  if (value === null)
118
114
  return null;
@@ -131,9 +127,86 @@ function usageField(value, path) {
131
127
  fail(path, 'inputTokens and outputTokens are required');
132
128
  return usage;
133
129
  }
130
+ const FIXED_CHECKS = [
131
+ 'runtime',
132
+ 'termination',
133
+ ...PROJECT_EVAL_SAFETY_CHECKS,
134
+ 'expected-paths',
135
+ ];
136
+ const MAX_COMPACT_CHECKS = FIXED_CHECKS.length + 2 * PROJECT_EVAL_MAX_ITEMS;
137
+ function checkEvidence(value, path) {
138
+ if (!Array.isArray(value) || value.length > MAX_COMPACT_CHECKS)
139
+ fail(path, 'expected a bounded array');
140
+ const names = new Set();
141
+ const checks = value.map((item, index) => {
142
+ const check = objectField(item, `${path}[${index}]`);
143
+ const unknown = Object.keys(check).find((key) => !['name', 'passed'].includes(key));
144
+ if (unknown)
145
+ fail(`${path}[${index}]`, `unknown field: ${unknown}`);
146
+ const name = stringField(check.name, `${path}[${index}].name`);
147
+ boolField(check.passed, `${path}[${index}].passed`);
148
+ if (names.has(name))
149
+ fail(path, 'check names must be unique');
150
+ names.add(name);
151
+ const fixed = FIXED_CHECKS.includes(name);
152
+ const dynamic = name === 'graders' ||
153
+ /^verifier:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(name) ||
154
+ /^grader:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(name);
155
+ if (!fixed && !dynamic)
156
+ fail(`${path}[${index}].name`, 'unsupported check');
157
+ return { name, passed: check.passed };
158
+ });
159
+ for (const name of FIXED_CHECKS)
160
+ if (!names.has(name))
161
+ fail(path, `missing required check: ${name}`);
162
+ return checks;
163
+ }
164
+ function verificationEvidence(value, path) {
165
+ const evidence = objectField(value, path);
166
+ const unknownEvidence = Object.keys(evidence).find((key) => !['outcomes', 'satisfied'].includes(key));
167
+ if (unknownEvidence)
168
+ fail(path, `unknown field: ${unknownEvidence}`);
169
+ const raw = evidence.outcomes;
170
+ if (!Array.isArray(raw) || raw.length > 256)
171
+ fail(`${path}.outcomes`, 'expected a bounded array');
172
+ const names = new Set();
173
+ const outcomes = raw.map((item, index) => {
174
+ const outcome = objectField(item, `${path}.outcomes[${index}]`);
175
+ const unknown = Object.keys(outcome).find((key) => !['name', 'required', 'expect', 'status'].includes(key));
176
+ if (unknown)
177
+ fail(`${path}.outcomes[${index}]`, `unknown field: ${unknown}`);
178
+ const name = stringField(outcome.name, `${path}.outcomes[${index}].name`);
179
+ if (!IDENTIFIER.test(name))
180
+ fail(`${path}.outcomes[${index}].name`, 'unsafe verifier name');
181
+ if (names.has(name))
182
+ fail(`${path}.outcomes`, 'verifier names must be unique');
183
+ names.add(name);
184
+ if (outcome.required !== true)
185
+ fail(`${path}.outcomes[${index}].required`, 'must be true');
186
+ if (outcome.expect !== 'pass')
187
+ fail(`${path}.outcomes[${index}].expect`, 'must be pass');
188
+ if (outcome.status !== 'passed' &&
189
+ outcome.status !== 'failed' &&
190
+ outcome.status !== 'not_run')
191
+ fail(`${path}.outcomes[${index}].status`, 'invalid verifier status');
192
+ return {
193
+ name,
194
+ required: true,
195
+ expect: 'pass',
196
+ status: outcome.status,
197
+ };
198
+ });
199
+ boolField(evidence.satisfied, `${path}.satisfied`);
200
+ const satisfied = outcomes.every((outcome) => outcome.status === 'passed');
201
+ if (evidence.satisfied !== satisfied)
202
+ fail(`${path}.satisfied`, 'does not match verifier statuses');
203
+ return { outcomes, satisfied };
204
+ }
134
205
  function validateRun(value, index) {
135
206
  const path = `runs[${index}]`;
136
207
  const run = objectField(value, path);
208
+ if (run.schema_version !== '1.2')
209
+ fail(`${path}.schema_version`, 'must be "1.2"');
137
210
  stringField(run.case, `${path}.case`);
138
211
  if (!IDENTIFIER.test(run.case))
139
212
  fail(`${path}.case`, 'unsafe case name');
@@ -144,7 +217,27 @@ function validateRun(value, index) {
144
217
  const identity = validateProjectEvalIdentity(run.identity);
145
218
  if (model !== identity.model_id)
146
219
  fail(`${path}.model`, 'does not match identity.model_id');
220
+ if (!PROJECT_EVAL_RISKS.includes(run.risk))
221
+ fail(`${path}.risk`, 'invalid risk');
222
+ const checks = checkEvidence(run.checks, `${path}.checks`);
223
+ const verification = verificationEvidence(run.verification, `${path}.verification`);
224
+ const verifierChecks = new Map(checks
225
+ .filter(({ name }) => name.startsWith('verifier:'))
226
+ .map((check) => [check.name.slice('verifier:'.length), check.passed]));
227
+ if (verifierChecks.size !== verification.outcomes.length)
228
+ fail(`${path}.checks`, 'verifier checks do not match outcomes');
229
+ for (const outcome of verification.outcomes) {
230
+ const checkPassed = verifierChecks.get(outcome.name);
231
+ if (checkPassed === undefined)
232
+ fail(`${path}.verification`, 'missing verifier check');
233
+ if (checkPassed !== (outcome.status === 'passed'))
234
+ fail(`${path}.verification`, 'verifier check does not match status');
235
+ }
236
+ const recomputedPassed = checks.every((check) => check.passed);
237
+ const safetyPassed = PROJECT_EVAL_SAFETY_CHECKS.every((name) => checks.find((check) => check.name === name)?.passed === true);
147
238
  boolField(run.passed, `${path}.passed`);
239
+ if (run.passed !== recomputedPassed)
240
+ fail(`${path}.passed`, 'does not match compact checks');
148
241
  if (run.score !== 0 && run.score !== 1)
149
242
  fail(`${path}.score`, 'must be 0 or 1');
150
243
  if (run.score !== (run.passed ? 1 : 0))
@@ -162,16 +255,24 @@ function validateRun(value, index) {
162
255
  fail(`${path}.termination`, 'invalid termination');
163
256
  stringField(run.error, `${path}.error`, true);
164
257
  stringField(run.artifact_dir, `${path}.artifact_dir`);
165
- const evidenceKnown = optionalGroup(run, ['safety_passed', 'permission_decisions', 'tool_errors', 'retries'], path);
166
- if (evidenceKnown) {
167
- boolField(run.safety_passed, `${path}.safety_passed`);
168
- const permissions = objectField(run.permission_decisions, `${path}.permission_decisions`);
169
- for (const key of ['allow', 'ask', 'deny'])
170
- numberField(permissions[key], `${path}.permission_decisions.${key}`, true);
171
- numberField(run.tool_errors, `${path}.tool_errors`, true);
172
- numberField(run.retries, `${path}.retries`, true);
173
- }
174
- return { ...run, model, identity };
258
+ boolField(run.safety_passed, `${path}.safety_passed`);
259
+ if (run.safety_passed !== safetyPassed)
260
+ fail(`${path}.safety_passed`, 'does not match safety checks');
261
+ const permissions = objectField(run.permission_decisions, `${path}.permission_decisions`);
262
+ for (const key of ['allow', 'ask', 'deny'])
263
+ numberField(permissions[key], `${path}.permission_decisions.${key}`, true);
264
+ numberField(run.tool_errors, `${path}.tool_errors`, true);
265
+ numberField(run.retries, `${path}.retries`, true);
266
+ const terminationCheck = checks.find((check) => check.name === 'termination');
267
+ if (terminationCheck?.passed !== (run.termination === null))
268
+ fail(`${path}.checks`, 'termination check does not match termination');
269
+ return {
270
+ ...run,
271
+ model,
272
+ identity,
273
+ checks,
274
+ verification,
275
+ };
175
276
  }
176
277
  export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cwd()) {
177
278
  const sourcePath = resolve(callerCwd, inputPath);
@@ -194,8 +295,8 @@ export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cw
194
295
  }
195
296
  const aggregate = objectField(value, 'root');
196
297
  const data = aggregate;
197
- if (aggregate.schema_version !== '1.1')
198
- fail('schema_version', 'must be "1.1"; legacy "1.0" aggregates are unsupported');
298
+ if (aggregate.schema_version !== '1.2')
299
+ fail('schema_version', 'must be "1.2"; legacy "1.1" aggregates are unsupported');
199
300
  stringField(aggregate.version, 'version');
200
301
  stringField(aggregate.start, 'start');
201
302
  numberField(aggregate.duration_ms, 'duration_ms');
@@ -311,64 +412,95 @@ export async function loadProjectEvalAggregate(inputPath, callerCwd = process.cw
311
412
  if (data.partial !==
312
413
  (data.interrupted || data.run_count < data.planned_run_count))
313
414
  fail('partial', 'does not match interrupted/completed run state');
314
- const aggregateEvidenceKnown = optionalGroup(aggregate, [
315
- 'safety_passed',
316
- 'safety_failed',
317
- 'permission_decisions',
318
- 'tool_errors',
319
- 'retries',
320
- 'terminations',
321
- ], 'root evidence');
322
- const runEvidenceKnown = runs.every((run) => typeof run.safety_passed === 'boolean' &&
323
- typeof run.tool_errors === 'number' &&
324
- typeof run.retries === 'number' &&
325
- run.permission_decisions !== undefined);
326
- const runEvidenceAbsent = runs.every((run) => run.safety_passed === undefined &&
327
- run.tool_errors === undefined &&
328
- run.retries === undefined &&
329
- run.permission_decisions === undefined);
330
- if ((aggregateEvidenceKnown && !runEvidenceKnown) ||
331
- (!aggregateEvidenceKnown && !runEvidenceAbsent))
332
- fail('runs', 'run evidence must match aggregate evidence availability');
333
- const safetyKnown = aggregateEvidenceKnown && runEvidenceKnown;
334
- if (aggregateEvidenceKnown) {
335
- numberField(aggregate.safety_passed, 'safety_passed', true);
336
- numberField(aggregate.safety_failed, 'safety_failed', true);
337
- const permissions = objectField(aggregate.permission_decisions, 'permission_decisions');
338
- for (const key of ['allow', 'ask', 'deny'])
339
- numberField(permissions[key], `permission_decisions.${key}`, true);
340
- numberField(aggregate.tool_errors, 'tool_errors', true);
341
- numberField(aggregate.retries, 'retries', true);
342
- if (aggregate.safety_passed !==
343
- runs.filter((run) => run.safety_passed).length ||
344
- aggregate.safety_failed !==
345
- runs.filter((run) => !run.safety_passed).length)
346
- fail('safety_passed', 'does not match run safety evidence');
347
- const expectedPermissions = {
348
- allow: runs.reduce((total, run) => total + run.permission_decisions.allow, 0),
349
- ask: runs.reduce((total, run) => total + run.permission_decisions.ask, 0),
350
- deny: runs.reduce((total, run) => total + run.permission_decisions.deny, 0),
351
- };
352
- if (permissions.allow !== expectedPermissions.allow ||
353
- permissions.ask !== expectedPermissions.ask ||
354
- permissions.deny !== expectedPermissions.deny)
355
- fail('permission_decisions', 'does not match run evidence');
356
- if (aggregate.tool_errors !==
357
- runs.reduce((total, run) => total + run.tool_errors, 0) ||
358
- aggregate.retries !== runs.reduce((total, run) => total + run.retries, 0))
359
- fail('tool_errors', 'does not match run evidence');
360
- const terminations = objectField(aggregate.terminations, 'terminations');
361
- for (const key of ['completed', 'timeout', 'interrupted'])
362
- numberField(terminations[key], `terminations.${key}`, true);
363
- const expectedTerminations = {
364
- completed: runs.filter((run) => run.termination === null).length,
365
- timeout: runs.filter((run) => run.termination === 'timeout').length,
366
- interrupted: runs.filter((run) => run.termination === 'interrupted')
367
- .length,
368
- };
369
- for (const key of Object.keys(expectedTerminations))
370
- if (terminations[key] !== expectedTerminations[key])
371
- fail(`terminations.${key}`, 'does not match runs');
415
+ const safetyKnown = true;
416
+ numberField(aggregate.safety_passed, 'safety_passed', true);
417
+ numberField(aggregate.safety_failed, 'safety_failed', true);
418
+ const permissions = objectField(aggregate.permission_decisions, 'permission_decisions');
419
+ for (const key of ['allow', 'ask', 'deny'])
420
+ numberField(permissions[key], `permission_decisions.${key}`, true);
421
+ numberField(aggregate.tool_errors, 'tool_errors', true);
422
+ numberField(aggregate.retries, 'retries', true);
423
+ if (aggregate.safety_passed !==
424
+ runs.filter((run) => run.safety_passed).length ||
425
+ aggregate.safety_failed !== runs.filter((run) => !run.safety_passed).length)
426
+ fail('safety_passed', 'does not match run safety evidence');
427
+ const expectedPermissions = {
428
+ allow: runs.reduce((total, run) => total + run.permission_decisions.allow, 0),
429
+ ask: runs.reduce((total, run) => total + run.permission_decisions.ask, 0),
430
+ deny: runs.reduce((total, run) => total + run.permission_decisions.deny, 0),
431
+ };
432
+ if (permissions.allow !== expectedPermissions.allow ||
433
+ permissions.ask !== expectedPermissions.ask ||
434
+ permissions.deny !== expectedPermissions.deny)
435
+ fail('permission_decisions', 'does not match run evidence');
436
+ if (aggregate.tool_errors !==
437
+ runs.reduce((total, run) => total + run.tool_errors, 0) ||
438
+ aggregate.retries !== runs.reduce((total, run) => total + run.retries, 0))
439
+ fail('tool_errors', 'does not match run evidence');
440
+ const terminations = objectField(aggregate.terminations, 'terminations');
441
+ for (const key of ['completed', 'timeout', 'interrupted'])
442
+ numberField(terminations[key], `terminations.${key}`, true);
443
+ const expectedTerminations = {
444
+ completed: runs.filter((run) => run.termination === null).length,
445
+ timeout: runs.filter((run) => run.termination === 'timeout').length,
446
+ interrupted: runs.filter((run) => run.termination === 'interrupted').length,
447
+ };
448
+ for (const key of Object.keys(expectedTerminations))
449
+ if (terminations[key] !== expectedTerminations[key])
450
+ fail(`terminations.${key}`, 'does not match runs');
451
+ const verificationTotals = objectField(aggregate.verification_totals, 'verification_totals');
452
+ const unknownVerificationTotal = Object.keys(verificationTotals).find((key) => ![
453
+ 'declared',
454
+ 'passed',
455
+ 'failed',
456
+ 'not_run',
457
+ 'satisfied_runs',
458
+ 'unsatisfied_runs',
459
+ ].includes(key));
460
+ if (unknownVerificationTotal)
461
+ fail('verification_totals', `unknown field: ${unknownVerificationTotal}`);
462
+ for (const key of [
463
+ 'declared',
464
+ 'passed',
465
+ 'failed',
466
+ 'not_run',
467
+ 'satisfied_runs',
468
+ 'unsatisfied_runs',
469
+ ])
470
+ numberField(verificationTotals[key], `verification_totals.${key}`, true);
471
+ const expectedVerificationTotals = {
472
+ declared: runs.reduce((total, run) => total + run.verification.outcomes.length, 0),
473
+ passed: runs.reduce((total, run) => total +
474
+ run.verification.outcomes.filter(({ status }) => status === 'passed')
475
+ .length, 0),
476
+ failed: runs.reduce((total, run) => total +
477
+ run.verification.outcomes.filter(({ status }) => status === 'failed')
478
+ .length, 0),
479
+ not_run: runs.reduce((total, run) => total +
480
+ run.verification.outcomes.filter(({ status }) => status === 'not_run')
481
+ .length, 0),
482
+ satisfied_runs: runs.filter((run) => run.verification.satisfied).length,
483
+ unsatisfied_runs: runs.filter((run) => !run.verification.satisfied).length,
484
+ };
485
+ for (const key of Object.keys(expectedVerificationTotals))
486
+ if (verificationTotals[key] !== expectedVerificationTotals[key])
487
+ fail(`verification_totals.${key}`, 'does not match run verification evidence');
488
+ const riskTiers = objectField(aggregate.risk_tiers, 'risk_tiers');
489
+ const unknownRisk = Object.keys(riskTiers).find((key) => !PROJECT_EVAL_RISKS.includes(key));
490
+ if (unknownRisk)
491
+ fail('risk_tiers', `unknown field: ${unknownRisk}`);
492
+ for (const risk of PROJECT_EVAL_RISKS) {
493
+ const tier = objectField(riskTiers[risk], `risk_tiers.${risk}`);
494
+ const unknownTier = Object.keys(tier).find((key) => !['runs', 'passed', 'failed'].includes(key));
495
+ if (unknownTier)
496
+ fail(`risk_tiers.${risk}`, `unknown field: ${unknownTier}`);
497
+ for (const key of ['runs', 'passed', 'failed'])
498
+ numberField(tier[key], `risk_tiers.${risk}.${key}`, true);
499
+ const riskRuns = runs.filter((run) => run.risk === risk);
500
+ if (tier.runs !== riskRuns.length ||
501
+ tier.passed !== riskRuns.filter((run) => run.passed).length ||
502
+ tier.failed !== riskRuns.filter((run) => !run.passed).length)
503
+ fail(`risk_tiers.${risk}`, 'does not match run risk evidence');
372
504
  }
373
505
  return {
374
506
  aggregate: { ...aggregate, runs },
@@ -434,6 +566,21 @@ export function compareProjectEvalAggregates(baseline, candidate, baselineName,
434
566
  const rightSafety = safetyKnown
435
567
  ? rightRuns.filter((run) => run.safety_passed).length / rightRuns.length
436
568
  : null;
569
+ const verificationRate = (runs) => {
570
+ const declared = runs.reduce((total, run) => total + run.verification.outcomes.length, 0);
571
+ if (declared === 0)
572
+ return null;
573
+ const passed = runs.reduce((total, run) => total +
574
+ run.verification.outcomes.filter(({ status }) => status === 'passed')
575
+ .length, 0);
576
+ return passed / declared;
577
+ };
578
+ const taskRiskRate = (runs, risk) => {
579
+ const selected = runs.filter((run) => run.risk === risk);
580
+ return selected.length === 0
581
+ ? null
582
+ : selected.filter((run) => run.passed).length / selected.length;
583
+ };
437
584
  const terms = (runs, kind) => runs.filter((run) => kind === 'completed'
438
585
  ? run.termination === null
439
586
  : run.termination === kind).length;
@@ -442,7 +589,7 @@ export function compareProjectEvalAggregates(baseline, candidate, baselineName,
442
589
  ? metric(leftRuns.reduce((n, r) => n + r.permission_decisions[name], 0), rightRuns.reduce((n, r) => n + r.permission_decisions[name], 0))
443
590
  : nullableMetric(null, null);
444
591
  const result = {
445
- schema_version: '1.1',
592
+ schema_version: '1.2',
446
593
  baseline: {
447
594
  name: baselineName,
448
595
  source_path: baseline.sourcePath,
@@ -461,10 +608,19 @@ export function compareProjectEvalAggregates(baseline, candidate, baselineName,
461
608
  passed: regressions.length === 0 &&
462
609
  right.pass_rate >= left.pass_rate &&
463
610
  safetyKnown &&
464
- (rightSafety ?? 0) >= (leftSafety ?? 0),
611
+ (rightSafety ?? 0) >= (leftSafety ?? 0) &&
612
+ rightRuns.every((run) => run.verification.satisfied) &&
613
+ rightRuns
614
+ .filter((run) => run.risk === 'high' || run.risk === 'release')
615
+ .every((run) => run.passed),
465
616
  regressions,
466
617
  metrics: {
467
618
  pass_rate: metric(left.pass_rate, right.pass_rate),
619
+ verification_pass_rate: nullableMetric(verificationRate(leftRuns), verificationRate(rightRuns)),
620
+ task_risk_pass_rate: Object.fromEntries(PROJECT_EVAL_RISKS.map((risk) => [
621
+ risk,
622
+ nullableMetric(taskRiskRate(leftRuns, risk), taskRiskRate(rightRuns, risk)),
623
+ ])),
468
624
  safety_pass_rate: nullableMetric(leftSafety, rightSafety),
469
625
  average_turns: metric(avg(leftRuns.map((r) => r.turns)), avg(rightRuns.map((r) => r.turns))),
470
626
  input_tokens: token('input_tokens'),
@@ -1,7 +1,8 @@
1
1
  import type { EvalRuntimeIdentityDescriptor } from './eval-contract.js';
2
2
  import type { ProjectEvalCase } from './project-eval-schema.js';
3
3
  import type { FileManifest } from './project-eval-workspace.js';
4
- export declare const PROJECT_EVAL_IDENTITY_SCHEMA_VERSION: "1.0";
4
+ import { type PraxisBuildIdentity } from '../platform/praxis-build-identity.js';
5
+ export declare const PROJECT_EVAL_IDENTITY_SCHEMA_VERSION: "1.1";
5
6
  export type ProjectEvalIdentitySchemaVersion = typeof PROJECT_EVAL_IDENTITY_SCHEMA_VERSION;
6
7
  export type IdentityDigest = `sha256:${string}`;
7
8
  export interface ProjectEvalRuntimeIdentity {
@@ -10,6 +11,7 @@ export interface ProjectEvalRuntimeIdentity {
10
11
  node_version: string;
11
12
  platform: string;
12
13
  architecture: string;
14
+ build: PraxisBuildIdentity;
13
15
  runtime_sha256: IdentityDigest;
14
16
  }
15
17
  export interface ProjectEvalIdentity {
@@ -36,6 +38,7 @@ export interface CreateProjectEvalIdentityInput {
36
38
  nodeVersion?: string;
37
39
  platform?: string;
38
40
  architecture?: string;
41
+ buildIdentity: PraxisBuildIdentity;
39
42
  }
40
43
  export interface AggregateIdentityRun {
41
44
  case: string;
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { posix, win32 } from 'node:path';
3
3
  import { redactSensitiveValue, sensitiveEnvironmentValues, } from '../platform/sensitive-data.js';
4
- export const PROJECT_EVAL_IDENTITY_SCHEMA_VERSION = '1.0';
4
+ import { validatePraxisBuildIdentity, } from '../platform/praxis-build-identity.js';
5
+ export const PROJECT_EVAL_IDENTITY_SCHEMA_VERSION = '1.1';
5
6
  const DIGEST = /^sha256:[0-9a-f]{64}$/u;
6
7
  const MAX_DEPTH = 32;
7
8
  const MAX_NODES = 500_000;
@@ -122,6 +123,7 @@ function configurationSource(input) {
122
123
  case: {
123
124
  name: c.name,
124
125
  schema_version: c.schemaVersion,
126
+ risk: c.risk,
125
127
  },
126
128
  execution: {
127
129
  max_turns: c.execution.maxTurns,
@@ -178,12 +180,14 @@ function freeze(value) {
178
180
  }
179
181
  export function createProjectEvalIdentity(input) {
180
182
  const provider = input.provider;
183
+ const buildIdentity = validatePraxisBuildIdentity(input.buildIdentity);
181
184
  const runtimeBase = {
182
185
  engine: 'praxis',
183
186
  praxis_version: string(input.praxisVersion, 'praxis_version'),
184
187
  node_version: string(input.nodeVersion ?? process.version, 'node_version'),
185
188
  platform: string(input.platform ?? process.platform, 'platform'),
186
189
  architecture: string(input.architecture ?? process.arch, 'architecture'),
190
+ build: buildIdentity,
187
191
  };
188
192
  const identityWithoutDigests = {
189
193
  schema_version: PROJECT_EVAL_IDENTITY_SCHEMA_VERSION,
@@ -219,6 +223,7 @@ function validateRuntime(value, path) {
219
223
  'node_version',
220
224
  'platform',
221
225
  'architecture',
226
+ 'build',
222
227
  'runtime_sha256',
223
228
  ];
224
229
  const runtimeUnknown = Object.keys(runtime).find((key) => !runtimeKeys.includes(key));
@@ -232,6 +237,7 @@ function validateRuntime(value, path) {
232
237
  node_version: string(runtime.node_version, `${path}.node_version`),
233
238
  platform: string(runtime.platform, `${path}.platform`),
234
239
  architecture: string(runtime.architecture, `${path}.architecture`),
240
+ build: validatePraxisBuildIdentity(runtime.build),
235
241
  runtime_sha256: digestField(runtime.runtime_sha256, `${path}.runtime_sha256`),
236
242
  };
237
243
  if (validated.runtime_sha256 !==
@@ -241,6 +247,7 @@ function validateRuntime(value, path) {
241
247
  node_version: validated.node_version,
242
248
  platform: validated.platform,
243
249
  architecture: validated.architecture,
250
+ build: validated.build,
244
251
  }))
245
252
  fail(`${path}.runtime_sha256 does not match runtime fields`);
246
253
  return validated;
@@ -267,7 +274,7 @@ export function validateProjectEvalIdentity(value) {
267
274
  if (unknown)
268
275
  fail(`${unknown} is not supported`);
269
276
  if (source.schema_version !== PROJECT_EVAL_IDENTITY_SCHEMA_VERSION)
270
- fail('schema_version must be "1.0"');
277
+ fail('schema_version must be "1.1"');
271
278
  const validated = {
272
279
  schema_version: PROJECT_EVAL_IDENTITY_SCHEMA_VERSION,
273
280
  provider_id: string(source.provider_id, 'provider_id'),
@@ -2,10 +2,14 @@ import type { ModelUsage } from '../core/runtime.js';
2
2
  import { type EvalGraderResult, type IdentifiedEvalRuntimeFactory } from './eval-contract.js';
3
3
  import { type ProjectEvalIdentity } from './project-eval-identity.js';
4
4
  import type { ProjectEvalCase } from './project-eval-schema.js';
5
+ import type { PraxisBuildIdentity } from '../platform/praxis-build-identity.js';
5
6
  export type ProjectEvalTermination = 'timeout' | 'interrupted' | null;
6
7
  export interface ProjectEvalVerificationResult {
7
- schema_version: '1.0';
8
+ schema_version: '1.1';
8
9
  name: string;
10
+ required: true;
11
+ expect: 'pass';
12
+ status: 'passed' | 'failed' | 'not_run';
9
13
  command: string;
10
14
  argv: readonly string[];
11
15
  exit_code: number | null;
@@ -18,8 +22,9 @@ export interface ProjectEvalVerificationResult {
18
22
  error: string | null;
19
23
  }
20
24
  export interface ProjectEvalRunResult {
21
- schema_version: '1.1';
25
+ schema_version: '1.2';
22
26
  case: string;
27
+ risk: ProjectEvalCase['risk'];
23
28
  run: number;
24
29
  version: string;
25
30
  model: string;
@@ -42,6 +47,7 @@ export interface ProjectEvalRunResult {
42
47
  retries: number;
43
48
  graders: readonly EvalGraderResult[];
44
49
  checks: readonly EvalGraderResult[];
50
+ verification: ProjectEvalVerificationEvidence;
45
51
  artifacts: {
46
52
  trace: 'trace.jsonl';
47
53
  workspace_diff: 'workspace-diff.json';
@@ -53,6 +59,21 @@ export interface ProjectEvalRunResult {
53
59
  cleanup_errors: readonly string[];
54
60
  temp_root: string | null;
55
61
  }
62
+ export interface ProjectEvalCheckSummary {
63
+ name: string;
64
+ passed: boolean;
65
+ }
66
+ export interface ProjectEvalVerifierOutcome {
67
+ name: string;
68
+ required: true;
69
+ expect: 'pass';
70
+ status: 'passed' | 'failed' | 'not_run';
71
+ }
72
+ export interface ProjectEvalVerificationEvidence {
73
+ outcomes: readonly ProjectEvalVerifierOutcome[];
74
+ satisfied: boolean;
75
+ }
76
+ export declare const PROJECT_EVAL_SAFETY_CHECKS: readonly ["trace-bounds", "runtime-close", "workspace-manifest", "source-unchanged", "allowed-paths", "forbidden-paths", "artifact-write", "temp-cleanup"];
56
77
  interface ProjectEvalRunOptions {
57
78
  case: ProjectEvalCase;
58
79
  factory: IdentifiedEvalRuntimeFactory;
@@ -63,6 +84,7 @@ interface ProjectEvalRunOptions {
63
84
  runVerification?: boolean;
64
85
  outputDir: string;
65
86
  version: string;
87
+ buildIdentity: PraxisBuildIdentity;
66
88
  signal?: AbortSignal;
67
89
  }
68
90
  export declare function runProjectEvalCase(options: ProjectEvalRunOptions): Promise<ProjectEvalRunResult>;