@wix/pathgrade 1.0.27 → 1.0.29

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 (64) hide show
  1. package/README.md +12 -19
  2. package/dist/adapters/jest/invocation-adapter.js +5 -1
  3. package/dist/adapters/jest/reporter.js +4 -1
  4. package/dist/adapters/jest/results.js +8 -0
  5. package/dist/adapters/node-test/index.d.ts +5 -0
  6. package/dist/adapters/node-test/index.js +35 -7
  7. package/dist/adapters/node-test/invocation-adapter.js +3 -1
  8. package/dist/adapters/node-test/runner-adapter.js +22 -15
  9. package/dist/adapters/vitest/reporter.js +4 -1
  10. package/dist/agents/claude.js +5 -4
  11. package/dist/agents/codex-app-server/agent.js +1 -51
  12. package/dist/agents/codex-app-server/turn-notifications.d.ts +6 -0
  13. package/dist/agents/codex-app-server/turn-notifications.js +51 -0
  14. package/dist/agents/codex-app-server/turn-state.d.ts +19 -0
  15. package/dist/agents/codex-app-server/turn-state.js +1 -0
  16. package/dist/agents/opencode/protocol.d.ts +7 -0
  17. package/dist/agents/opencode/protocol.js +47 -0
  18. package/dist/agents/opencode.js +1 -47
  19. package/dist/analytics/engine.js +5 -2
  20. package/dist/commands/report.js +16 -2
  21. package/dist/commands/run-args.d.ts +1 -0
  22. package/dist/commands/run-args.js +13 -0
  23. package/dist/commands/run-changed.js +5 -15
  24. package/dist/config/pathgrade.d.ts +3 -0
  25. package/dist/config/pathgrade.js +33 -1
  26. package/dist/internal/direct-mcp-v2/claude-direct-mcp.d.ts +2 -1
  27. package/dist/internal/direct-mcp-v2/claude-direct-mcp.js +35 -17
  28. package/dist/internal/direct-mcp-v2/claude-profile.d.ts +33 -7
  29. package/dist/internal/direct-mcp-v2/claude-profile.js +104 -72
  30. package/dist/internal/direct-mcp-v2/public-scenario-runtime.js +13 -16
  31. package/dist/pathgrade.js +10 -2
  32. package/dist/reporters/cli.js +13 -6
  33. package/dist/reporters/github-comment.js +12 -9
  34. package/dist/reporters/loader.d.ts +1 -0
  35. package/dist/reporters/loader.js +27 -2
  36. package/dist/reporters/report-summary.js +13 -5
  37. package/dist/reporting/core.d.ts +1 -0
  38. package/dist/reporting/core.js +183 -105
  39. package/dist/reporting/types.d.ts +19 -3
  40. package/dist/runners/adapter-loader.js +17 -12
  41. package/dist/runners/direct-reporter-attempts.d.ts +1 -0
  42. package/dist/runners/direct-reporter-attempts.js +7 -0
  43. package/dist/runners/invocation.d.ts +2 -0
  44. package/dist/runners/model-builders.js +1 -0
  45. package/dist/runners/model-validation.js +31 -0
  46. package/dist/runners/model.d.ts +4 -0
  47. package/dist/runners/orchestrator.d.ts +2 -0
  48. package/dist/runners/orchestrator.js +11 -1
  49. package/dist/runners/repeated-attempts.d.ts +7 -0
  50. package/dist/runners/repeated-attempts.js +149 -0
  51. package/dist/runners/repeated-invocation.d.ts +7 -0
  52. package/dist/runners/repeated-invocation.js +129 -0
  53. package/dist/runners/report-projection.js +16 -6
  54. package/dist/runners/vitest-adapter.js +10 -0
  55. package/dist/runners/vitest-invocation.js +2 -0
  56. package/dist/sdk/agent-runtime-options.d.ts +12 -0
  57. package/dist/sdk/agent-runtime-options.js +67 -0
  58. package/dist/sdk/agent.js +4 -57
  59. package/dist/sdk/case-context.js +7 -2
  60. package/dist/sdk/lifecycle.js +8 -3
  61. package/dist/sdk/result-capture.js +4 -1
  62. package/dist/types.d.ts +34 -5
  63. package/dist/viewer.html +19 -19
  64. package/package.json +2 -2
@@ -5,23 +5,25 @@ export function buildPathgradeReport(input) {
5
5
  const consolidatedGroups = [];
6
6
  const traces = [];
7
7
  const summaries = [];
8
+ const attemptsRequested = input.attemptsRequested ?? 1;
9
+ const attemptsCompleted = input.attemptsCompleted ?? attemptsRequested;
8
10
  const builtGroups = input.groups.map(group => ({
9
11
  groupName: group.groupName,
10
12
  cases: group.cases.map(toBuiltCase),
11
13
  }));
12
14
  for (const group of builtGroups) {
13
- for (const testCase of group.cases) {
15
+ for (const testCase of group.cases)
14
16
  warnings.push(...testCase.warnings);
15
- }
16
17
  }
17
18
  const reportableGroups = builtGroups
18
- .map(group => ({
19
- ...group,
20
- cases: group.cases.filter(reportableBuiltCase),
21
- }))
19
+ .map(group => ({ ...group, cases: group.cases.filter(reportableBuiltCase) }))
22
20
  .filter(group => group.cases.length > 0);
23
21
  for (const group of reportableGroups) {
24
- const report = buildEvalReport(group.groupName, group.cases);
22
+ const report = buildEvalReport(group.groupName, group.cases, {
23
+ attemptsRequested,
24
+ attemptsCompleted,
25
+ threshold: input.threshold,
26
+ });
25
27
  summaries.push(buildSummary(group.groupName, group.cases, report));
26
28
  const traceFile = `traces/${slug(group.groupName)}.json`;
27
29
  traces.push({ traceFile, trials: report.trials });
@@ -30,26 +32,25 @@ export function buildPathgradeReport(input) {
30
32
  return rest;
31
33
  });
32
34
  const { trials: _trials, ...rest } = report;
33
- consolidatedGroups.push({
34
- ...rest,
35
- trials: strippedTrials,
36
- trace_file: traceFile,
37
- });
35
+ consolidatedGroups.push({ ...rest, trials: strippedTrials, trace_file: traceFile });
38
36
  }
39
- const scores = reportableGroups.flatMap(group => group.cases
40
- .filter(testCase => testCase.resultKind !== 'synthetic_no_evaluation')
41
- .map(testCase => testCase.score)
42
- .filter((score) => score !== undefined));
43
- const overallPassRate = average(scores);
37
+ const scores = reportableGroups.flatMap(group => group.cases.flatMap(testCase => (testCase.attempts
38
+ .filter(attempt => attempt.resultKind !== 'synthetic_no_evaluation')
39
+ .map(attempt => attempt.score)
40
+ .filter((score) => score !== undefined))));
41
+ const overallMeanReward = average(scores);
44
42
  const status = input.threshold != null
45
- ? (overallPassRate >= input.threshold ? 'pass' : 'fail')
43
+ ? (overallMeanReward >= input.threshold ? 'pass' : 'fail')
46
44
  : (reportableGroups.every(group => group.cases.every(testCase => testCase.state === 'passed')) ? 'pass' : 'fail');
47
45
  return {
48
46
  report: {
49
- version: 1,
47
+ version: 2,
50
48
  timestamp: new Date().toISOString(),
51
49
  ...(input.threshold != null ? { threshold: input.threshold } : {}),
52
- overall_pass_rate: overallPassRate,
50
+ attempts_requested: attemptsRequested,
51
+ attempts_completed: attemptsCompleted,
52
+ overall_mean_reward: overallMeanReward,
53
+ overall_pass_rate: overallMeanReward,
53
54
  status,
54
55
  groups: consolidatedGroups,
55
56
  ...(input.selection ? { selection: input.selection } : {}),
@@ -65,139 +66,216 @@ function reportableBuiltCase(testCase) {
65
66
  return testCase.state !== 'skipped' && testCase.state !== 'pending';
66
67
  }
67
68
  function toBuiltCase(testCase) {
68
- if (testCase.evaluations === undefined) {
69
- return fallbackCase(testCase, []);
70
- }
71
- if (testCase.evaluations.length === 0) {
72
- return fallbackCase(testCase, [
73
- `empty results for "${testCase.name}" — evaluate() may not have been called`,
74
- ]);
69
+ const caseId = testCase.caseId ?? `case:${slug(testCase.name)}`;
70
+ const warnings = [];
71
+ const attempts = testCase.attempts?.map(attempt => buildAttempt({ testCase, caseId, attempt, warnings }))
72
+ ?? [buildAttempt({
73
+ testCase,
74
+ caseId,
75
+ attempt: {
76
+ attemptId: `${caseId}:attempt-1`,
77
+ attemptIndex: 1,
78
+ outcome: outcomeForState(testCase.state),
79
+ runnerDurationMs: testCase.runnerDurationMs,
80
+ ...(testCase.evaluations !== undefined ? { evaluations: testCase.evaluations } : {}),
81
+ ...(testCase.diagnostics ? { diagnostics: testCase.diagnostics } : {}),
82
+ },
83
+ warnings,
84
+ })];
85
+ return { caseId, name: testCase.name, state: testCase.state, attempts, reportable: testCase.reportable, warnings };
86
+ }
87
+ function buildAttempt(input) {
88
+ const { testCase, caseId, attempt } = input;
89
+ const evaluations = attempt.evaluations;
90
+ if (evaluations?.length === 0) {
91
+ input.warnings.push(`empty results for "${testCase.name}" — evaluate() may not have been called`);
75
92
  }
76
- // A case can own multiple agents. Preserve the existing "last evaluation"
77
- // rule among real evaluations without letting a later synthetic result from
78
- // an unevaluated sibling agent erase a legitimate score.
79
- const evaluation = testCase.evaluations.findLast(candidate => candidate.resultKind !== 'synthetic_no_evaluation') ?? testCase.evaluations[testCase.evaluations.length - 1];
80
- const diagnostics = evaluation.diagnostics
93
+ const evaluation = terminalEvaluation(evaluations);
94
+ const diagnostics = evaluation?.diagnostics
95
+ ?? attempt.diagnostics
81
96
  ?? testCase.diagnostics
82
- ?? (evaluation.resultKind === 'synthetic_no_evaluation'
83
- ? buildDiagnosticsReport({
84
- score: undefined,
85
- warnings: ['evaluate() was not called; no evaluation score is available'],
86
- log: [],
87
- })
97
+ ?? (evaluation?.resultKind === 'synthetic_no_evaluation'
98
+ ? buildDiagnosticsReport({ score: undefined, warnings: ['evaluate() was not called; no evaluation score is available'], log: [] })
88
99
  : undefined);
100
+ const fallbackScore = attempt.outcome.kind === 'not-run'
101
+ ? undefined
102
+ : attempt.outcome.kind === 'passed' ? 1 : 0;
103
+ const score = evaluation?.score ?? fallbackScore;
89
104
  return {
90
- name: testCase.name,
91
- state: testCase.state,
92
- score: evaluation.score,
93
- runnerDurationMs: testCase.runnerDurationMs,
105
+ caseId,
106
+ attemptId: attempt.attemptId,
107
+ attemptIndex: attempt.attemptIndex,
108
+ outcome: attempt.outcome,
109
+ score: evaluation?.resultKind === 'synthetic_no_evaluation' ? undefined : score,
110
+ runnerDurationMs: attempt.runnerDurationMs,
94
111
  diagnostics,
95
- reportable: testCase.reportable,
96
- resultKind: evaluation.resultKind,
112
+ hasTerminalEvaluation: evaluation !== undefined && evaluation.resultKind !== 'synthetic_no_evaluation',
113
+ resultKind: evaluation?.resultKind,
97
114
  trial: normalizeTrial({
115
+ caseId,
116
+ attemptId: attempt.attemptId,
117
+ attemptIndex: attempt.attemptIndex,
98
118
  name: testCase.name,
99
- score: evaluation.score,
100
- runnerDurationMs: testCase.runnerDurationMs,
101
- trial: evaluation.trial,
119
+ score,
120
+ runnerDurationMs: attempt.runnerDurationMs,
121
+ trial: evaluation?.trial,
102
122
  diagnostics,
103
- resultKind: evaluation.resultKind,
104
- scoringDurationMs: evaluation.scoringDurationMs,
105
- recordedAt: evaluation.recordedAt,
106
- agent: evaluation.agent,
123
+ resultKind: evaluation?.resultKind,
124
+ scoringDurationMs: evaluation?.scoringDurationMs,
125
+ recordedAt: evaluation?.recordedAt,
126
+ agent: evaluation?.agent,
127
+ runnerOutcome: attempt.outcome.kind,
107
128
  }),
108
- warnings: [],
109
129
  };
110
130
  }
111
- function fallbackCase(testCase, warnings) {
112
- const score = testCase.state === 'passed' ? 1 : 0;
113
- return {
114
- name: testCase.name,
115
- state: testCase.state,
116
- score,
117
- runnerDurationMs: testCase.runnerDurationMs,
118
- diagnostics: testCase.diagnostics,
119
- reportable: testCase.reportable,
120
- trial: normalizeTrial({
121
- name: testCase.name,
122
- score,
123
- runnerDurationMs: testCase.runnerDurationMs,
124
- diagnostics: testCase.diagnostics,
125
- }),
126
- warnings,
127
- };
131
+ function terminalEvaluation(evaluations) {
132
+ if (!evaluations || evaluations.length === 0)
133
+ return undefined;
134
+ return evaluations.findLast(candidate => candidate.resultKind !== 'synthetic_no_evaluation')
135
+ ?? evaluations[evaluations.length - 1];
128
136
  }
129
137
  function normalizeTrial(input) {
130
138
  const base = input.trial ?? {
131
- trial_id: 1,
139
+ trial_id: input.attemptIndex,
132
140
  ...(input.score !== undefined ? { reward: input.score } : {}),
133
- scorer_results: [],
134
- duration_ms: input.runnerDurationMs,
135
- n_commands: 0,
136
- input_tokens: 0,
137
- output_tokens: 0,
138
- session_log: [],
141
+ scorer_results: [], duration_ms: input.runnerDurationMs, n_commands: 0,
142
+ input_tokens: 0, output_tokens: 0, session_log: [],
139
143
  };
140
144
  const skills = base.skills_used ?? extractSkillsFromLog(base.session_log);
141
145
  const { reward: existingReward, ...baseWithoutReward } = base;
142
146
  return {
143
147
  ...baseWithoutReward,
144
- ...(input.resultKind === 'synthetic_no_evaluation'
145
- ? {}
146
- : { reward: existingReward ?? input.score }),
148
+ trial_id: input.attemptIndex,
149
+ case_id: input.caseId,
150
+ attempt_id: input.attemptId,
151
+ attempt_index: input.attemptIndex,
152
+ runner_outcome: input.runnerOutcome,
153
+ ...(input.resultKind === 'synthetic_no_evaluation' ? {} : { reward: existingReward ?? input.score }),
147
154
  name: input.name,
148
155
  duration_ms: base.duration_ms || input.runnerDurationMs,
149
156
  diagnostics: input.diagnostics ?? base.diagnostics,
150
157
  ...(input.resultKind ? { result_kind: input.resultKind } : {}),
151
- ...(input.scoringDurationMs !== undefined
152
- ? { scoring_duration_ms: input.scoringDurationMs }
153
- : {}),
158
+ ...(input.scoringDurationMs !== undefined ? { scoring_duration_ms: input.scoringDurationMs } : {}),
154
159
  ...(input.recordedAt ? { recorded_at: input.recordedAt } : {}),
155
160
  ...(input.agent ? { agent: input.agent } : {}),
156
161
  ...(skills.length > 0 ? { skills_used: skills } : {}),
157
162
  };
158
163
  }
159
- function buildEvalReport(groupName, cases) {
160
- const trials = cases.map((testCase, index) => ({
161
- ...testCase.trial,
164
+ function buildEvalReport(groupName, cases, run) {
165
+ const trials = cases.flatMap(testCase => testCase.attempts).map((attempt, index) => ({
166
+ ...attempt.trial,
162
167
  trial_id: index + 1,
163
168
  }));
164
- const passRate = average(cases.map(testCase => testCase.state === 'passed' ? 1 : 0));
169
+ const rewards = cases.flatMap(testCase => testCase.attempts)
170
+ .filter(attempt => attempt.resultKind !== 'synthetic_no_evaluation')
171
+ .map(attempt => attempt.score)
172
+ .filter((score) => score !== undefined);
173
+ const meanReward = average(rewards);
174
+ const metrics = binaryMetrics(cases, run);
165
175
  const skills = new Set();
166
- for (const trial of trials) {
167
- for (const skill of trial.skills_used ?? []) {
176
+ for (const trial of trials)
177
+ for (const skill of trial.skills_used ?? [])
168
178
  skills.add(skill);
169
- }
170
- }
171
179
  return {
172
180
  task: groupName,
173
- pass_rate: passRate,
174
- pass_at_k: 1 - Math.pow(1 - passRate, trials.length),
175
- pass_pow_k: Math.pow(passRate, trials.length),
181
+ status: run.threshold != null
182
+ ? (meanReward >= run.threshold ? 'pass' : 'fail')
183
+ : (cases.every(testCase => testCase.state === 'passed') ? 'pass' : 'fail'),
184
+ mean_reward: meanReward,
185
+ ...(metrics.successRate !== undefined ? { success_rate: metrics.successRate } : {}),
186
+ ...(metrics.passAtK ? {
187
+ pass_at_k: metrics.passAtK,
188
+ pass_at_k_method: 'finite_sample_unbiased',
189
+ eligible_case_count: metrics.eligibleCaseCount,
190
+ attempts_per_case: metrics.attemptsPerCase,
191
+ } : {}),
192
+ ...(metrics.unavailableReason ? { pass_at_k_unavailable_reason: metrics.unavailableReason } : {}),
176
193
  trials,
177
194
  skills_used: [...skills],
178
195
  };
179
196
  }
197
+ function binaryMetrics(cases, run) {
198
+ if (run.attemptsCompleted !== run.attemptsRequested)
199
+ return { unavailableReason: 'incomplete_attempts' };
200
+ const successesPerCase = [];
201
+ for (const testCase of cases) {
202
+ if (testCase.attempts.length !== run.attemptsRequested)
203
+ return { unavailableReason: 'incomplete_attempts' };
204
+ let successes = 0;
205
+ for (const attempt of testCase.attempts) {
206
+ if (attempt.outcome.kind === 'not-run')
207
+ return { unavailableReason: 'incomplete_attempts' };
208
+ if (!attempt.hasTerminalEvaluation)
209
+ return { unavailableReason: 'incomplete_attempts' };
210
+ if (attempt.score !== undefined && attempt.score !== 0 && attempt.score !== 1) {
211
+ return { unavailableReason: 'non_binary_reward' };
212
+ }
213
+ if (attempt.outcome.kind === 'passed') {
214
+ if (attempt.score === undefined)
215
+ return { unavailableReason: 'incomplete_attempts' };
216
+ if (attempt.score === 1)
217
+ successes++;
218
+ }
219
+ }
220
+ successesPerCase.push(successes);
221
+ }
222
+ const n = run.attemptsRequested;
223
+ const passAtK = {};
224
+ for (let k = 1; k <= n; k++) {
225
+ passAtK[String(k)] = average(successesPerCase.map(successes => finiteSamplePassAtK(n, successes, k)));
226
+ }
227
+ const totalSuccesses = successesPerCase.reduce((sum, value) => sum + value, 0);
228
+ return {
229
+ successRate: cases.length === 0 ? 0 : totalSuccesses / (cases.length * n),
230
+ passAtK,
231
+ eligibleCaseCount: cases.length,
232
+ attemptsPerCase: n,
233
+ };
234
+ }
235
+ export function finiteSamplePassAtK(n, successes, k) {
236
+ if (k < 1 || k > n)
237
+ throw new RangeError(`k must be in [1, ${n}]`);
238
+ const failures = n - successes;
239
+ if (failures < k)
240
+ return 1;
241
+ let allFailures = 1;
242
+ for (let offset = 0; offset < k; offset++)
243
+ allFailures *= (failures - offset) / (n - offset);
244
+ return 1 - allFailures;
245
+ }
180
246
  function buildSummary(groupName, cases, report) {
247
+ const attempts = cases.flatMap(testCase => testCase.attempts);
248
+ const casesById = new Map(cases.map(testCase => [testCase.caseId, testCase]));
181
249
  return {
182
250
  task: groupName,
183
- pass_rate: report.pass_rate,
184
- pass_at_k: report.pass_at_k,
185
- pass_pow_k: report.pass_pow_k,
186
- average_duration_ms: average(cases.map(testCase => testCase.runnerDurationMs)),
187
- trial_count: cases.length,
188
- diagnostics: cases.flatMap(testCase => testCase.diagnostics
189
- ? [{ caseName: testCase.name, state: testCase.state, resultKind: testCase.resultKind, report: testCase.diagnostics }]
190
- : []),
251
+ mean_reward: report.mean_reward ?? 0,
252
+ ...(report.success_rate !== undefined ? { success_rate: report.success_rate } : {}),
253
+ ...(typeof report.pass_at_k === 'object' ? { pass_at_k: report.pass_at_k } : {}),
254
+ ...(report.pass_at_k_unavailable_reason ? { pass_at_k_unavailable_reason: report.pass_at_k_unavailable_reason } : {}),
255
+ ...(report.attempts_per_case !== undefined ? { attempts_per_case: report.attempts_per_case } : {}),
256
+ ...(report.eligible_case_count !== undefined ? { eligible_case_count: report.eligible_case_count } : {}),
257
+ average_duration_ms: average(attempts.map(attempt => attempt.runnerDurationMs)),
258
+ trial_count: attempts.length,
259
+ diagnostics: attempts.flatMap(attempt => attempt.diagnostics ? [{
260
+ caseName: casesById.get(attempt.caseId)?.name ?? attempt.caseId,
261
+ state: casesById.get(attempt.caseId)?.state ?? 'failed',
262
+ resultKind: attempt.resultKind,
263
+ report: attempt.diagnostics,
264
+ }] : []),
191
265
  };
192
266
  }
267
+ function outcomeForState(state) {
268
+ if (state === 'passed')
269
+ return { kind: 'passed' };
270
+ if (state === 'failed')
271
+ return { kind: 'failed' };
272
+ return { kind: 'not-run', reason: state };
273
+ }
193
274
  function average(values) {
194
275
  if (values.length === 0)
195
276
  return 0;
196
277
  return values.reduce((sum, value) => sum + value, 0) / values.length;
197
278
  }
198
279
  function slug(value) {
199
- return value
200
- .toLowerCase()
201
- .replace(/[^a-z0-9]+/g, '-')
202
- .replace(/^-+|-+$/g, '') || 'report';
280
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'report';
203
281
  }
@@ -1,10 +1,13 @@
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
4
  import type { PathgradeReport, PathgradeSelectionReport, TrialResult } from '../types.js';
4
5
  export type ReportCaseState = 'passed' | 'failed' | 'skipped' | 'pending';
5
6
  export interface ReportRunInput {
6
7
  threshold?: number;
7
8
  selection?: PathgradeSelectionReport;
9
+ attemptsRequested?: number;
10
+ attemptsCompleted?: number;
8
11
  groups: ReportGroupInput[];
9
12
  }
10
13
  export interface ReportGroupInput {
@@ -13,6 +16,7 @@ export interface ReportGroupInput {
13
16
  }
14
17
  export interface ReportCaseInput {
15
18
  caseId?: string;
19
+ repeatKey?: string;
16
20
  name: string;
17
21
  state: ReportCaseState;
18
22
  runnerDurationMs: number;
@@ -22,6 +26,15 @@ export interface ReportCaseInput {
22
26
  runnerCaseId?: string;
23
27
  reportable?: boolean;
24
28
  evaluations?: readonly ReportEvaluationInput[];
29
+ attempts?: readonly ReportAttemptInput[];
30
+ diagnostics?: DiagnosticsReport;
31
+ }
32
+ export interface ReportAttemptInput {
33
+ attemptId: string;
34
+ attemptIndex: number;
35
+ outcome: AttemptOutcome;
36
+ runnerDurationMs: number;
37
+ evaluations?: readonly ReportEvaluationInput[];
25
38
  diagnostics?: DiagnosticsReport;
26
39
  }
27
40
  export interface ReportEvaluationInput {
@@ -48,9 +61,12 @@ export interface ArtifactWriteResult {
48
61
  }
49
62
  export interface ReportSummaryGroup {
50
63
  task: string;
51
- pass_rate: number;
52
- pass_at_k: number;
53
- pass_pow_k: number;
64
+ mean_reward: number;
65
+ success_rate?: number;
66
+ pass_at_k?: Record<string, number>;
67
+ pass_at_k_unavailable_reason?: import('../types.js').PassAtKUnavailableReason;
68
+ attempts_per_case?: number;
69
+ eligible_case_count?: number;
54
70
  average_duration_ms: number;
55
71
  trial_count: number;
56
72
  diagnostics: ReportSummaryDiagnostics[];
@@ -6,6 +6,7 @@ import { createJestAdapter } from '../adapters/jest/runner-adapter.js';
6
6
  import { createNodeTestInvocationAdapter } from '../adapters/node-test/invocation-adapter.js';
7
7
  import { createVitestInvocationAdapter } from './vitest-invocation.js';
8
8
  import { resolveRunnerAdapter } from './selection.js';
9
+ import { withRepeatedAttempts } from './repeated-invocation.js';
9
10
  export async function loadRunnerAdapter(input) {
10
11
  const name = input.adapterName ?? 'vitest';
11
12
  if (name === 'vitest' || name === 'node-test') {
@@ -24,20 +25,24 @@ export async function loadRunnerAdapter(input) {
24
25
  }
25
26
  export async function loadRunnerInvocationAdapter(input) {
26
27
  const name = input.adapterName ?? 'vitest';
28
+ let adapter;
27
29
  if (name === 'vitest')
28
- return createVitestInvocationAdapter({ spawnVitest: input.spawnVitest });
29
- if (name === 'node-test')
30
- return createNodeTestInvocationAdapter({ config: input.config });
31
- if (name === 'jest')
32
- return createJestInvocationAdapter({ config: input.config });
33
- const mod = await importExternalAdapter({
34
- adapterName: name,
35
- cwd: input.cwd ?? process.cwd(),
36
- });
37
- if (typeof mod.createPathgradeInvocationAdapter !== 'function') {
38
- throw new Error(`Pathgrade adapter package "${adapterPackageSpecifier(name)}" must export createPathgradeInvocationAdapter().`);
30
+ adapter = createVitestInvocationAdapter({ spawnVitest: input.spawnVitest });
31
+ else if (name === 'node-test')
32
+ adapter = createNodeTestInvocationAdapter({ config: input.config });
33
+ else if (name === 'jest')
34
+ adapter = createJestInvocationAdapter({ config: input.config });
35
+ else {
36
+ const mod = await importExternalAdapter({
37
+ adapterName: name,
38
+ cwd: input.cwd ?? process.cwd(),
39
+ });
40
+ if (typeof mod.createPathgradeInvocationAdapter !== 'function') {
41
+ throw new Error(`Pathgrade adapter package "${adapterPackageSpecifier(name)}" must export createPathgradeInvocationAdapter().`);
42
+ }
43
+ adapter = mod.createPathgradeInvocationAdapter({ config: input.config });
39
44
  }
40
- return mod.createPathgradeInvocationAdapter({ config: input.config });
45
+ return withRepeatedAttempts({ adapter, config: input.config });
41
46
  }
42
47
  function adapterPackageSpecifier(adapterName) {
43
48
  if (adapterName.startsWith('.') || path.isAbsolute(adapterName) || adapterName.includes('/')) {
@@ -0,0 +1 @@
1
+ export declare function assertDirectReporterAttemptsSupported(attempts: number, env: Readonly<NodeJS.ProcessEnv>): void;
@@ -0,0 +1,7 @@
1
+ export function assertDirectReporterAttemptsSupported(attempts, env) {
2
+ const effectiveAttempts = Number(env.PATHGRADE_ATTEMPT_COUNT);
3
+ if (attempts <= 1 || effectiveAttempts === 1 || env.PATHGRADE_ATTEMPT_INDEX !== undefined)
4
+ return;
5
+ throw new Error(`Pathgrade attempts=${attempts} requires "pathgrade run"; `
6
+ + 'a direct test-runner reporter cannot restart Jest or Vitest.');
7
+ }
@@ -1,5 +1,7 @@
1
1
  export interface RunnerInvocationAdapter {
2
2
  readonly name: string;
3
+ readonly supportsRepeatedAttempts?: boolean;
4
+ readonly writeEmptyReport?: boolean;
3
5
  run(input: RunnerInvocationInput): Promise<number>;
4
6
  }
5
7
  export interface RunnerInvocationInput {
@@ -36,6 +36,7 @@ export function buildNormalizedRunSnapshotFromReportGroups(run, groups) {
36
36
  }));
37
37
  return {
38
38
  id: caseId,
39
+ ...(testCase.repeatKey ? { repeatKey: testCase.repeatKey } : {}),
39
40
  runId: `${run.adapterName}:run`,
40
41
  unitId: unitId(group.groupName, groupIndex),
41
42
  name: testCase.name,
@@ -18,6 +18,37 @@ function validateRun(snapshot, completeness, errors) {
18
18
  if (!snapshot.model.run.adapterName) {
19
19
  errors.push({ path: 'model.run.adapterName', message: 'Run adapterName is required.' });
20
20
  }
21
+ const { attemptsRequested, attemptsCompleted } = snapshot.model.run;
22
+ if (attemptsRequested === undefined && attemptsCompleted !== undefined) {
23
+ errors.push({
24
+ path: 'model.run.attemptsRequested',
25
+ message: 'attemptsRequested is required when attemptsCompleted is present.',
26
+ });
27
+ }
28
+ if (attemptsCompleted === undefined && attemptsRequested !== undefined) {
29
+ errors.push({
30
+ path: 'model.run.attemptsCompleted',
31
+ message: 'attemptsCompleted is required when attemptsRequested is present.',
32
+ });
33
+ }
34
+ if (attemptsRequested !== undefined && (!Number.isSafeInteger(attemptsRequested) || attemptsRequested < 1)) {
35
+ errors.push({
36
+ path: 'model.run.attemptsRequested',
37
+ message: 'attemptsRequested must be a positive integer.',
38
+ });
39
+ }
40
+ if (attemptsCompleted !== undefined && (!Number.isSafeInteger(attemptsCompleted) || attemptsCompleted < 0)) {
41
+ errors.push({
42
+ path: 'model.run.attemptsCompleted',
43
+ message: 'attemptsCompleted must be a non-negative integer.',
44
+ });
45
+ }
46
+ if (attemptsRequested !== undefined && attemptsCompleted !== undefined && attemptsCompleted > attemptsRequested) {
47
+ errors.push({
48
+ path: 'model.run.attemptsCompleted',
49
+ message: 'attemptsCompleted cannot exceed attemptsRequested.',
50
+ });
51
+ }
21
52
  if (completeness === 'final' && snapshot.model.run.status === 'parked' && (snapshot.model.run.diagnostics ?? []).length === 0) {
22
53
  errors.push({ path: 'model.run.diagnostics', message: 'Final parked snapshots require diagnostics explaining why parked is terminal.' });
23
54
  }
@@ -16,6 +16,8 @@ export interface RunRecord {
16
16
  id: string;
17
17
  adapterName: string;
18
18
  status: RunStatus;
19
+ attemptsRequested?: number;
20
+ attemptsCompleted?: number;
19
21
  diagnostics?: Diagnostic[];
20
22
  nativeReferences?: NativeReference[];
21
23
  }
@@ -39,6 +41,8 @@ export type ScoringPolicy = {
39
41
  };
40
42
  export interface RunCaseRecord {
41
43
  id: string;
44
+ /** Adapter-owned semantic identity used only to match this case across runner invocations. */
45
+ repeatKey?: string;
42
46
  runId: string;
43
47
  unitId?: string;
44
48
  name: string;
@@ -8,6 +8,8 @@ export interface PathgradeRunOptions {
8
8
  runnerArgs: string[];
9
9
  env: NodeJS.ProcessEnv;
10
10
  artifactRoot: string;
11
+ /** Child-run handoff path. When set, write only the normalized snapshot. */
12
+ normalizedSnapshotPath?: string;
11
13
  reporterMode?: AdapterReporterMode;
12
14
  threshold?: number;
13
15
  selection?: PathgradeSelectionReport;
@@ -1,5 +1,7 @@
1
1
  import { buildPathgradeReport } from '../reporting/core.js';
2
2
  import { writePathgradeArtifacts } from '../reporting/artifacts.js';
3
+ import path from 'node:path';
4
+ import fs from 'fs-extra';
3
5
  import { projectNormalizedRunSnapshotToReportInput } from './report-projection.js';
4
6
  export async function runWithAdapter(input) {
5
7
  const { adapter, options } = input;
@@ -18,7 +20,15 @@ export async function runWithAdapter(input) {
18
20
  signal: options.signal,
19
21
  });
20
22
  const runExitCode = normalizeRunExitCode(run.status, run.exitCode);
21
- const reportInput = projectNormalizedRunSnapshotToReportInput(await adapter.collectNormalizedRunSnapshot(run), { selection });
23
+ const snapshot = await adapter.collectNormalizedRunSnapshot(run);
24
+ if (options.normalizedSnapshotPath) {
25
+ // Projection performs the strict final-snapshot validation before handoff.
26
+ projectNormalizedRunSnapshotToReportInput(snapshot, { selection });
27
+ await fs.ensureDir(path.dirname(options.normalizedSnapshotPath));
28
+ await fs.writeJson(options.normalizedSnapshotPath, snapshot, { spaces: 2 });
29
+ return runExitCode;
30
+ }
31
+ const reportInput = projectNormalizedRunSnapshotToReportInput(snapshot, { selection });
22
32
  let built = buildPathgradeReport({
23
33
  threshold: options.threshold,
24
34
  ...reportInput,
@@ -0,0 +1,7 @@
1
+ import type { NormalizedRunSnapshot } from './model.js';
2
+ export type RepeatAggregationFailure = 'incomplete_attempts' | 'case_identity_mismatch';
3
+ export declare class RepeatAggregationError extends Error {
4
+ readonly reason: RepeatAggregationFailure;
5
+ constructor(reason: RepeatAggregationFailure, message: string);
6
+ }
7
+ export declare function mergeRepeatedRunSnapshots(snapshots: readonly NormalizedRunSnapshot[]): NormalizedRunSnapshot;