@effect-agent/pr-review 0.1.0-beta.20 → 0.1.0-beta.22

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.
@@ -2,8 +2,12 @@ import { Schema } from "effect";
2
2
 
3
3
  import type { ChangedFile } from "./diff.ts";
4
4
  import { ChangedPath, isReviewableFile } from "./diff.ts";
5
- import type { FindingSeverity } from "./review-agent.ts";
6
- import { ReviewFinding } from "./review-agent.ts";
5
+ import {
6
+ fileReviewEvidenceChunks,
7
+ type FindingSeverity,
8
+ MAX_PATCH_CHARS,
9
+ type ReviewFinding,
10
+ } from "./review-agent.ts";
7
11
 
8
12
  // ---------------------------------------------------------------------------
9
13
  // Pure, deterministic planning for the fan-out reviewer: group the changeset
@@ -19,25 +23,101 @@ export const MAX_REVIEW_UNITS = 8;
19
23
  /** A unit never carries more files than this, regardless of their size. */
20
24
  export const MAX_UNIT_FILES = 12;
21
25
 
22
- /** Soft changed-line budget per unit; a single oversized file still gets its own unit. */
26
+ /** Compatibility export; complete evidence chars now own unit packing. */
23
27
  export const UNIT_CHANGED_LINE_BUDGET = 800;
24
28
 
25
- /** Flat per-file cost so many tiny files still spread across units. */
26
- const FILE_OVERHEAD_LINES = 20;
29
+ /**
30
+ * Bound the complete model-visible evidence assigned to one child. This is a
31
+ * character bound rather than a token estimate because it is deterministic,
32
+ * provider-independent, and enforced before any model call.
33
+ */
34
+ export const UNIT_EVIDENCE_CHAR_BUDGET = 240_000;
35
+
36
+ /** Maximum complete evidence shards placed in one child brief. */
37
+ export const MAX_UNIT_EVIDENCE_SHARDS = 12;
38
+
39
+ /**
40
+ * Keep overflow diagnostics bounded to one plan's total assignment capacity.
41
+ * The plan separately records the exact overflow count and every affected
42
+ * path, so identifiers are a deterministic diagnostic sample rather than the
43
+ * authority for whether input coverage is complete.
44
+ */
45
+ export const MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS = MAX_REVIEW_UNITS * MAX_UNIT_EVIDENCE_SHARDS;
46
+
47
+ /** @deprecated Use `MAX_PATCH_CHARS`; this is now the per-shard bound. */
48
+ export const MAX_FILE_EVIDENCE_CHARS = MAX_PATCH_CHARS;
27
49
 
28
50
  /** The merged review never exceeds the `CodeReview` findings bound. */
29
51
  export const MAX_MERGED_FINDINGS = 20;
30
52
 
31
53
  export const ReviewUnitId = Schema.NonEmptyString.check(Schema.isMaxLength(32));
32
54
 
55
+ /** High-risk surfaces that receive an explicit specialist focus label. */
56
+ export const ReviewRiskCategory = Schema.Literals([
57
+ "authentication-authorization",
58
+ "security-boundary",
59
+ "persistence-durability",
60
+ "concurrency",
61
+ "credential-handling",
62
+ "external-side-effects",
63
+ ]);
64
+ export type ReviewRiskCategory = typeof ReviewRiskCategory.Type;
65
+
66
+ export const ReviewDiscoveryPerspective = Schema.Literals(["general", "risk-specialist"]);
67
+ export type ReviewDiscoveryPerspective = typeof ReviewDiscoveryPerspective.Type;
68
+
69
+ export const ReviewPassId = Schema.NonEmptyString.check(Schema.isMaxLength(64));
70
+ export const ReviewEvidenceShardId = Schema.NonEmptyString.check(Schema.isMaxLength(32));
71
+
72
+ /** One complete bounded slice of a changed path's model-visible evidence. */
73
+ export class ReviewEvidenceShard extends Schema.Class<ReviewEvidenceShard>(
74
+ "@effect-agent/pr-review/ReviewEvidenceShard",
75
+ )({
76
+ shardId: ReviewEvidenceShardId,
77
+ path: ChangedPath,
78
+ ordinal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
79
+ total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
80
+ evidenceChars: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).check(
81
+ Schema.isLessThanOrEqualTo(MAX_PATCH_CHARS),
82
+ ),
83
+ }) {}
84
+
85
+ const EvidenceShardIds = Schema.Array(ReviewEvidenceShardId)
86
+ .check(Schema.isMinLength(1))
87
+ .check(Schema.isMaxLength(MAX_UNIT_EVIDENCE_SHARDS));
88
+
89
+ /** One required, independently scoped discovery attempt. */
90
+ export class ReviewDiscoveryPass extends Schema.Class<ReviewDiscoveryPass>(
91
+ "@effect-agent/pr-review/ReviewDiscoveryPass",
92
+ )({
93
+ passId: ReviewPassId,
94
+ unitId: ReviewUnitId,
95
+ paths: Schema.Array(ChangedPath)
96
+ .check(Schema.isMinLength(1))
97
+ .check(Schema.isMaxLength(MAX_UNIT_FILES)),
98
+ evidenceShardIds: EvidenceShardIds,
99
+ perspective: ReviewDiscoveryPerspective,
100
+ /** Empty for the general pass; explicit deterministic focus for specialists. */
101
+ riskCategories: Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6)),
102
+ }) {}
103
+
33
104
  /** One bounded slice of the changeset delegated to one child reviewer. */
34
105
  export class ReviewUnit extends Schema.Class<ReviewUnit>("@effect-agent/pr-review/ReviewUnit")({
35
106
  unitId: ReviewUnitId,
36
107
  paths: Schema.Array(ChangedPath)
37
108
  .check(Schema.isMinLength(1))
38
109
  .check(Schema.isMaxLength(MAX_UNIT_FILES)),
110
+ evidenceShards: Schema.Array(ReviewEvidenceShard)
111
+ .check(Schema.isMinLength(1))
112
+ .check(Schema.isMaxLength(MAX_UNIT_EVIDENCE_SHARDS)),
39
113
  /** additions + deletions across the unit's files, for honest sizing. */
40
114
  changedLines: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
115
+ /** Complete model-visible diff/content evidence assigned to each child. */
116
+ evidenceChars: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).check(
117
+ Schema.isLessThanOrEqualTo(UNIT_EVIDENCE_CHAR_BUDGET),
118
+ ),
119
+ /** Host-classified focus labels for the unit's redundant specialist pass. */
120
+ riskCategories: Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6)),
41
121
  }) {}
42
122
 
43
123
  /** The complete deterministic fan-out plan over one changeset. */
@@ -48,8 +128,20 @@ export class ReviewUnitPlan extends Schema.Class<ReviewUnitPlan>(
48
128
  /** True when the source returned fewer files than the pull request has. */
49
129
  truncated: Schema.Boolean,
50
130
  units: Schema.Array(ReviewUnit).check(Schema.isMaxLength(MAX_REVIEW_UNITS)),
131
+ /** Exact discovery calls the coordinator must make. */
132
+ discoveryPasses: Schema.Array(ReviewDiscoveryPass).check(
133
+ Schema.isMaxLength(MAX_REVIEW_UNITS * 2),
134
+ ),
51
135
  /** Changed files with neither a textual diff nor bounded base/head text. */
52
136
  undiffablePaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
137
+ /** Assigned paths with one or more evidence shards beyond plan capacity. */
138
+ partialEvidencePaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
139
+ /** Exact number of shards beyond the bounded unit capacity. */
140
+ unassignedEvidenceShardCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
141
+ /** Bounded deterministic prefix of the unassigned shard identifiers. */
142
+ unassignedEvidenceShardIds: Schema.Array(ReviewEvidenceShardId).check(
143
+ Schema.isMaxLength(MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS),
144
+ ),
53
145
  /**
54
146
  * Diffable files beyond the fan-out capacity (MAX_REVIEW_UNITS units of
55
147
  * MAX_UNIT_FILES files). Never silently dropped: the coordinator must name
@@ -58,36 +150,217 @@ export class ReviewUnitPlan extends Schema.Class<ReviewUnitPlan>(
58
150
  unassignedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
59
151
  }) {}
60
152
 
61
- const fileCost = (file: ChangedFile): number => {
62
- const contentChars =
63
- (file.reviewBaseContent?.length ?? 0) + (file.reviewHeadContent?.length ?? 0);
64
- const contentWeight = Math.ceil(contentChars / 200);
65
- return file.additions + file.deletions + contentWeight + FILE_OVERHEAD_LINES;
153
+ const riskRules: ReadonlyArray<{
154
+ readonly category: ReviewRiskCategory;
155
+ readonly patterns: ReadonlyArray<RegExp>;
156
+ }> = [
157
+ {
158
+ category: "authentication-authorization",
159
+ patterns: [/auth/, /authoriz/, /permission/, /principal/, /access[-_ ]?control/, /role\b/],
160
+ },
161
+ {
162
+ category: "security-boundary",
163
+ patterns: [
164
+ /security/,
165
+ /sandbox/,
166
+ /untrusted/,
167
+ /schema\.decode/,
168
+ /validation/,
169
+ /injection/,
170
+ /csrf/,
171
+ /xss/,
172
+ /path traversal/,
173
+ ],
174
+ },
175
+ {
176
+ category: "persistence-durability",
177
+ patterns: [
178
+ /durab/,
179
+ /persist/,
180
+ /storage/,
181
+ /database/,
182
+ /\bsql\b/,
183
+ /journal/,
184
+ /ledger/,
185
+ /checkpoint/,
186
+ /migration/,
187
+ /transaction/,
188
+ ],
189
+ },
190
+ {
191
+ category: "concurrency",
192
+ patterns: [
193
+ /concurr/,
194
+ /semaphore/,
195
+ /\bfiber/,
196
+ /race/,
197
+ /mutex/,
198
+ /\block\b/,
199
+ /queue/,
200
+ /parallel/,
201
+ /interrupt/,
202
+ ],
203
+ },
204
+ {
205
+ category: "credential-handling",
206
+ patterns: [/credential/, /secret/, /password/, /api[-_ ]?key/, /bearer/, /hmac/, /signature/],
207
+ },
208
+ {
209
+ category: "external-side-effects",
210
+ patterns: [
211
+ /publish/,
212
+ /webhook/,
213
+ /github/,
214
+ /fetch\(/,
215
+ /http/,
216
+ /send[-_ ]?(email|message)/,
217
+ /write[-_ ]?(file|record)/,
218
+ /delete/,
219
+ /mutation/,
220
+ /side[-_ ]?effect/,
221
+ /spawn/,
222
+ /exec/,
223
+ ],
224
+ },
225
+ ];
226
+
227
+ /**
228
+ * Deterministic host policy for specialist assignment. It intentionally
229
+ * favors false positives: an extra bounded pass costs work, while a missed
230
+ * high-risk classification removes redundancy. This is not a claim that the
231
+ * keyword policy recognizes every semantically risky change.
232
+ */
233
+ export const classifyReviewRisks = (file: ChangedFile): ReadonlyArray<ReviewRiskCategory> => {
234
+ const text = [
235
+ file.path,
236
+ file.previousPath ?? "",
237
+ file.patch ?? "",
238
+ file.reviewBaseContent ?? "",
239
+ file.reviewHeadContent ?? "",
240
+ ]
241
+ .join("\n")
242
+ .toLowerCase();
243
+ return riskRules
244
+ .filter((rule) => rule.patterns.some((pattern) => pattern.test(text)))
245
+ .map((rule) => rule.category);
246
+ };
247
+
248
+ /**
249
+ * Whether every claimed finding anchor was present in the exact bounded
250
+ * evidence shards assigned to one unit. This is stricter than checking the
251
+ * full pull-request diff when an oversized path spans multiple units.
252
+ */
253
+ export const findingAnchorInUnitEvidence = (
254
+ finding: ReviewFinding,
255
+ unit: ReviewUnit,
256
+ files: ReadonlyArray<ChangedFile>,
257
+ ): boolean => {
258
+ const file = files.find((candidate) => candidate.path === finding.path);
259
+ if (file?.patch === undefined || finding.endLine < finding.startLine) return false;
260
+ const assignedOrdinals = new Set(
261
+ unit.evidenceShards
262
+ .filter((shard) => shard.path === finding.path)
263
+ .map((shard) => shard.ordinal),
264
+ );
265
+ const visibleLines = new Set<number>();
266
+ const chunks = fileReviewEvidenceChunks(file);
267
+ for (let index = 0; index < chunks.length; index += 1) {
268
+ if (!assignedOrdinals.has(index + 1)) continue;
269
+ for (const line of chunks[index]?.annotatedPatch.split("\n") ?? []) {
270
+ const match = /^R(\d+) /.exec(line);
271
+ if (match?.[1] !== undefined) visibleLines.add(Number(match[1]));
272
+ }
273
+ }
274
+ for (let line = finding.startLine; line <= finding.endLine; line += 1) {
275
+ if (!visibleLines.has(line)) return false;
276
+ }
277
+ return true;
278
+ };
279
+
280
+ interface PlannedEvidenceShard {
281
+ readonly shard: ReviewEvidenceShard;
282
+ readonly file: ChangedFile;
283
+ readonly changedLines: number;
284
+ }
285
+
286
+ const uniquePaths = (shards: ReadonlyArray<PlannedEvidenceShard>): ReadonlyArray<string> => [
287
+ ...new Set(shards.map(({ shard }) => shard.path)),
288
+ ];
289
+
290
+ const plannedEvidenceShards = (
291
+ files: ReadonlyArray<ChangedFile>,
292
+ ): ReadonlyArray<PlannedEvidenceShard> => {
293
+ const planned: Array<PlannedEvidenceShard> = [];
294
+ let shardIndex = 0;
295
+ for (const file of files) {
296
+ const chunks = fileReviewEvidenceChunks(file);
297
+ for (let index = 0; index < chunks.length; index += 1) {
298
+ const chunk = chunks[index];
299
+ if (chunk === undefined) continue;
300
+ shardIndex += 1;
301
+ planned.push({
302
+ shard: ReviewEvidenceShard.make({
303
+ shardId: `shard-${String(shardIndex).padStart(4, "0")}`,
304
+ path: file.path,
305
+ ordinal: index + 1,
306
+ total: chunks.length,
307
+ evidenceChars: chunk.annotatedPatch.length,
308
+ }),
309
+ file,
310
+ changedLines: index === 0 ? file.additions + file.deletions : 0,
311
+ });
312
+ }
313
+ }
314
+ return planned;
66
315
  };
67
316
 
68
- const unitOf = (index: number, files: ReadonlyArray<ChangedFile>): ReviewUnit =>
317
+ const unitOf = (index: number, shards: ReadonlyArray<PlannedEvidenceShard>): ReviewUnit =>
69
318
  ReviewUnit.make({
70
319
  unitId: `unit-${String(index + 1).padStart(3, "0")}`,
71
- paths: files.map((file) => file.path),
72
- changedLines: files.reduce((total, file) => total + file.additions + file.deletions, 0),
320
+ paths: uniquePaths(shards),
321
+ evidenceShards: shards.map(({ shard }) => shard),
322
+ changedLines: shards.reduce((total, shard) => total + shard.changedLines, 0),
323
+ evidenceChars: shards.reduce((total, { shard }) => total + shard.evidenceChars, 0),
324
+ riskCategories: [...new Set(shards.flatMap(({ file }) => classifyReviewRisks(file)))],
73
325
  });
74
326
 
327
+ const discoveryPassesFor = (units: ReadonlyArray<ReviewUnit>): ReadonlyArray<ReviewDiscoveryPass> =>
328
+ units.flatMap((unit) => [
329
+ ReviewDiscoveryPass.make({
330
+ passId: `${unit.unitId}-general`,
331
+ unitId: unit.unitId,
332
+ paths: unit.paths,
333
+ evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
334
+ perspective: "general",
335
+ riskCategories: [],
336
+ }),
337
+ ReviewDiscoveryPass.make({
338
+ passId: `${unit.unitId}-specialist`,
339
+ unitId: unit.unitId,
340
+ paths: unit.paths,
341
+ evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
342
+ perspective: "risk-specialist",
343
+ riskCategories: unit.riskCategories,
344
+ }),
345
+ ]);
346
+
75
347
  /**
76
348
  * Group the changeset into at most `MAX_REVIEW_UNITS` review units.
77
349
  *
78
350
  * Deterministic by construction: files are ordered by path (so files sharing
79
351
  * a directory become neighbors — directory affinity without a heuristic),
80
- * then packed greedily in that order under the soft changed-line budget and
81
- * the hard per-unit file bound. Capacity is finite and explicit:
352
+ * then split into complete line-bounded evidence shards and packed greedily
353
+ * under the hard evidence and per-unit shard bounds. Capacity is finite and
354
+ * explicit:
82
355
  *
83
356
  * - files without a textual diff are still delegated when the source
84
357
  * recovered complete bounded UTF-8 base/head content. Findings from that
85
358
  * evidence cannot anchor inline and are reported as concerns;
86
359
  * - files with neither form of textual evidence surface in
87
360
  * `undiffablePaths` instead of laundering missing coverage;
88
- * - reviewable files beyond `MAX_REVIEW_UNITS` full units surface in
89
- * `unassignedPaths` so the review can report them as unreviewed, never
90
- * silently truncated.
361
+ * - an oversized path spans as many deterministic shards and units as needed;
362
+ * - shards beyond `MAX_REVIEW_UNITS` full units surface explicitly, so a path
363
+ * is partial only when finite plan capacity is genuinely exhausted.
91
364
  */
92
365
  export const planReviewUnits = (
93
366
  files: ReadonlyArray<ChangedFile>,
@@ -97,37 +370,58 @@ export const planReviewUnits = (
97
370
  const reviewable = ordered.filter(isReviewableFile);
98
371
  const undiffable = ordered.filter((file) => !isReviewableFile(file));
99
372
 
100
- const groups: Array<Array<ChangedFile>> = [];
101
- const unassigned: Array<ChangedFile> = [];
102
- let current: Array<ChangedFile> = [];
103
- let currentCost = 0;
104
- for (const file of reviewable) {
105
- const cost = fileCost(file);
373
+ const shards = plannedEvidenceShards(reviewable);
374
+ const groups: Array<Array<PlannedEvidenceShard>> = [];
375
+ const unassigned: Array<PlannedEvidenceShard> = [];
376
+ let current: Array<PlannedEvidenceShard> = [];
377
+ let currentEvidenceChars = 0;
378
+ for (const shard of shards) {
379
+ const nextPaths = new Set([...uniquePaths(current), shard.shard.path]);
106
380
  const wouldOverflow =
107
- current.length >= MAX_UNIT_FILES ||
108
- (current.length > 0 && currentCost + cost > UNIT_CHANGED_LINE_BUDGET);
381
+ current.length >= MAX_UNIT_EVIDENCE_SHARDS ||
382
+ nextPaths.size > MAX_UNIT_FILES ||
383
+ (current.length > 0 &&
384
+ currentEvidenceChars + shard.shard.evidenceChars > UNIT_EVIDENCE_CHAR_BUDGET);
109
385
  if (wouldOverflow) {
110
386
  groups.push(current);
111
387
  current = [];
112
- currentCost = 0;
388
+ currentEvidenceChars = 0;
113
389
  }
114
390
  if (groups.length >= MAX_REVIEW_UNITS) {
115
- unassigned.push(file);
391
+ unassigned.push(shard);
116
392
  continue;
117
393
  }
118
- current.push(file);
119
- currentCost += cost;
394
+ current.push(shard);
395
+ currentEvidenceChars += shard.shard.evidenceChars;
120
396
  }
121
397
  if (current.length > 0 && groups.length < MAX_REVIEW_UNITS) {
122
398
  groups.push(current);
123
399
  }
124
400
 
401
+ const units = groups.map((group, index) => unitOf(index, group));
402
+ const assignedShardIds = new Set(
403
+ units.flatMap((unit) => unit.evidenceShards.map((shard) => shard.shardId)),
404
+ );
405
+ const assignedPaths = new Set(
406
+ shards
407
+ .filter(({ shard }) => assignedShardIds.has(shard.shardId))
408
+ .map(({ shard }) => shard.path),
409
+ );
410
+ const unassignedPathsWithEvidence = new Set(unassigned.map(({ shard }) => shard.path));
125
411
  return ReviewUnitPlan.make({
126
412
  totalFiles: files.length,
127
413
  truncated: files.length < options.totalChangedFiles,
128
- units: groups.map((group, index) => unitOf(index, group)),
414
+ units,
415
+ discoveryPasses: discoveryPassesFor(units),
129
416
  undiffablePaths: undiffable.map((file) => file.path),
130
- unassignedPaths: unassigned.map((file) => file.path),
417
+ partialEvidencePaths: [...unassignedPathsWithEvidence].filter((path) =>
418
+ assignedPaths.has(path),
419
+ ),
420
+ unassignedEvidenceShardCount: unassigned.length,
421
+ unassignedEvidenceShardIds: unassigned
422
+ .slice(0, MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS)
423
+ .map(({ shard }) => shard.shardId),
424
+ unassignedPaths: [...unassignedPathsWithEvidence].filter((path) => !assignedPaths.has(path)),
131
425
  });
132
426
  };
133
427
 
@@ -10,9 +10,10 @@ import {
10
10
  import { type Tool } from "effect/unstable/ai";
11
11
 
12
12
  import {
13
- assessReviewCoverage,
14
- collectUnitFileSummaries,
13
+ assessReviewPipeline,
14
+ ReviewAssurance,
15
15
  ReviewCoverage,
16
+ ReviewInputCoverage,
16
17
  type ReviewShape,
17
18
  } from "./coverage.ts";
18
19
  import type { ChangedFile } from "./diff.ts";
@@ -66,11 +67,11 @@ export const reviewBudgetLimits = UsageBudgetLimits.make({
66
67
  * while bounded children run.
67
68
  */
68
69
  export const fanOutReviewBudgetLimits = UsageBudgetLimits.make({
69
- maxInputTokens: 400_000,
70
- maxOutputTokens: 16_000,
71
- maxToolCalls: 24,
70
+ maxInputTokens: 600_000,
71
+ maxOutputTokens: 32_000,
72
+ maxToolCalls: 32,
72
73
  maxCostMicrousd: 2_000_000,
73
- maxDurationMillis: 900_000,
74
+ maxDurationMillis: 1_200_000,
74
75
  });
75
76
 
76
77
  /** Everything one review run produced, publication receipt included. */
@@ -84,6 +85,10 @@ export class ReviewRunOutcome extends Schema.Class<ReviewRunOutcome>(
84
85
  activeConcerns: Schema.Array(ReviewConcern).check(Schema.isMaxLength(10)),
85
86
  /** Host-owned structural coverage used by the Actions check conclusion. */
86
87
  coverage: ReviewCoverage,
88
+ /** Exact path/evidence assignment, distinct from semantic review work. */
89
+ inputCoverage: ReviewInputCoverage,
90
+ /** Settlement of configured discovery, specialist, and verification work. */
91
+ assurance: ReviewAssurance,
87
92
  plan: ReviewPublicationPlan,
88
93
  published: Schema.optionalKey(PublishedReview),
89
94
  turns: Schema.Int.check(Schema.isGreaterThan(0)),
@@ -245,31 +250,30 @@ export const executeReview = <
245
250
  // The engine validated the terminal JSON against the output schema; this
246
251
  // decode recovers the typed value on this side of the generic boundary.
247
252
  const decoded = yield* Schema.decodeUnknownEffect(CodeReview)(result.output);
248
- // Under fan-out, the merged walkthrough must be traceable to the children:
249
- // only entries a successfully settled delegation actually reported for its
250
- // OWN unit's paths survive (the flat reviewer needs no such check — its
251
- // walkthrough carries the same single-agent trust as its findings, and
252
- // both stay changeset-validated by planPublication).
253
+ const reviewTotalFiles = executionContext?.totalFiles ?? metadata.totalChangedFiles;
254
+ const pipeline = assessReviewPipeline({
255
+ shape: options.reviewShape ?? "flat",
256
+ files,
257
+ totalFiles: reviewTotalFiles,
258
+ anchorFiles,
259
+ totalAnchorFiles: metadata.totalChangedFiles,
260
+ events,
261
+ });
262
+ // The coordinator owns prose only. Fan-out findings and concerns are
263
+ // reconstructed from exact verifier-confirmed discovery candidates; an
264
+ // unsupported or coordinator-invented candidate cannot reach publication.
253
265
  const verifiedReview =
254
- options.reviewShape !== "fan-out" || decoded.walkthrough === undefined
266
+ options.reviewShape !== "fan-out"
255
267
  ? decoded
256
- : (() => {
257
- const verified = new Set(
258
- collectUnitFileSummaries(events).map(
259
- (entry) => `${entry.path}\u0000${entry.summary}`,
260
- ),
261
- );
262
- const walkthrough = decoded.walkthrough.filter((entry) =>
263
- verified.has(`${entry.path}\u0000${entry.summary}`),
264
- );
265
- return CodeReview.make({
266
- summary: decoded.summary,
267
- verdict: decoded.verdict,
268
- findings: decoded.findings,
269
- ...(decoded.concerns !== undefined ? { concerns: decoded.concerns } : {}),
270
- ...(walkthrough.length > 0 ? { walkthrough } : {}),
271
- });
272
- })();
268
+ : CodeReview.make({
269
+ summary: decoded.summary,
270
+ verdict: decoded.verdict,
271
+ findings: rankAndDedupeFindings(pipeline.confirmedFindings),
272
+ ...(pipeline.confirmedConcerns.length === 0
273
+ ? {}
274
+ : { concerns: rankAndDedupeConcerns(pipeline.confirmedConcerns) }),
275
+ ...(pipeline.walkthrough.length === 0 ? {} : { walkthrough: pipeline.walkthrough }),
276
+ });
273
277
  const review = enforceFindingsBound(verifiedReview, clampMaxFindings(options.maxFindings));
274
278
  const usage = yield* budget.snapshot;
275
279
  const affectedPaths = new Set(
@@ -311,18 +315,11 @@ export const executeReview = <
311
315
  const key = `${concern.title}\u0000${concern.body}`;
312
316
  return activeConcernKeys.has(key) && !currentConcernKeys.has(key);
313
317
  });
314
- const reviewTotalFiles = executionContext?.totalFiles ?? metadata.totalChangedFiles;
315
- const coverage = assessReviewCoverage({
316
- shape: options.reviewShape ?? "flat",
317
- files,
318
- totalFiles: reviewTotalFiles,
319
- anchorFiles,
320
- totalAnchorFiles: metadata.totalChangedFiles,
321
- events,
322
- });
318
+ const { assurance, coverage, inputCoverage } = pipeline;
323
319
  const stateCandidate =
324
320
  executionContext !== undefined &&
325
- coverage.status === "complete" &&
321
+ inputCoverage.status === "complete" &&
322
+ assurance.status === "settled" &&
326
323
  fingerprint !== undefined &&
327
324
  metadata.baseSha !== undefined &&
328
325
  executionContext.stateAuthenticator?.status === "available"
@@ -349,7 +346,8 @@ export const executeReview = <
349
346
  marker: undefined,
350
347
  notice:
351
348
  executionContext?.stateAuthenticator?.status === "unavailable" &&
352
- coverage.status === "complete"
349
+ inputCoverage.status === "complete" &&
350
+ assurance.status === "settled"
353
351
  ? (executionContext.stateAuthenticator.unavailableReason ??
354
352
  "authenticated continuity state is unavailable")
355
353
  : undefined,
@@ -377,8 +375,13 @@ export const executeReview = <
377
375
  runUrl: options.runUrl,
378
376
  usage,
379
377
  usageScope: options.usageScope,
380
- fingerprint: coverage.status === "complete" ? fingerprint : undefined,
378
+ fingerprint:
379
+ inputCoverage.status === "complete" && assurance.status === "settled"
380
+ ? fingerprint
381
+ : undefined,
381
382
  coverage,
383
+ inputCoverage,
384
+ assurance,
382
385
  carriedFindings,
383
386
  carriedConcerns,
384
387
  reviewMode: executionContext?.mode,
@@ -398,6 +401,8 @@ export const executeReview = <
398
401
  activeFindings,
399
402
  activeConcerns,
400
403
  coverage,
404
+ inputCoverage,
405
+ assurance,
401
406
  plan,
402
407
  turns: result.turns,
403
408
  usage,
@@ -415,6 +420,8 @@ export const executeReview = <
415
420
  activeFindings,
416
421
  activeConcerns,
417
422
  coverage,
423
+ inputCoverage,
424
+ assurance,
418
425
  plan,
419
426
  published,
420
427
  turns: result.turns,