@wix/pathgrade 1.0.38 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/claude/sdk-message-projector.js +4 -5
  3. package/dist/agents/claude/tool-permission-bridge.js +2 -1
  4. package/dist/agents/codex-app-server/item-projection.js +2 -2
  5. package/dist/agents/codex-app-server/mcp-approval-correlator.js +3 -2
  6. package/dist/agents/opencode.js +4 -5
  7. package/dist/commands/report.js +5 -25
  8. package/dist/internal/direct-mcp-v2/acp-author-projector.js +20 -7
  9. package/dist/reporters/cli.js +20 -1
  10. package/dist/reporters/github-comment.js +18 -7
  11. package/dist/reporters/loader.d.ts +4 -0
  12. package/dist/reporters/loader.js +26 -9
  13. package/dist/reporting/core.js +17 -7
  14. package/dist/reporting/report-parser.js +57 -2
  15. package/dist/reporting/types.d.ts +2 -1
  16. package/dist/runners/orchestrator.js +3 -4
  17. package/dist/runners/repeated-invocation.js +2 -5
  18. package/dist/runners/report-projection.js +1 -0
  19. package/dist/sdk/agent.js +10 -1
  20. package/dist/sdk/evaluate.js +58 -3
  21. package/dist/sdk/judge-prompt-builder.js +11 -7
  22. package/dist/sdk/mcp-event-input.d.ts +1 -0
  23. package/dist/sdk/mcp-event-input.js +3 -0
  24. package/dist/sdk/mcp-evidence.js +16 -3
  25. package/dist/sdk/mcp-safety.js +2 -2
  26. package/dist/sdk/scripted-mcp-events.js +3 -2
  27. package/dist/sdk/tool-event-log.js +18 -3
  28. package/dist/sdk/tool-event-secrets.d.ts +4 -0
  29. package/dist/sdk/tool-event-secrets.js +33 -2
  30. package/dist/sdk/types.d.ts +2 -0
  31. package/dist/tool-event-results.d.ts +3 -0
  32. package/dist/tool-event-results.js +153 -23
  33. package/dist/types.d.ts +9 -5
  34. package/package.json +2 -2
package/README.md CHANGED
@@ -461,7 +461,7 @@ export default {
461
461
 
462
462
  Pathgrade reads `pathgrade.config.*` for CLI and affected-selection behavior. `runner.adapter` and `--adapter=<name|path>` select the runner; `--adapter` wins over config. `attempts` defaults to `1` and must be a positive integer. Built-in adapters `vitest`, `jest`, and `node-test` support repeated attempts; third-party invocation adapters must advertise `supportsRepeatedAttempts: true` and produce the normalized child snapshot contract.
463
463
 
464
- New reports use schema version 2. They preserve every attempt with `case_id`, `attempt_id`, and `attempt_index`; include the display-safe `runner_outcome` that determined binary success; publish `mean_reward` for partial scores; and publish finite-sample pass@k only for complete binary attempts. Runner assertions, runner diagnostics, and native runner references remain in the normalized run model and are not copied into public report artifacts; evaluation diagnostics retain their existing report behavior. A partial reward or incomplete attempt makes pass@k explicitly unavailable. Version-1 reports remain readable, but Pathgrade no longer computes pass@k or pass^k by pooling heterogeneous cases.
464
+ New reports use schema version 2. They preserve every attempt with `case_id`, `attempt_id`, and `attempt_index`; include the display-safe `runner_outcome` that determined binary success; publish `mean_reward` for partial scores; and publish finite-sample pass@k only for complete binary attempts. `runner_status` and `threshold_status` expose the two gates independently, and canonical `status` passes only when runner assertions pass and any configured threshold passes. Runner assertions, runner diagnostics, and native runner references remain in the normalized run model and are not copied into public report artifacts; evaluation diagnostics retain their existing report behavior. A partial reward or incomplete attempt makes pass@k explicitly unavailable. Version-1 and older version-2 reports remain readable with missing gate fields derived during loading, but Pathgrade no longer computes pass@k or pass^k by pooling heterogeneous cases.
465
465
 
466
466
  Third-party runner adapters are supported through `@wix/pathgrade/adapter-kit`. Adapter names resolve as follows:
467
467
 
@@ -22,10 +22,9 @@ const SDK_ERROR_SUBTYPES = [
22
22
  'error_max_structured_output_retries',
23
23
  ];
24
24
  import { TOOL_NAME_MAP, buildSummary, enrichSkillEvents } from '../../tool-events.js';
25
- import { sanitizePersistenceValue } from '../../tool-event-results.js';
25
+ import { sanitizePersistenceValue, sanitizeUntrustedPersistenceValue } from '../../tool-event-results.js';
26
26
  import { attachTurnResultSensitiveValues } from '../../sdk/turn-result-secrets.js';
27
- import { attachToolEventSensitiveValues } from '../../sdk/tool-event-secrets.js';
28
- import { attachOriginalMcpInput } from '../../sdk/mcp-event-input.js';
27
+ import { attachLiveMcpInput, attachToolEventSensitiveValues } from '../../sdk/tool-event-secrets.js';
29
28
  import { parseClaudeSdkMcpToolName } from './mcp-tool-name.js';
30
29
  import { applyObservedToolResult, extractObservedToolResults, } from './tool-results.js';
31
30
  export function projectSdkMessages(input) {
@@ -145,7 +144,7 @@ export function projectSdkMessages(input) {
145
144
  ...(input.deniedMcpEvents?.all() ?? []),
146
145
  ]);
147
146
  const finalToolEvents = prependSlashCommandSkillEvent(enriched, input.firstMessage, initSkills).map((event) => attachToolEventSensitiveValues(event, input.sensitiveValues ?? []));
148
- const traceOutput = sanitizePersistenceValue(input.messages, input.sensitiveValues)
147
+ const traceOutput = sanitizeUntrustedPersistenceValue(input.messages, input.sensitiveValues)
149
148
  .map((message) => JSON.stringify(message))
150
149
  .join('\n');
151
150
  const result = {
@@ -216,7 +215,7 @@ function buildToolEvent(block, turnNumber, answerStore, mcpServerNames, deniedMc
216
215
  tool: mcpTool.tool,
217
216
  status: 'incomplete',
218
217
  };
219
- return attachOriginalMcpInput({
218
+ return attachLiveMcpInput({
220
219
  action: 'mcp_tool_call',
221
220
  provider: 'claude',
222
221
  providerToolName: normalizedProviderToolName,
@@ -3,6 +3,7 @@ import { createAskUserBridge, } from './ask-user-bridge.js';
3
3
  import { parseClaudeSdkMcpToolName } from './mcp-tool-name.js';
4
4
  import { canonicalizeJson } from '../../core/canonical-json.js';
5
5
  import { buildScriptedMcpApprovalEvent, buildScriptedMcpDeniedCallEvent, } from '../../sdk/scripted-mcp-events.js';
6
+ import { attachLiveMcpInput } from '../../sdk/tool-event-secrets.js';
6
7
  export function createClaudeToolPermissionBridge(deps) {
7
8
  const askUserBridge = createAskUserBridge({
8
9
  askBus: deps.askBus,
@@ -149,5 +150,5 @@ function recordDeniedMcpEvent(opts) {
149
150
  input: opts.input,
150
151
  })).slice(0, 200),
151
152
  };
152
- opts.store.record(opts.toolUseId, event);
153
+ opts.store.record(opts.toolUseId, attachLiveMcpInput(event, opts.input));
153
154
  }
@@ -1,6 +1,6 @@
1
1
  import { buildSummary, extractSkillNameFromPath, inferCodexExecAction, } from '../../tool-events.js';
2
2
  import { sanitizeToolEventResult } from '../../tool-event-results.js';
3
- import { attachOriginalMcpInput } from '../../sdk/mcp-event-input.js';
3
+ import { attachLiveMcpInput } from '../../sdk/tool-event-secrets.js';
4
4
  export function projectItemIntoTurn(item, turn, sensitiveValues, timing = {}) {
5
5
  if (item.type === 'agentMessage') {
6
6
  const message = item;
@@ -76,7 +76,7 @@ function projectMcpCall(call, turn, sensitiveValues, timing) {
76
76
  const status = call.status === 'completed' && error === undefined
77
77
  ? 'completed' : call.status === 'failed' || error !== undefined ? 'error' : 'incomplete';
78
78
  const resultContent = call.result === undefined ? undefined : JSON.stringify(call.result);
79
- turn.nonAskToolEvents.push(attachOriginalMcpInput({
79
+ turn.nonAskToolEvents.push(attachLiveMcpInput({
80
80
  action: 'mcp_tool_call', provider: 'codex', providerToolName, toolUseId: call.id,
81
81
  turnNumber: turn.turnNumber, status, ...projectToolTiming(timing, finiteNumber(call.durationMs)),
82
82
  arguments: { ...args, server: call.server, tool: call.tool, status: call.status ?? 'unknown' },
@@ -1,6 +1,7 @@
1
1
  import { decideMcpToolCall, redactMcpSecrets, } from '../../sdk/mcp-safety.js';
2
2
  import { canonicalizeJson } from '../../core/canonical-json.js';
3
3
  import { buildScriptedMcpApprovalEvent, buildScriptedMcpDeniedCallEvent, } from '../../sdk/scripted-mcp-events.js';
4
+ import { attachLiveMcpInput } from '../../sdk/tool-event-secrets.js';
4
5
  function isRecord(value) {
5
6
  return !!value && typeof value === 'object' && !Array.isArray(value);
6
7
  }
@@ -64,7 +65,7 @@ export function queuePolicyDeniedMcpToolCall(turn, request, decision, rawParams)
64
65
  export function buildPolicyDeniedMcpToolEvent(turnNumber, pending, terminal) {
65
66
  const args = redactMcpSecrets(pending.request.arguments);
66
67
  const providerToolName = `${pending.request.serverName}.${pending.request.toolName}`;
67
- return {
68
+ return attachLiveMcpInput({
68
69
  action: 'mcp_tool_call', provider: 'codex', providerToolName,
69
70
  ...(terminal ? { toolUseId: terminal.id } : {}),
70
71
  turnNumber, status: 'error',
@@ -79,7 +80,7 @@ export function buildPolicyDeniedMcpToolEvent(turnNumber, pending, terminal) {
79
80
  },
80
81
  summary: `MCP tool ${providerToolName} policy_denied`, confidence: 'high',
81
82
  rawSnippet: JSON.stringify(redactMcpSecrets(terminal ? { approval: pending.rawParams, terminal } : pending.rawParams)),
82
- };
83
+ }, pending.request.arguments);
83
84
  }
84
85
  export function consumeMatchingMcpDenial(turn, serverName, toolName, args) {
85
86
  const argsKey = canonicalizeJson(args);
@@ -3,12 +3,11 @@ import * as path from 'node:path';
3
3
  import fs from 'fs-extra';
4
4
  import { BaseAgent, getRuntimeEnv, getWorkspacePath, } from '../types.js';
5
5
  import { buildSummary, enrichSkillEvents } from '../tool-events.js';
6
- import { collectSensitiveEnvValues, sanitizePersistenceValue, sanitizeToolEventResult, } from '../tool-event-results.js';
6
+ import { collectSensitiveEnvValues, sanitizeToolEventResult, sanitizeUntrustedPersistenceValue, } from '../tool-event-results.js';
7
7
  import { readStagedMcpServers } from '../providers/mcp-config.js';
8
8
  import { removeSandboxRoot } from '../providers/sandbox-lifecycle.js';
9
9
  import { attachTurnResultSensitiveValues, cloneTurnResultWithSensitiveValues } from '../sdk/turn-result-secrets.js';
10
- import { attachOriginalMcpInput } from '../sdk/mcp-event-input.js';
11
- import { attachToolEventSensitiveValues } from '../sdk/tool-event-secrets.js';
10
+ import { attachLiveMcpInput, attachToolEventSensitiveValues } from '../sdk/tool-event-secrets.js';
12
11
  import { currentOpenCodePlatformKey, OPENCODE_RUNTIME_LOCK, } from './opencode/contract.js';
13
12
  import { OpenCodeRuntimePolicy, OPENCODE_PERMISSION } from './opencode/runtime-policy.js';
14
13
  import { killOpenCodeProcessGroup, registerOpenCodeProcessGroup, unregisterOpenCodeProcessGroup, } from './opencode/process-groups.js';
@@ -217,7 +216,7 @@ export function parseOpenCodeOutput(stdout, processResult, mcpToolNames, sensiti
217
216
  rawSnippet: JSON.stringify({ tool, status: state.status, input }).slice(0, 2_000),
218
217
  };
219
218
  toolEvents.push(action === 'mcp_tool_call' && input
220
- ? attachOriginalMcpInput(toolEvent, input)
219
+ ? attachLiveMcpInput(toolEvent, input)
221
220
  : toolEvent);
222
221
  sanitizedTrace.push({ type, tool, status, input });
223
222
  continue;
@@ -256,7 +255,7 @@ export function parseOpenCodeOutput(stdout, processResult, mcpToolNames, sensiti
256
255
  if (stepFinishCount === 0)
257
256
  throw new Error('OpenCode protocol error: missing step_finish');
258
257
  const assistantMessage = textParts.join('');
259
- const traceOutput = sanitizePersistenceValue(sanitizedTrace, sensitiveValues)
258
+ const traceOutput = sanitizeUntrustedPersistenceValue(sanitizedTrace, sensitiveValues)
260
259
  .map((event) => JSON.stringify(event))
261
260
  .join('\n');
262
261
  return {
@@ -20,6 +20,7 @@
20
20
  import * as path from 'path';
21
21
  import fs from 'fs-extra';
22
22
  import { readSidecar } from '../affected/sidecar.js';
23
+ import { parsePathgradeReport } from '../reporting/report-parser.js';
23
24
  import { formatNoAffectedEvalsMarkdown, formatReportMarkdown, MISSING_RESULTS_BODY, postOrUpdateComment, resolvePrContext, } from '../reporters/github-comment.js';
24
25
  const DEFAULT_RESULTS_PATH = path.join('.pathgrade', 'results.json');
25
26
  /**
@@ -39,38 +40,17 @@ function resolveCommentId(explicit) {
39
40
  return workflow;
40
41
  return 'default';
41
42
  }
42
- function isPathgradeReport(value) {
43
- if (!value || typeof value !== 'object')
44
- return false;
45
- const v = value;
46
- return ((v.version === 1 || v.version === 2) &&
47
- typeof v.overall_pass_rate === 'number' &&
48
- (v.status === 'pass' || v.status === 'fail') &&
49
- Array.isArray(v.groups) &&
50
- (v.version === 1 || (typeof v.overall_mean_reward === 'number'
51
- && hasValidAttemptCounts(v)
52
- && v.groups.every(group => typeof group.mean_reward === 'number'))));
53
- }
54
- function hasValidAttemptCounts(report) {
55
- const requested = report.attempts_requested;
56
- const completed = report.attempts_completed;
57
- return typeof requested === 'number'
58
- && Number.isSafeInteger(requested)
59
- && requested >= 1
60
- && typeof completed === 'number'
61
- && Number.isSafeInteger(completed)
62
- && completed >= 0
63
- && completed <= requested;
64
- }
65
43
  async function loadReport(resolvedPath) {
66
44
  if (!(await fs.pathExists(resolvedPath))) {
67
45
  throw new Error(`results file not found at ${resolvedPath}`);
68
46
  }
69
47
  const raw = await fs.readJSON(resolvedPath);
70
- if (!isPathgradeReport(raw)) {
48
+ try {
49
+ return parsePathgradeReport(raw);
50
+ }
51
+ catch {
71
52
  throw new Error(`results file at ${resolvedPath} is not a valid pathgrade report`);
72
53
  }
73
- return raw;
74
54
  }
75
55
  function printMarkdownAndPassRate(markdown, passRate) {
76
56
  // Strip trailing newlines so we control spacing precisely:
@@ -1,5 +1,7 @@
1
1
  import { buildSummary } from '../../tool-events.js';
2
- import { sanitizePersistenceValue, sanitizeToolEventResult } from '../../tool-event-results.js';
2
+ import { sanitizePersistenceValue, sanitizeToolEventResult, sanitizeUntrustedPersistenceValue, } from '../../tool-event-results.js';
3
+ import { getOriginalMcpInput } from '../../sdk/mcp-event-input.js';
4
+ import { attachLiveMcpInput, attachToolEventSensitiveValues } from '../../sdk/tool-event-secrets.js';
3
5
  export const ACP_AUTHOR_TRACE_MAX_CHARS = 64 * 1024;
4
6
  function object(value) {
5
7
  return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
@@ -92,7 +94,7 @@ export function projectAcpAuthorTurn(input) {
92
94
  sessionUpdate: current.sessionUpdate,
93
95
  title: current.title,
94
96
  ...(current.status ? { status: current.status } : {}),
95
- ...(current.rawInput ? { rawInput: current.rawInput } : {}),
97
+ ...(current.rawInput ? { rawInput: sanitizeUntrustedPersistenceValue(current.rawInput, redactionValues) } : {}),
96
98
  });
97
99
  if (current.status !== 'completed' && current.status !== 'failed') {
98
100
  if (current.toolCallId)
@@ -114,7 +116,7 @@ export function projectAcpAuthorTurn(input) {
114
116
  toolCalls.set(current.toolCallId, { ...correlated, terminalFingerprint });
115
117
  const normalized = input.normalizeToolName(correlated.title);
116
118
  const resultText = textValue(correlated.status === 'completed' ? correlated.output : correlated.error);
117
- toolEvents.push({
119
+ const event = {
118
120
  action: normalized.action,
119
121
  provider: input.provider,
120
122
  providerToolName: normalized.providerToolName,
@@ -124,18 +126,21 @@ export function projectAcpAuthorTurn(input) {
124
126
  ...(resultText !== undefined ? { result: sanitizeToolEventResult({ content: resultText }, redactionValues) } : {}),
125
127
  summary: buildSummary(normalized.action, normalized.providerToolName, correlated.rawInput),
126
128
  confidence: normalized.action === 'unknown' ? 'low' : 'high',
127
- rawSnippet: JSON.stringify(sanitizePersistenceValue({ title: normalized.providerToolName, status: correlated.status, rawInput: correlated.rawInput }, redactionValues)).slice(0, 2_000),
128
- });
129
+ rawSnippet: JSON.stringify(sanitizeUntrustedPersistenceValue({ title: normalized.providerToolName, status: correlated.status, rawInput: correlated.rawInput }, redactionValues)).slice(0, 2_000),
130
+ };
131
+ toolEvents.push(normalized.action === 'mcp_tool_call' && correlated.rawInput
132
+ ? attachLiveMcpInput(event, correlated.rawInput)
133
+ : event);
129
134
  }
130
135
  const assistantMessage = sanitizePersistenceValue(assistantChunks.join(''), redactionValues);
131
- const rawTrace = JSON.stringify(sanitizePersistenceValue({
136
+ const rawTrace = JSON.stringify(sanitizeUntrustedPersistenceValue({
132
137
  terminal: { stopReason: input.terminal.stopReason },
133
138
  updates: trace,
134
139
  }, redactionValues));
135
140
  const traceOutput = rawTrace.length > ACP_AUTHOR_TRACE_MAX_CHARS
136
141
  ? `${rawTrace.slice(0, ACP_AUTHOR_TRACE_MAX_CHARS - 1)}…`
137
142
  : rawTrace;
138
- return sanitizePersistenceValue({
143
+ const result = sanitizePersistenceValue({
139
144
  rawOutput: traceOutput,
140
145
  traceOutput,
141
146
  assistantMessage,
@@ -144,4 +149,12 @@ export function projectAcpAuthorTurn(input) {
144
149
  exitCode: input.terminal.stopReason === 'end_turn' && !providerError ? 0 : 1,
145
150
  toolEvents,
146
151
  }, redactionValues);
152
+ result.toolEvents.forEach((event, index) => {
153
+ const originalInput = toolEvents[index] ? getOriginalMcpInput(toolEvents[index]) : undefined;
154
+ if (event.action === 'mcp_tool_call' && originalInput) {
155
+ attachLiveMcpInput(event, originalInput);
156
+ attachToolEventSensitiveValues(event, redactionValues);
157
+ }
158
+ });
159
+ return result;
147
160
  }
@@ -19,7 +19,8 @@ export async function runCliPreview(resultsDir, opts) {
19
19
  console.log(`\n${fmt.bold('pathgrade preview')} ${fmt.dim(`${entries.length} reports from ${resolved}`)}\n`);
20
20
  for (const { file, ...report } of entries) {
21
21
  const meanReward = report.mean_reward ?? report.pass_rate ?? 0;
22
- const isPass = report.status === undefined ? meanReward >= 0.5 : report.status === 'pass';
22
+ const isPass = report.run_runner_status === 'fail'
23
+ ? false : report.status === undefined ? meanReward >= 0.5 : report.status === 'pass';
23
24
  const trials = report.trials || [];
24
25
  const avgDur = trials.reduce((s, t) => s + (t.duration_ms || 0), 0) / (trials.length || 1);
25
26
  const totalTokens = trials.reduce((s, t) => s + (t.input_tokens || 0) + (t.output_tokens || 0) + (t.conversation_input_tokens || 0) + (t.conversation_output_tokens || 0), 0);
@@ -43,6 +44,17 @@ export async function runCliPreview(resultsDir, opts) {
43
44
  for (const [label, value] of metrics) {
44
45
  console.log(` ${fmt.dim(label.padEnd(14))} ${fmt.bold(value)}`);
45
46
  }
47
+ if (report.runner_status !== undefined || report.status !== undefined) {
48
+ console.log(` ${fmt.dim('Runner assertions'.padEnd(20))} ${gateLabel(report.runner_status ?? report.status)}`);
49
+ }
50
+ if (report.run_runner_status !== undefined) {
51
+ console.log(` ${fmt.dim('Run assertions'.padEnd(20))} ${gateLabel(report.run_runner_status)}`);
52
+ }
53
+ if (report.threshold != null) {
54
+ const thresholdStatus = report.threshold_status === 'pass' || report.threshold_status === 'fail' ? report.threshold_status : meanReward >= report.threshold ? 'pass' : 'fail';
55
+ console.log(` ${fmt.dim('Aggregate threshold'.padEnd(20))} ${gateLabel(thresholdStatus)} — ${(meanReward * 100).toFixed(1)}% ${thresholdStatus === 'pass' ? '>=' : '<'} ${(report.threshold * 100).toFixed(1)}%`);
56
+ }
57
+ console.log(` ${fmt.dim('Overall'.padEnd(20))} ${gateLabel(isPass ? 'pass' : 'fail')}`);
46
58
  console.log();
47
59
  // ── Trials
48
60
  for (const trial of trials) {
@@ -85,6 +97,10 @@ export async function runCliPreview(resultsDir, opts) {
85
97
  }
86
98
  console.log();
87
99
  }
100
+ if (trials.some((trial) => trial.scorer_results?.some(scorer => scorer.status === 'skipped'))) {
101
+ console.log(` ${fmt.dim('Skipped scorers keep their declared weight and contribute zero.')}`);
102
+ console.log();
103
+ }
88
104
  // ── LLM scorer details
89
105
  const hasLlm = trials.some((t) => t.scorer_results?.some((g) => g.scorer_type === 'llm_rubric'));
90
106
  if (hasLlm) {
@@ -120,6 +136,9 @@ export async function runCliPreview(resultsDir, opts) {
120
136
  console.log();
121
137
  }
122
138
  }
139
+ function gateLabel(status) {
140
+ return status === 'pass' ? fmt.pass('PASS') : fmt.fail('FAIL');
141
+ }
123
142
  function formatPassAtK(value, reason) {
124
143
  if (typeof value === 'number')
125
144
  return `${(value * 100).toFixed(1)}% (legacy v1)`;
@@ -83,15 +83,22 @@ export function formatReportMarkdown(report, opts) {
83
83
  }
84
84
  if (report.threshold != null) {
85
85
  lines.push('');
86
- lines.push(`Threshold: ${pct(report.threshold)} ${report.status.toUpperCase()}`);
86
+ lines.push(`**Runner assertions:** ${(report.runner_status ?? report.status).toUpperCase()}`);
87
+ lines.push(`**Aggregate threshold:** ${(report.threshold_status ?? (meanReward >= report.threshold ? 'pass' : 'fail')).toUpperCase()} — ${pct(meanReward)} ${meanReward >= report.threshold ? '>=' : '<'} ${pct(report.threshold)}`);
88
+ lines.push(`**Overall:** ${report.status.toUpperCase()}`);
89
+ }
90
+ else {
91
+ lines.push('');
92
+ lines.push(`**Runner assertions:** ${(report.runner_status ?? report.status).toUpperCase()}`);
93
+ lines.push(`**Overall:** ${report.status.toUpperCase()}`);
87
94
  }
88
95
  lines.push('');
89
- lines.push('| Group | Mean reward | Success rate | pass@k | Skills | Avg duration |');
90
- lines.push('|---|---|---|---|---|---|');
96
+ lines.push('| Group | Status | Mean reward | Success rate | pass@k | Skills | Avg duration |');
97
+ lines.push('|---|---|---|---|---|---|---|');
91
98
  for (const group of report.groups) {
92
99
  const skills = group.skills_used.length > 0 ? group.skills_used.join(', ') : '—';
93
100
  const avg = computeAvgDuration(group.trials);
94
- lines.push(`| ${escapeTableCell(group.task)} | ${pct(group.mean_reward ?? group.pass_rate ?? 0)} | ${group.success_rate === undefined ? '—' : pct(group.success_rate)} | ${escapeTableCell(formatPassAtK(group.pass_at_k, group.pass_at_k_unavailable_reason))} | ${escapeTableCell(skills)} | ${durationSeconds(avg)} |`);
101
+ lines.push(`| ${escapeTableCell(group.task)} | ${(group.status ?? (group.pass_rate === 1 ? 'pass' : 'fail')).toUpperCase()} | ${pct(group.mean_reward ?? group.pass_rate ?? 0)} | ${group.success_rate === undefined ? '—' : pct(group.success_rate)} | ${escapeTableCell(formatPassAtK(group.pass_at_k, group.pass_at_k_unavailable_reason))} | ${escapeTableCell(skills)} | ${durationSeconds(avg)} |`);
95
102
  }
96
103
  if (report.selection) {
97
104
  lines.push('');
@@ -320,10 +327,14 @@ function formatGroupDetails(group) {
320
327
  out.push(`reward: **${trial.reward === undefined ? 'n/a' : trial.reward.toFixed(2)}** | duration: ${durationSeconds(trial.duration_ms)} | completion: \`${reason}\``);
321
328
  if (trial.scorer_results.length > 0) {
322
329
  out.push('');
323
- out.push('| Scorer | Score | Weight | Details |');
324
- out.push('|---|---|---|---|');
330
+ out.push('| Scorer | Status | Score | Weight | Details |');
331
+ out.push('|---|---|---|---|---|');
325
332
  for (const s of trial.scorer_results) {
326
- out.push(`| ${escapeTableCell(s.scorer_type)} | ${s.score.toFixed(2)} | ${s.weight.toFixed(2)} | ${escapeTableCell(s.details ?? '')} |`);
333
+ out.push(`| ${escapeTableCell(s.scorer_type)} | ${(s.status ?? 'ok').toUpperCase()} | ${s.score.toFixed(2)} | ${s.weight.toFixed(2)} | ${escapeTableCell(s.details ?? '')} |`);
334
+ }
335
+ if (trial.scorer_results.some(scorer => scorer.status === 'skipped')) {
336
+ out.push('');
337
+ out.push('Skipped scorers keep their declared weight and contribute zero.');
327
338
  }
328
339
  }
329
340
  const warnings = trial.diagnostics?.warnings ?? [];
@@ -3,6 +3,10 @@ export interface LoadedReport extends EvalReport {
3
3
  file: string;
4
4
  timestamp?: string;
5
5
  status?: 'pass' | 'fail';
6
+ runner_status?: 'pass' | 'fail';
7
+ run_runner_status?: 'pass' | 'fail';
8
+ threshold_status?: 'pass' | 'fail' | 'not_configured';
9
+ threshold?: number;
6
10
  }
7
11
  export declare function loadReports(resultsDir: string, opts?: {
8
12
  skipTraces?: boolean;
@@ -34,11 +34,18 @@ export async function loadReports(resultsDir, opts) {
34
34
  if (raw.version !== 1 && raw.version !== 2)
35
35
  continue;
36
36
  for (const group of raw.groups) {
37
+ const runnerStatus = groupRunnerStatus(group);
38
+ const thresholdStatus = groupThresholdStatus(raw, group);
37
39
  const report = {
38
40
  file,
39
41
  timestamp: raw.timestamp,
42
+ threshold: raw.threshold,
40
43
  ...group,
41
- status: groupStatus(raw, group),
44
+ runner_status: runnerStatus,
45
+ ...(raw.runner_status === 'pass' || raw.runner_status === 'fail'
46
+ ? { run_runner_status: raw.runner_status } : {}),
47
+ threshold_status: thresholdStatus,
48
+ status: runnerStatus === 'fail' || thresholdStatus === 'fail' ? 'fail' : 'pass',
42
49
  };
43
50
  if (!opts?.skipTraces && group.trace_file) {
44
51
  await hydrateTraces(report, group.trace_file, resolved);
@@ -50,15 +57,25 @@ export async function loadReports(resultsDir, opts) {
50
57
  }
51
58
  return results;
52
59
  }
53
- function groupStatus(raw, group) {
60
+ function groupRunnerStatus(group) {
61
+ if (group.runner_status === 'pass' || group.runner_status === 'fail')
62
+ return group.runner_status;
63
+ const outcomes = Array.isArray(group.trials)
64
+ ? group.trials.map((trial) => trial.runner_outcome).filter((outcome) => outcome !== undefined)
65
+ : [];
66
+ if (outcomes.length > 0) {
67
+ return outcomes.every((outcome) => outcome === 'passed') ? 'pass' : 'fail';
68
+ }
54
69
  if (group.status === 'pass' || group.status === 'fail')
55
70
  return group.status;
56
71
  const meanReward = group.mean_reward ?? group.pass_rate ?? 0;
57
- if (typeof raw.threshold === 'number')
58
- return meanReward >= raw.threshold ? 'pass' : 'fail';
59
- const outcomes = Array.isArray(group.trials) ? group.trials.map((trial) => trial.runner_outcome) : [];
60
- if (outcomes.length > 0 && outcomes.every((outcome) => (outcome === 'passed' || outcome === 'failed' || outcome === 'not-run'))) {
61
- return outcomes.every((outcome) => outcome === 'passed') ? 'pass' : 'fail';
62
- }
63
- return meanReward >= 0.5 ? 'pass' : 'fail';
72
+ return meanReward === 1 ? 'pass' : 'fail';
73
+ }
74
+ function groupThresholdStatus(raw, group) {
75
+ if (group.threshold_status === 'pass' || group.threshold_status === 'fail'
76
+ || group.threshold_status === 'not_configured')
77
+ return group.threshold_status;
78
+ if (typeof raw.threshold !== 'number')
79
+ return 'not_configured';
80
+ return (group.mean_reward ?? group.pass_rate ?? 0) >= raw.threshold ? 'pass' : 'fail';
64
81
  }
@@ -52,9 +52,12 @@ export function buildPathgradeReport(input) {
52
52
  .map(attempt => attempt.score)
53
53
  .filter((score) => score !== undefined))));
54
54
  const overallMeanReward = average(scores);
55
- const status = input.threshold != null
56
- ? (overallMeanReward >= input.threshold ? 'pass' : 'fail')
57
- : (reportableGroups.every(group => group.cases.every(testCase => testCase.state === 'passed')) ? 'pass' : 'fail');
55
+ const runnerStatus = (input.runStatus === undefined || input.runStatus === 'completed')
56
+ && reportableGroups.every(group => group.cases.every(testCase => (testCase.state !== 'failed'
57
+ && testCase.attempts.every(attempt => attempt.outcome.kind === 'passed')))) ? 'pass' : 'fail';
58
+ const thresholdStatus = input.threshold == null
59
+ ? 'not_configured'
60
+ : overallMeanReward >= input.threshold ? 'pass' : 'fail';
58
61
  return {
59
62
  report: {
60
63
  version: 2,
@@ -64,7 +67,9 @@ export function buildPathgradeReport(input) {
64
67
  attempts_completed: attemptsCompleted,
65
68
  overall_mean_reward: overallMeanReward,
66
69
  overall_pass_rate: overallMeanReward,
67
- status,
70
+ runner_status: runnerStatus,
71
+ threshold_status: thresholdStatus,
72
+ status: runnerStatus === 'fail' || thresholdStatus === 'fail' ? 'fail' : 'pass',
68
73
  groups: consolidatedGroups,
69
74
  ...(input.selection ? { selection: input.selection } : {}),
70
75
  },
@@ -185,15 +190,20 @@ function buildEvalReport(groupName, cases, run) {
185
190
  .filter((score) => score !== undefined);
186
191
  const meanReward = average(rewards);
187
192
  const metrics = binaryMetrics(cases, run);
193
+ const runnerStatus = cases.every(testCase => (testCase.state !== 'failed'
194
+ && testCase.attempts.every(attempt => attempt.outcome.kind === 'passed'))) ? 'pass' : 'fail';
195
+ const thresholdStatus = run.threshold == null
196
+ ? 'not_configured'
197
+ : meanReward >= run.threshold ? 'pass' : 'fail';
188
198
  const skills = new Set();
189
199
  for (const trial of trials)
190
200
  for (const skill of trial.skills_used ?? [])
191
201
  skills.add(skill);
192
202
  return {
193
203
  task: groupName,
194
- status: run.threshold != null
195
- ? (meanReward >= run.threshold ? 'pass' : 'fail')
196
- : (cases.every(testCase => testCase.state === 'passed') ? 'pass' : 'fail'),
204
+ runner_status: runnerStatus,
205
+ threshold_status: thresholdStatus,
206
+ status: runnerStatus === 'fail' || thresholdStatus === 'fail' ? 'fail' : 'pass',
197
207
  mean_reward: meanReward,
198
208
  ...(metrics.successRate !== undefined ? { success_rate: metrics.successRate } : {}),
199
209
  ...(metrics.passAtK ? {
@@ -6,18 +6,44 @@ export function parsePathgradeReport(value) {
6
6
  || (value.version === 2 && (typeof value.overall_mean_reward !== 'number' || !validAttemptCounts(value)))
7
7
  || !optionalNumber(value.threshold)
8
8
  || (value.status !== 'pass' && value.status !== 'fail')
9
+ || !optionalGateStatus(value.runner_status)
10
+ || !optionalThresholdStatus(value.threshold_status)
9
11
  || (value.run_kind !== undefined && value.run_kind !== 'evaluation' && value.run_kind !== 'no-affected')
10
12
  || !Array.isArray(value.groups)
11
13
  || !value.groups.every(group => isReportGroup(group, value.version))
12
14
  || !isSelection(value.selection)) {
13
15
  throw new Error('PathGrade results.json is missing or has an unsupported schema');
14
16
  }
15
- return value;
17
+ const threshold = value.threshold;
18
+ const groups = value.groups.map(group => {
19
+ const runnerStatus = deriveRunnerStatus(group);
20
+ const thresholdStatus = deriveThresholdStatus(group.mean_reward ?? group.pass_rate, threshold);
21
+ return {
22
+ ...group,
23
+ runner_status: runnerStatus,
24
+ threshold_status: thresholdStatus,
25
+ status: composeStatus(runnerStatus, thresholdStatus),
26
+ };
27
+ });
28
+ const legacyFailure = value.runner_status === undefined && value.status === 'fail' && groups.length === 0;
29
+ const runnerStatus = value.runner_status !== 'fail' && !legacyFailure
30
+ && (groups.length > 0 || value.runner_status === 'pass' || value.status === 'pass')
31
+ && groups.every(group => group.runner_status === 'pass') ? 'pass' : 'fail';
32
+ const thresholdStatus = deriveThresholdStatus(value.overall_mean_reward ?? value.overall_pass_rate, threshold);
33
+ return {
34
+ ...value,
35
+ groups,
36
+ runner_status: runnerStatus,
37
+ threshold_status: thresholdStatus,
38
+ status: composeStatus(runnerStatus, thresholdStatus),
39
+ };
16
40
  }
17
41
  function isReportGroup(value, version) {
18
42
  return isRecord(value)
19
43
  && typeof value.task === 'string'
20
44
  && (version === 1 ? legacyMetrics(value) : schemaV2Metrics(value))
45
+ && optionalGateStatus(value.runner_status)
46
+ && optionalThresholdStatus(value.threshold_status)
21
47
  && optionalString(value.source_file)
22
48
  && typeof value.trace_file === 'string'
23
49
  && Array.isArray(value.skills_used)
@@ -26,6 +52,34 @@ function isReportGroup(value, version) {
26
52
  && value.trials.every(isTrial)
27
53
  && (value.comparison_contract === undefined || isComparisonContract(value.comparison_contract));
28
54
  }
55
+ function deriveRunnerStatus(value) {
56
+ if (value.runner_status === 'fail')
57
+ return 'fail';
58
+ const outcomes = value.trials
59
+ .map(trial => trial.runner_outcome)
60
+ .filter(outcome => outcome !== undefined);
61
+ if (outcomes.length > 0)
62
+ return outcomes.every(outcome => outcome === 'passed') ? 'pass' : 'fail';
63
+ if (value.runner_status === 'pass')
64
+ return value.runner_status;
65
+ if (value.status === 'pass' || value.status === 'fail')
66
+ return value.status;
67
+ return value.pass_rate === 1 ? 'pass' : 'fail';
68
+ }
69
+ function deriveThresholdStatus(reward, threshold) {
70
+ if (threshold === undefined)
71
+ return 'not_configured';
72
+ return typeof reward === 'number' && reward >= threshold ? 'pass' : 'fail';
73
+ }
74
+ function composeStatus(runnerStatus, thresholdStatus) {
75
+ return runnerStatus === 'fail' || thresholdStatus === 'fail' ? 'fail' : 'pass';
76
+ }
77
+ function optionalGateStatus(value) {
78
+ return value === undefined || value === 'pass' || value === 'fail';
79
+ }
80
+ function optionalThresholdStatus(value) {
81
+ return optionalGateStatus(value) || value === 'not_configured';
82
+ }
29
83
  function isTrial(value) {
30
84
  return isRecord(value)
31
85
  && typeof value.trial_id === 'number'
@@ -40,7 +94,8 @@ function isTrial(value) {
40
94
  && value.scorer_results.every(scorer => isRecord(scorer)
41
95
  && typeof scorer.scorer_type === 'string'
42
96
  && typeof scorer.score === 'number'
43
- && typeof scorer.weight === 'number');
97
+ && typeof scorer.weight === 'number'
98
+ && (scorer.status === undefined || scorer.status === 'ok' || scorer.status === 'error' || scorer.status === 'skipped'));
44
99
  }
45
100
  function legacyMetrics(value) {
46
101
  return typeof value.pass_rate === 'number'
@@ -1,9 +1,10 @@
1
1
  import type { DiagnosticsReport } from '../sdk/diagnostics.js';
2
2
  import type { AgentExecutionMetadata, EvaluationResultKind } from '../sdk/types.js';
3
- import type { AttemptOutcome } from '../runners/model.js';
3
+ import type { AttemptOutcome, RunStatus } from '../runners/model.js';
4
4
  import type { PathgradeReport, PathgradeSelectionReport, TrialResult } from '../types.js';
5
5
  export type ReportCaseState = 'passed' | 'failed' | 'skipped' | 'pending';
6
6
  export interface ReportRunInput {
7
+ runStatus?: RunStatus;
7
8
  threshold?: number;
8
9
  selection?: PathgradeSelectionReport;
9
10
  attemptsRequested?: number;
@@ -43,8 +43,8 @@ export async function runWithAdapter(input) {
43
43
  if (loadedSelection) {
44
44
  built = buildPathgradeReport({
45
45
  threshold: options.threshold,
46
+ ...reportInput,
46
47
  selection: loadedSelection,
47
- groups: reportInput.groups,
48
48
  });
49
49
  }
50
50
  const mode = options.reporterMode ?? 'cli';
@@ -56,14 +56,13 @@ export async function runWithAdapter(input) {
56
56
  if (mode === 'browser') {
57
57
  await options.openBrowser?.();
58
58
  }
59
- if (options.threshold != null && built.report.status === 'fail') {
59
+ if (options.threshold != null && built.report.threshold_status === 'fail') {
60
60
  options.onThresholdFailure?.({
61
61
  overallPassRate: built.report.overall_pass_rate,
62
62
  threshold: options.threshold,
63
63
  });
64
- return runExitCode === 0 ? 1 : runExitCode;
65
64
  }
66
- return runExitCode;
65
+ return built.report.status === 'fail' && runExitCode === 0 ? 1 : runExitCode;
67
66
  }
68
67
  finally {
69
68
  await lifecycle.cleanupRun();
@@ -80,13 +80,10 @@ export function withRepeatedAttempts(input) {
80
80
  if (input.config.reporter === 'browser') {
81
81
  await (input.openBrowser ?? openBrowserViewer)();
82
82
  }
83
- if (input.config.ci.threshold != null && built.report.status === 'fail') {
83
+ if (input.config.ci.threshold != null && built.report.threshold_status === 'fail') {
84
84
  process.stdout.write(`\n CI THRESHOLD FAILED avg score ${built.report.overall_pass_rate.toFixed(3)} < threshold ${input.config.ci.threshold}\n\n`);
85
- resultCode = firstNonzeroExit || 1;
86
- }
87
- else {
88
- resultCode = firstNonzeroExit;
89
85
  }
86
+ resultCode = firstNonzeroExit || (built.report.status === 'fail' ? 1 : 0);
90
87
  }
91
88
  }
92
89
  catch (error) {
@@ -41,6 +41,7 @@ export function projectNormalizedRunSnapshotToReportInput(snapshot, options = {}
41
41
  }
42
42
  return {
43
43
  ...(options.selection ? { selection: options.selection } : {}),
44
+ runStatus: snapshot.model.run.status,
44
45
  attemptsRequested: snapshot.model.run.attemptsRequested ?? 1,
45
46
  attemptsCompleted: snapshot.model.run.attemptsCompleted ?? 1,
46
47
  groups: [...groups.values()],