@wix/pathgrade 1.0.26 → 1.0.28

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 (73) hide show
  1. package/README.md +14 -21
  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/sdk-message-projector.js +5 -0
  11. package/dist/agents/codex-app-server/agent.js +7 -57
  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/codex.js +1 -0
  17. package/dist/agents/cursor.js +1 -0
  18. package/dist/agents/opencode/protocol.d.ts +7 -0
  19. package/dist/agents/opencode/protocol.js +47 -0
  20. package/dist/agents/opencode.js +5 -49
  21. package/dist/analytics/engine.js +5 -2
  22. package/dist/commands/report.d.ts +10 -2
  23. package/dist/commands/report.js +41 -6
  24. package/dist/commands/run-args.d.ts +1 -0
  25. package/dist/commands/run-args.js +13 -0
  26. package/dist/commands/run-changed.js +5 -15
  27. package/dist/config/pathgrade.d.ts +3 -0
  28. package/dist/config/pathgrade.js +33 -1
  29. package/dist/pathgrade.js +32 -3
  30. package/dist/reporters/cli.js +13 -6
  31. package/dist/reporters/github-comment.d.ts +12 -3
  32. package/dist/reporters/github-comment.js +92 -18
  33. package/dist/reporters/loader.d.ts +1 -0
  34. package/dist/reporters/loader.js +27 -2
  35. package/dist/reporters/report-summary.js +13 -5
  36. package/dist/reporting/artifacts.js +5 -2
  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 +11 -59
  59. package/dist/sdk/case-context.js +7 -2
  60. package/dist/sdk/evaluate.d.ts +2 -0
  61. package/dist/sdk/evaluate.js +14 -9
  62. package/dist/sdk/index.d.ts +2 -0
  63. package/dist/sdk/index.js +2 -0
  64. package/dist/sdk/lifecycle.js +16 -6
  65. package/dist/sdk/result-capture.js +4 -1
  66. package/dist/sdk/types.d.ts +2 -0
  67. package/dist/tool-event-results.d.ts +1 -1
  68. package/dist/tool-event-results.js +2 -1
  69. package/dist/tool-events.d.ts +5 -0
  70. package/dist/tool-events.js +5 -0
  71. package/dist/types.d.ts +34 -5
  72. package/dist/viewer.html +19 -19
  73. package/package.json +2 -2
@@ -0,0 +1,47 @@
1
+ export const NATIVE_TOOL_ACTIONS = {
2
+ bash: 'run_shell',
3
+ read: 'read_file',
4
+ write: 'write_file',
5
+ edit: 'edit_file',
6
+ grep: 'search_code',
7
+ glob: 'list_files',
8
+ list: 'list_files',
9
+ skill: 'use_skill',
10
+ todowrite: 'update_todos',
11
+ lsp: 'search_code',
12
+ };
13
+ export const ALLOWED_EVENT_TYPES = new Set([
14
+ 'tool_use',
15
+ 'step_start',
16
+ 'step_finish',
17
+ 'text',
18
+ 'reasoning',
19
+ 'error',
20
+ ]);
21
+ export function protocolRecord(value, label) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
23
+ throw new Error(`OpenCode protocol error: ${label} must be an object`);
24
+ }
25
+ return value;
26
+ }
27
+ export function protocolFiniteNumber(value, label) {
28
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
29
+ throw new Error(`OpenCode protocol error: ${label} must be a nonnegative finite number`);
30
+ }
31
+ return value;
32
+ }
33
+ export function protocolRequiredString(value, label) {
34
+ if (typeof value !== 'string' || !value) {
35
+ throw new Error(`OpenCode protocol error: ${label} must be a nonempty string`);
36
+ }
37
+ return value;
38
+ }
39
+ export function sanitizedProviderError(event) {
40
+ const error = protocolRecord(event.error, 'error');
41
+ const data = error.data && typeof error.data === 'object' && !Array.isArray(error.data)
42
+ ? error.data
43
+ : {};
44
+ const status = typeof data.statusCode === 'number' ? ` status=${data.statusCode}` : '';
45
+ const retryable = typeof data.isRetryable === 'boolean' ? ` retryable=${data.isRetryable}` : '';
46
+ return new Error(`OpenCode provider error${status}${retryable}`);
47
+ }
@@ -6,7 +6,7 @@ import { buildSummary, enrichSkillEvents } from '../tool-events.js';
6
6
  import { collectSensitiveEnvValues, sanitizePersistenceValue, sanitizeToolEventResult, } from '../tool-event-results.js';
7
7
  import { readStagedMcpServers } from '../providers/mcp-config.js';
8
8
  import { removeSandboxRoot } from '../providers/sandbox-lifecycle.js';
9
- import { attachTurnResultSensitiveValues } from '../sdk/turn-result-secrets.js';
9
+ import { attachTurnResultSensitiveValues, cloneTurnResultWithSensitiveValues } from '../sdk/turn-result-secrets.js';
10
10
  import { attachOriginalMcpInput } from '../sdk/mcp-event-input.js';
11
11
  import { attachToolEventSensitiveValues } from '../sdk/tool-event-secrets.js';
12
12
  import { currentOpenCodePlatformKey, OPENCODE_RUNTIME_LOCK, } from './opencode/contract.js';
@@ -14,28 +14,9 @@ import { OpenCodeRuntimePolicy, OPENCODE_PERMISSION } from './opencode/runtime-p
14
14
  import { killOpenCodeProcessGroup, registerOpenCodeProcessGroup, unregisterOpenCodeProcessGroup, } from './opencode/process-groups.js';
15
15
  import { assertCleanManagedOpenCodeHost, sha256File } from './opencode/host-safety.js';
16
16
  import { getOpenCodeScenarioSession } from './opencode/scenario.js';
17
+ import { ALLOWED_EVENT_TYPES, NATIVE_TOOL_ACTIONS, protocolFiniteNumber as finiteNumber, protocolRecord as record, protocolRequiredString as requiredString, sanitizedProviderError, } from './opencode/protocol.js';
17
18
  export { assertCleanManagedOpenCodeHost, managedOpenCodeConfigPaths } from './opencode/host-safety.js';
18
19
  const OUTPUT_CAP_BYTES = 16 * 1024 * 1024;
19
- const NATIVE_TOOL_ACTIONS = {
20
- bash: 'run_shell',
21
- read: 'read_file',
22
- write: 'write_file',
23
- edit: 'edit_file',
24
- grep: 'search_code',
25
- glob: 'list_files',
26
- list: 'list_files',
27
- skill: 'use_skill',
28
- todowrite: 'update_todos',
29
- lsp: 'search_code',
30
- };
31
- const ALLOWED_EVENT_TYPES = new Set([
32
- 'tool_use',
33
- 'step_start',
34
- 'step_finish',
35
- 'text',
36
- 'reasoning',
37
- 'error',
38
- ]);
39
20
  export function spawnOpenCode(executable, args, options) {
40
21
  return new Promise((resolve, reject) => {
41
22
  const stdout = [];
@@ -124,33 +105,6 @@ export function spawnOpenCode(executable, args, options) {
124
105
  }
125
106
  });
126
107
  }
127
- function record(value, label) {
128
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
129
- throw new Error(`OpenCode protocol error: ${label} must be an object`);
130
- }
131
- return value;
132
- }
133
- function finiteNumber(value, label) {
134
- if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
135
- throw new Error(`OpenCode protocol error: ${label} must be a nonnegative finite number`);
136
- }
137
- return value;
138
- }
139
- function requiredString(value, label) {
140
- if (typeof value !== 'string' || !value) {
141
- throw new Error(`OpenCode protocol error: ${label} must be a nonempty string`);
142
- }
143
- return value;
144
- }
145
- function sanitizedProviderError(event) {
146
- const error = record(event.error, 'error');
147
- const data = error.data && typeof error.data === 'object' && !Array.isArray(error.data)
148
- ? error.data
149
- : {};
150
- const status = typeof data.statusCode === 'number' ? ` status=${data.statusCode}` : '';
151
- const retryable = typeof data.isRetryable === 'boolean' ? ` retryable=${data.isRetryable}` : '';
152
- return new Error(`OpenCode provider error${status}${retryable}`);
153
- }
154
108
  export function parseOpenCodeOutput(stdout, processResult, mcpToolNames, sensitiveValues = []) {
155
109
  if (processResult.overflow)
156
110
  throw new Error('OpenCode output exceeded the 16 MiB limit');
@@ -451,7 +405,9 @@ class OpenCodeSession {
451
405
  throw new Error('OpenCode protocol error: resumed session ID changed');
452
406
  }
453
407
  this.sessionId = parsed.sessionId;
454
- return parsed.result;
408
+ return cloneTurnResultWithSensitiveValues(parsed.result, {
409
+ resolvedModel: this.runtimePolicy.model,
410
+ });
455
411
  }
456
412
  catch (error) {
457
413
  this.failed = true;
@@ -40,10 +40,10 @@ export class AnalyticsEngine {
40
40
  for (const [task, data] of Object.entries(taskGroups)) {
41
41
  const allReports = [...data.withSkill, ...data.withoutSkill];
42
42
  const avgWith = data.withSkill.length > 0
43
- ? data.withSkill.reduce((a, b) => a + b.pass_rate, 0) / data.withSkill.length
43
+ ? data.withSkill.reduce((a, b) => a + reportMeanReward(b), 0) / data.withSkill.length
44
44
  : 0;
45
45
  const avgWithout = data.withoutSkill.length > 0
46
- ? data.withoutSkill.reduce((a, b) => a + b.pass_rate, 0) / data.withoutSkill.length
46
+ ? data.withoutSkill.reduce((a, b) => a + reportMeanReward(b), 0) / data.withoutSkill.length
47
47
  : 0;
48
48
  const allTrials = allReports.flatMap(r => r.trials);
49
49
  const avgDurationMs = allTrials.length > 0
@@ -64,3 +64,6 @@ export class AnalyticsEngine {
64
64
  return stats;
65
65
  }
66
66
  }
67
+ function reportMeanReward(report) {
68
+ return report.mean_reward ?? report.pass_rate ?? 0;
69
+ }
@@ -24,6 +24,14 @@ export interface ReportOptions {
24
24
  noComment?: boolean;
25
25
  /** Override the comment-id used in the dedup marker. See `resolveCommentId`. */
26
26
  commentId?: string;
27
+ /** Provider-neutral URL for a richer external report. */
28
+ detailsUrl?: string;
29
+ /** Provider-neutral status notice included in the report body. */
30
+ notice?: string;
31
+ /** Surface missing context, malformed results, and GitHub API failures. */
32
+ strict?: boolean;
33
+ /** Skip a comment update when the pull request has advanced. */
34
+ expectedHeadSha?: string;
27
35
  }
28
36
  /**
29
37
  * Resolve the comment-id used for the dedup marker.
@@ -33,7 +41,7 @@ export interface ReportOptions {
33
41
  */
34
42
  export declare function resolveCommentId(explicit?: string): string;
35
43
  /**
36
- * Entry point for `pathgrade report`. Never throws and never sets a
37
- * failing exit code even on malformed inputs or network errors.
44
+ * Entry point for `pathgrade report`. The default display-only mode never
45
+ * fails CI. Strict mode is intended for orchestrators that own publication.
38
46
  */
39
47
  export declare function runReport(cwd: string, opts?: ReportOptions): Promise<void>;
@@ -43,10 +43,24 @@ function isPathgradeReport(value) {
43
43
  if (!value || typeof value !== 'object')
44
44
  return false;
45
45
  const v = value;
46
- return (v.version === 1 &&
46
+ return ((v.version === 1 || v.version === 2) &&
47
47
  typeof v.overall_pass_rate === 'number' &&
48
48
  (v.status === 'pass' || v.status === 'fail') &&
49
- Array.isArray(v.groups));
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;
50
64
  }
51
65
  async function loadReport(resolvedPath) {
52
66
  if (!(await fs.pathExists(resolvedPath))) {
@@ -66,8 +80,8 @@ function printMarkdownAndPassRate(markdown, passRate) {
66
80
  console.log(String(passRate));
67
81
  }
68
82
  /**
69
- * Entry point for `pathgrade report`. Never throws and never sets a
70
- * failing exit code even on malformed inputs or network errors.
83
+ * Entry point for `pathgrade report`. The default display-only mode never
84
+ * fails CI. Strict mode is intended for orchestrators that own publication.
71
85
  */
72
86
  export async function runReport(cwd, opts = {}) {
73
87
  const resolvedPath = path.resolve(cwd, opts.resultsPath ?? DEFAULT_RESULTS_PATH);
@@ -77,6 +91,9 @@ export async function runReport(cwd, opts = {}) {
77
91
  // Branch point: when true, post the comment and print only the pass rate.
78
92
  const shouldPost = inCI && !noComment;
79
93
  const prContext = shouldPost ? resolvePrContext(process.env) : null;
94
+ if (opts.strict && shouldPost && !prContext) {
95
+ throw new Error('pathgrade report: strict PR commenting requires GITHUB_TOKEN, GITHUB_REPOSITORY, and pull-request context');
96
+ }
80
97
  let report = null;
81
98
  let loadError = null;
82
99
  try {
@@ -95,6 +112,8 @@ export async function runReport(cwd, opts = {}) {
95
112
  await postOrUpdateComment(prContext, {
96
113
  commentId,
97
114
  body: markdown,
115
+ strict: opts.strict,
116
+ expectedHeadSha: opts.expectedHeadSha,
98
117
  });
99
118
  console.log('0');
100
119
  }
@@ -108,15 +127,31 @@ export async function runReport(cwd, opts = {}) {
108
127
  await postOrUpdateComment(prContext, {
109
128
  commentId,
110
129
  body: MISSING_RESULTS_BODY,
130
+ strict: opts.strict,
131
+ expectedHeadSha: opts.expectedHeadSha,
111
132
  });
112
133
  }
113
134
  // Always emit a pass rate line so downstream capture doesn't explode.
114
135
  console.log('0');
136
+ if (opts.strict)
137
+ throw new Error(`pathgrade report: ${loadError}`);
115
138
  return;
116
139
  }
117
- const markdown = formatReportMarkdown(report, { commentId });
140
+ const markdown = formatReportMarkdown(report, {
141
+ commentId,
142
+ detailsUrl: opts.detailsUrl,
143
+ notice: opts.notice,
144
+ });
118
145
  if (prContext) {
119
- await postOrUpdateComment(prContext, { commentId, body: markdown });
146
+ const postResult = await postOrUpdateComment(prContext, {
147
+ commentId,
148
+ body: markdown,
149
+ strict: opts.strict,
150
+ expectedHeadSha: opts.expectedHeadSha,
151
+ });
152
+ if (postResult === 'stale') {
153
+ console.error('pathgrade report: pull-request head advanced; skipped stale comment update');
154
+ }
120
155
  // Only the pass rate goes to stdout — the markdown lives on the PR.
121
156
  console.log(String(report.overall_pass_rate));
122
157
  return;
@@ -8,6 +8,7 @@
8
8
  export interface PathgradeRunArgs {
9
9
  runnerArgs: string[];
10
10
  adapterName?: string;
11
+ attempts?: number;
11
12
  forceDiagnostics: boolean;
12
13
  forceVerbose: boolean;
13
14
  changed: boolean;
@@ -14,6 +14,7 @@ export function parsePathgradeRunArgs(args) {
14
14
  let since;
15
15
  let changedFilesPath;
16
16
  let adapterName;
17
+ let attempts;
17
18
  let passthrough = false;
18
19
  for (const arg of args) {
19
20
  if (passthrough) {
@@ -44,6 +45,10 @@ export function parsePathgradeRunArgs(args) {
44
45
  adapterName = arg.slice('--adapter='.length);
45
46
  continue;
46
47
  }
48
+ if (arg.startsWith('--attempts=')) {
49
+ attempts = parseAttempts(arg.slice('--attempts='.length));
50
+ continue;
51
+ }
47
52
  if (arg.startsWith('--since=')) {
48
53
  since = arg.slice('--since='.length);
49
54
  continue;
@@ -68,8 +73,16 @@ export function parsePathgradeRunArgs(args) {
68
73
  changed,
69
74
  quiet,
70
75
  adapterName,
76
+ attempts,
71
77
  since,
72
78
  changedFilesPath,
73
79
  };
74
80
  return warnings.length > 0 ? { ...base, warnings } : base;
75
81
  }
82
+ function parseAttempts(value) {
83
+ const attempts = Number(value);
84
+ if (!Number.isSafeInteger(attempts) || attempts < 1) {
85
+ throw new Error('pathgrade: --attempts must be an integer greater than or equal to 1');
86
+ }
87
+ return attempts;
88
+ }
@@ -27,13 +27,13 @@ export async function runChanged(opts) {
27
27
  const runnerEnv = buildRunnerEnv(parsed, {
28
28
  PATHGRADE_SELECTION_INVOCATION_ID: selectionInvocationId,
29
29
  });
30
- const configPath = findVitestConfigArg(parsed.runnerArgs);
31
30
  let config;
32
31
  let runnerInvocation;
33
32
  try {
34
33
  config = await resolvePathgradeConfig({
35
34
  cwd,
36
- legacyVitestConfigPath: configPath,
35
+ cli: parsed.attempts === undefined ? undefined : { attempts: parsed.attempts },
36
+ runnerArgs: parsed.runnerArgs,
37
37
  warn: w => {
38
38
  if (!parsed.quiet)
39
39
  process.stderr.write(`${w}\n`);
@@ -107,6 +107,7 @@ export async function runChanged(opts) {
107
107
  totalEvals: evalFiles.length,
108
108
  changedCount: changedFiles.length,
109
109
  result,
110
+ attempts: config.attempts,
110
111
  });
111
112
  }
112
113
  // Persist the sidecar immediately — even on empty selection, so the
@@ -128,7 +129,7 @@ export async function runChanged(opts) {
128
129
  cwd,
129
130
  runnerArgs,
130
131
  selectedFiles,
131
- env: runnerEnv,
132
+ env: { ...runnerEnv, PATHGRADE_ATTEMPT_COUNT: String(config.attempts) },
132
133
  });
133
134
  }
134
135
  function printRunStartSummary(input) {
@@ -140,6 +141,7 @@ function printRunStartSummary(input) {
140
141
  const globalLabel = result.globalMatch ? `\`${result.globalMatch}\`` : 'none';
141
142
  process.stderr.write(` global matches: ${globalLabel}\n`);
142
143
  process.stderr.write(` selected: ${result.selected.length} / ${totalEvals} evals\n`);
144
+ process.stderr.write(` attempts: ${input.attempts} per selected case\n`);
143
145
  for (const entry of result.selected) {
144
146
  process.stderr.write(` ${entry.file}\n`);
145
147
  }
@@ -158,15 +160,3 @@ function readChangedFilesList(filePath) {
158
160
  function errMsg(err) {
159
161
  return err instanceof Error ? err.message : String(err);
160
162
  }
161
- function findVitestConfigArg(args) {
162
- for (let i = 0; i < args.length; i++) {
163
- const arg = args[i];
164
- if (arg === '--config' || arg === '-c')
165
- return args[i + 1];
166
- if (arg.startsWith('--config='))
167
- return arg.slice('--config='.length);
168
- if (arg.startsWith('-c='))
169
- return arg.slice('-c='.length);
170
- }
171
- return undefined;
172
- }
@@ -1,4 +1,5 @@
1
1
  export interface PathgradeConfig {
2
+ attempts?: number;
2
3
  runner?: {
3
4
  adapter?: string;
4
5
  args?: string[];
@@ -18,6 +19,7 @@ export interface PathgradeConfig {
18
19
  };
19
20
  }
20
21
  export interface ResolvedPathgradeConfig {
22
+ attempts: number;
21
23
  runner: {
22
24
  adapter: string;
23
25
  args: string[];
@@ -44,5 +46,6 @@ export declare function resolvePathgradeConfig(input: {
44
46
  cli?: PathgradeConfig;
45
47
  configPath?: string;
46
48
  legacyVitestConfigPath?: string;
49
+ runnerArgs?: readonly string[];
47
50
  warn?: (message: string) => void;
48
51
  }): Promise<ResolvedPathgradeConfig>;
@@ -17,6 +17,7 @@ const PATHGRADE_CONFIG_CANDIDATES = [
17
17
  ];
18
18
  export function defaultPathgradeConfig() {
19
19
  return {
20
+ attempts: 1,
20
21
  runner: {
21
22
  adapter: 'vitest',
22
23
  args: [],
@@ -35,13 +36,30 @@ export function defaultPathgradeConfig() {
35
36
  }
36
37
  export async function resolvePathgradeConfig(input) {
37
38
  const fileConfig = await loadPathgradeConfigFile(input.cwd, input.configPath);
38
- const legacyConfig = await loadLegacyVitestConfig(input.cwd, input.legacyVitestConfigPath, input.warn);
39
+ const legacyConfig = await loadLegacyVitestConfig(input.cwd, input.legacyVitestConfigPath ?? findVitestConfigArg([
40
+ ...(fileConfig?.runner?.args ?? []),
41
+ ...(input.runnerArgs ?? []),
42
+ ]), input.warn);
39
43
  return mergePathgradeConfig(mergePathgradeConfig(mergePathgradeConfig(defaultPathgradeConfig(), legacyConfig), fileConfig), input.cli);
40
44
  }
45
+ function findVitestConfigArg(args) {
46
+ let configPath;
47
+ for (let i = 0; i < args.length; i++) {
48
+ const arg = args[i];
49
+ if (arg === '--config' || arg === '-c')
50
+ configPath = args[i + 1];
51
+ else if (arg.startsWith('--config='))
52
+ configPath = arg.slice('--config='.length);
53
+ else if (arg.startsWith('-c='))
54
+ configPath = arg.slice('-c='.length);
55
+ }
56
+ return configPath;
57
+ }
41
58
  function mergePathgradeConfig(base, override) {
42
59
  if (!override)
43
60
  return base;
44
61
  return {
62
+ attempts: override.attempts ?? base.attempts,
45
63
  runner: {
46
64
  adapter: override.runner?.adapter ?? base.runner.adapter,
47
65
  args: override.runner?.args ?? base.runner.args,
@@ -91,6 +109,7 @@ function validatePathgradeConfig(value, label) {
91
109
  throw invalidConfig(label, 'default export must be an object');
92
110
  }
93
111
  validateOptionalObject(value.runner, label, 'runner');
112
+ validateOptionalPositiveInteger(value.attempts, label, 'attempts');
94
113
  const runner = asOptionalObject(value.runner);
95
114
  validateOptionalString(runner?.adapter, label, 'runner.adapter');
96
115
  validateOptionalStringArray(runner?.args, label, 'runner.args');
@@ -139,6 +158,11 @@ function validateOptionalNumber(value, label, field) {
139
158
  throw invalidConfig(label, `${field} must be a number`);
140
159
  }
141
160
  }
161
+ function validateOptionalPositiveInteger(value, label, field) {
162
+ if (value !== undefined && (!Number.isSafeInteger(value) || Number(value) < 1)) {
163
+ throw invalidConfig(label, `${field} must be an integer greater than or equal to 1`);
164
+ }
165
+ }
142
166
  function invalidConfig(label, reason) {
143
167
  return new InvalidPathgradeConfigError(`pathgrade: invalid ${label}: ${reason}`);
144
168
  }
@@ -179,6 +203,14 @@ async function loadLegacyVitestConfig(cwd, configPath, warn = () => { }) {
179
203
  if (!isObject(opts))
180
204
  return undefined;
181
205
  return {
206
+ ...(opts.reporter === 'cli' || opts.reporter === 'browser' || opts.reporter === 'json'
207
+ ? { reporter: opts.reporter }
208
+ : {}),
209
+ ...(typeof opts.diagnostics === 'boolean' ? { diagnostics: opts.diagnostics } : {}),
210
+ ...(typeof opts.verbose === 'boolean' ? { verbose: opts.verbose } : {}),
211
+ ...(isObject(opts.ci) && typeof opts.ci.threshold === 'number'
212
+ ? { ci: { threshold: opts.ci.threshold } }
213
+ : {}),
182
214
  evals: {
183
215
  ...(Array.isArray(opts.include) ? { include: opts.include } : {}),
184
216
  ...(Array.isArray(opts.exclude) ? { exclude: opts.exclude } : {}),
package/dist/pathgrade.js CHANGED
@@ -156,10 +156,27 @@ export async function runPathgradeCli(options = {}) {
156
156
  const reportArgs = args.slice(1);
157
157
  const resultsPathFlag = reportArgs.find(a => a.startsWith('--results-path='));
158
158
  const commentIdFlag = reportArgs.find(a => a.startsWith('--comment-id='));
159
+ const detailsUrlFlag = reportArgs.find(a => a.startsWith('--details-url='));
160
+ const noticeFlag = reportArgs.find(a => a.startsWith('--notice='));
161
+ const expectedHeadShaFlag = reportArgs.find(a => a.startsWith('--expected-head-sha='));
159
162
  const noComment = reportArgs.includes('--no-comment');
163
+ const strict = reportArgs.includes('--strict');
160
164
  const resultsPath = resultsPathFlag ? resultsPathFlag.split('=').slice(1).join('=') : undefined;
161
165
  const commentId = commentIdFlag ? commentIdFlag.split('=').slice(1).join('=') : undefined;
162
- await runReport(process.cwd(), { resultsPath, commentId, noComment });
166
+ const detailsUrl = detailsUrlFlag ? detailsUrlFlag.split('=').slice(1).join('=') : undefined;
167
+ const notice = noticeFlag ? noticeFlag.split('=').slice(1).join('=') : undefined;
168
+ const expectedHeadSha = expectedHeadShaFlag
169
+ ? expectedHeadShaFlag.split('=').slice(1).join('=')
170
+ : undefined;
171
+ await runReport(process.cwd(), {
172
+ resultsPath,
173
+ commentId,
174
+ detailsUrl,
175
+ notice,
176
+ expectedHeadSha,
177
+ noComment,
178
+ strict,
179
+ });
163
180
  return;
164
181
  }
165
182
  if (command === 'run' || !command || command.startsWith('-')) {
@@ -184,7 +201,14 @@ export async function runPathgradeCli(options = {}) {
184
201
  await clearSidecar(process.cwd());
185
202
  const env = buildRunnerEnv(parsed);
186
203
  try {
187
- const config = await resolvePathgradeConfig({ cwd: process.cwd() });
204
+ const config = await resolvePathgradeConfig({
205
+ cwd: process.cwd(),
206
+ cli: parsed.attempts === undefined ? undefined : { attempts: parsed.attempts },
207
+ runnerArgs: parsed.runnerArgs,
208
+ });
209
+ if (!parsed.quiet && config.attempts > 1) {
210
+ console.error(`pathgrade: ${config.attempts} sequential attempts per selected case`);
211
+ }
188
212
  const runner = await loadRunnerInvocationAdapter({
189
213
  adapterName: parsed.adapterName ?? config.runner.adapter,
190
214
  cwd: process.cwd(),
@@ -193,7 +217,7 @@ export async function runPathgradeCli(options = {}) {
193
217
  process.exitCode = await runner.run({
194
218
  cwd: process.cwd(),
195
219
  runnerArgs: [...config.runner.args, ...parsed.runnerArgs],
196
- env,
220
+ env: { ...env, PATHGRADE_ATTEMPT_COUNT: String(config.attempts) },
197
221
  });
198
222
  }
199
223
  catch (err) {
@@ -216,6 +240,7 @@ function printHelp(cliName) {
216
240
  [--since=<ref>] Override base ref (implies git mode)
217
241
  [--changed-files=<path>] Use an explicit newline-delimited file list
218
242
  [--adapter=<name|path>] Select built-in or third-party runner adapter
243
+ [--attempts=N] Run every selected case N times sequentially
219
244
  [--diagnostics] Print full diagnostics for passing evals too
220
245
  [--quiet] Suppress the run-start summary
221
246
  [--verbose|-v] Stream live per-turn events to stderr during the run
@@ -234,6 +259,10 @@ function printHelp(cliName) {
234
259
  [--results-path=<path>] Override results.json location
235
260
  [--no-comment] Print markdown to stdout; do not post
236
261
  [--comment-id=<id>] Override comment marker (default: $GITHUB_WORKFLOW:$GITHUB_JOB)
262
+ [--details-url=<url>] Add a provider-neutral full-report link
263
+ [--notice=<text>] Add a provider-neutral report status notice
264
+ [--expected-head-sha=<sha>] Skip stale PR comment updates
265
+ [--strict] Fail on missing context, results, or GitHub errors
237
266
  ${cliName} affected Print eval files affected by a change-set (one per line)
238
267
  [--since=<ref>] Diff <ref>...HEAD (overrides git auto-detection)
239
268
  [--changed-files=<path>] Newline-delimited repo-relative file list
@@ -18,8 +18,8 @@ export async function runCliPreview(resultsDir, opts) {
18
18
  }
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
- const passRate = report.pass_rate ?? 0;
22
- const isPass = passRate >= 0.5;
21
+ const meanReward = report.mean_reward ?? report.pass_rate ?? 0;
22
+ const isPass = report.status === undefined ? meanReward >= 0.5 : report.status === 'pass';
23
23
  const trials = report.trials || [];
24
24
  const avgDur = trials.reduce((s, t) => s + (t.duration_ms || 0), 0) / (trials.length || 1);
25
25
  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);
@@ -33,9 +33,9 @@ export async function runCliPreview(resultsDir, opts) {
33
33
  console.log();
34
34
  // ── Summary metrics
35
35
  const metrics = [
36
- ['Pass Rate', `${(passRate * 100).toFixed(1)}%`],
37
- ['pass@k', report.pass_at_k != null ? `${(report.pass_at_k * 100).toFixed(1)}%` : '—'],
38
- ['pass^k', report.pass_pow_k != null ? `${(report.pass_pow_k * 100).toFixed(1)}%` : '—'],
36
+ ['Mean Reward', `${(meanReward * 100).toFixed(1)}%`],
37
+ ['Success Rate', report.success_rate != null ? `${(report.success_rate * 100).toFixed(1)}%` : '—'],
38
+ ['pass@k', formatPassAtK(report.pass_at_k, report.pass_at_k_unavailable_reason)],
39
39
  ['Avg Duration', `${(avgDur / 1000).toFixed(1)}s`],
40
40
  ['Total Tokens', `~${totalTokens}`],
41
41
  ['Skills', report.skills_used?.join(', ') || 'none'],
@@ -47,7 +47,7 @@ export async function runCliPreview(resultsDir, opts) {
47
47
  // ── Trials
48
48
  for (const trial of trials) {
49
49
  const evaluated = trial.reward !== undefined;
50
- const tp = evaluated && trial.reward >= 0.5;
50
+ const tp = evaluated && (trial.runner_outcome === undefined ? trial.reward >= 0.5 : trial.runner_outcome === 'passed' && trial.reward === 1);
51
51
  const trialStatus = !evaluated ? fmt.dim('N/A') : tp ? fmt.pass('PASS') : fmt.fail('FAIL');
52
52
  const reward = fmt.bold(evaluated ? trial.reward.toFixed(2) : 'n/a');
53
53
  const dur = `${((trial.duration_ms || 0) / 1000).toFixed(1)}s`;
@@ -120,6 +120,13 @@ export async function runCliPreview(resultsDir, opts) {
120
120
  console.log();
121
121
  }
122
122
  }
123
+ function formatPassAtK(value, reason) {
124
+ if (typeof value === 'number')
125
+ return `${(value * 100).toFixed(1)}% (legacy v1)`;
126
+ if (value)
127
+ return Object.entries(value).map(([k, metric]) => `@${k} ${(metric * 100).toFixed(1)}%`).join(', ');
128
+ return reason ? `— (${reason.replaceAll('_', ' ')})` : '—';
129
+ }
123
130
  function formatScorerStatus(status) {
124
131
  switch (status) {
125
132
  case 'error':
@@ -10,6 +10,10 @@
10
10
  import type { PathgradeReport, PathgradeSelectionReport } from '../types.js';
11
11
  export interface FormatOptions {
12
12
  commentId: string;
13
+ /** Provider-neutral URL for a richer external report. */
14
+ detailsUrl?: string;
15
+ /** Provider-neutral status notice rendered above report details. */
16
+ notice?: string;
13
17
  }
14
18
  /** Minimal body posted when `.pathgrade/results.json` is missing. */
15
19
  export declare const MISSING_RESULTS_BODY = "Pathgrade evals did not produce results. Check the workflow logs.";
@@ -55,14 +59,19 @@ export interface PostOptions {
55
59
  commentId: string;
56
60
  /** Body WITHOUT the marker — the marker is prepended if not already present. */
57
61
  body: string;
62
+ /** Throw instead of swallowing GitHub API failures. */
63
+ strict?: boolean;
64
+ /** Skip the update if the pull request has advanced to another head. */
65
+ expectedHeadSha?: string;
58
66
  }
67
+ export type PostResult = 'created' | 'updated' | 'stale';
59
68
  /**
60
69
  * Find an existing PR comment carrying `<!-- pathgrade:${commentId} -->`
61
- * and update it; otherwise create a new one. Swallows all errors (logs
62
- * to stderr) `pathgrade report` must never fail CI.
70
+ * and update it; otherwise create a new one. Default mode logs and swallows
71
+ * transport errors; strict mode rethrows them for an owning orchestrator.
63
72
  *
64
73
  * Bodies longer than GitHub's 65,536-character limit are truncated with a
65
74
  * sentinel pointing to workflow artifacts, preserving the leading dedup
66
75
  * marker so subsequent runs still find and update this comment.
67
76
  */
68
- export declare function postOrUpdateComment(ctx: PrContext, opts: PostOptions): Promise<void>;
77
+ export declare function postOrUpdateComment(ctx: PrContext, opts: PostOptions): Promise<PostResult | undefined>;