@claudexor/review 1.0.0 → 2.1.2

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.
@@ -1,12 +1,13 @@
1
1
  import { parseUnifiedDiff, runCapture } from "@claudexor/core";
2
2
  import { preflightEvidence, writeDiffEvidence } from "@claudexor/context";
3
3
  import { FallbackReason, HarnessRunSpec, ReviewFinding as ReviewFindingSchema, } from "@claudexor/schema";
4
- import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
4
+ import { existsSync, lstatSync, readlinkSync, realpathSync, statSync } from "node:fs";
5
5
  import { cp, mkdir, readdir, rm } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
8
- import { appendLine, containsSecretLikeToken, ensureDir, newId, nowIso, readTextSafe, redactSecrets, sha256, writeJson, writeText, } from "@claudexor/util";
8
+ import { appendLine, containsSecretLikeToken, ensureDir, newId, nowIso, readTextSafe, redactSecrets, sensitiveResourcePolicy, sha256, writeJson, writeText, } from "@claudexor/util";
9
9
  import { dedupeFindings, extractJsonBlocks, parseFindingsDetailed, } from "./findings.js";
10
+ import { buildReviewPrompt } from "./reviewPrompt.js";
10
11
  import { buildRouteProof, classifyDiversity } from "./route.js";
11
12
  const DEFAULT_REVIEWER_TIMEOUT_MS = 10 * 60_000;
12
13
  const DEFAULT_REVIEWER_TRANSIENT_RETRY_POLICY = {
@@ -14,41 +15,81 @@ const DEFAULT_REVIEWER_TRANSIENT_RETRY_POLICY = {
14
15
  initialDelayMs: 1_000,
15
16
  maxDelayMs: 10_000,
16
17
  };
17
- function reviewPrompt(label, candidateRoot, evidenceDir, patch) {
18
- return [
19
- "You are an adversarial code reviewer.",
20
- `Candidate root: ${candidateRoot}.`,
21
- `First read the evidence packet in ${evidenceDir} (USER_INTENT.md, FORBIDDEN_FINDINGS.md, PLAN_ACCEPTED.md, DECIDED_TRADEOFFS.md, TESTS.txt, DIFF.patch, DIFF_SUMMARY.md). If a mandatory file is missing, return INSUFFICIENT_EVIDENCE.`,
22
- `Review ${label}'s change from the file-backed patch artifact, not from this prompt. Full patch: ${patch.diffPath}. Summary: ${patch.summaryPath}. Patch digest: ${patch.diffSha256}.`,
23
- "All code/file evidence must come from Candidate root or the evidence packet. Do not inspect or cite sibling/base repository paths outside Candidate root; if required evidence is unavailable there, return INSUFFICIENT_EVIDENCE.",
24
- "Treat TESTS.txt as the gate evidence. Do not rerun full build/test gates from the review; run only small targeted commands when needed to verify a concrete finding.",
25
- "In finding evidence, cite candidate files with paths relative to Candidate root. Cite evidence packet files by their evidence filename (for example DIFF.patch or TESTS.txt). Do not cite absolute Candidate root, reviewer workspace, or evidenceDir paths; those are disposable transport paths and will be rejected as evidence.",
26
- "Output ONLY a JSON array of findings.",
27
- `Each finding: {"severity":"BLOCK|FIX_FIRST|WARN|NIT|OUT_OF_SCOPE|INSUFFICIENT_EVIDENCE|NEEDS_HUMAN","category":"correctness|regression|security|performance|maintainability|test_gap|spec_gap|deploy|architecture|ux","claim":"...","evidence":{"files":[{"path":"...","lines":"..."}]},"proposed_fix":"..."}.`,
28
- "Rules: no evidence => do NOT use BLOCK. Do not relitigate FORBIDDEN_FINDINGS or DECIDED_TRADEOFFS.",
29
- "",
30
- "Patch summary (not a replacement for reading DIFF.patch):",
31
- patch.summary,
32
- ].join("\n");
18
+ const BLOCKED_REVIEWER_RUNTIME_ROOTS = new Set("auth cache daemon home homes logs runs secrets state tmp workspaces".split(" "));
19
+ const TEXT_EVIDENCE_SUFFIXES = [".md", ".txt", ".json", ".yaml", ".yml", ".patch"];
20
+ 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;
21
+ function readExistingDiffEvidence(dir, diff) {
22
+ const diffText = diff.endsWith("\n") ? diff : `${diff}\n`;
23
+ const summaryPath = join(dir, "DIFF_SUMMARY.md");
24
+ const summary = readTextSafe(summaryPath);
25
+ if (summary === null)
26
+ throw new Error("sealed review packet is missing DIFF_SUMMARY.md");
27
+ return {
28
+ diffPath: join(dir, "DIFF.patch"),
29
+ summaryPath,
30
+ diffSha256: sha256(diffText),
31
+ summary,
32
+ };
33
+ }
34
+ function reviewerRouteProof(reviewer, modelId, source, peerFamilies) {
35
+ return buildRouteProof({
36
+ harness_id: reviewer.adapter.id,
37
+ provider_family: reviewer.providerFamily,
38
+ model_hint: reviewer.requestedModel ?? null,
39
+ }, {
40
+ provider: reviewer.providerFamily,
41
+ model_id: modelId,
42
+ evidence_source: modelId ? source : "unavailable",
43
+ }, peerFamilies);
44
+ }
45
+ function reviewerInfo(reviewer, routeProofStatus, observedModel = null) {
46
+ return {
47
+ harness_id: reviewer.adapter.id,
48
+ requested_model: reviewer.requestedModel ?? null,
49
+ requested_effort: reviewer.requestedEffort ?? null,
50
+ observed_model: observedModel,
51
+ route_proof_status: routeProofStatus,
52
+ };
33
53
  }
34
- /**
35
- * Cross-family review of one anonymized candidate. Each reviewer runs its review
36
- * intent and emits JSON findings; we attach route proofs and verify the
37
- * reviewers span >= 2 distinct provider families.
38
- */
39
54
  export async function reviewCandidate(input) {
40
55
  const findingsByReviewer = input.reviewers.map(() => []);
41
- const routeProofs = [];
42
- const reviewerRequests = [];
43
- const healthyFamilies = new Set();
56
+ const reviewerFamilies = input.reviewers.map((reviewer) => reviewer.providerFamily);
57
+ const routeProofs = input.reviewers.map((reviewer, index) => reviewerRouteProof(reviewer, null, "unavailable", reviewerFamilies.filter((_, otherIndex) => otherIndex !== index)));
58
+ const reviewerRequests = input.reviewers.map((reviewer) => ({
59
+ harness_id: reviewer.adapter.id,
60
+ provider_family: reviewer.providerFamily,
61
+ requested_model: reviewer.requestedModel ?? null,
62
+ requested_effort: reviewer.requestedEffort ?? null,
63
+ }));
44
64
  const healthyReviewerIndexes = new Set();
45
- let reviewSpendUsd = 0;
46
- let reviewSpendEstimated = false;
65
+ const reviewSpendByReviewer = input.reviewers.map(() => 0);
66
+ const reviewSpendEstimatedByReviewer = input.reviewers.map(() => false);
47
67
  const reviewerTimeoutMs = input.reviewerTimeoutMs ?? DEFAULT_REVIEWER_TIMEOUT_MS;
68
+ const reviewWaveId = input.env?.["CLAUDEXOR_REVIEW_WAVE_ID"] ?? process.env["CLAUDEXOR_REVIEW_WAVE_ID"] ?? null;
69
+ if (input.evidenceReadOnly && input.frozenIdentity && !REVIEW_WAVE_ID.test(reviewWaveId ?? "")) {
70
+ throw new Error("sealed release review requires CLAUDEXOR_REVIEW_WAVE_ID UUID");
71
+ }
72
+ const frozenMetadata = input.frozenIdentity
73
+ ? {
74
+ candidate_sha: input.frozenIdentity.candidateSha,
75
+ candidate_tree: input.frozenIdentity.candidateTree,
76
+ packet_manifest_sha256: input.frozenIdentity.packetManifestSha256,
77
+ ...(reviewWaveId ? { review_wave_id: reviewWaveId } : {}),
78
+ }
79
+ : {};
48
80
  if (containsSecretLikeToken(input.diff || "(empty diff)\n")) {
49
81
  throw new Error("diff evidence contains a secret-like token; refusing to persist raw DIFF.patch");
50
82
  }
51
- writeDiffEvidence(input.evidenceDir, input.diff);
83
+ if (input.evidenceReadOnly) {
84
+ const packetDiff = readTextSafe(join(input.evidenceDir, "DIFF.patch"));
85
+ const normalizedDiff = input.diff.endsWith("\n") ? input.diff : `${input.diff}\n`;
86
+ if (packetDiff === null || packetDiff !== normalizedDiff) {
87
+ throw new Error("sealed review packet DIFF.patch does not match the verified review diff");
88
+ }
89
+ }
90
+ else {
91
+ writeDiffEvidence(input.evidenceDir, input.diff);
92
+ }
52
93
  const preflight = preflightEvidence(input.evidenceDir);
53
94
  if (!preflight.ok) {
54
95
  const parts = [
@@ -60,69 +101,70 @@ export async function reviewCandidate(input) {
60
101
  const artifactsBaseDir = input.artifactsDir ?? join(input.evidenceDir, "reviewer-artifacts");
61
102
  ensureDir(artifactsBaseDir);
62
103
  const persistentEvidenceDir = join(artifactsBaseDir, "evidence");
63
- await copyReviewEvidencePacket(input.evidenceDir, persistentEvidenceDir);
64
- const persistentPatch = writeDiffEvidence(persistentEvidenceDir, input.diff);
65
- writeJson(join(persistentEvidenceDir, "metadata.json"), {
104
+ await copyReviewEvidencePacket(input.evidenceDir, persistentEvidenceDir, input.evidenceReadOnly === true);
105
+ const persistentPatch = input.evidenceReadOnly
106
+ ? readExistingDiffEvidence(persistentEvidenceDir, input.diff)
107
+ : writeDiffEvidence(persistentEvidenceDir, input.diff);
108
+ writeJson(input.evidenceReadOnly
109
+ ? join(artifactsBaseDir, "evidence-metadata.json")
110
+ : join(persistentEvidenceDir, "metadata.json"), {
66
111
  source_evidence_dir: input.evidenceDir,
67
112
  candidate_root: input.cwd,
68
113
  persistent_evidence_dir: persistentEvidenceDir,
69
114
  diff_path: persistentPatch.diffPath,
70
115
  summary_path: persistentPatch.summaryPath,
71
116
  diff_sha256: persistentPatch.diffSha256,
117
+ ...frozenMetadata,
72
118
  });
73
- const artifacts = [];
74
- const reviewerFamilies = input.reviewers.map((r) => r.providerFamily);
119
+ const artifacts = input.reviewers.map(() => undefined);
75
120
  const preservePaths = extractDiffTouchedPaths(input.diff);
76
121
  const reviewerWorkspaceBaseDir = selectReviewerWorkspaceBaseDir(input.cwd, artifactsBaseDir, input.evidenceDir);
77
- try {
78
- for (const [index, reviewer] of input.reviewers.entries()) {
79
- if (input.signal?.aborted)
80
- break;
81
- reviewerRequests.push({
82
- harness_id: reviewer.adapter.id,
83
- provider_family: reviewer.providerFamily,
84
- requested_model: reviewer.requestedModel ?? null,
85
- requested_effort: reviewer.requestedEffort ?? null,
122
+ const runReviewer = async (reviewer, index) => {
123
+ if (input.signal?.aborted)
124
+ return;
125
+ const artifact = createReviewerArtifactContext(artifactsBaseDir, index, reviewer);
126
+ artifacts[index] = artifact;
127
+ let reviewerWorkspace = null;
128
+ let spec = null;
129
+ try {
130
+ reviewerWorkspace = await prepareReviewerWorkspace({
131
+ sourceRoot: input.cwd,
132
+ sourceEvidenceDir: persistentEvidenceDir,
133
+ workspaceBaseDir: reviewerWorkspaceBaseDir,
134
+ reviewerDirName: `${String(index + 1).padStart(2, "0")}-${safeFilePart(reviewer.adapter.id)}`,
135
+ excludeRoots: [artifactsBaseDir],
136
+ preservePaths,
137
+ preserveEvidenceBytes: input.evidenceReadOnly === true,
86
138
  });
87
- const artifact = createReviewerArtifactContext(artifactsBaseDir, index, reviewer);
88
- artifacts.push(artifact);
89
- let reviewerWorkspace = null;
90
- let spec = null;
91
- try {
92
- reviewerWorkspace = await prepareReviewerWorkspace({
93
- sourceRoot: input.cwd,
94
- sourceEvidenceDir: persistentEvidenceDir,
95
- workspaceBaseDir: reviewerWorkspaceBaseDir,
96
- reviewerDirName: `${String(index + 1).padStart(2, "0")}-${safeFilePart(reviewer.adapter.id)}`,
97
- excludeRoots: [artifactsBaseDir],
98
- preservePaths,
99
- });
100
- const reviewerPatch = writeDiffEvidence(reviewerWorkspace.evidenceDir, input.diff);
101
- updateReviewerMetadata(artifact, {
102
- candidate_evidence_dir: reviewerWorkspace.evidenceDir,
103
- candidate_root: reviewerWorkspace.root,
104
- source_candidate_evidence_dir: input.evidenceDir,
105
- source_candidate_root: input.cwd,
106
- reviewer_workspace_root: reviewerWorkspace.root,
107
- persistent_evidence_dir: persistentEvidenceDir,
108
- persistent_diff_path: persistentPatch.diffPath,
109
- persistent_summary_path: persistentPatch.summaryPath,
110
- diff_sha256: persistentPatch.diffSha256,
111
- });
112
- const runtimePrompt = reviewPrompt(input.candidateLabel, reviewerWorkspace.root, reviewerWorkspace.evidenceDir, reviewerPatch);
113
- spec = HarnessRunSpec.parse({
114
- session_id: newId("rev"),
115
- intent: "review",
116
- prompt: runtimePrompt,
117
- cwd: reviewerWorkspace.root,
118
- access: "readonly",
119
- model_hint: reviewer.requestedModel ?? null,
120
- effort_hint: reviewer.requestedEffort ?? null,
121
- auth_preference: reviewer.authPreference ?? "auto",
122
- env_inheritance: input.envInheritance ?? "mirror_native",
123
- ...(input.env ? { env: input.env } : {}),
124
- });
125
- writeText(artifact.promptPath, redactSecrets(`Persistent local replay evidence:
139
+ const reviewerPatch = input.evidenceReadOnly
140
+ ? readExistingDiffEvidence(reviewerWorkspace.evidenceDir, input.diff)
141
+ : writeDiffEvidence(reviewerWorkspace.evidenceDir, input.diff);
142
+ updateReviewerMetadata(artifact, {
143
+ candidate_evidence_dir: reviewerWorkspace.evidenceDir,
144
+ candidate_root: reviewerWorkspace.root,
145
+ source_candidate_evidence_dir: input.evidenceDir,
146
+ source_candidate_root: input.cwd,
147
+ reviewer_workspace_root: reviewerWorkspace.root,
148
+ persistent_evidence_dir: persistentEvidenceDir,
149
+ persistent_diff_path: persistentPatch.diffPath,
150
+ persistent_summary_path: persistentPatch.summaryPath,
151
+ diff_sha256: persistentPatch.diffSha256,
152
+ ...frozenMetadata,
153
+ });
154
+ const runtimePrompt = buildReviewPrompt(input.candidateLabel, reviewerWorkspace.root, reviewerWorkspace.evidenceDir, reviewerPatch, input.evidenceReadOnly === true);
155
+ spec = HarnessRunSpec.parse({
156
+ session_id: newId("rev"),
157
+ intent: "review",
158
+ prompt: runtimePrompt,
159
+ cwd: reviewerWorkspace.root,
160
+ access: "readonly",
161
+ model_hint: reviewer.requestedModel ?? null,
162
+ effort_hint: reviewer.requestedEffort ?? null,
163
+ auth_preference: reviewer.authPreference ?? "auto",
164
+ env_inheritance: input.envInheritance ?? "mirror_native",
165
+ ...(input.env ? { env: input.env } : {}),
166
+ });
167
+ writeText(artifact.promptPath, redactSecrets(`Persistent local replay evidence:
126
168
  - evidence_dir: ${persistentEvidenceDir}
127
169
  - candidate_root: ${reviewerWorkspace.root}
128
170
  - source_candidate_root: ${input.cwd}
@@ -134,172 +176,124 @@ Runtime prompt used during review follows. Its candidate-tree paths may be trans
134
176
 
135
177
  ${runtimePrompt}
136
178
  `));
137
- }
138
- catch (err) {
139
- const failedAt = nowIso();
140
- const message = redactSecrets(err instanceof Error ? err.message : String(err));
141
- updateReviewerMetadata(artifact, {
142
- status: "failed",
143
- failure_time: failedAt,
144
- error: `reviewer setup failed: ${message}`,
145
- });
146
- writeParseError(artifact, { error: `reviewer setup failed: ${message}` });
147
- emitReviewerProgress(artifact, reviewer, input.onReviewerEvent, {
148
- type: "reviewer.failed",
149
- at: failedAt,
150
- duration_ms: 0,
151
- message: `Reviewer setup failed: ${message}`,
152
- });
153
- if (reviewerWorkspace)
154
- await cleanupReviewerWorkspace(reviewerWorkspace, artifact);
155
- const proof = buildRouteProof({
156
- harness_id: reviewer.adapter.id,
157
- provider_family: reviewer.providerFamily,
158
- model_hint: reviewer.requestedModel ?? null,
159
- }, {
160
- provider: reviewer.providerFamily,
161
- model_id: null,
162
- evidence_source: "unavailable",
163
- }, reviewerFamilies);
164
- routeProofs.push(proof);
165
- findingsByReviewer[index]?.push(insufficientEvidenceFinding({
166
- harness_id: reviewer.adapter.id,
167
- requested_model: reviewer.requestedModel ?? null,
168
- requested_effort: reviewer.requestedEffort ?? null,
169
- observed_model: null,
170
- route_proof_status: proof.status,
171
- }, `Reviewer setup failed: ${message}`));
172
- continue;
173
- }
174
- if (!reviewerWorkspace || !spec)
175
- continue;
176
- let text = "";
177
- // Stream-observed model: ONLY a model the native CLI actually emitted in its
178
- // stream (stream_event/transcript/model_catalog). This is the honest
179
- // `observed_model` for findings — an accepted argv echo is NOT an observation.
180
- let streamObservedModel;
181
- // Route-proof model: stream-observed when present, else the accepted argv arg
182
- // (metadata tier). Drives RouteProof.observed.model_id + status.
183
- let routeModel;
184
- let routeSource = "unavailable";
185
- let reviewerError = null;
186
- try {
187
- const out = await collectReviewerOutput(reviewer, spec, reviewerTimeoutMs, input.transientRetryPolicy ?? DEFAULT_REVIEWER_TRANSIENT_RETRY_POLICY, artifact, input.onReviewerEvent, input.signal);
188
- text = out.text;
189
- streamObservedModel = out.observedModel;
190
- routeModel = out.observedModel;
191
- routeSource = out.observedSource;
192
- reviewSpendUsd += out.costUsd;
193
- if (out.costEstimated)
194
- reviewSpendEstimated = true;
195
- // accepted_model_arg semantics: when WE passed an explicit model argument
196
- // and the native CLI completed without rejecting it, the accepted argv is
197
- // metadata-level route evidence (weaker than stream-observed, stronger
198
- // than nothing). Some CLIs (codex exec --json) never echo the model. This
199
- // populates ONLY the route proof — never streamObservedModel, so the
200
- // finding's observed_model stays null (an argv echo is not an observation).
201
- if (!routeModel && reviewer.requestedModel) {
202
- routeModel = reviewer.requestedModel;
203
- routeSource = "metadata";
204
- }
205
- }
206
- catch (err) {
207
- reviewerError = redactSecrets(err instanceof Error ? err.message : String(err));
208
- // Budget truth: a reviewer that streamed paid tokens then timed out/failed
209
- // still spent money. Fold the partial cost into the ledger (the success
210
- // path adds out.costUsd above; these paths are mutually exclusive).
211
- const partial = err;
212
- if (typeof partial?.partialText === "string" && partial.partialText.trim() !== "") {
213
- text = partial.partialText;
214
- }
215
- if (partial && typeof partial.partialCostUsd === "number" && partial.partialCostUsd > 0) {
216
- reviewSpendUsd += partial.partialCostUsd;
217
- if (partial.partialCostEstimated)
218
- reviewSpendEstimated = true;
219
- }
220
- if (partial?.partialObservedModel) {
221
- streamObservedModel = partial.partialObservedModel;
222
- routeModel = partial.partialObservedModel;
223
- routeSource = partial.partialObservedSource ?? "stream_event";
224
- }
225
- writeParseError(artifact, { error: reviewerError });
226
- }
227
- finally {
179
+ }
180
+ catch (err) {
181
+ const failedAt = nowIso();
182
+ const message = redactSecrets(err instanceof Error ? err.message : String(err));
183
+ updateReviewerMetadata(artifact, {
184
+ status: "failed",
185
+ failure_time: failedAt,
186
+ error: `reviewer setup failed: ${message}`,
187
+ });
188
+ writeParseError(artifact, { error: `reviewer setup failed: ${message}` });
189
+ emitReviewerProgress(artifact, reviewer, input.onReviewerEvent, {
190
+ type: "reviewer.failed",
191
+ at: failedAt,
192
+ duration_ms: 0,
193
+ message: `Reviewer setup failed: ${message}`,
194
+ });
195
+ if (reviewerWorkspace)
228
196
  await cleanupReviewerWorkspace(reviewerWorkspace, artifact);
197
+ const proof = reviewerRouteProof(reviewer, null, "unavailable", reviewerFamilies);
198
+ routeProofs[index] = proof;
199
+ findingsByReviewer[index]?.push(insufficientEvidenceFinding(reviewerInfo(reviewer, proof.status), `Reviewer setup failed: ${message}`));
200
+ return;
201
+ }
202
+ if (!reviewerWorkspace || !spec)
203
+ return;
204
+ let text = "";
205
+ let streamObservedModel;
206
+ let routeModel;
207
+ let routeSource = "unavailable";
208
+ let reviewerError = null;
209
+ try {
210
+ const out = await collectReviewerOutput(reviewer, spec, reviewerTimeoutMs, input.transientRetryPolicy ?? DEFAULT_REVIEWER_TRANSIENT_RETRY_POLICY, artifact, input.onReviewerEvent, input.signal);
211
+ text = out.text;
212
+ streamObservedModel = out.observedModel;
213
+ routeModel = out.observedModel;
214
+ routeSource = out.observedSource;
215
+ reviewSpendByReviewer[index] = out.costUsd;
216
+ reviewSpendEstimatedByReviewer[index] = out.costEstimated;
217
+ if (!routeModel && reviewer.requestedModel) {
218
+ routeModel = reviewer.requestedModel;
219
+ routeSource = "metadata";
229
220
  }
230
- const proof = buildRouteProof({
231
- harness_id: reviewer.adapter.id,
232
- provider_family: reviewer.providerFamily,
233
- model_hint: reviewer.requestedModel ?? null,
234
- }, {
235
- provider: reviewer.providerFamily,
236
- model_id: routeModel ?? null,
237
- evidence_source: routeModel ? routeSource : "unavailable",
238
- },
239
- // The other reviewers' families this route is meant to be diverse against
240
- // (mirrors the implementer route proof; reviewer diversity is otherwise
241
- // enforced via classifyDiversity's same_model_fallback status below).
242
- reviewerFamilies.filter((_, i) => i !== index));
243
- routeProofs.push(proof);
244
- const info = {
245
- harness_id: reviewer.adapter.id,
246
- requested_model: reviewer.requestedModel ?? null,
247
- requested_effort: reviewer.requestedEffort ?? null,
248
- // Honest observation only: a finding's observed_model is the STREAM-observed
249
- // model or null. An accepted argv arg lives in the route proof's model_id,
250
- // not here — it must not masquerade as an observed model.
251
- observed_model: streamObservedModel ?? null,
252
- route_proof_status: proof.status,
253
- };
254
- const jsonBlocks = extractJsonBlocks(text);
255
- writeJson(artifact.parsedPath, redactValue(jsonBlocks));
256
- if (reviewerError && (text.trim() === "" || jsonBlocks.length === 0)) {
257
- findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, `Reviewer failed: ${reviewerError}`));
258
- continue;
259
- }
260
- if (text.trim() === "" || jsonBlocks.length === 0) {
261
- writeParseError(artifact, { error: "no_parseable_json", text_sha256: sha256(text) });
262
- findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, "Reviewer produced no parseable JSON findings."));
263
- continue;
264
- }
265
- const parsed = parseFindingsDetailed(text, info);
266
- const parseError = {};
267
- let parsedFindingsRecorded = false;
268
- const recordParsedFindings = () => {
269
- if (parsedFindingsRecorded)
270
- return;
271
- findingsByReviewer[index]?.push(...parsed.findings);
272
- parsedFindingsRecorded = true;
273
- };
274
- if (parsed.malformed > 0) {
275
- Object.assign(parseError, {
276
- error: "malformed_findings",
277
- malformed: parsed.malformed,
278
- text_sha256: sha256(text),
279
- });
280
- recordParsedFindings();
281
- findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, `Reviewer produced ${parsed.malformed} malformed finding item(s).`));
221
+ }
222
+ catch (err) {
223
+ reviewerError = redactSecrets(err instanceof Error ? err.message : String(err));
224
+ const partial = err;
225
+ if (typeof partial?.partialText === "string" && partial.partialText.trim() !== "") {
226
+ text = partial.partialText;
282
227
  }
283
- if (reviewerError) {
284
- Object.assign(parseError, {
285
- error: reviewerError,
286
- recovered_json_blocks: jsonBlocks.length,
287
- text_sha256: sha256(text),
288
- });
289
- recordParsedFindings();
290
- findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, parsed.findings.length === 0
291
- ? `Reviewer failed after parseable JSON with no findings: ${reviewerError}`
292
- : `Reviewer failed after parseable JSON output: ${reviewerError}`));
228
+ if (partial && typeof partial.partialCostUsd === "number" && partial.partialCostUsd > 0) {
229
+ reviewSpendByReviewer[index] = partial.partialCostUsd;
230
+ reviewSpendEstimatedByReviewer[index] = partial.partialCostEstimated === true;
293
231
  }
294
- if (Object.keys(parseError).length > 0) {
295
- writeParseError(artifact, parseError);
296
- continue;
232
+ if (partial?.partialObservedModel) {
233
+ streamObservedModel = partial.partialObservedModel;
234
+ routeModel = partial.partialObservedModel;
235
+ routeSource = partial.partialObservedSource ?? "stream_event";
297
236
  }
298
- if (reviewer.providerFamily !== "unknown")
299
- healthyFamilies.add(reviewer.providerFamily);
300
- healthyReviewerIndexes.add(index);
237
+ writeParseError(artifact, { error: reviewerError });
238
+ }
239
+ finally {
240
+ await cleanupReviewerWorkspace(reviewerWorkspace, artifact);
241
+ }
242
+ const proof = reviewerRouteProof(reviewer, routeModel ?? null, routeSource, reviewerFamilies.filter((_, i) => i !== index));
243
+ routeProofs[index] = proof;
244
+ const info = reviewerInfo(reviewer, proof.status, streamObservedModel ?? null);
245
+ const jsonBlocks = extractJsonBlocks(text);
246
+ writeJson(artifact.parsedPath, redactValue(jsonBlocks));
247
+ if (reviewerError && (text.trim() === "" || jsonBlocks.length === 0)) {
248
+ findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, `Reviewer failed: ${reviewerError}`));
249
+ return;
250
+ }
251
+ if (text.trim() === "" || jsonBlocks.length === 0) {
252
+ writeParseError(artifact, { error: "no_parseable_json", text_sha256: sha256(text) });
253
+ findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, "Reviewer produced no parseable JSON findings."));
254
+ return;
255
+ }
256
+ const parsed = parseFindingsDetailed(text, info);
257
+ const parseError = {};
258
+ let parsedFindingsRecorded = false;
259
+ const recordParsedFindings = () => {
260
+ if (parsedFindingsRecorded)
261
+ return;
301
262
  findingsByReviewer[index]?.push(...parsed.findings);
263
+ parsedFindingsRecorded = true;
264
+ };
265
+ if (parsed.malformed > 0) {
266
+ Object.assign(parseError, {
267
+ error: "malformed_findings",
268
+ malformed: parsed.malformed,
269
+ text_sha256: sha256(text),
270
+ });
271
+ recordParsedFindings();
272
+ findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, `Reviewer produced ${parsed.malformed} malformed finding item(s).`));
273
+ }
274
+ if (reviewerError) {
275
+ Object.assign(parseError, {
276
+ error: reviewerError,
277
+ recovered_json_blocks: jsonBlocks.length,
278
+ text_sha256: sha256(text),
279
+ });
280
+ recordParsedFindings();
281
+ findingsByReviewer[index]?.push(insufficientEvidenceFinding(info, parsed.findings.length === 0
282
+ ? `Reviewer failed after parseable JSON with no findings: ${reviewerError}`
283
+ : `Reviewer failed after parseable JSON output: ${reviewerError}`));
302
284
  }
285
+ if (Object.keys(parseError).length > 0) {
286
+ writeParseError(artifact, parseError);
287
+ return;
288
+ }
289
+ healthyReviewerIndexes.add(index);
290
+ findingsByReviewer[index]?.push(...parsed.findings);
291
+ };
292
+ try {
293
+ const reviewerRuns = await Promise.allSettled(input.reviewers.map((reviewer, index) => runReviewer(reviewer, index)));
294
+ const failedRun = reviewerRuns.find((run) => run.status === "rejected");
295
+ if (failedRun)
296
+ throw failedRun.reason;
303
297
  const classifiedProofs = classifyDiversity(routeProofs);
304
298
  for (const [index, proof] of classifiedProofs.entries()) {
305
299
  const artifact = artifacts[index];
@@ -321,12 +315,12 @@ ${runtimePrompt}
321
315
  });
322
316
  });
323
317
  });
324
- const healthyProviders = [...healthyFamilies];
325
- // Two-tier route proof: crossFamilyVerified — the strong tier that unblocks
326
- // apply requires the model to have been OBSERVED in the reviewer stream
327
- // (status "verified"). An argv/metadata echo ("accepted_model_arg") is a weaker
328
- // tier: it proves we PASSED a model arg, not that the CLI ran it, so it must
329
- // NOT unblock apply on unobserved proof. same_model_fallback never counts.
318
+ const healthyProviders = [
319
+ ...new Set(input.reviewers
320
+ .filter((_, index) => healthyReviewerIndexes.has(index))
321
+ .map((reviewer) => reviewer.providerFamily)
322
+ .filter((family) => family !== "unknown")),
323
+ ];
330
324
  const observedFamilies = [
331
325
  ...new Set(classifiedProofs
332
326
  .filter((p, index) => p.status === "verified" && healthyReviewerIndexes.has(index))
@@ -341,8 +335,8 @@ ${runtimePrompt}
341
335
  healthyProviders,
342
336
  crossFamilyVerified: observedFamilies.length >= 2,
343
337
  distinctProviders: observedFamilies,
344
- reviewSpendUsd,
345
- reviewSpendEstimated,
338
+ reviewSpendUsd: reviewSpendByReviewer.reduce((sum, spend) => sum + spend, 0),
339
+ reviewSpendEstimated: reviewSpendEstimatedByReviewer.some(Boolean),
346
340
  };
347
341
  }
348
342
  finally {
@@ -376,9 +370,6 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
376
370
  let firstEventTime = null;
377
371
  let observedModel;
378
372
  let observedSource = "unavailable";
379
- // Reviewer spend tracked at function scope so a timed-out/failed reviewer still
380
- // contributes its PARTIAL cost to the ledger (budget truth). It is attached to
381
- // the thrown error so the caller can fold it in.
382
373
  let costUsd = 0;
383
374
  let costEstimated = false;
384
375
  let partialText = "";
@@ -506,13 +497,12 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
506
497
  text,
507
498
  observedModel: attemptObservedModel,
508
499
  observedSource: attemptObservedSource,
509
- artifactDir: artifact.dir,
510
500
  costUsd,
511
501
  costEstimated,
512
502
  };
513
503
  };
514
504
  const consume = consumeOnce(0);
515
- const removeExternalAbortListeners = [];
505
+ let removeExternalAbortListener = () => { };
516
506
  const cancelled = new Promise((_, reject) => {
517
507
  if (!signal)
518
508
  return;
@@ -534,7 +524,7 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
534
524
  queueMicrotask(onAbort);
535
525
  else
536
526
  signal.addEventListener("abort", onAbort, { once: true });
537
- removeExternalAbortListeners.push(() => signal.removeEventListener("abort", onAbort));
527
+ removeExternalAbortListener = () => signal.removeEventListener("abort", onAbort);
538
528
  });
539
529
  const timed = new Promise((_, reject) => {
540
530
  timeout = setTimeout(() => {
@@ -610,9 +600,7 @@ async function collectReviewerOutput(reviewer, spec, timeoutMs, transientRetryPo
610
600
  settled = true;
611
601
  if (timeout)
612
602
  clearTimeout(timeout);
613
- for (const removeExternalAbortListener of removeExternalAbortListeners) {
614
- removeExternalAbortListener();
615
- }
603
+ removeExternalAbortListener();
616
604
  consume.catch(() => {
617
605
  /* timeout path: consume may reject after the race already returned */
618
606
  });
@@ -665,11 +653,13 @@ async function prepareReviewerWorkspace(input) {
665
653
  const resolvedSourceEvidenceDir = realpathSync(sourceEvidenceDir);
666
654
  const evidenceExcludeRoots = excludeRoots.filter((root) => !isSameOrInside(root, sourceEvidenceDir));
667
655
  await rm(evidenceDir, { recursive: true, force: true });
668
- await cp(sourceEvidenceDir, evidenceDir, {
669
- recursive: true,
670
- dereference: false,
671
- filter: (sourcePath) => shouldCopyReviewerPath(sourceEvidenceDir, resolvedSourceEvidenceDir, sourcePath, evidenceExcludeRoots),
672
- });
656
+ await cp(sourceEvidenceDir, evidenceDir, input.preserveEvidenceBytes
657
+ ? { recursive: true, dereference: false }
658
+ : {
659
+ recursive: true,
660
+ dereference: false,
661
+ filter: (sourcePath) => shouldCopyReviewerPath(sourceEvidenceDir, resolvedSourceEvidenceDir, sourcePath, evidenceExcludeRoots),
662
+ });
673
663
  }
674
664
  await mkdir(evidenceDir, { recursive: true, mode: 0o700 });
675
665
  await initializeReviewerWorkspaceGit(root);
@@ -680,14 +670,20 @@ async function prepareReviewerWorkspace(input) {
680
670
  throw err;
681
671
  }
682
672
  }
683
- async function copyReviewEvidencePacket(sourceEvidenceDir, persistentEvidenceDir) {
673
+ async function copyReviewEvidencePacket(sourceEvidenceDir, persistentEvidenceDir, preserveBytes = false) {
684
674
  const source = resolve(sourceEvidenceDir);
685
675
  const target = resolve(persistentEvidenceDir);
686
676
  await rm(target, { recursive: true, force: true });
687
- await mkdir(target, { recursive: true, mode: 0o700 });
688
677
  if (!existsSync(source)) {
678
+ await mkdir(target, { recursive: true, mode: 0o700 });
689
679
  return;
690
680
  }
681
+ if (preserveBytes) {
682
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
683
+ await cp(source, target, { recursive: true, dereference: false });
684
+ return;
685
+ }
686
+ await mkdir(target, { recursive: true, mode: 0o700 });
691
687
  const resolvedSource = realpathSync(source);
692
688
  for (const entry of await readdir(source, { withFileTypes: true })) {
693
689
  const sourcePath = join(source, entry.name);
@@ -725,13 +721,7 @@ async function copyReviewEvidenceEntry(sourceEvidenceDir, resolvedSourceEvidence
725
721
  await cp(sourcePath, targetPath, { recursive: false, dereference: false });
726
722
  }
727
723
  function shouldTextSanitizeEvidenceFile(path) {
728
- const lower = path.toLowerCase();
729
- return (lower.endsWith(".md") ||
730
- lower.endsWith(".txt") ||
731
- lower.endsWith(".json") ||
732
- lower.endsWith(".yaml") ||
733
- lower.endsWith(".yml") ||
734
- lower.endsWith(".patch"));
724
+ return TEXT_EVIDENCE_SUFFIXES.some((extension) => path.toLowerCase().endsWith(extension));
735
725
  }
736
726
  function shouldFailClosedEvidenceFile(path) {
737
727
  return path.toLowerCase().endsWith(".patch");
@@ -742,9 +732,7 @@ function shouldCopyEvidencePacketPath(sourceEvidenceDir, resolvedSourceEvidenceD
742
732
  return false;
743
733
  if (isSameOrInside(targetEvidenceDir, resolvedSourcePath))
744
734
  return false;
745
- return shouldCopyReviewerPath(sourceEvidenceDir, resolvedSourceEvidenceDir, resolvedSourcePath, [
746
- targetEvidenceDir,
747
- ]);
735
+ return shouldCopyReviewerPath(sourceEvidenceDir, resolvedSourceEvidenceDir, resolvedSourcePath, [targetEvidenceDir], new Set(), false);
748
736
  }
749
737
  async function cleanupReviewerWorkspace(workspace, artifact) {
750
738
  try {
@@ -777,7 +765,7 @@ async function cleanupTemporaryReviewerWorkspaceBaseDir(workspaceBaseDir, artifa
777
765
  }
778
766
  }
779
767
  }
780
- function shouldCopyReviewerPath(sourceRoot, resolvedSourceRoot, sourcePath, excludeRoots, preservePaths = new Set()) {
768
+ function shouldCopyReviewerPath(sourceRoot, resolvedSourceRoot, sourcePath, excludeRoots, preservePaths = new Set(), enforceContentPolicy = true) {
781
769
  const resolvedSourcePath = resolve(sourcePath);
782
770
  if (!isCopyableReviewerSymlink(sourceRoot, resolvedSourceRoot, resolvedSourcePath, excludeRoots)) {
783
771
  return false;
@@ -788,55 +776,35 @@ function shouldCopyReviewerPath(sourceRoot, resolvedSourceRoot, sourcePath, excl
788
776
  if (!rel)
789
777
  return true;
790
778
  const parts = rel.split(/[\\/]+/);
791
- if (parts.some(isReviewerSecretLikePathPart)) {
779
+ if (sensitiveResourcePolicy.classifyPath(rel).sensitive) {
792
780
  return false;
793
781
  }
794
782
  if (parts[0] === ".claudexor") {
795
- return isCopyableReviewerClaudexorPath(rel, parts, preservePaths);
783
+ return (isCopyableReviewerClaudexorPath(rel, parts, preservePaths) &&
784
+ (!enforceContentPolicy || reviewerFileContentAllowed(resolvedSourcePath)));
796
785
  }
797
- if (parts.some((part) => [
798
- ".git",
799
- ".adversarial-review",
800
- ".turbo",
801
- "node_modules",
802
- ].includes(part))) {
786
+ if (parts.some((part) => [".git", ".adversarial-review", ".turbo", "node_modules"].includes(part))) {
803
787
  return false;
804
788
  }
805
789
  if (parts.some((part) => [".next", ".cache", "coverage", "dist"].includes(part)) &&
806
790
  !isPreservedReviewerPath(rel, preservePaths)) {
807
791
  return false;
808
792
  }
809
- return !rel.endsWith(".tsbuildinfo");
793
+ return (!rel.endsWith(".tsbuildinfo") &&
794
+ (!enforceContentPolicy || reviewerFileContentAllowed(resolvedSourcePath)));
810
795
  }
811
- function isReviewerSecretLikePathPart(part) {
812
- const lower = part.toLowerCase();
813
- if (lower.startsWith(".env") && !isSafeEnvTemplateName(lower))
814
- return true;
815
- if ([
816
- ".npmrc",
817
- ".netrc",
818
- ".pypirc",
819
- ".git-credentials",
820
- ".ssh",
821
- ".aws",
822
- ".azure",
823
- ".gcloud",
824
- ".cursor",
825
- ".codex",
826
- ".claude",
827
- ".anthropic",
828
- ".openai",
829
- ].includes(lower)) {
830
- return true;
796
+ function reviewerFileContentAllowed(path) {
797
+ let targetStat;
798
+ try {
799
+ targetStat = statSync(path);
831
800
  }
832
- if (/^id_(rsa|dsa|ecdsa|ed25519)$/.test(lower))
833
- return true;
834
- if (lower.endsWith(".pem") || lower.endsWith(".p12") || lower.endsWith(".pfx"))
801
+ catch {
802
+ return false;
803
+ }
804
+ if (!targetStat.isFile())
835
805
  return true;
836
- return false;
837
- }
838
- function isSafeEnvTemplateName(lower) {
839
- return [".env.example", ".env.sample", ".env.template"].includes(lower);
806
+ const content = readTextSafe(path);
807
+ return content !== null && !sensitiveResourcePolicy.containsSensitiveContent(content);
840
808
  }
841
809
  function isCopyableReviewerClaudexorPath(rel, parts, preservePaths) {
842
810
  if (parts.length === 1) {
@@ -846,22 +814,8 @@ function isCopyableReviewerClaudexorPath(rel, parts, preservePaths) {
846
814
  return true;
847
815
  }
848
816
  const runtimeRoot = parts[1]?.toLowerCase();
849
- if (runtimeRoot &&
850
- [
851
- "auth",
852
- "cache",
853
- "daemon",
854
- "home",
855
- "homes",
856
- "logs",
857
- "runs",
858
- "secrets",
859
- "state",
860
- "tmp",
861
- "workspaces",
862
- ].includes(runtimeRoot)) {
817
+ if (runtimeRoot && BLOCKED_REVIEWER_RUNTIME_ROOTS.has(runtimeRoot))
863
818
  return false;
864
- }
865
819
  return isPreservedReviewerPath(rel, preservePaths);
866
820
  }
867
821
  function isPreservedReviewerPath(rel, preservePaths) {
@@ -877,15 +831,10 @@ function isPreservedReviewerPath(rel, preservePaths) {
877
831
  }
878
832
  return false;
879
833
  }
880
- /** Test-only alias for the preserve-set extractor. */
881
834
  export function __testExtractDiffTouchedPaths(diff) {
882
835
  return extractDiffTouchedPaths(diff);
883
836
  }
884
837
  function extractDiffTouchedPaths(diff) {
885
- // One structural parser owns diff headers (INV-050): parseUnifiedDiff is
886
- // quote-aware and decodes git's C-quoted paths (incl. octal escapes for
887
- // non-ASCII), which the old private tokenizer mis-decoded — a mis-decoded
888
- // touched path silently dropped the file from the reviewer preserve set.
889
838
  const paths = new Set();
890
839
  for (const file of parseUnifiedDiff(diff).files) {
891
840
  if (file.oldPath)
@@ -921,57 +870,40 @@ function isCopyableReviewerSymlink(sourceRoot, resolvedSourceRoot, sourcePath, e
921
870
  return true;
922
871
  let linkTarget = "";
923
872
  let resolvedTarget = "";
873
+ let targetKind = "other";
924
874
  try {
925
875
  linkTarget = readlinkSync(sourcePath);
926
876
  resolvedTarget = realpathSync(sourcePath);
877
+ const targetStat = statSync(sourcePath);
878
+ targetKind = targetStat.isDirectory() ? "directory" : targetStat.isFile() ? "file" : "other";
927
879
  }
928
880
  catch {
929
881
  return false;
930
882
  }
931
- if (isAbsolute(linkTarget))
932
- return false;
933
- if (!isSameOrInside(resolvedSourceRoot, resolvedTarget))
934
- return false;
935
- if (excludeRoots.some((root) => isSameOrInside(root, resolvedTarget)))
936
- return false;
937
- const sourceParentRel = relative(sourceRoot, dirname(sourcePath));
938
- if (sourceParentRel.split(/[\\/]+/)[0] === ".." || isAbsolute(sourceParentRel))
939
- return false;
940
- const relocatedTargetRel = normalize(join(sourceParentRel, linkTarget));
941
- const relocatedFirstPart = relocatedTargetRel.split(/[\\/]+/)[0];
942
- if (relocatedFirstPart === ".." || isAbsolute(relocatedTargetRel))
943
- return false;
944
- const relocatedTargetPath = resolve(sourceRoot, relocatedTargetRel);
945
- if (!isSameOrInside(sourceRoot, relocatedTargetPath))
946
- return false;
947
- return !excludeRoots.some((root) => isSameOrInside(root, relocatedTargetPath));
883
+ return sensitiveResourcePolicy.assessSymlink({
884
+ sourceRoot,
885
+ canonicalSourceRoot: resolvedSourceRoot,
886
+ sourcePath,
887
+ linkTarget,
888
+ resolvedTargetPath: resolvedTarget,
889
+ targetKind,
890
+ allowedTargetKinds: ["file", "directory"],
891
+ excludedRoots: excludeRoots,
892
+ relocationRoot: sourceRoot,
893
+ }).allowed;
948
894
  }
949
895
  async function initializeReviewerWorkspaceGit(root) {
950
- await runGitOrThrow("init", root, [
951
- "-c",
952
- "init.templateDir=",
953
- "-c",
954
- "core.hooksPath=/dev/null",
955
- "init",
956
- ]);
957
- await runGitOrThrow("config user.email", root, [
958
- "-c",
959
- "core.hooksPath=/dev/null",
960
- "config",
961
- "user.email",
962
- "claudexor-review@example.invalid",
963
- ]);
964
- await runGitOrThrow("config user.name", root, [
965
- "-c",
966
- "core.hooksPath=/dev/null",
967
- "config",
968
- "user.name",
969
- "Claudexor Review",
970
- ]);
971
- await runGitOrThrow("add", root, ["-c", "core.hooksPath=/dev/null", "add", "-A", "--force"]);
896
+ const noHooks = ["-c", "core.hooksPath=/dev/null"];
897
+ await runGitOrThrow("init", root, ["-c", "init.templateDir=", ...noHooks, "init"]);
898
+ for (const [key, value] of [
899
+ ["user.email", "claudexor-review@example.invalid"],
900
+ ["user.name", "Claudexor Review"],
901
+ ]) {
902
+ await runGitOrThrow(`config ${key}`, root, [...noHooks, "config", key, value]);
903
+ }
904
+ await runGitOrThrow("add", root, [...noHooks, "add", "-A", "--force"]);
972
905
  await runGitOrThrow("commit", root, [
973
- "-c",
974
- "core.hooksPath=/dev/null",
906
+ ...noHooks,
975
907
  "commit",
976
908
  "--allow-empty",
977
909
  "--no-verify",
@@ -1034,6 +966,9 @@ function emitReviewerProgress(artifact, reviewer, onReviewerEvent, patch) {
1034
966
  requested_model: reviewer.requestedModel ?? null,
1035
967
  requested_effort: reviewer.requestedEffort ?? null,
1036
968
  artifact_dir: artifact.dir,
969
+ ...(typeof artifact.metadata["review_wave_id"] === "string"
970
+ ? { review_wave_id: artifact.metadata["review_wave_id"] }
971
+ : {}),
1037
972
  ...patch,
1038
973
  };
1039
974
  const redacted = redactValue(event);