@wix/pathgrade 1.0.26 → 1.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -428,14 +428,14 @@ pathgrade analyze [--skill=<name>] [--dir=<path>]
428
428
  pathgrade affected [--since=<ref>] [--changed-files=<path>] [--explain] [--json]
429
429
  pathgrade preview [browser] [--last=N] [--filter=text]
430
430
  pathgrade preview-reactions --snapshot <run-snapshot.json> --reactions <file.ts>
431
- pathgrade report [--results-path=<path>] [--no-comment] [--comment-id=<id>]
431
+ pathgrade report [--results-path=<path>] [--no-comment] [--comment-id=<id>] [--details-url=<url>] [--expected-head-sha=<sha>] [--strict]
432
432
  ```
433
433
 
434
434
  Useful details:
435
435
 
436
436
  - `pathgrade run --changed` computes affected evals first, writes selection metadata to `.pathgrade/selection.json`, and only then launches the selected runner adapter. Vitest is the default adapter; Jest is selected with `runner.adapter: 'jest'` or `--adapter=jest`.
437
437
  - `pathgrade preview browser` starts a local viewer on `http://localhost:3847`.
438
- - `pathgrade report` posts or updates a PR comment in GitHub Actions; locally it prints the markdown report and then the numeric pass rate.
438
+ - `pathgrade report` posts or updates a PR comment in GitHub Actions; locally it prints the markdown report and then the numeric pass rate. Provider orchestrators can add a `--details-url`, suppress stale updates with `--expected-head-sha`, and opt into surfaced API failures with `--strict`.
439
439
  - `pathgrade validate --affected` is a strict mode for CI: every discovered eval must either live under a `SKILL.md` anchor or export valid `__pathgradeMeta`.
440
440
 
441
441
  Run `pathgrade --help` for the full help text.
@@ -30,6 +30,7 @@ import { parseClaudeSdkMcpToolName } from './mcp-tool-name.js';
30
30
  import { applyObservedToolResult, extractObservedToolResults, } from './tool-results.js';
31
31
  export function projectSdkMessages(input) {
32
32
  let sessionId;
33
+ let resolvedModel;
33
34
  let initSkills;
34
35
  let assistantText = '';
35
36
  let resultText = '';
@@ -52,6 +53,9 @@ export function projectSdkMessages(input) {
52
53
  sessionId = sid;
53
54
  const sub = msg.subtype;
54
55
  if (sub === 'init') {
56
+ const model = msg.model;
57
+ if (typeof model === 'string' && model)
58
+ resolvedModel = model;
55
59
  const skills = msg.skills;
56
60
  if (Array.isArray(skills))
57
61
  initSkills = skills.filter((s) => typeof s === 'string');
@@ -153,6 +157,7 @@ export function projectSdkMessages(input) {
153
157
  traceOutput,
154
158
  toolEvents: finalToolEvents,
155
159
  runtimePoliciesApplied: [],
160
+ ...(resolvedModel ? { resolvedModel } : {}),
156
161
  inputTokens,
157
162
  outputTokens,
158
163
  ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
@@ -397,7 +397,7 @@ export class CodexAppServerAgent extends BaseAgent {
397
397
  message: info.message ?? 'app-server exited',
398
398
  pid: info.pid,
399
399
  signal: info.signal,
400
- sensitiveValues,
400
+ sensitiveValues, resolvedModel: model,
401
401
  });
402
402
  }
403
403
  if (turn.turnFailed) {
@@ -406,14 +406,14 @@ export class CodexAppServerAgent extends BaseAgent {
406
406
  activeTurn: turn,
407
407
  exitCode: 1,
408
408
  message: turn.failureMessage ?? 'turn failed',
409
- sensitiveValues,
409
+ sensitiveValues, resolvedModel: model,
410
410
  });
411
411
  }
412
412
  await authoritativeTurnReady;
413
413
  if (turn.turnFailed) {
414
414
  return assembleTurnResult({
415
415
  askBus, activeTurn: turn, exitCode: 1,
416
- message: turn.failureMessage ?? 'turn failed', sensitiveValues,
416
+ message: turn.failureMessage ?? 'turn failed', sensitiveValues, resolvedModel: model,
417
417
  });
418
418
  }
419
419
  return assembleTurnResult({
@@ -421,7 +421,7 @@ export class CodexAppServerAgent extends BaseAgent {
421
421
  activeTurn: turn,
422
422
  exitCode: 0,
423
423
  message: turn.assistantMessageParts.join('\n\n'),
424
- sensitiveValues,
424
+ sensitiveValues, resolvedModel: model,
425
425
  });
426
426
  }
427
427
  finally {
@@ -612,7 +612,7 @@ function shouldEnableMcpElicitations(options) {
612
612
  return options?.mcpConfigOrigin === 'generated_mock';
613
613
  }
614
614
  function assembleTurnResult(args) {
615
- const { askBus, activeTurn, exitCode, message, pid, signal, sensitiveValues = [] } = args;
615
+ const { askBus, activeTurn, exitCode, message, pid, signal, sensitiveValues = [], resolvedModel } = args;
616
616
  for (const pending of activeTurn.pendingMcpDenials.splice(0)) {
617
617
  activeTurn.nonAskToolEvents.push(buildPolicyDeniedMcpToolEvent(activeTurn.turnNumber, pending));
618
618
  }
@@ -641,7 +641,7 @@ function assembleTurnResult(args) {
641
641
  visibleAssistantMessageSource: 'assistant_message',
642
642
  exitCode,
643
643
  traceOutput: rawOutput,
644
- toolEvents,
644
+ toolEvents, resolvedModel,
645
645
  ...(exitCode !== 0
646
646
  ? {
647
647
  crashInfo: {
@@ -25,6 +25,7 @@ export class CodexAgent extends TranscriptAgent {
25
25
  exitCode: result.exitCode,
26
26
  traceOutput: rawOutput,
27
27
  toolEvents,
28
+ resolvedModel: resolveCodexModel(options?.model),
28
29
  };
29
30
  }
30
31
  }
@@ -241,6 +241,7 @@ export class CursorAgent extends BaseAgent {
241
241
  timedOut: result.timedOut,
242
242
  toolEvents,
243
243
  runtimePoliciesApplied: appliedRuntimePolicies,
244
+ ...(options?.model ? { resolvedModel: options.model } : {}),
244
245
  ...(parsed.tokenUsage
245
246
  ? { inputTokens: parsed.tokenUsage.inputTokens, outputTokens: parsed.tokenUsage.outputTokens }
246
247
  : {}),
@@ -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';
@@ -451,7 +451,9 @@ class OpenCodeSession {
451
451
  throw new Error('OpenCode protocol error: resumed session ID changed');
452
452
  }
453
453
  this.sessionId = parsed.sessionId;
454
- return parsed.result;
454
+ return cloneTurnResultWithSensitiveValues(parsed.result, {
455
+ resolvedModel: this.runtimePolicy.model,
456
+ });
455
457
  }
456
458
  catch (error) {
457
459
  this.failed = true;
@@ -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>;
@@ -66,8 +66,8 @@ function printMarkdownAndPassRate(markdown, passRate) {
66
66
  console.log(String(passRate));
67
67
  }
68
68
  /**
69
- * Entry point for `pathgrade report`. Never throws and never sets a
70
- * failing exit code even on malformed inputs or network errors.
69
+ * Entry point for `pathgrade report`. The default display-only mode never
70
+ * fails CI. Strict mode is intended for orchestrators that own publication.
71
71
  */
72
72
  export async function runReport(cwd, opts = {}) {
73
73
  const resolvedPath = path.resolve(cwd, opts.resultsPath ?? DEFAULT_RESULTS_PATH);
@@ -77,6 +77,9 @@ export async function runReport(cwd, opts = {}) {
77
77
  // Branch point: when true, post the comment and print only the pass rate.
78
78
  const shouldPost = inCI && !noComment;
79
79
  const prContext = shouldPost ? resolvePrContext(process.env) : null;
80
+ if (opts.strict && shouldPost && !prContext) {
81
+ throw new Error('pathgrade report: strict PR commenting requires GITHUB_TOKEN, GITHUB_REPOSITORY, and pull-request context');
82
+ }
80
83
  let report = null;
81
84
  let loadError = null;
82
85
  try {
@@ -95,6 +98,8 @@ export async function runReport(cwd, opts = {}) {
95
98
  await postOrUpdateComment(prContext, {
96
99
  commentId,
97
100
  body: markdown,
101
+ strict: opts.strict,
102
+ expectedHeadSha: opts.expectedHeadSha,
98
103
  });
99
104
  console.log('0');
100
105
  }
@@ -108,15 +113,31 @@ export async function runReport(cwd, opts = {}) {
108
113
  await postOrUpdateComment(prContext, {
109
114
  commentId,
110
115
  body: MISSING_RESULTS_BODY,
116
+ strict: opts.strict,
117
+ expectedHeadSha: opts.expectedHeadSha,
111
118
  });
112
119
  }
113
120
  // Always emit a pass rate line so downstream capture doesn't explode.
114
121
  console.log('0');
122
+ if (opts.strict)
123
+ throw new Error(`pathgrade report: ${loadError}`);
115
124
  return;
116
125
  }
117
- const markdown = formatReportMarkdown(report, { commentId });
126
+ const markdown = formatReportMarkdown(report, {
127
+ commentId,
128
+ detailsUrl: opts.detailsUrl,
129
+ notice: opts.notice,
130
+ });
118
131
  if (prContext) {
119
- await postOrUpdateComment(prContext, { commentId, body: markdown });
132
+ const postResult = await postOrUpdateComment(prContext, {
133
+ commentId,
134
+ body: markdown,
135
+ strict: opts.strict,
136
+ expectedHeadSha: opts.expectedHeadSha,
137
+ });
138
+ if (postResult === 'stale') {
139
+ console.error('pathgrade report: pull-request head advanced; skipped stale comment update');
140
+ }
120
141
  // Only the pass rate goes to stdout — the markdown lives on the PR.
121
142
  console.log(String(report.overall_pass_rate));
122
143
  return;
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('-')) {
@@ -234,6 +251,10 @@ function printHelp(cliName) {
234
251
  [--results-path=<path>] Override results.json location
235
252
  [--no-comment] Print markdown to stdout; do not post
236
253
  [--comment-id=<id>] Override comment marker (default: $GITHUB_WORKFLOW:$GITHUB_JOB)
254
+ [--details-url=<url>] Add a provider-neutral full-report link
255
+ [--notice=<text>] Add a provider-neutral report status notice
256
+ [--expected-head-sha=<sha>] Skip stale PR comment updates
257
+ [--strict] Fail on missing context, results, or GitHub errors
237
258
  ${cliName} affected Print eval files affected by a change-set (one per line)
238
259
  [--since=<ref>] Diff <ref>...HEAD (overrides git auto-detection)
239
260
  [--changed-files=<path>] Newline-delimited repo-relative file list
@@ -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>;
@@ -26,6 +26,12 @@ export function formatNoAffectedEvalsMarkdown(selection) {
26
26
  return lines.join('\n');
27
27
  }
28
28
  export function commentMarker(commentId) {
29
+ if (commentId.length === 0
30
+ || commentId.length > 128
31
+ || /[<>\r\n]/.test(commentId)
32
+ || commentId.includes('--')) {
33
+ throw new Error('Pathgrade comment id must be 1-128 characters and cannot contain HTML comment delimiters');
34
+ }
29
35
  return `<!-- pathgrade:${commentId} -->`;
30
36
  }
31
37
  function pct(n) {
@@ -59,9 +65,19 @@ export function formatReportMarkdown(report, opts) {
59
65
  lines.push('');
60
66
  lines.push(`### ${icon} Pathgrade report`);
61
67
  lines.push('');
68
+ lines.push(`**Last run:** \`${report.timestamp}\``);
69
+ lines.push('');
62
70
  lines.push(`**Pass rate:** ${pct(p)} | ` +
63
71
  `**pass@${totalTrials}:** ${pct(overallPassAtK)} | ` +
64
72
  `**pass^${totalTrials}:** ${pct(overallPassPowK)}`);
73
+ if (opts.detailsUrl) {
74
+ lines.push('');
75
+ lines.push(`**Details:** [Open the full report](${formatLinkDestination(opts.detailsUrl)})`);
76
+ }
77
+ if (opts.notice) {
78
+ lines.push('');
79
+ lines.push(`> ${opts.notice.replace(/[\r\n]+/g, ' ').trim()}`);
80
+ }
65
81
  if (report.threshold != null) {
66
82
  lines.push('');
67
83
  lines.push(`Threshold: ${pct(report.threshold)} — ${report.status.toUpperCase()}`);
@@ -100,14 +116,14 @@ export function formatSelectionSection(selection) {
100
116
  out.push('### Selection');
101
117
  const total = selection.selected.length + selection.skipped.length;
102
118
  if (selection.global_match) {
103
- out.push(`Ran **all ${total}** evals — global trigger \`${selection.global_match}\` matched.`);
119
+ out.push(`Selected **all ${total}** eval files — global trigger \`${selection.global_match}\` matched.`);
104
120
  return out.join('\n');
105
121
  }
106
122
  if (selection.skipped.length === 0 && selection.selected.length > 0) {
107
- out.push(`Ran **all ${total}** evals — every eval had a matching change.`);
123
+ out.push(`Selected **all ${total}** eval files — every eval had a matching change.`);
108
124
  return out.join('\n');
109
125
  }
110
- out.push(`Ran **${selection.selected.length} of ${total}** evals based on changes vs \`${selection.base_ref}\`.`);
126
+ out.push(`Selected **${selection.selected.length} of ${total}** eval files based on changes vs \`${selection.base_ref}\`.`);
111
127
  if (selection.skipped.length > 0) {
112
128
  out.push('');
113
129
  out.push(`<details><summary>${selection.skipped.length} skipped (unaffected)</summary>`);
@@ -171,13 +187,36 @@ function apiHeaders(token) {
171
187
  };
172
188
  }
173
189
  async function listPrComments(ctx) {
174
- const url = `https://api.github.com/repos/${ctx.owner}/${ctx.repo}/issues/${ctx.prNumber}/comments?per_page=100`;
190
+ const comments = [];
191
+ for (let page = 1; page <= 100; page += 1) {
192
+ const url = `https://api.github.com/repos/${ctx.owner}/${ctx.repo}/issues/${ctx.prNumber}/comments?per_page=100&page=${page}`;
193
+ const res = await fetch(url, { headers: apiHeaders(ctx.token) });
194
+ if (!res.ok) {
195
+ throw new Error(`GET ${url} returned ${res.status}`);
196
+ }
197
+ const data = await res.json();
198
+ if (!Array.isArray(data)) {
199
+ throw new Error(`GET ${url} returned a non-array response`);
200
+ }
201
+ for (const value of data) {
202
+ if (isGithubComment(value))
203
+ comments.push(value);
204
+ }
205
+ if (data.length < 100)
206
+ break;
207
+ }
208
+ return comments;
209
+ }
210
+ async function currentPrHeadSha(ctx) {
211
+ const url = `https://api.github.com/repos/${ctx.owner}/${ctx.repo}/pulls/${ctx.prNumber}`;
175
212
  const res = await fetch(url, { headers: apiHeaders(ctx.token) });
176
- if (!res.ok) {
213
+ if (!res.ok)
177
214
  throw new Error(`GET ${url} returned ${res.status}`);
215
+ const data = await res.json();
216
+ if (!isRecord(data) || !isRecord(data.head) || typeof data.head.sha !== 'string') {
217
+ throw new Error(`GET ${url} returned no pull-request head SHA`);
178
218
  }
179
- const data = (await res.json());
180
- return Array.isArray(data) ? data : [];
219
+ return data.head.sha;
181
220
  }
182
221
  async function createPrComment(ctx, body) {
183
222
  const url = `https://api.github.com/repos/${ctx.owner}/${ctx.repo}/issues/${ctx.prNumber}/comments`;
@@ -206,8 +245,8 @@ const GITHUB_COMMENT_MAX = 65536;
206
245
  const TRUNCATION_SENTINEL = '\n\n_…output truncated to fit GitHub\'s comment size limit — see the workflow artifacts for the full report._';
207
246
  /**
208
247
  * Find an existing PR comment carrying `<!-- pathgrade:${commentId} -->`
209
- * and update it; otherwise create a new one. Swallows all errors (logs
210
- * to stderr) `pathgrade report` must never fail CI.
248
+ * and update it; otherwise create a new one. Default mode logs and swallows
249
+ * transport errors; strict mode rethrows them for an owning orchestrator.
211
250
  *
212
251
  * Bodies longer than GitHub's 65,536-character limit are truncated with a
213
252
  * sentinel pointing to workflow artifacts, preserving the leading dedup
@@ -221,19 +260,51 @@ export async function postOrUpdateComment(ctx, opts) {
221
260
  : withMarker.slice(0, GITHUB_COMMENT_MAX - TRUNCATION_SENTINEL.length) +
222
261
  TRUNCATION_SENTINEL;
223
262
  try {
263
+ if (opts.expectedHeadSha) {
264
+ const currentHead = await currentPrHeadSha(ctx);
265
+ if (currentHead !== opts.expectedHeadSha)
266
+ return 'stale';
267
+ }
224
268
  const existing = await listPrComments(ctx);
225
269
  const match = existing.find((c) => typeof c.body === 'string' && c.body.includes(marker));
226
270
  if (match) {
227
271
  await updatePrComment(ctx, match.id, body);
272
+ return 'updated';
228
273
  }
229
274
  else {
230
275
  await createPrComment(ctx, body);
276
+ return 'created';
231
277
  }
232
278
  }
233
279
  catch (err) {
234
280
  const message = err instanceof Error ? err.message : String(err);
235
281
  console.error(`pathgrade report: failed to post PR comment — ${message}`);
282
+ if (opts.strict)
283
+ throw err;
284
+ }
285
+ }
286
+ function formatLinkDestination(value) {
287
+ let url;
288
+ try {
289
+ url = new URL(value);
236
290
  }
291
+ catch {
292
+ throw new Error('Pathgrade details URL must be an absolute HTTP(S) URL');
293
+ }
294
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
295
+ throw new Error('Pathgrade details URL must be an absolute HTTP(S) URL');
296
+ }
297
+ return url.toString().replace(/\)/g, '%29');
298
+ }
299
+ function isRecord(value) {
300
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
301
+ }
302
+ function isGithubComment(value) {
303
+ return isRecord(value)
304
+ && typeof value.id === 'number'
305
+ && Number.isSafeInteger(value.id)
306
+ && value.id > 0
307
+ && typeof value.body === 'string';
237
308
  }
238
309
  function formatGroupDetails(group) {
239
310
  const out = [];
@@ -1,18 +1,21 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
+ import { collectSensitiveEnvValues, sanitizePersistenceValue, } from '../tool-event-results.js';
3
4
  export async function writePathgradeArtifacts(artifactRoot, built) {
4
5
  await fs.ensureDir(path.join(artifactRoot, 'traces'));
6
+ const sensitiveValues = collectSensitiveEnvValues(process.env);
7
+ const report = sanitizePersistenceValue(built.report, sensitiveValues);
5
8
  const gitignorePath = path.join(artifactRoot, '.gitignore');
6
9
  if (!(await fs.pathExists(gitignorePath))) {
7
10
  await fs.writeFile(gitignorePath, '*\n');
8
11
  }
9
12
  const traceFiles = [];
10
13
  for (const trace of built.traces) {
11
- await fs.writeJson(path.join(artifactRoot, trace.traceFile), trace.trials, { spaces: 2 });
14
+ await fs.writeJson(path.join(artifactRoot, trace.traceFile), sanitizePersistenceValue(trace.trials, sensitiveValues), { spaces: 2 });
12
15
  traceFiles.push(trace.traceFile);
13
16
  }
14
17
  const resultsPath = path.join(artifactRoot, 'results.json');
15
- await fs.writeJson(resultsPath, built.report, { spaces: 2 });
18
+ await fs.writeJson(resultsPath, report, { spaces: 2 });
16
19
  return {
17
20
  resultsPath,
18
21
  traceFiles,
package/dist/sdk/agent.js CHANGED
@@ -4,7 +4,7 @@ import { lifecycleCore } from './lifecycle.js';
4
4
  import { ChatSessionImpl } from './chat.js';
5
5
  import { runConversation } from './converse.js';
6
6
  import { createPersona } from './persona.js';
7
- import { evaluate } from './evaluate.js';
7
+ import { evaluateStepScorers } from './evaluate.js';
8
8
  import { createManagedSession } from './managed-session.js';
9
9
  import { createAgentLLM } from '../utils/llm.js';
10
10
  import { buildRunSnapshot } from './snapshots.js';
@@ -39,6 +39,7 @@ class AgentImpl {
39
39
  llm;
40
40
  timeoutSetting;
41
41
  modelOpt;
42
+ resolvedModel;
42
43
  conversationWindowOpt;
43
44
  interactionMode = null;
44
45
  _messages = [];
@@ -100,6 +101,7 @@ class AgentImpl {
100
101
  return {
101
102
  name: this.agentName,
102
103
  ...(this.modelOpt ? { requestedModel: this.modelOpt } : {}),
104
+ ...(this.resolvedModel ? { resolvedModel: this.resolvedModel } : {}),
103
105
  transport: this.scenarioArtifact && (this.agentName === 'opencode' || this.agentName === 'cursor')
104
106
  ? 'acp'
105
107
  : resolveExecutionTransport(this.agentName, this.transport),
@@ -145,6 +147,9 @@ class AgentImpl {
145
147
  return Math.ceil((turns * 80_000 + 200_000) / 1000);
146
148
  }
147
149
  accumulateTurnUsage(turnResult) {
150
+ if (turnResult.resolvedModel) {
151
+ this.resolvedModel = turnResult.resolvedModel;
152
+ }
148
153
  if (turnResult.inputTokens || turnResult.outputTokens) {
149
154
  this.llm.addTokens?.(turnResult.inputTokens ?? 0, turnResult.outputTokens ?? 0);
150
155
  }
@@ -303,7 +308,7 @@ class AgentImpl {
303
308
  // scorer judge calls accumulate on the same tracker.
304
309
  const agent = this;
305
310
  const runStepScorers = async (scorers) => {
306
- const result = await evaluate(agent, scorers, { llm: this.llm });
311
+ const result = await evaluateStepScorers(agent, scorers, { llm: this.llm });
307
312
  const score = result.score;
308
313
  if (score === undefined) {
309
314
  throw new Error('Step scorer evaluation did not produce a score');
@@ -9,5 +9,7 @@ type EvaluateFromSnapshot = (snapshotPath: string, scorers: Scorer[], opts?: Eva
9
9
  type EvaluateFn = ((agent: Agent, scorers: Scorer[], opts?: EvaluateOptions) => Promise<RecordedEvalResult>) & {
10
10
  fromSnapshot: EvaluateFromSnapshot;
11
11
  };
12
+ /** Internal runConversation hook: step scorers must not consume final-run attribution. */
13
+ export declare function evaluateStepScorers(agent: Agent, scorers: Scorer[], opts?: EvaluateOptions): Promise<RecordedEvalResult>;
12
14
  export declare const evaluate: EvaluateFn;
13
15
  export {};
@@ -1,4 +1,4 @@
1
- import { extractSkillsFromLog, extractToolEventsFromLog } from '../tool-events.js';
1
+ import { countShellCommandsFromLog, extractSkillsFromLog, extractToolEventsFromLog } from '../tool-events.js';
2
2
  import { getRuntime } from './eval-runtime.js';
3
3
  import { emitEvalResult } from './result-capture.js';
4
4
  import { runJudgePipeline } from './judge-pipeline.js';
@@ -33,7 +33,7 @@ export class EvalScorerError extends Error {
33
33
  // Local per-call: no module-level state, no cleanup export needed.
34
34
  function makeEvaluateAgent() {
35
35
  const conversationAttributed = new WeakSet();
36
- return async function evaluateAgent(agent, scorers, opts) {
36
+ return async function evaluateAgent(agent, scorers, opts, attributeConversation = true) {
37
37
  const toolEvents = extractToolEventsFromLog(agent.log);
38
38
  const ctx = {
39
39
  workspace: agent.workspace,
@@ -46,12 +46,12 @@ function makeEvaluateAgent() {
46
46
  };
47
47
  const trackedLLM = opts?.llm ?? agent.llm;
48
48
  // Snapshot conversation tokens BEFORE running scorers, for first-eval attribution.
49
- const before = trackedLLM.tokenUsage ?? { inputTokens: 0, outputTokens: 0 };
49
+ const before = agent.llm.tokenUsage ?? { inputTokens: 0, outputTokens: 0 };
50
50
  // Snapshot conversation cost too. AgentImpl's `sendTurn` accumulates
51
51
  // per-turn `costUsd` onto `trackedLLM` via `addCost`, so by the time
52
52
  // `evaluate()` runs the pre-evaluate cost is the conversation's
53
53
  // accumulated agent-turn cost.
54
- const beforeCostUsd = trackedLLM.costUsd ?? 0;
54
+ const beforeCostUsd = agent.llm.costUsd ?? 0;
55
55
  // measure() returns the delta consumed by this evaluate call.
56
56
  const scoringStartedAt = performance.now();
57
57
  const { result: evalResult, tokens: deltaTokenUsage } = trackedLLM.measure
@@ -60,12 +60,13 @@ function makeEvaluateAgent() {
60
60
  const r = await evaluateWithContext(ctx, scorers, { ...opts, llm: trackedLLM });
61
61
  return { result: r, tokens: r.tokenUsage ?? { inputTokens: 0, outputTokens: 0 } };
62
62
  })();
63
- // Attribute conversation tokens on the first evaluate() for this agent.
64
- const isFirstEval = !conversationAttributed.has(agent);
63
+ // Attribute conversation tokens on the first public evaluate() for this agent.
64
+ const isFirstEval = attributeConversation && !conversationAttributed.has(agent);
65
65
  const conversationTokens = isFirstEval && (before.inputTokens > 0 || before.outputTokens > 0)
66
66
  ? { conversation_input_tokens: before.inputTokens, conversation_output_tokens: before.outputTokens }
67
67
  : undefined;
68
- // Same first-eval attribution rule for cost. Omitted entirely when
68
+ // Agent usage is independent of the optional scorer LLM. Same
69
+ // first-eval attribution rule for cost; omit it when unavailable.
69
70
  // no conversation cost was captured (Codex / Cursor today).
70
71
  const conversationCost = isFirstEval && beforeCostUsd > 0
71
72
  ? { conversation_cost_usd: beforeCostUsd }
@@ -89,6 +90,10 @@ function makeEvaluateAgent() {
89
90
  };
90
91
  }
91
92
  const evaluateAgent = makeEvaluateAgent();
93
+ /** Internal runConversation hook: step scorers must not consume final-run attribution. */
94
+ export function evaluateStepScorers(agent, scorers, opts) {
95
+ return evaluateAgent(agent, scorers, opts, false);
96
+ }
92
97
  async function fromSnapshot(snapshotPath, scorers, opts) {
93
98
  const snapshot = await loadRunSnapshot(snapshotPath);
94
99
  const trackedLLM = opts?.llm ?? createLLMClient({ adapters: [{
@@ -268,7 +273,7 @@ function matchesArtifactPattern(artifactPath, pattern) {
268
273
  return pattern.test(artifactPath);
269
274
  }
270
275
  function buildTrialResult(log, result, scenarioEvidence, conversationTokens, conversationCost) {
271
- const nCommands = log.filter((entry) => entry.type === 'command').length;
276
+ const nCommands = countShellCommandsFromLog(log);
272
277
  const skills = extractSkillsFromLog(log);
273
278
  return {
274
279
  trial_id: 0,
@@ -300,7 +305,7 @@ function getProcessEnv() {
300
305
  }
301
306
  return env;
302
307
  }
303
- export const evaluate = Object.assign(evaluateAgent, {
308
+ export const evaluate = Object.assign((agent, scorers, opts) => evaluateAgent(agent, scorers, opts), {
304
309
  fromSnapshot,
305
310
  });
306
311
  function toTrialScorerResult(result) {
@@ -19,6 +19,8 @@ export { previewReactions } from './reaction-preview.js';
19
19
  export { setRuntime, resetRuntime } from './eval-runtime.js';
20
20
  export { DEFAULT_COPY_IGNORE } from '../providers/copy-filter.js';
21
21
  export { extractToolEventsFromLog } from '../tool-events.js';
22
+ export { collectSensitiveEnvValues, sanitizePersistenceValue, } from '../tool-event-results.js';
23
+ export { parseEnvFile } from '../utils/env.js';
22
24
  export { createAskBus, requireAskBusForLiveBatches, AskBusTimeoutError } from './ask-bus/bus.js';
23
25
  export { toAskUserToolEvent } from './ask-bus/projection.js';
24
26
  export type { AskUserToolEvent, AskUserToolEventArguments, AskUserToolEventQuestionArgument, } from './ask-bus/projection.js';
package/dist/sdk/index.js CHANGED
@@ -17,6 +17,8 @@ export { previewReactions } from './reaction-preview.js';
17
17
  export { setRuntime, resetRuntime } from './eval-runtime.js';
18
18
  export { DEFAULT_COPY_IGNORE } from '../providers/copy-filter.js';
19
19
  export { extractToolEventsFromLog } from '../tool-events.js';
20
+ export { collectSensitiveEnvValues, sanitizePersistenceValue, } from '../tool-event-results.js';
21
+ export { parseEnvFile } from '../utils/env.js';
20
22
  export { createAskBus, requireAskBusForLiveBatches, AskBusTimeoutError } from './ask-bus/bus.js';
21
23
  export { toAskUserToolEvent } from './ask-bus/projection.js';
22
24
  export { buildAskBatchLogEntries } from './agent-result-log.js';
@@ -1,5 +1,6 @@
1
1
  import { getCurrentCaseContext } from './case-context.js';
2
2
  import { buildDiagnosticsReport } from './diagnostics.js';
3
+ import { countShellCommandsFromLog } from '../tool-events.js';
3
4
  const pendingAgents = new Set();
4
5
  const agentOwners = new WeakMap();
5
6
  const agentResults = new WeakMap();
@@ -131,7 +132,7 @@ async function flushCase(input) {
131
132
  function synthesizeTrialFromAgent(agent) {
132
133
  if (agent.log.length === 0)
133
134
  return null;
134
- const nCommands = agent.log.filter((entry) => entry.type === 'command').length;
135
+ const nCommands = countShellCommandsFromLog(agent.log);
135
136
  const tokenUsage = agent.llm.tokenUsage;
136
137
  const conversationEnd = [...agent.log].reverse().find((entry) => entry.type === 'conversation_end');
137
138
  const completionReason = conversationEnd?.completion_reason ?? (agent.log.some((entry) => entry.type === 'agent_result') ? 'completed' : undefined);
@@ -147,8 +148,12 @@ function synthesizeTrialFromAgent(agent) {
147
148
  n_commands: nCommands,
148
149
  input_tokens: 0,
149
150
  output_tokens: 0,
150
- conversation_input_tokens: tokenUsage?.inputTokens ?? 0,
151
- conversation_output_tokens: tokenUsage?.outputTokens ?? 0,
151
+ ...(tokenUsage && (tokenUsage.inputTokens > 0 || tokenUsage.outputTokens > 0)
152
+ ? {
153
+ conversation_input_tokens: tokenUsage.inputTokens,
154
+ conversation_output_tokens: tokenUsage.outputTokens,
155
+ }
156
+ : {}),
152
157
  session_log: [...agent.log],
153
158
  },
154
159
  diagnostics: buildDiagnosticsReport({
@@ -17,6 +17,8 @@ export interface AgentExecutionMetadata {
17
17
  name: AgentName;
18
18
  /** Model override requested by the caller; not necessarily the provider-resolved model. */
19
19
  requestedModel?: string;
20
+ /** Concrete model observed or selected for the executed turn. */
21
+ resolvedModel?: string;
20
22
  transport?: AgentExecutionTransport;
21
23
  interactionMode?: AgentInteractionMode;
22
24
  }
@@ -1,6 +1,6 @@
1
1
  import type { ToolEventResult } from './tool-events.js';
2
2
  export declare const TOOL_RESULT_MAX_CHARS: number;
3
- export declare function collectSensitiveEnvValues(env?: Readonly<Record<string, string>>): string[];
3
+ export declare function collectSensitiveEnvValues(env?: Readonly<Record<string, string | undefined>>): string[];
4
4
  /**
5
5
  * Clone a persistence payload while removing secrets from both structured
6
6
  * containers and any other strings that repeat their values. The second pass
@@ -41,6 +41,7 @@ const NON_SECRET_ENVIRONMENT_KEY_NAMES = new Set(['tokencount', 'tokenizersparal
41
41
  const BOUNDARY_ONLY_SENSITIVE_VALUE_MAX_LENGTH = 1;
42
42
  export function collectSensitiveEnvValues(env) {
43
43
  return [...new Set(Object.entries(env ?? {})
44
+ .filter((entry) => typeof entry[1] === 'string')
44
45
  .filter(([key, value]) => isSecretEnvironmentKey(key) && value.length > 0)
45
46
  .map(([, value]) => value))]
46
47
  .sort((a, b) => b.length - a.length);
@@ -153,7 +154,7 @@ function redactCredentialShapes(value, sensitiveValues, explicitSensitiveValues)
153
154
  redacted = redacted.replace(/([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s/@]+(@)/gi, '$1<redacted>$2');
154
155
  }
155
156
  if (redacted.includes('=') || redacted.includes(':')) {
156
- redacted = redacted.replace(/(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|session(?:id)?|cookie)\s*[=:]\s*)[^\s,;"'}]+/gi, '$1<redacted>');
157
+ redacted = redacted.replace(/(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|token|secret|auth|password|passwd|session(?:id)?|cookie)\s*[=:]\s*)[^\s,;"'}]+/gi, '$1<redacted>');
157
158
  }
158
159
  return redacted;
159
160
  }
@@ -64,6 +64,11 @@ export declare function extractToolEventsFromLog(log: ReadonlyArray<{
64
64
  type: string;
65
65
  tool_event?: ToolEvent;
66
66
  }>): ToolEvent[];
67
+ /** Count shell commands across legacy command logs and provider tool events. */
68
+ export declare function countShellCommandsFromLog(log: ReadonlyArray<{
69
+ type: string;
70
+ tool_event?: ToolEvent;
71
+ }>): number;
67
72
  /**
68
73
  * Extract deduplicated skill names from tool events that have action 'use_skill'.
69
74
  */
@@ -101,6 +101,11 @@ export function extractToolEventsFromLog(log) {
101
101
  .filter((e) => e.type === 'tool_event' && e.tool_event != null)
102
102
  .map((e) => e.tool_event);
103
103
  }
104
+ /** Count shell commands across legacy command logs and provider tool events. */
105
+ export function countShellCommandsFromLog(log) {
106
+ return log.filter((entry) => entry.type === 'command'
107
+ || (entry.type === 'tool_event' && entry.tool_event?.action === 'run_shell')).length;
108
+ }
104
109
  /**
105
110
  * Extract deduplicated skill names from tool events that have action 'use_skill'.
106
111
  */
package/dist/types.d.ts CHANGED
@@ -291,6 +291,8 @@ export interface AgentTurnResult {
291
291
  timedOut?: boolean;
292
292
  toolEvents: import('./tool-events.js').ToolEvent[];
293
293
  runtimePoliciesApplied?: RuntimePolicyDescriptor[];
294
+ /** Concrete model that executed this turn, as resolved by the provider or runner. */
295
+ resolvedModel?: string;
294
296
  inputTokens?: number;
295
297
  outputTokens?: number;
296
298
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/pathgrade",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "packageManager": "yarn@4.12.0",
5
5
  "description": "Evaluate whether AI agents discover and use your skills correctly",
6
6
  "exports": {
@@ -140,5 +140,5 @@
140
140
  "typescript": "^5.9.3",
141
141
  "zod": "4.3.6"
142
142
  },
143
- "falconPackageHash": "23390180e5694ad3ec546fb637b601045310b46b4d7f10927a63e0db"
143
+ "falconPackageHash": "5c7c088ce2f8c51308dde9f92d3f259465bbf34021afddb3af39d2d9"
144
144
  }