@effect-agent/pr-review 0.1.0-beta.19 → 0.1.0-beta.21

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,16 +1,39 @@
1
1
  import { Option, Schema } from "effect";
2
2
  import type { RunEvent } from "effect-agent";
3
3
 
4
+ import { anchorViolation } from "./anchors.ts";
4
5
  import type { ChangedFile } from "./diff.ts";
5
6
  import { isReviewableFile } from "./diff.ts";
6
- import { FileReviewDelegationFailure, FileReviewRequest, FileReviewUnitResult } from "./fan-out.ts";
7
- import { FileDiffQuery, type WalkthroughEntry } from "./review-agent.ts";
8
- import { planReviewUnits } from "./review-units.ts";
7
+ import {
8
+ FileReviewDelegationFailure,
9
+ FileReviewRequest,
10
+ FileReviewUnitResult,
11
+ ReviewCandidate,
12
+ reviewCandidateSubjectKey,
13
+ } from "./fan-out.ts";
14
+ import {
15
+ FileDiffView,
16
+ FileDiffQuery,
17
+ type ReviewConcern,
18
+ type ReviewFinding,
19
+ type WalkthroughEntry,
20
+ } from "./review-agent.ts";
21
+ import {
22
+ findingAnchorInUnitEvidence,
23
+ planReviewUnits,
24
+ type ReviewDiscoveryPass,
25
+ type ReviewUnit,
26
+ } from "./review-units.ts";
9
27
 
10
28
  // ---------------------------------------------------------------------------
11
- // Host-owned coverage. Model summaries are untrusted prose; the check result
12
- // is based on deterministic unit planning plus the semantic Tool events that
13
- // prove which required review operations actually settled successfully.
29
+ // Two different claims are deliberately modeled:
30
+ //
31
+ // - input coverage: every required path was assigned bounded evidence or was
32
+ // explicitly reported outside the pipeline's capacity;
33
+ // - review assurance: every configured discovery/specialist pass and every
34
+ // candidate-verification batch settled exactly.
35
+ //
36
+ // Neither claims that the model found every defect.
14
37
  // ---------------------------------------------------------------------------
15
38
 
16
39
  export const ReviewShape = Schema.Literals(["flat", "fan-out"]);
@@ -23,6 +46,11 @@ export class FailedReviewUnit extends Schema.Class<FailedReviewUnit>(
23
46
  errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
24
47
  }) {}
25
48
 
49
+ /**
50
+ * Compatibility diagnostic retained for callers that consumed the original
51
+ * `coverage` field. New UI and state decisions use ReviewInputCoverage and
52
+ * ReviewAssurance directly.
53
+ */
26
54
  export class ReviewCoverage extends Schema.Class<ReviewCoverage>(
27
55
  "@effect-agent/pr-review/ReviewCoverage",
28
56
  )({
@@ -37,11 +65,62 @@ export class ReviewCoverage extends Schema.Class<ReviewCoverage>(
37
65
  Schema.isMaxLength(300),
38
66
  ),
39
67
  failedUnits: Schema.Array(FailedReviewUnit).check(Schema.isMaxLength(8)),
68
+ reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
69
+ Schema.isMaxLength(32),
70
+ ),
71
+ }) {}
72
+
73
+ export class ReviewInputCoverage extends Schema.Class<ReviewInputCoverage>(
74
+ "@effect-agent/pr-review/ReviewInputCoverage",
75
+ )({
76
+ status: Schema.Literals(["complete", "incomplete"]),
77
+ requiredPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
78
+ Schema.isMaxLength(300),
79
+ ),
80
+ assignedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
81
+ Schema.isMaxLength(300),
82
+ ),
83
+ /** Assigned paths whose model-visible diff was truncated by the evidence bound. */
84
+ partialPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
85
+ Schema.isMaxLength(300),
86
+ ),
87
+ unassignedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
88
+ Schema.isMaxLength(300),
89
+ ),
40
90
  reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
41
91
  Schema.isMaxLength(20),
42
92
  ),
43
93
  }) {}
44
94
 
95
+ export class FailedReviewPass extends Schema.Class<FailedReviewPass>(
96
+ "@effect-agent/pr-review/FailedReviewPass",
97
+ )({
98
+ workId: Schema.NonEmptyString.check(Schema.isMaxLength(96)),
99
+ stage: Schema.Literals(["discovery", "specialist", "verification"]),
100
+ errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
101
+ }) {}
102
+
103
+ export class ReviewAssurance extends Schema.Class<ReviewAssurance>(
104
+ "@effect-agent/pr-review/ReviewAssurance",
105
+ )({
106
+ status: Schema.Literals(["settled", "incomplete", "unverified"]),
107
+ requiredGeneralDiscoveryPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
108
+ completedGeneralDiscoveryPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
109
+ requiredSpecialistPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
110
+ completedSpecialistPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
111
+ requiredVerificationPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
112
+ completedVerificationPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
113
+ discoveredCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
114
+ confirmedCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
115
+ rejectedCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
116
+ unsettledCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
117
+ /** Every failure remains visible within the coordinator's 32-call hard bound. */
118
+ failedPasses: Schema.Array(FailedReviewPass).check(Schema.isMaxLength(64)),
119
+ reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
120
+ Schema.isMaxLength(32),
121
+ ),
122
+ }) {}
123
+
45
124
  interface ToolTrace {
46
125
  readonly declared: Map<string, Extract<RunEvent, { readonly _tag: "ToolCallDeclared" }>>;
47
126
  readonly succeeded: Map<string, Extract<RunEvent, { readonly _tag: "ToolCallSucceeded" }>>;
@@ -81,24 +160,30 @@ const boundedListReason = (label: string, values: Iterable<string>): string => {
81
160
  return rendered;
82
161
  };
83
162
 
84
- const flatCoverage = (
163
+ const flatInputCoverage = (
85
164
  files: ReadonlyArray<ChangedFile>,
86
165
  totalFiles: number,
87
166
  trace: ToolTrace,
88
- ): ReviewCoverage => {
167
+ ): ReviewInputCoverage => {
89
168
  const requiredPaths = sortedUnique(files.map((file) => file.path));
90
- const reviewed = new Set<string>();
169
+ const assigned = new Set<string>();
170
+ const partial = new Set<string>();
91
171
  const failedPaths = new Set<string>();
92
172
  for (const [toolCallId, declaration] of trace.declared) {
93
173
  if (declaration.toolName !== "read_file_diff") continue;
94
174
  const query = Schema.decodeUnknownOption(FileDiffQuery)(declaration.parameters);
95
175
  if (Option.isNone(query)) continue;
96
- if (trace.succeeded.has(toolCallId)) reviewed.add(query.value.path);
176
+ const success = trace.succeeded.get(toolCallId);
177
+ if (success !== undefined) {
178
+ assigned.add(query.value.path);
179
+ const view = Schema.decodeUnknownOption(FileDiffView)(success.result);
180
+ if (Option.isSome(view) && view.value.truncated) partial.add(query.value.path);
181
+ }
97
182
  if (trace.failed.has(toolCallId)) failedPaths.add(query.value.path);
98
183
  }
99
184
  const undiffable = files.filter((file) => !isReviewableFile(file)).map((file) => file.path);
100
- const unreviewed = requiredPaths.filter(
101
- (path) => !reviewed.has(path) || undiffable.includes(path) || failedPaths.has(path),
185
+ const unassigned = requiredPaths.filter(
186
+ (path) => !assigned.has(path) || undiffable.includes(path) || failedPaths.has(path),
102
187
  );
103
188
  const reasons: Array<string> = [];
104
189
  if (files.length < totalFiles) {
@@ -109,92 +194,31 @@ const flatCoverage = (
109
194
  boundedListReason("required paths have no reviewable diff or bounded text", undiffable),
110
195
  );
111
196
  }
112
- if (failedPaths.size > 0) {
113
- reasons.push(boundedListReason("diff reads failed", failedPaths));
197
+ if (failedPaths.size > 0) reasons.push(boundedListReason("diff reads failed", failedPaths));
198
+ if (partial.size > 0) {
199
+ reasons.push(boundedListReason("model-visible diff evidence was truncated", partial));
114
200
  }
115
- if (unreviewed.length > 0) {
116
- reasons.push(boundedListReason("required paths were not successfully reviewed", unreviewed));
201
+ if (unassigned.length > 0) {
202
+ reasons.push(boundedListReason("required paths received no successful diff input", unassigned));
117
203
  }
118
- return ReviewCoverage.make({
204
+ return ReviewInputCoverage.make({
119
205
  status: reasons.length === 0 ? "complete" : "incomplete",
120
206
  requiredPaths,
121
- reviewedPaths: sortedUnique(reviewed),
122
- unreviewedPaths: sortedUnique(unreviewed),
123
- failedUnits: [],
207
+ assignedPaths: sortedUnique(assigned),
208
+ partialPaths: sortedUnique(partial),
209
+ unassignedPaths: sortedUnique(unassigned),
124
210
  reasons,
125
211
  });
126
212
  };
127
213
 
128
- const fanOutCoverage = (
214
+ const fanOutInputCoverage = (
129
215
  files: ReadonlyArray<ChangedFile>,
130
216
  totalFiles: number,
131
- trace: ToolTrace,
132
- ): ReviewCoverage => {
217
+ ): ReviewInputCoverage => {
133
218
  const plan = planReviewUnits(files, { totalChangedFiles: totalFiles });
134
- const declarationsByUnit = new Map<
135
- string,
136
- Array<{ readonly id: string; readonly paths: ReadonlyArray<string> }>
137
- >();
138
- for (const [toolCallId, declaration] of trace.declared) {
139
- if (declaration.toolName !== "delegate_file_review") continue;
140
- const request = Schema.decodeUnknownOption(FileReviewRequest)(declaration.parameters);
141
- if (Option.isNone(request)) continue;
142
- const declarations = declarationsByUnit.get(request.value.unitId) ?? [];
143
- declarations.push({ id: toolCallId, paths: request.value.paths });
144
- declarationsByUnit.set(request.value.unitId, declarations);
145
- }
146
-
147
- const reviewed = new Set<string>();
148
- const unreviewed = new Set<string>([...plan.undiffablePaths, ...plan.unassignedPaths]);
149
- const failedUnits: Array<FailedReviewUnit> = [];
219
+ const assignedPaths = sortedUnique(plan.units.flatMap((unit) => unit.paths));
220
+ const unassignedPaths = sortedUnique([...plan.undiffablePaths, ...plan.unassignedPaths]);
150
221
  const reasons: Array<string> = [];
151
- for (const unit of plan.units) {
152
- const declarations = declarationsByUnit.get(unit.unitId) ?? [];
153
- const expectedPaths = [...unit.paths];
154
- const exact = declarations.filter(
155
- (declaration) =>
156
- declaration.paths.length === expectedPaths.length &&
157
- declaration.paths.every((path, index) => path === expectedPaths[index]),
158
- );
159
- const successful = exact.filter((declaration) => {
160
- const event = trace.succeeded.get(declaration.id);
161
- if (event === undefined || trace.failed.has(declaration.id)) return false;
162
- const result = Schema.decodeUnknownOption(FileReviewUnitResult)(event.result);
163
- return Option.isSome(result) && result.value.unitId === unit.unitId;
164
- });
165
- if (declarations.length === 1 && exact.length === 1 && successful.length === 1) {
166
- for (const path of unit.paths) reviewed.add(path);
167
- continue;
168
- }
169
- for (const path of unit.paths) unreviewed.add(path);
170
- const failure = declarations
171
- .map((declaration) => trace.failed.get(declaration.id))
172
- .find((event) => event !== undefined);
173
- const returnedFailure = declarations
174
- .map((declaration) => trace.succeeded.get(declaration.id))
175
- .filter((event) => event !== undefined)
176
- .map((event) => Schema.decodeUnknownOption(FileReviewDelegationFailure)(event.result))
177
- .find(Option.isSome);
178
- failedUnits.push(
179
- FailedReviewUnit.make({
180
- unitId: unit.unitId,
181
- errorTag:
182
- failure?.errorTag ??
183
- (returnedFailure !== undefined
184
- ? returnedFailure.value._tag === "FileReviewUnitFailed"
185
- ? `${returnedFailure.value._tag}:${returnedFailure.value.childErrorTag}`
186
- : returnedFailure.value._tag
187
- : undefined) ??
188
- (declarations.length === 0
189
- ? "UnitNotAssigned"
190
- : declarations.length > 1
191
- ? "UnitAssignedMultipleTimes"
192
- : exact.length === 0
193
- ? "UnitAssignmentMismatch"
194
- : "UnitDidNotSettleSuccessfully"),
195
- }),
196
- );
197
- }
198
222
  if (plan.truncated) {
199
223
  reasons.push(`review range exposed ${files.length} of ${totalFiles} required files`);
200
224
  }
@@ -206,78 +230,532 @@ const fanOutCoverage = (
206
230
  ),
207
231
  );
208
232
  }
209
- if (plan.unassignedPaths.length > 0) {
210
- reasons.push(boundedListReason("fan-out capacity left paths unassigned", plan.unassignedPaths));
233
+ if (plan.partialEvidencePaths.length > 0) {
234
+ reasons.push(
235
+ boundedListReason(
236
+ "fan-out capacity left some deterministic evidence shards unassigned",
237
+ plan.partialEvidencePaths,
238
+ ),
239
+ );
211
240
  }
212
- if (failedUnits.length > 0) {
241
+ if (plan.unassignedEvidenceShardCount > 0) {
242
+ reasons.push(
243
+ `${plan.unassignedEvidenceShardCount} deterministic evidence shard(s) exceeded fan-out capacity`,
244
+ );
213
245
  reasons.push(
214
246
  boundedListReason(
215
- "review units did not complete",
216
- failedUnits.map((unit) => `${unit.unitId} (${unit.errorTag})`),
247
+ `unassigned evidence shard identifier sample (${plan.unassignedEvidenceShardIds.length} of ${plan.unassignedEvidenceShardCount})`,
248
+ plan.unassignedEvidenceShardIds,
217
249
  ),
218
250
  );
219
251
  }
220
- return ReviewCoverage.make({
252
+ if (plan.unassignedPaths.length > 0) {
253
+ reasons.push(boundedListReason("fan-out capacity left paths unassigned", plan.unassignedPaths));
254
+ }
255
+ return ReviewInputCoverage.make({
221
256
  status: reasons.length === 0 ? "complete" : "incomplete",
222
257
  requiredPaths: sortedUnique(files.map((file) => file.path)),
223
- reviewedPaths: sortedUnique(reviewed),
224
- unreviewedPaths: sortedUnique(unreviewed),
225
- failedUnits,
258
+ assignedPaths,
259
+ partialPaths: plan.partialEvidencePaths,
260
+ unassignedPaths,
226
261
  reasons,
227
262
  });
228
263
  };
229
264
 
230
- /**
231
- * Host-verified per-file summaries from the fan-out run's Tool events: for
232
- * every successfully settled delegation, the child-reported `fileSummaries`
233
- * whose paths belong to that invocation's requested unit. This is the
234
- * declassification check `projectResult` cannot perform itself (it never sees
235
- * the request): a child assigned file A cannot smuggle a summary for changed
236
- * file B into the merged walkthrough, and a coordinator cannot invent or edit
237
- * entries — only exact child-reported, in-unit summaries survive.
238
- */
239
- export const collectUnitFileSummaries = (
240
- events: ReadonlyArray<RunEvent>,
241
- ): ReadonlyArray<WalkthroughEntry> => {
242
- const trace = toolTrace(events);
243
- const entries: Array<WalkthroughEntry> = [];
244
- for (const [toolCallId, declaration] of trace.declared) {
245
- if (declaration.toolName !== "delegate_file_review") continue;
265
+ const sameStrings = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
266
+ left.length === right.length && left.every((value, index) => value === right[index]);
267
+
268
+ const candidateKey = (candidate: ReviewCandidate): string =>
269
+ JSON.stringify(Schema.encodeSync(ReviewCandidate)(candidate));
270
+
271
+ const sameCandidates = (
272
+ left: ReadonlyArray<ReviewCandidate>,
273
+ right: ReadonlyArray<ReviewCandidate>,
274
+ ): boolean =>
275
+ left.length === right.length &&
276
+ left.every((candidate, index) => {
277
+ const corresponding = right[index];
278
+ return corresponding !== undefined && candidateKey(candidate) === candidateKey(corresponding);
279
+ });
280
+
281
+ const delegationDeclarations = (trace: ToolTrace) =>
282
+ [...trace.declared].flatMap(([id, declaration]) => {
283
+ if (declaration.toolName !== "delegate_file_review") return [];
246
284
  const request = Schema.decodeUnknownOption(FileReviewRequest)(declaration.parameters);
247
- if (Option.isNone(request)) continue;
248
- const success = trace.succeeded.get(toolCallId);
249
- if (success === undefined || trace.failed.has(toolCallId)) continue;
250
- const result = Schema.decodeUnknownOption(FileReviewUnitResult)(success.result);
251
- if (Option.isNone(result) || result.value.unitId !== request.value.unitId) continue;
252
- const assigned = new Set(request.value.paths);
253
- for (const entry of result.value.fileSummaries ?? []) {
254
- if (assigned.has(entry.path)) entries.push(entry);
285
+ return Option.isNone(request) ? [] : [{ id, request: request.value }];
286
+ });
287
+
288
+ const failureTag = (trace: ToolTrace, id: string): string | undefined => {
289
+ const failed = trace.failed.get(id);
290
+ if (failed !== undefined) return failed.errorTag;
291
+ const succeeded = trace.succeeded.get(id);
292
+ if (succeeded === undefined) return undefined;
293
+ const returned = Schema.decodeUnknownOption(FileReviewDelegationFailure)(succeeded.result);
294
+ if (Option.isNone(returned)) return undefined;
295
+ return returned.value._tag === "FileReviewUnitFailed"
296
+ ? `${returned.value._tag}:${returned.value.childErrorTag}`
297
+ : returned.value._tag;
298
+ };
299
+
300
+ const exactDiscoveryRequest = (request: FileReviewRequest, pass: ReviewDiscoveryPass): boolean =>
301
+ request.phase === "discovery" &&
302
+ request.workId === pass.passId &&
303
+ request.unitId === pass.unitId &&
304
+ request.perspective === pass.perspective &&
305
+ sameStrings(request.paths, pass.paths) &&
306
+ sameStrings(request.evidenceShardIds, pass.evidenceShardIds) &&
307
+ sameStrings(request.riskCategories, pass.riskCategories) &&
308
+ request.candidates.length === 0;
309
+
310
+ const validCandidate = (
311
+ candidate: ReviewCandidate,
312
+ pass: ReviewDiscoveryPass,
313
+ unit: ReviewUnit,
314
+ files: ReadonlyArray<ChangedFile>,
315
+ anchorFiles: ReadonlyArray<ChangedFile>,
316
+ ): boolean => {
317
+ const allowed = new Set(pass.paths);
318
+ const kind = candidate._tag === "FindingCandidate" ? "finding" : "concern";
319
+ const idPrefix = `${pass.passId}:${kind}:`;
320
+ return (
321
+ candidate.candidateId.startsWith(idPrefix) &&
322
+ /^\d{3}$/.test(candidate.candidateId.slice(idPrefix.length)) &&
323
+ candidate.workId === pass.passId &&
324
+ candidate.unitId === pass.unitId &&
325
+ candidate.evidencePaths.length > 0 &&
326
+ candidate.evidencePaths.every((path) => allowed.has(path)) &&
327
+ (candidate._tag !== "FindingCandidate" ||
328
+ (allowed.has(candidate.finding.path) &&
329
+ anchorViolation(candidate.finding, anchorFiles) === undefined &&
330
+ findingAnchorInUnitEvidence(candidate.finding, unit, files)))
331
+ );
332
+ };
333
+
334
+ interface AssuranceAssessment {
335
+ readonly assurance: ReviewAssurance;
336
+ readonly confirmedFindings: ReadonlyArray<ReviewFinding>;
337
+ readonly confirmedConcerns: ReadonlyArray<ReviewConcern>;
338
+ readonly walkthrough: ReadonlyArray<WalkthroughEntry>;
339
+ }
340
+
341
+ const flatAssurance = (): AssuranceAssessment => ({
342
+ assurance: ReviewAssurance.make({
343
+ status: "unverified",
344
+ requiredGeneralDiscoveryPasses: 1,
345
+ completedGeneralDiscoveryPasses: 1,
346
+ requiredSpecialistPasses: 0,
347
+ completedSpecialistPasses: 0,
348
+ requiredVerificationPasses: 1,
349
+ completedVerificationPasses: 0,
350
+ discoveredCandidates: 0,
351
+ confirmedCandidates: 0,
352
+ rejectedCandidates: 0,
353
+ unsettledCandidates: 0,
354
+ failedPasses: [],
355
+ reasons: [
356
+ "flat review has no independent candidate-verification pass; use the fan-out pipeline for a settled assurance result",
357
+ ],
358
+ }),
359
+ confirmedFindings: [],
360
+ confirmedConcerns: [],
361
+ walkthrough: [],
362
+ });
363
+
364
+ const fanOutAssurance = (
365
+ files: ReadonlyArray<ChangedFile>,
366
+ totalFiles: number,
367
+ anchorFiles: ReadonlyArray<ChangedFile>,
368
+ trace: ToolTrace,
369
+ ): AssuranceAssessment => {
370
+ const plan = planReviewUnits(files, { totalChangedFiles: totalFiles });
371
+ const declarations = delegationDeclarations(trace);
372
+ const consumedDeclarationIds = new Set<string>();
373
+ const failedPasses: Array<FailedReviewPass> = [];
374
+ const reasons: Array<string> = [];
375
+ const candidatesByUnit = new Map<string, Array<ReviewCandidate>>();
376
+ const walkthrough: Array<WalkthroughEntry> = [];
377
+ let completedGeneralDiscoveryPasses = 0;
378
+ let completedSpecialistPasses = 0;
379
+
380
+ for (const pass of plan.discoveryPasses) {
381
+ const unit = plan.units.find((candidate) => candidate.unitId === pass.unitId);
382
+ const matching = declarations.filter(({ request }) => exactDiscoveryRequest(request, pass));
383
+ const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
384
+ if (matching.length !== 1) {
385
+ failedPasses.push(
386
+ FailedReviewPass.make({
387
+ workId: pass.passId,
388
+ stage,
389
+ errorTag: matching.length === 0 ? "PassNotAssigned" : "PassAssignedMultipleTimes",
390
+ }),
391
+ );
392
+ continue;
393
+ }
394
+ const call = matching[0];
395
+ if (call === undefined) {
396
+ failedPasses.push(
397
+ FailedReviewPass.make({
398
+ workId: pass.passId,
399
+ stage,
400
+ errorTag: "PassLookupInvariantFailed",
401
+ }),
402
+ );
403
+ continue;
404
+ }
405
+ consumedDeclarationIds.add(call.id);
406
+ const failure = failureTag(trace, call.id);
407
+ const succeeded = trace.succeeded.get(call.id);
408
+ const result =
409
+ succeeded === undefined
410
+ ? Option.none()
411
+ : Schema.decodeUnknownOption(FileReviewUnitResult)(succeeded.result);
412
+ if (
413
+ failure !== undefined ||
414
+ Option.isNone(result) ||
415
+ result.value.phase !== "discovery" ||
416
+ result.value.workId !== pass.passId ||
417
+ result.value.unitId !== pass.unitId ||
418
+ result.value.assessments.length !== 0
419
+ ) {
420
+ failedPasses.push(
421
+ FailedReviewPass.make({
422
+ workId: pass.passId,
423
+ stage,
424
+ errorTag: failure ?? "DiscoveryDidNotSettleExactly",
425
+ }),
426
+ );
427
+ continue;
428
+ }
429
+ const ids = new Set<string>();
430
+ let candidatesValid = true;
431
+ for (const candidate of result.value.candidates) {
432
+ if (
433
+ unit === undefined ||
434
+ ids.has(candidate.candidateId) ||
435
+ !validCandidate(candidate, pass, unit, files, anchorFiles)
436
+ ) {
437
+ candidatesValid = false;
438
+ break;
439
+ }
440
+ ids.add(candidate.candidateId);
441
+ }
442
+ const unitCandidates = candidatesByUnit.get(pass.unitId) ?? [];
443
+ const unitIds = new Set(unitCandidates.map((candidate) => candidate.candidateId));
444
+ if (result.value.candidates.some((candidate) => unitIds.has(candidate.candidateId))) {
445
+ candidatesValid = false;
446
+ }
447
+ if (!candidatesValid) {
448
+ failedPasses.push(
449
+ FailedReviewPass.make({
450
+ workId: pass.passId,
451
+ stage,
452
+ errorTag: "DiscoveryCandidateMismatch",
453
+ }),
454
+ );
455
+ continue;
456
+ }
457
+ if (stage === "specialist") {
458
+ completedSpecialistPasses += 1;
459
+ } else {
460
+ completedGeneralDiscoveryPasses += 1;
461
+ }
462
+ const subjectKeys = new Set(unitCandidates.map(reviewCandidateSubjectKey));
463
+ for (const candidate of result.value.candidates) {
464
+ const subjectKey = reviewCandidateSubjectKey(candidate);
465
+ if (subjectKeys.has(subjectKey)) continue;
466
+ subjectKeys.add(subjectKey);
467
+ unitCandidates.push(candidate);
468
+ }
469
+ candidatesByUnit.set(pass.unitId, unitCandidates);
470
+ if (pass.perspective === "general") {
471
+ const allowed = new Set(pass.paths);
472
+ walkthrough.push(...result.value.fileSummaries.filter((entry) => allowed.has(entry.path)));
255
473
  }
256
474
  }
257
- return entries;
475
+
476
+ const confirmedCandidates: Array<ReviewCandidate> = [];
477
+ let rejectedCandidates = 0;
478
+ let unsettledCandidates = 0;
479
+ let requiredVerificationPasses = 0;
480
+ let completedVerificationPasses = 0;
481
+ for (const unit of plan.units) {
482
+ const candidates = candidatesByUnit.get(unit.unitId) ?? [];
483
+ if (candidates.length === 0) continue;
484
+ requiredVerificationPasses += 1;
485
+ const workId = `${unit.unitId}-verification`;
486
+ const matching = declarations.filter(
487
+ ({ request }) =>
488
+ request.phase === "verification" &&
489
+ request.workId === workId &&
490
+ request.unitId === unit.unitId &&
491
+ request.perspective === "candidate-verification" &&
492
+ sameStrings(request.paths, unit.paths) &&
493
+ sameStrings(
494
+ request.evidenceShardIds,
495
+ unit.evidenceShards.map((shard) => shard.shardId),
496
+ ) &&
497
+ sameStrings(request.riskCategories, unit.riskCategories) &&
498
+ sameCandidates(request.candidates, candidates),
499
+ );
500
+ if (matching.length !== 1) {
501
+ unsettledCandidates += candidates.length;
502
+ failedPasses.push(
503
+ FailedReviewPass.make({
504
+ workId,
505
+ stage: "verification",
506
+ errorTag:
507
+ matching.length === 0
508
+ ? "VerificationNotAssignedOrCandidateMismatch"
509
+ : "VerificationAssignedMultipleTimes",
510
+ }),
511
+ );
512
+ continue;
513
+ }
514
+ const call = matching[0];
515
+ if (call === undefined) {
516
+ unsettledCandidates += candidates.length;
517
+ failedPasses.push(
518
+ FailedReviewPass.make({
519
+ workId,
520
+ stage: "verification",
521
+ errorTag: "PassLookupInvariantFailed",
522
+ }),
523
+ );
524
+ continue;
525
+ }
526
+ consumedDeclarationIds.add(call.id);
527
+ const failure = failureTag(trace, call.id);
528
+ const succeeded = trace.succeeded.get(call.id);
529
+ const result =
530
+ succeeded === undefined
531
+ ? Option.none()
532
+ : Schema.decodeUnknownOption(FileReviewUnitResult)(succeeded.result);
533
+ if (
534
+ failure !== undefined ||
535
+ Option.isNone(result) ||
536
+ result.value.phase !== "verification" ||
537
+ result.value.workId !== workId ||
538
+ result.value.unitId !== unit.unitId ||
539
+ result.value.candidates.length !== 0 ||
540
+ result.value.fileSummaries.length !== 0
541
+ ) {
542
+ unsettledCandidates += candidates.length;
543
+ failedPasses.push(
544
+ FailedReviewPass.make({
545
+ workId,
546
+ stage: "verification",
547
+ errorTag: failure ?? "VerificationDidNotSettleExactly",
548
+ }),
549
+ );
550
+ continue;
551
+ }
552
+ const expectedIds = new Set(candidates.map((candidate) => candidate.candidateId));
553
+ const assessedIds = new Set<string>();
554
+ const exactAssessments = result.value.assessments.every((assessment) => {
555
+ if (!expectedIds.has(assessment.candidateId) || assessedIds.has(assessment.candidateId)) {
556
+ return false;
557
+ }
558
+ assessedIds.add(assessment.candidateId);
559
+ return true;
560
+ });
561
+ if (
562
+ expectedIds.size !== candidates.length ||
563
+ !exactAssessments ||
564
+ assessedIds.size !== expectedIds.size
565
+ ) {
566
+ unsettledCandidates += candidates.length;
567
+ failedPasses.push(
568
+ FailedReviewPass.make({
569
+ workId,
570
+ stage: "verification",
571
+ errorTag: "VerificationAssessmentMismatch",
572
+ }),
573
+ );
574
+ continue;
575
+ }
576
+ completedVerificationPasses += 1;
577
+ const byId = new Map(
578
+ candidates.map((candidate) => [candidate.candidateId, candidate] as const),
579
+ );
580
+ for (const assessment of result.value.assessments) {
581
+ if (assessment.disposition === "confirmed") {
582
+ const candidate = byId.get(assessment.candidateId);
583
+ if (candidate !== undefined) confirmedCandidates.push(candidate);
584
+ } else {
585
+ rejectedCandidates += 1;
586
+ }
587
+ }
588
+ }
589
+
590
+ const unexpected = [...trace.declared].filter(
591
+ ([id, declaration]) =>
592
+ declaration.toolName === "delegate_file_review" && !consumedDeclarationIds.has(id),
593
+ );
594
+ for (const [, declaration] of unexpected) {
595
+ const request = Schema.decodeUnknownOption(FileReviewRequest)(declaration.parameters);
596
+ failedPasses.push(
597
+ FailedReviewPass.make({
598
+ workId: Option.isSome(request) ? request.value.workId : "invalid-delegation-request",
599
+ stage:
600
+ Option.isSome(request) && request.value.phase === "verification"
601
+ ? "verification"
602
+ : "discovery",
603
+ errorTag: "UnexpectedPass",
604
+ }),
605
+ );
606
+ }
607
+ if (failedPasses.length > 0) {
608
+ reasons.push(
609
+ boundedListReason(
610
+ "configured review passes did not settle",
611
+ failedPasses.map((pass) => `${pass.workId} (${pass.errorTag})`),
612
+ ),
613
+ );
614
+ }
615
+ if (unsettledCandidates > 0) {
616
+ reasons.push(
617
+ `${unsettledCandidates} discovered candidate(s) did not receive exact verification`,
618
+ );
619
+ }
620
+ const requiredSpecialistPasses = plan.discoveryPasses.filter(
621
+ (pass) => pass.perspective === "risk-specialist",
622
+ ).length;
623
+ const requiredGeneralDiscoveryPasses = plan.discoveryPasses.length - requiredSpecialistPasses;
624
+ const discoveredCandidates = [...candidatesByUnit.values()].reduce(
625
+ (total, candidates) => total + candidates.length,
626
+ 0,
627
+ );
628
+ return {
629
+ assurance: ReviewAssurance.make({
630
+ status: reasons.length === 0 ? "settled" : "incomplete",
631
+ requiredGeneralDiscoveryPasses,
632
+ completedGeneralDiscoveryPasses,
633
+ requiredSpecialistPasses,
634
+ completedSpecialistPasses,
635
+ requiredVerificationPasses,
636
+ completedVerificationPasses,
637
+ discoveredCandidates,
638
+ confirmedCandidates: confirmedCandidates.length,
639
+ rejectedCandidates,
640
+ unsettledCandidates,
641
+ failedPasses,
642
+ reasons,
643
+ }),
644
+ confirmedFindings: confirmedCandidates.flatMap((candidate) =>
645
+ candidate._tag === "FindingCandidate" ? [candidate.finding] : [],
646
+ ),
647
+ confirmedConcerns: confirmedCandidates.flatMap((candidate) =>
648
+ candidate._tag === "ConcernCandidate" ? [candidate.concern] : [],
649
+ ),
650
+ walkthrough,
651
+ };
258
652
  };
259
653
 
260
- /** Assess one settled run without trusting its prose summary or verdict. */
261
- export const assessReviewCoverage = (input: {
654
+ const compatibilityCoverage = (
655
+ inputCoverage: ReviewInputCoverage,
656
+ assurance: ReviewAssurance,
657
+ ): ReviewCoverage => {
658
+ const assuranceIncomplete = assurance.status === "incomplete";
659
+ const failedUnits = new Map<string, FailedReviewUnit>();
660
+ for (const pass of assurance.failedPasses) {
661
+ const unitId = pass.workId.slice(0, "unit-000".length);
662
+ if (!failedUnits.has(unitId)) {
663
+ failedUnits.set(
664
+ unitId,
665
+ FailedReviewUnit.make({ unitId, errorTag: `${pass.stage}:${pass.errorTag}` }),
666
+ );
667
+ }
668
+ }
669
+ return ReviewCoverage.make({
670
+ status: inputCoverage.status === "complete" && !assuranceIncomplete ? "complete" : "incomplete",
671
+ requiredPaths: inputCoverage.requiredPaths,
672
+ reviewedPaths: inputCoverage.assignedPaths,
673
+ unreviewedPaths: sortedUnique([
674
+ ...inputCoverage.partialPaths,
675
+ ...inputCoverage.unassignedPaths,
676
+ ]),
677
+ failedUnits: [...failedUnits.values()].slice(0, 8),
678
+ reasons: [...inputCoverage.reasons, ...(assuranceIncomplete ? assurance.reasons : [])],
679
+ });
680
+ };
681
+
682
+ export interface ReviewPipelineAssessment {
683
+ readonly inputCoverage: ReviewInputCoverage;
684
+ readonly assurance: ReviewAssurance;
685
+ /** Deprecated compatibility aggregate. */
686
+ readonly coverage: ReviewCoverage;
687
+ readonly confirmedFindings: ReadonlyArray<ReviewFinding>;
688
+ readonly confirmedConcerns: ReadonlyArray<ReviewConcern>;
689
+ readonly walkthrough: ReadonlyArray<WalkthroughEntry>;
690
+ }
691
+
692
+ /** Assess one settled run without trusting coordinator prose or findings. */
693
+ export const assessReviewPipeline = (input: {
262
694
  readonly shape: ReviewShape;
263
695
  readonly files: ReadonlyArray<ChangedFile>;
264
696
  readonly totalFiles: number;
265
697
  readonly anchorFiles: ReadonlyArray<ChangedFile>;
266
698
  readonly totalAnchorFiles: number;
267
699
  readonly events: ReadonlyArray<RunEvent>;
268
- }): ReviewCoverage => {
700
+ }): ReviewPipelineAssessment => {
269
701
  const trace = toolTrace(input.events);
270
- const coverage =
702
+ let inputCoverage =
271
703
  input.shape === "fan-out"
272
- ? fanOutCoverage(input.files, input.totalFiles, trace)
273
- : flatCoverage(input.files, input.totalFiles, trace);
274
- if (input.anchorFiles.length >= input.totalAnchorFiles) return coverage;
275
- return ReviewCoverage.make({
276
- ...coverage,
277
- status: "incomplete",
278
- reasons: [
279
- ...coverage.reasons,
280
- `full pull-request anchor surface exposed ${input.anchorFiles.length} of ${input.totalAnchorFiles} required files`,
281
- ],
704
+ ? fanOutInputCoverage(input.files, input.totalFiles)
705
+ : flatInputCoverage(input.files, input.totalFiles, trace);
706
+ if (input.anchorFiles.length < input.totalAnchorFiles) {
707
+ inputCoverage = ReviewInputCoverage.make({
708
+ ...inputCoverage,
709
+ status: "incomplete",
710
+ reasons: [
711
+ ...inputCoverage.reasons,
712
+ `full pull-request anchor surface exposed ${input.anchorFiles.length} of ${input.totalAnchorFiles} required files`,
713
+ ],
714
+ });
715
+ }
716
+ const assessed =
717
+ input.shape === "fan-out"
718
+ ? fanOutAssurance(input.files, input.totalFiles, input.anchorFiles, trace)
719
+ : flatAssurance();
720
+ return {
721
+ inputCoverage,
722
+ assurance: assessed.assurance,
723
+ coverage: compatibilityCoverage(inputCoverage, assessed.assurance),
724
+ confirmedFindings: assessed.confirmedFindings,
725
+ confirmedConcerns: assessed.confirmedConcerns,
726
+ walkthrough: assessed.walkthrough,
727
+ };
728
+ };
729
+
730
+ /** Compatibility helper; prefer assessReviewPipeline for precise claims. */
731
+ export const assessReviewCoverage = (input: {
732
+ readonly shape: ReviewShape;
733
+ readonly files: ReadonlyArray<ChangedFile>;
734
+ readonly totalFiles: number;
735
+ readonly anchorFiles: ReadonlyArray<ChangedFile>;
736
+ readonly totalAnchorFiles: number;
737
+ readonly events: ReadonlyArray<RunEvent>;
738
+ }): ReviewCoverage => assessReviewPipeline(input).coverage;
739
+
740
+ /** Host-verified summaries from successful general discovery passes only. */
741
+ export const collectUnitFileSummaries = (
742
+ events: ReadonlyArray<RunEvent>,
743
+ ): ReadonlyArray<WalkthroughEntry> => {
744
+ const trace = toolTrace(events);
745
+ return delegationDeclarations(trace).flatMap(({ id, request }) => {
746
+ if (request.phase !== "discovery" || request.perspective !== "general") return [];
747
+ const success = trace.succeeded.get(id);
748
+ if (success === undefined || trace.failed.has(id)) return [];
749
+ const result = Schema.decodeUnknownOption(FileReviewUnitResult)(success.result);
750
+ if (
751
+ Option.isNone(result) ||
752
+ result.value.phase !== "discovery" ||
753
+ result.value.workId !== request.workId ||
754
+ result.value.unitId !== request.unitId
755
+ ) {
756
+ return [];
757
+ }
758
+ const assigned = new Set(request.paths);
759
+ return result.value.fileSummaries.filter((entry) => assigned.has(entry.path));
282
760
  });
283
761
  };