@claudexor/review 3.1.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/findings.d.ts +11 -0
  2. package/dist/findings.d.ts.map +1 -1
  3. package/dist/findings.js +48 -5
  4. package/dist/findings.js.map +1 -1
  5. package/dist/readiness.d.ts.map +1 -1
  6. package/dist/revalidate.js.map +1 -1
  7. package/dist/reviewEngine.d.ts +2 -4
  8. package/dist/reviewEngine.d.ts.map +1 -1
  9. package/dist/reviewEngine.js +202 -372
  10. package/dist/reviewEngine.js.map +1 -1
  11. package/dist/reviewPrompt.d.ts +122 -1
  12. package/dist/reviewPrompt.d.ts.map +1 -1
  13. package/dist/reviewPrompt.js +102 -17
  14. package/dist/reviewPrompt.js.map +1 -1
  15. package/dist/reviewRuntimeTypes.d.ts +9 -2
  16. package/dist/reviewRuntimeTypes.d.ts.map +1 -1
  17. package/dist/reviewRuntimeTypes.js +9 -1
  18. package/dist/reviewRuntimeTypes.js.map +1 -1
  19. package/dist/reviewerCostKnowledge.d.ts +26 -0
  20. package/dist/reviewerCostKnowledge.d.ts.map +1 -0
  21. package/dist/reviewerCostKnowledge.js +73 -0
  22. package/dist/reviewerCostKnowledge.js.map +1 -0
  23. package/dist/reviewerSpendAccumulator.d.ts +26 -0
  24. package/dist/reviewerSpendAccumulator.d.ts.map +1 -0
  25. package/dist/reviewerSpendAccumulator.js +48 -0
  26. package/dist/reviewerSpendAccumulator.js.map +1 -0
  27. package/dist/reviewerWorkspace.d.ts +24 -0
  28. package/dist/reviewerWorkspace.d.ts.map +1 -0
  29. package/dist/reviewerWorkspace.js +360 -0
  30. package/dist/reviewerWorkspace.js.map +1 -0
  31. package/dist/route.js.map +1 -1
  32. package/dist/sealedReviewEnvelope.d.ts +20 -0
  33. package/dist/sealedReviewEnvelope.d.ts.map +1 -0
  34. package/dist/sealedReviewEnvelope.js +176 -0
  35. package/dist/sealedReviewEnvelope.js.map +1 -0
  36. package/package.json +6 -6
@@ -1,23 +1,25 @@
1
- import { parseUnifiedDiff, runCapture } from "@claudexor/core";
2
1
  import { preflightEvidence, writeDiffEvidence } from "@claudexor/context";
3
2
  import { HarnessRunSpec, ReviewFinding as ReviewFindingSchema } from "@claudexor/schema";
4
- import { existsSync, lstatSync, readlinkSync, realpathSync, statSync } from "node:fs";
5
- import { cp, mkdir, readdir, rm } from "node:fs/promises";
6
- import { tmpdir } from "node:os";
7
- import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
8
- import { appendLine, containsSecretLikeToken, ensureDir, newId, nowIso, readTextSafe, redactSecrets, sensitiveResourcePolicy, sha256, writeJson, writeText, } from "@claudexor/util";
9
- import { dedupeFindings, extractJsonBlocks, parseFindingsDetailed, } from "./findings.js";
10
- import { buildReviewPrompt } from "./reviewPrompt.js";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
5
+ import { rm } from "node:fs/promises";
6
+ import { join, relative, resolve } from "node:path";
7
+ import { appendLine, containsSecretLikeToken, engineBuildIdentity, ensureDir, newId, nowIso, readTextSafe, redactSecrets, sha256, writeJson, writeText, } from "@claudexor/util";
8
+ import { dedupeFindings, extractJsonBlocks, parseFindingsDetailed, parseSealedReviewEnvelopeDetailed, sealedReviewTranscriptChunk, } from "./findings.js";
9
+ import { buildReviewPrompt, SEALED_REVIEW_OUTPUT_SCHEMA } from "./reviewPrompt.js";
10
+ import { sealedReviewTranscriptFromEvents } from "./sealedReviewEnvelope.js";
11
+ import { buildReviewerCandidateInventory, cleanupTemporaryReviewerWorkspaceBaseDir, copyReviewEvidencePacket, extractDiffPostimagePaths, isSameOrInside, prepareReviewerWorkspace, selectReviewerWorkspaceBaseDir, } from "./reviewerWorkspace.js";
12
+ export { extractDiffPostimagePaths as __testExtractDiffPostimagePaths } from "./reviewerWorkspace.js";
11
13
  import { buildRouteProof, classifyDiversity } from "./route.js";
12
- import { reviewerAuthMode, reviewerAuthSwitchFromEvent, summarizeReviewerSpend, } from "./reviewRuntimeTypes.js";
14
+ import { reviewerAuthMode, reviewerAuthSwitchFromEvent } from "./reviewRuntimeTypes.js";
15
+ import { ReviewerCostKnowledge } from "./reviewerCostKnowledge.js";
16
+ import { ReviewerSpendAccumulator } from "./reviewerSpendAccumulator.js";
13
17
  const DEFAULT_REVIEWER_TIMEOUT_MS = 10 * 60_000;
14
18
  const DEFAULT_REVIEWER_TRANSIENT_RETRY_POLICY = {
15
19
  maxRetries: 2,
16
20
  initialDelayMs: 1_000,
17
21
  maxDelayMs: 10_000,
18
22
  };
19
- const BLOCKED_REVIEWER_RUNTIME_ROOTS = new Set("auth cache daemon home homes logs runs secrets state tmp workspaces".split(" "));
20
- const TEXT_EVIDENCE_SUFFIXES = [".md", ".txt", ".json", ".yaml", ".yml", ".patch"];
21
23
  const REVIEW_WAVE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
22
24
  function readExistingDiffEvidence(dir, diff) {
23
25
  const diffText = diff.endsWith("\n") ? diff : `${diff}\n`;
@@ -53,6 +55,26 @@ function reviewerInfo(reviewer, routeProofStatus, observedModel = null) {
53
55
  };
54
56
  }
55
57
  export async function reviewCandidate(input) {
58
+ const sourceRoot = resolve(input.cwd);
59
+ const sourceExists = existsSync(sourceRoot);
60
+ const canonicalSourceRoot = sourceExists ? realpathSync(sourceRoot) : sourceRoot;
61
+ const sourceEvidencePath = resolve(input.evidenceDir);
62
+ const sourceEvidenceRoot = existsSync(input.evidenceDir)
63
+ ? realpathSync(input.evidenceDir)
64
+ : sourceEvidencePath;
65
+ if (sourceExists && isSameOrInside(sourceEvidenceRoot, canonicalSourceRoot)) {
66
+ throw new Error("review evidence directory must not contain the candidate root");
67
+ }
68
+ const evidenceInsideLexicalSource = sourceExists && isSameOrInside(sourceRoot, sourceEvidencePath);
69
+ const evidenceInsideCanonicalSource = sourceExists && isSameOrInside(canonicalSourceRoot, sourceEvidenceRoot);
70
+ const evidencePathInSourceNamespace = evidenceInsideCanonicalSource
71
+ ? resolve(sourceRoot, relative(canonicalSourceRoot, sourceEvidenceRoot))
72
+ : null;
73
+ const candidateEvidenceExcludeRoots = evidenceInsideLexicalSource || evidenceInsideCanonicalSource
74
+ ? [
75
+ ...new Set([sourceEvidencePath, sourceEvidenceRoot, evidencePathInSourceNamespace].filter((path) => path !== null)),
76
+ ]
77
+ : [];
56
78
  const findingsByReviewer = input.reviewers.map(() => []);
57
79
  const reviewerFamilies = input.reviewers.map((reviewer) => reviewer.providerFamily);
58
80
  const routeProofs = input.reviewers.map((reviewer, index) => reviewerRouteProof(reviewer, null, "unavailable", reviewerFamilies.filter((_, otherIndex) => otherIndex !== index)));
@@ -63,23 +85,33 @@ export async function reviewCandidate(input) {
63
85
  requested_effort: reviewer.requestedEffort ?? null,
64
86
  }));
65
87
  const healthyReviewerIndexes = new Set();
66
- const reviewSpendByReviewer = input.reviewers.map(() => 0);
67
- const reviewSpendEstimatedByReviewer = input.reviewers.map(() => false);
68
- const reviewCashByReviewer = input.reviewers.map(() => 0);
69
- const reviewValuationByReviewer = input.reviewers.map(() => 0);
70
- const reviewUnknownByReviewer = input.reviewers.map(() => 0);
88
+ const reviewerSpend = new ReviewerSpendAccumulator(input.reviewers.length);
71
89
  const reviewerTimeoutMs = input.reviewerTimeoutMs ?? DEFAULT_REVIEWER_TIMEOUT_MS;
72
90
  const reviewWaveId = input.env?.["CLAUDEXOR_REVIEW_WAVE_ID"] ?? process.env["CLAUDEXOR_REVIEW_WAVE_ID"] ?? null;
73
91
  if (input.evidenceReadOnly && input.frozenIdentity && !REVIEW_WAVE_ID.test(reviewWaveId ?? "")) {
74
92
  throw new Error("sealed release review requires CLAUDEXOR_REVIEW_WAVE_ID UUID");
75
93
  }
76
94
  const frozenMetadata = input.frozenIdentity
77
- ? {
78
- candidate_sha: input.frozenIdentity.candidateSha,
79
- candidate_tree: input.frozenIdentity.candidateTree,
80
- packet_manifest_sha256: input.frozenIdentity.packetManifestSha256,
81
- ...(reviewWaveId ? { review_wave_id: reviewWaveId } : {}),
82
- }
95
+ ? (() => {
96
+ const runtime = engineBuildIdentity();
97
+ const runtimeEntry = realpathSync(runtime.entry);
98
+ const runtimeEntryStat = lstatSync(runtimeEntry);
99
+ if (!runtimeEntryStat.isFile() || runtimeEntryStat.isSymbolicLink()) {
100
+ throw new Error("sealed release review runtime entry is not a regular file");
101
+ }
102
+ return {
103
+ candidate_sha: input.frozenIdentity.candidateSha,
104
+ candidate_tree: input.frozenIdentity.candidateTree,
105
+ packet_manifest_sha256: input.frozenIdentity.packetManifestSha256,
106
+ review_runtime_version: runtime.version,
107
+ review_runtime_build_sha: runtime.sha,
108
+ review_runtime_entry: runtimeEntry,
109
+ review_runtime_entry_sha256: createHash("sha256")
110
+ .update(readFileSync(runtimeEntry))
111
+ .digest("hex"),
112
+ ...(reviewWaveId ? { review_wave_id: reviewWaveId } : {}),
113
+ };
114
+ })()
83
115
  : {};
84
116
  if (containsSecretLikeToken(input.diff || "(empty diff)\n")) {
85
117
  throw new Error("diff evidence contains a secret-like token; refusing to persist raw DIFF.patch");
@@ -102,7 +134,15 @@ export async function reviewCandidate(input) {
102
134
  ].filter(Boolean);
103
135
  throw new Error(`mandatory evidence preflight failed (${parts.join("; ")})`);
104
136
  }
137
+ const postimagePaths = extractDiffPostimagePaths(input.diff);
138
+ const candidateInventory = await buildReviewerCandidateInventory(input.cwd, postimagePaths, input.evidenceReadOnly === true);
105
139
  const artifactsBaseDir = input.artifactsDir ?? join(input.evidenceDir, "reviewer-artifacts");
140
+ if (input.evidenceReadOnly &&
141
+ input.frozenIdentity &&
142
+ existsSync(artifactsBaseDir) &&
143
+ readdirSync(artifactsBaseDir).length > 0) {
144
+ throw new Error("sealed release review requires a fresh empty artifacts directory");
145
+ }
106
146
  ensureDir(artifactsBaseDir);
107
147
  const persistentEvidenceDir = join(artifactsBaseDir, "evidence");
108
148
  await copyReviewEvidencePacket(input.evidenceDir, persistentEvidenceDir, input.evidenceReadOnly === true);
@@ -118,11 +158,11 @@ export async function reviewCandidate(input) {
118
158
  diff_path: persistentPatch.diffPath,
119
159
  summary_path: persistentPatch.summaryPath,
120
160
  diff_sha256: persistentPatch.diffSha256,
121
- review_subject: input.reviewSubject ?? "code",
161
+ candidate_inventory_mode: candidateInventory.mode,
162
+ candidate_inventory_reason: candidateInventory.reason,
122
163
  ...frozenMetadata,
123
164
  });
124
165
  const artifacts = input.reviewers.map(() => undefined);
125
- const preservePaths = extractDiffTouchedPaths(input.diff);
126
166
  const reviewerWorkspaceBaseDir = selectReviewerWorkspaceBaseDir(input.cwd, artifactsBaseDir, input.evidenceDir);
127
167
  const runReviewer = async (reviewer, index) => {
128
168
  if (input.signal?.aborted)
@@ -137,8 +177,9 @@ export async function reviewCandidate(input) {
137
177
  sourceEvidenceDir: persistentEvidenceDir,
138
178
  workspaceBaseDir: reviewerWorkspaceBaseDir,
139
179
  reviewerDirName: `${String(index + 1).padStart(2, "0")}-${safeFilePart(reviewer.adapter.id)}`,
140
- excludeRoots: [artifactsBaseDir],
141
- preservePaths,
180
+ excludeRoots: [artifactsBaseDir, ...candidateEvidenceExcludeRoots],
181
+ postimagePaths,
182
+ candidateCopyPaths: candidateInventory.copyPaths,
142
183
  preserveEvidenceBytes: input.evidenceReadOnly === true,
143
184
  });
144
185
  const reviewerPatch = input.evidenceReadOnly
@@ -154,34 +195,42 @@ export async function reviewCandidate(input) {
154
195
  persistent_diff_path: persistentPatch.diffPath,
155
196
  persistent_summary_path: persistentPatch.summaryPath,
156
197
  diff_sha256: persistentPatch.diffSha256,
157
- review_subject: input.reviewSubject ?? "code",
198
+ candidate_inventory_mode: candidateInventory.mode,
199
+ candidate_inventory_reason: candidateInventory.reason,
158
200
  ...frozenMetadata,
159
201
  });
160
- const runtimePrompt = buildReviewPrompt(input.candidateLabel, reviewerWorkspace.root, reviewerWorkspace.evidenceDir, reviewerPatch, input.evidenceReadOnly === true, input.reviewSubject ?? "code");
202
+ const runtimePrompt = buildReviewPrompt(input.candidateLabel, reviewerWorkspace.root, reviewerWorkspace.evidenceDir, reviewerPatch, {
203
+ sealed: input.evidenceReadOnly === true,
204
+ candidateInventoryMode: candidateInventory.mode,
205
+ });
161
206
  spec = HarnessRunSpec.parse({
162
207
  session_id: newId("rev"),
163
208
  intent: "review",
164
209
  prompt: runtimePrompt,
165
210
  cwd: reviewerWorkspace.root,
166
211
  access: "readonly",
212
+ ...(input.evidenceReadOnly && input.frozenIdentity
213
+ ? {
214
+ external_context_policy: "live",
215
+ tool_permission_policy: { web: "live", allow: [], deny: [] },
216
+ }
217
+ : {}),
167
218
  model_hint: reviewer.requestedModel ?? null,
168
219
  effort_hint: reviewer.requestedEffort ?? null,
169
220
  auth_preference: reviewer.authPreference ?? "auto",
170
221
  env_inheritance: input.envInheritance ?? "mirror_native",
222
+ ...(input.evidenceReadOnly && input.frozenIdentity
223
+ ? { output_schema: SEALED_REVIEW_OUTPUT_SCHEMA }
224
+ : {}),
171
225
  ...(input.env ? { env: input.env } : {}),
172
226
  });
173
- writeText(artifact.promptPath, redactSecrets(`Persistent local replay evidence:
174
- - evidence_dir: ${persistentEvidenceDir}
175
- - candidate_root: ${reviewerWorkspace.root}
176
- - source_candidate_root: ${input.cwd}
177
- - source_candidate_evidence_dir: ${input.evidenceDir}
178
- - diff_path: ${persistentPatch.diffPath}
179
- - diff_sha256: ${persistentPatch.diffSha256}
180
-
181
- Runtime prompt used during review follows. Its candidate-tree paths may be transient after orchestrator cleanup; use the durable replay paths above for audit/replay.
182
-
183
- ${runtimePrompt}
184
- `));
227
+ writeText(artifact.promptPath, spec.prompt);
228
+ updateReviewerMetadata(artifact, {
229
+ session_id: spec.session_id,
230
+ external_context_policy: spec.external_context_policy,
231
+ tool_web_policy: spec.tool_permission_policy.web,
232
+ submitted_prompt_sha256: createHash("sha256").update(spec.prompt).digest("hex"),
233
+ });
185
234
  }
186
235
  catch (err) {
187
236
  const failedAt = nowIso();
@@ -212,17 +261,17 @@ ${runtimePrompt}
212
261
  let routeModel;
213
262
  let routeSource = "unavailable";
214
263
  let reviewerError = null;
264
+ let sealedProjectionError = null;
215
265
  try {
216
- const out = await collectReviewerOutput(reviewer, spec, reviewerTimeoutMs, input.transientRetryPolicy ?? DEFAULT_REVIEWER_TRANSIENT_RETRY_POLICY, artifact, input.onReviewerEvent, input.signal);
266
+ const out = await collectReviewerOutput(reviewer, spec, reviewerTimeoutMs, input.evidenceReadOnly && input.frozenIdentity
267
+ ? { maxRetries: 0, initialDelayMs: 0, maxDelayMs: 0 }
268
+ : (input.transientRetryPolicy ?? DEFAULT_REVIEWER_TRANSIENT_RETRY_POLICY), artifact, input.onReviewerEvent, input.signal, input.evidenceReadOnly === true);
217
269
  text = out.text;
270
+ sealedProjectionError = out.sealedProjectionError ?? null;
218
271
  streamObservedModel = out.observedModel;
219
272
  routeModel = out.observedModel;
220
273
  routeSource = out.observedSource;
221
- reviewSpendByReviewer[index] = out.costUsd;
222
- reviewSpendEstimatedByReviewer[index] = out.costEstimated;
223
- reviewCashByReviewer[index] = out.cashUsd;
224
- reviewValuationByReviewer[index] = out.valuationUsd;
225
- reviewUnknownByReviewer[index] = out.unknownUsd;
274
+ reviewerSpend.record(index, out);
226
275
  if (!routeModel && reviewer.requestedModel) {
227
276
  routeModel = reviewer.requestedModel;
228
277
  routeSource = "metadata";
@@ -234,13 +283,10 @@ ${runtimePrompt}
234
283
  if (typeof partial?.partialText === "string" && partial.partialText.trim() !== "") {
235
284
  text = partial.partialText;
236
285
  }
237
- if (partial && typeof partial.partialCostUsd === "number" && partial.partialCostUsd > 0) {
238
- reviewSpendByReviewer[index] = partial.partialCostUsd;
239
- reviewSpendEstimatedByReviewer[index] = partial.partialCostEstimated === true;
240
- reviewCashByReviewer[index] = partial.partialCashUsd ?? 0;
241
- reviewValuationByReviewer[index] = partial.partialValuationUsd ?? 0;
242
- reviewUnknownByReviewer[index] = partial.partialUnknownUsd ?? 0;
286
+ if (typeof partial?.partialSealedProjectionError === "string") {
287
+ sealedProjectionError = partial.partialSealedProjectionError;
243
288
  }
289
+ reviewerSpend.recordPartial(index, partial);
244
290
  if (partial?.partialObservedModel) {
245
291
  streamObservedModel = partial.partialObservedModel;
246
292
  routeModel = partial.partialObservedModel;
@@ -254,8 +300,24 @@ ${runtimePrompt}
254
300
  const proof = reviewerRouteProof(reviewer, routeModel ?? null, routeSource, reviewerFamilies.filter((_, i) => i !== index));
255
301
  routeProofs[index] = proof;
256
302
  const info = reviewerInfo(reviewer, proof.status, streamObservedModel ?? null);
257
- const jsonBlocks = extractJsonBlocks(text);
303
+ const sealedParse = input.evidenceReadOnly
304
+ ? parseSealedReviewEnvelopeDetailed(text, info)
305
+ : null;
306
+ const jsonBlocks = sealedParse?.blocks ?? extractJsonBlocks(text);
258
307
  writeJson(artifact.parsedPath, redactValue(jsonBlocks));
308
+ if (sealedParse && (sealedProjectionError || sealedParse.error)) {
309
+ const detail = sealedProjectionError ?? sealedParse.error ?? "invalid sealed review output";
310
+ writeParseError(artifact, {
311
+ error: "invalid_sealed_review_envelope",
312
+ detail,
313
+ malformed: sealedParse.malformed,
314
+ text_sha256: sha256(text),
315
+ ...(reviewerError ? { reviewer_error: reviewerError } : {}),
316
+ });
317
+ findingsByReviewer[index]?.push(...sealedParse.findings);
318
+ findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, `Invalid sealed review envelope: ${detail}.`));
319
+ return;
320
+ }
259
321
  if (reviewerError && (text.trim() === "" || jsonBlocks.length === 0)) {
260
322
  findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, `Reviewer failed: ${reviewerError}`));
261
323
  return;
@@ -265,7 +327,7 @@ ${runtimePrompt}
265
327
  findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, "Reviewer produced no parseable JSON findings."));
266
328
  return;
267
329
  }
268
- const parsed = parseFindingsDetailed(text, info);
330
+ const parsed = sealedParse ?? parseFindingsDetailed(text, info);
269
331
  const parseError = {};
270
332
  let parsedFindingsRecorded = false;
271
333
  const recordParsedFindings = () => {
@@ -274,7 +336,7 @@ ${runtimePrompt}
274
336
  findingsByReviewer[index]?.push(...parsed.findings);
275
337
  parsedFindingsRecorded = true;
276
338
  };
277
- if (parsed.malformed > 0) {
339
+ if (parsed.malformed > 0 && !sealedParse?.error) {
278
340
  Object.assign(parseError, {
279
341
  error: "malformed_findings",
280
342
  malformed: parsed.malformed,
@@ -347,14 +409,14 @@ ${runtimePrompt}
347
409
  healthyProviders,
348
410
  crossFamilyVerified: observedFamilies.length >= 2,
349
411
  distinctProviders: observedFamilies,
350
- ...summarizeReviewerSpend(reviewSpendByReviewer, reviewCashByReviewer, reviewValuationByReviewer, reviewUnknownByReviewer, reviewSpendEstimatedByReviewer),
412
+ ...reviewerSpend.summary(),
351
413
  };
352
414
  }
353
415
  finally {
354
416
  await cleanupTemporaryReviewerWorkspaceBaseDir(reviewerWorkspaceBaseDir, artifactsBaseDir);
355
417
  }
356
418
  }
357
- async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPolicy, artifact, onReviewerEvent, signal) {
419
+ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPolicy, artifact, onReviewerEvent, signal, sealed = false) {
358
420
  const controller = new AbortController();
359
421
  spec.extra["abortSignal"] = controller.signal;
360
422
  const startMs = Date.now();
@@ -383,14 +445,19 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
383
445
  let observedSource = "unavailable";
384
446
  let observedAuthMode = null;
385
447
  let currentAuthMode = null;
448
+ const observedAuthModes = new Set();
449
+ const ignoredSettings = new Set();
386
450
  let costUsd = 0;
387
451
  let costEstimated = false;
388
452
  let cashUsd = 0;
389
453
  let valuationUsd = 0;
390
454
  let unknownUsd = 0;
455
+ const costKnowledge = new ReviewerCostKnowledge();
391
456
  let partialText = "";
392
457
  const isCancelled = () => cancelledBySignal || signal?.aborted === true || controller.signal.aborted;
393
458
  const consumeOnce = async (nativeTry) => {
459
+ currentAuthMode = null;
460
+ costKnowledge.startAttempt();
394
461
  const iter = (reviewer.adapter.review ?? reviewer.adapter.run).call(reviewer.adapter, runSpec);
395
462
  currentIter = iter;
396
463
  let text = "";
@@ -399,9 +466,20 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
399
466
  let lastError = null;
400
467
  let attemptObservedModel;
401
468
  let attemptObservedSource = "unavailable";
469
+ const sealedMessageEvents = [];
402
470
  for await (const ev of iter) {
403
471
  const eventTime = nowIso();
404
- appendLine(artifact.eventsPath, JSON.stringify(redactValue(ev)));
472
+ const persistedEvent = redactValue(ev);
473
+ appendLine(artifact.eventsPath, JSON.stringify(persistedEvent));
474
+ const ignored = ev.payload?.["ignored_settings"];
475
+ if (Array.isArray(ignored)) {
476
+ for (const item of ignored)
477
+ if (typeof item === "string")
478
+ ignoredSettings.add(item);
479
+ if (ignoredSettings.size > 0) {
480
+ updateReviewerMetadata(artifact, { ignored_settings: [...ignoredSettings] });
481
+ }
482
+ }
405
483
  if (ev.transient)
406
484
  sawTransient = true;
407
485
  if (ev.type === "error") {
@@ -422,8 +500,12 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
422
500
  });
423
501
  }
424
502
  const disclosedAuthMode = reviewerAuthMode(ev.credential_route);
425
- if (disclosedAuthMode)
503
+ if (disclosedAuthMode) {
426
504
  currentAuthMode = disclosedAuthMode;
505
+ observedAuthModes.add(disclosedAuthMode);
506
+ updateReviewerMetadata(artifact, { auth_modes: [...observedAuthModes] });
507
+ }
508
+ costKnowledge.observeEvent(currentAuthMode);
427
509
  if (!observedAuthMode) {
428
510
  observedAuthMode = disclosedAuthMode;
429
511
  if (observedAuthMode)
@@ -437,16 +519,23 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
437
519
  at: firstEventTime,
438
520
  });
439
521
  }
440
- if (ev.type === "usage" && ev.usage?.cost_usd) {
522
+ if (ev.type === "usage" &&
523
+ typeof ev.usage?.cost_usd === "number" &&
524
+ Number.isFinite(ev.usage.cost_usd) &&
525
+ ev.usage.cost_usd >= 0) {
441
526
  costUsd += ev.usage.cost_usd;
442
527
  if (ev.usage.estimated)
443
528
  costEstimated = true;
444
- if (currentAuthMode === "local_session")
529
+ costKnowledge.observeUsage(currentAuthMode, ev.usage.estimated === true);
530
+ if (currentAuthMode === "local_session") {
445
531
  valuationUsd += ev.usage.cost_usd;
446
- else if (currentAuthMode === "api_key")
532
+ }
533
+ else if (currentAuthMode === "api_key") {
447
534
  cashUsd += ev.usage.cost_usd;
448
- else
535
+ }
536
+ else {
449
537
  unknownUsd += ev.usage.cost_usd;
538
+ }
450
539
  updateReviewerMetadata(artifact, {
451
540
  cost_usd: costUsd,
452
541
  cost_estimated: costEstimated,
@@ -455,11 +544,24 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
455
544
  unknown_usd: unknownUsd,
456
545
  });
457
546
  }
547
+ if (sealed && ev.type === "message" && ev.final === true) {
548
+ sealedMessageEvents.push(persistedEvent);
549
+ }
458
550
  if (ev.type === "message" && ev.text && ev.payload?.["auth_switched"] !== true) {
459
- const safeText = redactSecrets(ev.text);
460
- text += safeText + "\n";
461
- partialText += safeText + "\n";
462
- appendLine(artifact.transcriptPath, safeText);
551
+ const safeText = sealedReviewTranscriptChunk(persistedEvent);
552
+ if (safeText !== null) {
553
+ if (sealed) {
554
+ // Keep the long-running transcript visibly active for monitors. A
555
+ // clean completion replaces these progress bytes with the exact
556
+ // typed-final projection used by the sealed parser and sealer.
557
+ appendLine(artifact.transcriptPath, safeText);
558
+ }
559
+ else {
560
+ text += safeText;
561
+ partialText += safeText;
562
+ appendLine(artifact.transcriptPath, safeText);
563
+ }
564
+ }
463
565
  }
464
566
  if (ev.observed_model) {
465
567
  observedModel = ev.observed_model;
@@ -479,6 +581,17 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
479
581
  if (isCancelled()) {
480
582
  throw new Error("Reviewer cancelled");
481
583
  }
584
+ let sealedProjectionError;
585
+ if (sealed) {
586
+ try {
587
+ text = sealedReviewTranscriptFromEvents(sealedMessageEvents);
588
+ partialText = text;
589
+ writeText(artifact.transcriptPath, text);
590
+ }
591
+ catch (error) {
592
+ sealedProjectionError = error instanceof Error ? error.message : String(error);
593
+ }
594
+ }
482
595
  if (sawTransient &&
483
596
  text.trim() === "" &&
484
597
  nativeTry < transientRetryPolicy.maxRetries &&
@@ -508,10 +621,13 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
508
621
  session_id: newId("ses"),
509
622
  extra: { ...runSpec.extra, abortSignal: controller.signal },
510
623
  });
624
+ costKnowledge.finishAttempt();
511
625
  return consumeOnce(nativeTry + 1);
512
626
  }
513
627
  if (sawError && !timedOut) {
514
- throw new Error(`Reviewer emitted error event: ${lastError ?? "unknown error"}`);
628
+ throw Object.assign(new Error(`Reviewer emitted error event: ${lastError ?? "unknown error"}`), {
629
+ ...(sealedProjectionError ? { partialSealedProjectionError: sealedProjectionError } : {}),
630
+ });
515
631
  }
516
632
  if (!timedOut && !isCancelled()) {
517
633
  const completedTime = nowIso();
@@ -533,8 +649,11 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
533
649
  observed_source: attemptObservedSource,
534
650
  });
535
651
  }
652
+ const knowledge = costKnowledge.snapshot();
653
+ costKnowledge.finishAttempt();
536
654
  return {
537
655
  text,
656
+ ...(sealedProjectionError ? { sealedProjectionError } : {}),
538
657
  observedModel: attemptObservedModel,
539
658
  observedSource: attemptObservedSource,
540
659
  costUsd,
@@ -542,6 +661,7 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
542
661
  cashUsd,
543
662
  valuationUsd,
544
663
  unknownUsd,
664
+ ...knowledge,
545
665
  };
546
666
  };
547
667
  const consume = consumeOnce(0);
@@ -555,12 +675,15 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
555
675
  cancelledBySignal = true;
556
676
  controller.abort();
557
677
  void currentIter?.return?.();
678
+ const knowledge = costKnowledge.snapshot();
558
679
  reject(Object.assign(new Error("Reviewer cancelled"), {
559
680
  partialCostUsd: costUsd,
560
681
  partialCostEstimated: costEstimated,
561
682
  partialCashUsd: cashUsd,
562
683
  partialValuationUsd: valuationUsd,
563
684
  partialUnknownUsd: unknownUsd,
685
+ partialCashKnowledge: knowledge.cashKnowledge,
686
+ partialValuationKnowledge: knowledge.valuationKnowledge,
564
687
  partialObservedModel: observedModel,
565
688
  partialObservedSource: observedSource,
566
689
  partialText,
@@ -598,12 +721,15 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
598
721
  observed_source: observedSource,
599
722
  message: `Reviewer timed out after ${timeoutMs}ms`,
600
723
  });
724
+ const knowledge = costKnowledge.snapshot();
601
725
  reject(Object.assign(new Error(`Reviewer timed out after ${timeoutMs}ms`), {
602
726
  partialCostUsd: costUsd,
603
727
  partialCostEstimated: costEstimated,
604
728
  partialCashUsd: cashUsd,
605
729
  partialValuationUsd: valuationUsd,
606
730
  partialUnknownUsd: unknownUsd,
731
+ partialCashKnowledge: knowledge.cashKnowledge,
732
+ partialValuationKnowledge: knowledge.valuationKnowledge,
607
733
  partialObservedModel: observedModel,
608
734
  partialObservedSource: observedSource,
609
735
  partialText,
@@ -635,12 +761,15 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
635
761
  });
636
762
  }
637
763
  if (err && typeof err === "object") {
764
+ const knowledge = costKnowledge.snapshot();
638
765
  Object.assign(err, {
639
766
  partialCostUsd: costUsd,
640
767
  partialCostEstimated: costEstimated,
641
768
  partialCashUsd: cashUsd,
642
769
  partialValuationUsd: valuationUsd,
643
770
  partialUnknownUsd: unknownUsd,
771
+ partialCashKnowledge: knowledge.cashKnowledge,
772
+ partialValuationKnowledge: knowledge.valuationKnowledge,
644
773
  partialObservedModel: observedModel,
645
774
  partialObservedSource: observedSource,
646
775
  partialText,
@@ -658,126 +787,6 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
658
787
  });
659
788
  }
660
789
  }
661
- function selectReviewerWorkspaceBaseDir(sourceRoot, artifactsBaseDir, sourceEvidenceDir) {
662
- const durableBase = join(artifactsBaseDir, "workspaces");
663
- if (!isSameOrInside(sourceRoot, durableBase) && !isSameOrInside(sourceEvidenceDir, durableBase)) {
664
- return durableBase;
665
- }
666
- return join(tmpdir(), `claudexor-review-workspaces-${newId("ws")}`);
667
- }
668
- function isTemporaryReviewerWorkspaceBaseDir(baseDir) {
669
- const resolved = resolve(baseDir);
670
- const rel = relative(tmpdir(), resolved);
671
- return (isSameOrInside(tmpdir(), resolved) &&
672
- rel.split(/[\\/]+/)[0]?.startsWith("claudexor-review-workspaces-") === true);
673
- }
674
- async function prepareReviewerWorkspace(input) {
675
- const sourceRoot = resolve(input.sourceRoot);
676
- const workspaceBaseDir = resolve(input.workspaceBaseDir);
677
- const root = join(workspaceBaseDir, input.reviewerDirName);
678
- if (!existsSync(sourceRoot)) {
679
- throw new Error(`candidate root does not exist: ${sourceRoot}`);
680
- }
681
- if (isSameOrInside(sourceRoot, root)) {
682
- throw new Error(`reviewer workspace must be outside candidate root: ${root}`);
683
- }
684
- try {
685
- await rm(root, { recursive: true, force: true });
686
- await mkdir(root, { recursive: true, mode: 0o700 });
687
- const excludeRoots = input.excludeRoots.map((p) => resolve(p));
688
- const resolvedSourceRoot = realpathSync(sourceRoot);
689
- await cp(sourceRoot, root, {
690
- recursive: true,
691
- dereference: false,
692
- filter: (sourcePath) => shouldCopyReviewerPath(sourceRoot, resolvedSourceRoot, sourcePath, excludeRoots, input.preservePaths),
693
- });
694
- const sourceEvidenceDir = resolve(input.sourceEvidenceDir);
695
- const evidenceDir = join(root, ".claudexor-review-evidence");
696
- if (existsSync(sourceEvidenceDir)) {
697
- const resolvedSourceEvidenceDir = realpathSync(sourceEvidenceDir);
698
- const evidenceExcludeRoots = excludeRoots.filter((root) => !isSameOrInside(root, sourceEvidenceDir));
699
- await rm(evidenceDir, { recursive: true, force: true });
700
- await cp(sourceEvidenceDir, evidenceDir, input.preserveEvidenceBytes
701
- ? { recursive: true, dereference: false }
702
- : {
703
- recursive: true,
704
- dereference: false,
705
- filter: (sourcePath) => shouldCopyReviewerPath(sourceEvidenceDir, resolvedSourceEvidenceDir, sourcePath, evidenceExcludeRoots),
706
- });
707
- }
708
- await mkdir(evidenceDir, { recursive: true, mode: 0o700 });
709
- await initializeReviewerWorkspaceGit(root);
710
- return { root, evidenceDir };
711
- }
712
- catch (err) {
713
- await rm(root, { recursive: true, force: true });
714
- throw err;
715
- }
716
- }
717
- async function copyReviewEvidencePacket(sourceEvidenceDir, persistentEvidenceDir, preserveBytes = false) {
718
- const source = resolve(sourceEvidenceDir);
719
- const target = resolve(persistentEvidenceDir);
720
- await rm(target, { recursive: true, force: true });
721
- if (!existsSync(source)) {
722
- await mkdir(target, { recursive: true, mode: 0o700 });
723
- return;
724
- }
725
- if (preserveBytes) {
726
- await mkdir(dirname(target), { recursive: true, mode: 0o700 });
727
- await cp(source, target, { recursive: true, dereference: false });
728
- return;
729
- }
730
- await mkdir(target, { recursive: true, mode: 0o700 });
731
- const resolvedSource = realpathSync(source);
732
- for (const entry of await readdir(source, { withFileTypes: true })) {
733
- const sourcePath = join(source, entry.name);
734
- if (!shouldCopyEvidencePacketPath(source, resolvedSource, sourcePath, target)) {
735
- continue;
736
- }
737
- await copyReviewEvidenceEntry(source, resolvedSource, sourcePath, join(target, entry.name), target);
738
- }
739
- }
740
- async function copyReviewEvidenceEntry(sourceEvidenceDir, resolvedSourceEvidenceDir, sourcePath, targetPath, targetEvidenceDir) {
741
- const stat = lstatSync(sourcePath);
742
- if (stat.isDirectory()) {
743
- await mkdir(targetPath, { recursive: true, mode: 0o700 });
744
- for (const entry of await readdir(sourcePath, { withFileTypes: true })) {
745
- const childSource = join(sourcePath, entry.name);
746
- if (!shouldCopyEvidencePacketPath(sourceEvidenceDir, resolvedSourceEvidenceDir, childSource, targetEvidenceDir)) {
747
- continue;
748
- }
749
- await copyReviewEvidenceEntry(sourceEvidenceDir, resolvedSourceEvidenceDir, childSource, join(targetPath, entry.name), targetEvidenceDir);
750
- }
751
- return;
752
- }
753
- if (stat.isFile() && shouldTextSanitizeEvidenceFile(sourcePath)) {
754
- const raw = readTextSafe(sourcePath);
755
- if (raw === null)
756
- throw new Error(`could not read review evidence file: ${sourcePath}`);
757
- const text = shouldFailClosedEvidenceFile(sourcePath) ? raw : redactSecrets(raw);
758
- if (containsSecretLikeToken(text)) {
759
- throw new Error(`review evidence file contains a secret-like token: ${relative(sourceEvidenceDir, sourcePath)}`);
760
- }
761
- writeText(targetPath, text);
762
- return;
763
- }
764
- await mkdir(dirname(targetPath), { recursive: true, mode: 0o700 });
765
- await cp(sourcePath, targetPath, { recursive: false, dereference: false });
766
- }
767
- function shouldTextSanitizeEvidenceFile(path) {
768
- return TEXT_EVIDENCE_SUFFIXES.some((extension) => path.toLowerCase().endsWith(extension));
769
- }
770
- function shouldFailClosedEvidenceFile(path) {
771
- return path.toLowerCase().endsWith(".patch");
772
- }
773
- function shouldCopyEvidencePacketPath(sourceEvidenceDir, resolvedSourceEvidenceDir, sourcePath, targetEvidenceDir) {
774
- const resolvedSourcePath = resolve(sourcePath);
775
- if (isSameOrInside(resolvedSourcePath, targetEvidenceDir))
776
- return false;
777
- if (isSameOrInside(targetEvidenceDir, resolvedSourcePath))
778
- return false;
779
- return shouldCopyReviewerPath(sourceEvidenceDir, resolvedSourceEvidenceDir, resolvedSourcePath, [targetEvidenceDir], new Set(), false);
780
- }
781
790
  async function cleanupReviewerWorkspace(workspace, artifact) {
782
791
  try {
783
792
  await rm(workspace.root, { recursive: true, force: true });
@@ -790,188 +799,6 @@ async function cleanupReviewerWorkspace(workspace, artifact) {
790
799
  });
791
800
  }
792
801
  }
793
- async function cleanupTemporaryReviewerWorkspaceBaseDir(workspaceBaseDir, artifactsBaseDir) {
794
- if (!isTemporaryReviewerWorkspaceBaseDir(workspaceBaseDir))
795
- return;
796
- try {
797
- await rm(workspaceBaseDir, { recursive: true, force: true });
798
- }
799
- catch (err) {
800
- try {
801
- writeJson(join(artifactsBaseDir, "reviewer-workspace-base-cleanup-error.json"), {
802
- reviewer_workspace_base_cleanup: "failed",
803
- workspace_base_dir: workspaceBaseDir,
804
- error: redactSecrets(err instanceof Error ? err.message : String(err)),
805
- });
806
- }
807
- catch {
808
- // Do not let cleanup telemetry hide the review result or the original error.
809
- }
810
- }
811
- }
812
- function shouldCopyReviewerPath(sourceRoot, resolvedSourceRoot, sourcePath, excludeRoots, preservePaths = new Set(), enforceContentPolicy = true) {
813
- const resolvedSourcePath = resolve(sourcePath);
814
- if (!isCopyableReviewerSymlink(sourceRoot, resolvedSourceRoot, resolvedSourcePath, excludeRoots)) {
815
- return false;
816
- }
817
- if (excludeRoots.some((root) => isSameOrInside(root, resolvedSourcePath)))
818
- return false;
819
- const rel = relative(sourceRoot, resolvedSourcePath);
820
- if (!rel)
821
- return true;
822
- const parts = rel.split(/[\\/]+/);
823
- if (sensitiveResourcePolicy.classifyPath(rel).sensitive) {
824
- return false;
825
- }
826
- if (parts[0] === ".claudexor") {
827
- return (isCopyableReviewerClaudexorPath(rel, parts, preservePaths) &&
828
- (!enforceContentPolicy || reviewerFileContentAllowed(resolvedSourcePath)));
829
- }
830
- if (parts.some((part) => [".git", ".adversarial-review", ".turbo", "node_modules"].includes(part))) {
831
- return false;
832
- }
833
- if (parts.some((part) => [".next", ".cache", "coverage", "dist"].includes(part)) &&
834
- !isPreservedReviewerPath(rel, preservePaths)) {
835
- return false;
836
- }
837
- return (!rel.endsWith(".tsbuildinfo") &&
838
- (!enforceContentPolicy || reviewerFileContentAllowed(resolvedSourcePath)));
839
- }
840
- function reviewerFileContentAllowed(path) {
841
- let targetStat;
842
- try {
843
- targetStat = statSync(path);
844
- }
845
- catch {
846
- return false;
847
- }
848
- if (!targetStat.isFile())
849
- return true;
850
- const content = readTextSafe(path);
851
- return content !== null && !sensitiveResourcePolicy.containsSensitiveContent(content);
852
- }
853
- function isCopyableReviewerClaudexorPath(rel, parts, preservePaths) {
854
- if (parts.length === 1) {
855
- return true;
856
- }
857
- if (parts.length === 2 && parts[1] === "config.yaml") {
858
- return true;
859
- }
860
- const runtimeRoot = parts[1]?.toLowerCase();
861
- if (runtimeRoot && BLOCKED_REVIEWER_RUNTIME_ROOTS.has(runtimeRoot))
862
- return false;
863
- return isPreservedReviewerPath(rel, preservePaths);
864
- }
865
- function isPreservedReviewerPath(rel, preservePaths) {
866
- const normalized = normalizeReviewerRelativePath(rel);
867
- if (!normalized)
868
- return false;
869
- if (preservePaths.has(normalized))
870
- return true;
871
- const prefix = `${normalized}/`;
872
- for (const preserved of preservePaths) {
873
- if (preserved.startsWith(prefix))
874
- return true;
875
- }
876
- return false;
877
- }
878
- export function __testExtractDiffTouchedPaths(diff) {
879
- return extractDiffTouchedPaths(diff);
880
- }
881
- function extractDiffTouchedPaths(diff) {
882
- const paths = new Set();
883
- for (const file of parseUnifiedDiff(diff).files) {
884
- if (file.oldPath)
885
- addReviewerPreservePath(paths, file.oldPath);
886
- if (file.newPath)
887
- addReviewerPreservePath(paths, file.newPath);
888
- }
889
- return paths;
890
- }
891
- function addReviewerPreservePath(paths, value) {
892
- const normalized = normalizeReviewerRelativePath(value);
893
- if (normalized)
894
- paths.add(normalized);
895
- }
896
- function normalizeReviewerRelativePath(value) {
897
- if (!value || value === "/dev/null" || isAbsolute(value))
898
- return null;
899
- const normalized = normalize(value).replace(/\\/g, "/");
900
- if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
901
- return null;
902
- }
903
- return normalized;
904
- }
905
- function isCopyableReviewerSymlink(sourceRoot, resolvedSourceRoot, sourcePath, excludeRoots) {
906
- let stat;
907
- try {
908
- stat = lstatSync(sourcePath);
909
- }
910
- catch {
911
- return false;
912
- }
913
- if (!stat.isSymbolicLink())
914
- return true;
915
- let linkTarget = "";
916
- let resolvedTarget = "";
917
- let targetKind = "other";
918
- try {
919
- linkTarget = readlinkSync(sourcePath);
920
- resolvedTarget = realpathSync(sourcePath);
921
- const targetStat = statSync(sourcePath);
922
- targetKind = targetStat.isDirectory() ? "directory" : targetStat.isFile() ? "file" : "other";
923
- }
924
- catch {
925
- return false;
926
- }
927
- return sensitiveResourcePolicy.assessSymlink({
928
- sourceRoot,
929
- canonicalSourceRoot: resolvedSourceRoot,
930
- sourcePath,
931
- linkTarget,
932
- resolvedTargetPath: resolvedTarget,
933
- targetKind,
934
- allowedTargetKinds: ["file", "directory"],
935
- excludedRoots: excludeRoots,
936
- relocationRoot: sourceRoot,
937
- }).allowed;
938
- }
939
- async function initializeReviewerWorkspaceGit(root) {
940
- const noHooks = ["-c", "core.hooksPath=/dev/null"];
941
- await runGitOrThrow("init", root, ["-c", "init.templateDir=", ...noHooks, "init"]);
942
- for (const [key, value] of [
943
- ["user.email", "claudexor-review@example.invalid"],
944
- ["user.name", "Claudexor Review"],
945
- ]) {
946
- await runGitOrThrow(`config ${key}`, root, [...noHooks, "config", key, value]);
947
- }
948
- await runGitOrThrow("add", root, [...noHooks, "add", "-A", "--force"]);
949
- await runGitOrThrow("commit", root, [
950
- ...noHooks,
951
- "commit",
952
- "--allow-empty",
953
- "--no-verify",
954
- "--no-gpg-sign",
955
- "-m",
956
- "review baseline",
957
- ]);
958
- }
959
- async function runGitOrThrow(label, cwd, args) {
960
- const gitEnv = Object.fromEntries(Object.keys(process.env)
961
- .filter((key) => key.startsWith("GIT_"))
962
- .map((key) => [key, null]));
963
- gitEnv.GIT_CONFIG_NOSYSTEM = "1";
964
- const result = await runCapture("git", args, { cwd, env: gitEnv, timeoutMs: 60_000 });
965
- if (result.code === 0)
966
- return;
967
- const detail = redactSecrets((result.stderr || result.stdout || `exit ${result.code}`).trim());
968
- throw new Error(`failed to prepare reviewer workspace (${label}): ${detail}`);
969
- }
970
- function isSameOrInside(parent, target) {
971
- const rel = relative(resolve(parent), resolve(target));
972
- const firstPart = rel.split(/[\\/]+/)[0];
973
- return rel === "" || (!!rel && firstPart !== ".." && !isAbsolute(rel));
974
- }
975
802
  function createReviewerArtifactContext(baseDir, index, reviewer) {
976
803
  const dir = join(baseDir, `${String(index + 1).padStart(2, "0")}-${safeFilePart(reviewer.adapter.id)}`);
977
804
  ensureDir(dir);
@@ -1000,8 +827,11 @@ function createReviewerArtifactContext(baseDir, index, reviewer) {
1000
827
  return ctx;
1001
828
  }
1002
829
  function safeFilePart(value) {
1003
- const safe = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
1004
- return safe || "reviewer";
830
+ const safe = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+/, "");
831
+ let end = safe.length;
832
+ while (end > 0 && safe[end - 1] === "-")
833
+ end -= 1;
834
+ return safe.slice(0, end) || "reviewer";
1005
835
  }
1006
836
  function emitReviewerProgress(artifact, reviewer, onReviewerEvent, patch) {
1007
837
  const event = {