@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.
@@ -1,4 +1,4 @@
1
- import { Effect, Schema } from "effect";
1
+ import { Effect, Layer, Schema } from "effect";
2
2
  import {
3
3
  Agent,
4
4
  AgentPolicy,
@@ -11,16 +11,14 @@ import {
11
11
  } from "effect-agent";
12
12
  import { Tool, Toolkit } from "effect/unstable/ai";
13
13
 
14
- import { ChangedPath } from "./diff.ts";
14
+ import { anchorViolation } from "./anchors.ts";
15
+ import { ChangedFileStatus, ChangedPath } from "./diff.ts";
15
16
  import {
16
17
  clampMaxFindings,
17
18
  CodeReview,
18
- MAX_CONCERNS,
19
+ fileReviewEvidenceChunks,
20
+ MAX_PATCH_CHARS,
19
21
  MAX_WALKTHROUGH_SUMMARY_CHARS,
20
- ReadFile,
21
- ReadFileDiff,
22
- readFileDiffHandler,
23
- readFileHandler,
24
22
  REVIEW_TOOL_RESULT_MAX_BYTES,
25
23
  ReviewConcern,
26
24
  ReviewFinding,
@@ -29,85 +27,248 @@ import {
29
27
  } from "./review-agent.ts";
30
28
  import {
31
29
  MAX_REVIEW_UNITS,
30
+ MAX_UNIT_EVIDENCE_SHARDS,
32
31
  MAX_UNIT_FILES,
32
+ findingAnchorInUnitEvidence,
33
33
  planReviewUnits,
34
+ ReviewEvidenceShardId,
35
+ ReviewPassId,
36
+ ReviewRiskCategory,
34
37
  ReviewUnitId,
35
38
  ReviewUnitPlan,
36
39
  } from "./review-units.ts";
37
40
  import { PullRequestSource, PullRequestSourceFailure } from "./source.ts";
38
41
 
39
42
  // ---------------------------------------------------------------------------
40
- // The fan-out reviewer: the same review contract as the flat reviewer, but
41
- // the diff reading happens in bounded delegated children (S1 attached
42
- // ephemeral delegation) so no single context window has to hold every diff.
43
- // A coordinator lists the changeset as deterministic review units, delegates
44
- // one `delegate_file_review` call per unit, then merges the children's
45
- // bounded findings into one `CodeReview`. Publication and anchor validation
46
- // are unchanged: child output is untrusted input like everything else and
47
- // crosses to the host only through the same fail-closed planPublication path.
43
+ // The assured fan-out reviewer is a bounded, deterministic three-stage
44
+ // pipeline driven through attached S1 children:
45
+ //
46
+ // host plan -> independent discovery passes -> independent verification
47
+ //
48
+ // Host code owns partitioning, risk classification, bounded evidence, exact
49
+ // pass settlement, candidate provenance, and the final confirmed-candidate
50
+ // fold. The coordinator only schedules the declared work and writes prose.
48
51
  // ---------------------------------------------------------------------------
49
52
 
50
- /** One child returns at most this many findings; the merge caps the total. */
51
- export const MAX_CHILD_FINDINGS = 8;
53
+ /** One discovery pass returns at most this many anchored candidates. */
54
+ export const MAX_CHILD_FINDINGS = 6;
52
55
 
53
- /** One child returns at most this many non-anchored concerns. */
56
+ /** One discovery pass returns at most this many non-anchored candidates. */
54
57
  export const MAX_CHILD_CONCERNS = 3;
55
58
 
59
+ /** Every unit receives independent general and specialist discovery passes. */
60
+ export const MAX_UNIT_CANDIDATES = (MAX_CHILD_FINDINGS + MAX_CHILD_CONCERNS) * 2;
61
+
62
+ /** General + specialist discovery for every unit, then one verifier per unit. */
63
+ export const MAX_REVIEW_CHILDREN = MAX_REVIEW_UNITS * 3;
64
+
65
+ /** Structural minimum for a child that exposes no tools. */
66
+ export const MAX_FILE_REVIEW_TOOL_CALLS = 1;
67
+
68
+ export const ReviewWorkPhase = Schema.Literals(["discovery", "verification"]);
69
+ export type ReviewWorkPhase = typeof ReviewWorkPhase.Type;
70
+
71
+ export const ReviewWorkPerspective = Schema.Literals([
72
+ "general",
73
+ "risk-specialist",
74
+ "candidate-verification",
75
+ ]);
76
+ export type ReviewWorkPerspective = typeof ReviewWorkPerspective.Type;
77
+
78
+ export const ReviewCandidateId = Schema.NonEmptyString.check(Schema.isMaxLength(96));
79
+
80
+ export class FindingCandidate extends Schema.TaggedClass<FindingCandidate>()("FindingCandidate", {
81
+ candidateId: ReviewCandidateId,
82
+ workId: ReviewPassId,
83
+ unitId: ReviewUnitId,
84
+ finding: ReviewFinding,
85
+ evidencePaths: Schema.Array(ChangedPath)
86
+ .check(Schema.isMinLength(1))
87
+ .check(Schema.isMaxLength(1)),
88
+ }) {}
89
+
90
+ export class ConcernCandidate extends Schema.TaggedClass<ConcernCandidate>()("ConcernCandidate", {
91
+ candidateId: ReviewCandidateId,
92
+ workId: ReviewPassId,
93
+ unitId: ReviewUnitId,
94
+ concern: ReviewConcern,
95
+ evidencePaths: Schema.Array(ChangedPath)
96
+ .check(Schema.isMinLength(1))
97
+ .check(Schema.isMaxLength(3)),
98
+ }) {}
99
+
100
+ export const ReviewCandidate = Schema.Union([FindingCandidate, ConcernCandidate]);
101
+ export type ReviewCandidate = typeof ReviewCandidate.Type;
102
+
103
+ /** Deterministic host equivalence for claims repeated across discovery passes. */
104
+ export const reviewCandidateSubjectKey = (candidate: ReviewCandidate): string =>
105
+ candidate._tag === "FindingCandidate"
106
+ ? `finding:${JSON.stringify(Schema.encodeSync(ReviewFinding)(candidate.finding))}`
107
+ : `concern:${JSON.stringify(Schema.encodeSync(ReviewConcern)(candidate.concern))}`;
108
+
109
+ export class CandidateAssessment extends Schema.Class<CandidateAssessment>(
110
+ "@effect-agent/pr-review/CandidateAssessment",
111
+ )({
112
+ candidateId: ReviewCandidateId,
113
+ disposition: Schema.Literals(["confirmed", "rejected"]),
114
+ /**
115
+ * Exact suggestion settlement: required when the candidate finding carries
116
+ * a suggestion, forbidden otherwise. Untrusted child output cannot publish
117
+ * a GitHub replacement block by prompt compliance alone — the host keeps a
118
+ * confirmed finding's suggestion only on an exact "committable" settlement.
119
+ */
120
+ suggestion: Schema.optionalKey(
121
+ Schema.Literals(["committable", "not-committable"]).annotate({
122
+ description:
123
+ 'Required exactly when the candidate finding carries a suggestion: "committable" only when its text is the full replacement source for exactly lines startLine..endLine and nothing else. Forbidden for candidates without a suggestion.',
124
+ }),
125
+ ),
126
+ rationale: Schema.NonEmptyString.check(Schema.isMaxLength(600)),
127
+ }) {}
128
+
56
129
  /**
57
- * One mandatory diff read plus one bounded context read for every path in a
58
- * maximum-size unit. Keep the child and delegation reservation aligned.
130
+ * Exact suggestion settlement shape: a carried suggestion must be settled and
131
+ * nothing else may be. Enforced identically by the live delegation projection
132
+ * and the independent host coverage fold.
59
133
  */
60
- export const MAX_FILE_REVIEW_TOOL_CALLS = MAX_UNIT_FILES * 2;
134
+ export const assessmentSettlesSuggestionExactly = (
135
+ assessment: CandidateAssessment,
136
+ candidate: ReviewCandidate,
137
+ ): boolean =>
138
+ candidate._tag === "FindingCandidate" && candidate.finding.suggestion !== undefined
139
+ ? assessment.suggestion !== undefined
140
+ : assessment.suggestion === undefined;
61
141
 
62
- // ---------------------------------------------------------------------------
63
- // The child: a file reviewer over one unit. Its toolkit is intentionally
64
- // smaller than the flat reviewer's diff and head-file reads only, no
65
- // changeset listing so a child can never roam beyond its briefed unit
66
- // despite its observation surface being the whole changeset port.
67
- // ---------------------------------------------------------------------------
68
-
69
- export const FileReviewToolkit = Toolkit.make(ReadFileDiff, ReadFile);
142
+ /**
143
+ * Fail-closed publication of a confirmed finding: only an exact "committable"
144
+ * settlement keeps the suggestion; anything else publishes the finding with
145
+ * the suggestion stripped so unverified text can never become a one-click
146
+ * GitHub replacement block.
147
+ */
148
+ export const confirmedFindingForPublication = (
149
+ assessment: CandidateAssessment,
150
+ candidate: FindingCandidate,
151
+ ): ReviewFinding => {
152
+ if (candidate.finding.suggestion === undefined || assessment.suggestion === "committable") {
153
+ return candidate.finding;
154
+ }
155
+ const { suggestion: _stripped, ...finding } = candidate.finding;
156
+ return ReviewFinding.make(finding);
157
+ };
70
158
 
71
- export const FileReviewToolkitLayer = FileReviewToolkit.toLayer({
72
- read_file_diff: readFileDiffHandler,
73
- read_file: readFileHandler,
74
- });
159
+ /**
160
+ * Concern candidates need explicit paths internally to bind the claim to
161
+ * scheduled evidence. The verifier receives the complete bounded unit so it
162
+ * can use neighboring evidence to falsify the claim. The public ReviewConcern
163
+ * remains path-free after the host confirms and projects it.
164
+ */
165
+ export class DiscoveredConcern extends Schema.Class<DiscoveredConcern>(
166
+ "@effect-agent/pr-review/DiscoveredConcern",
167
+ )({
168
+ concern: ReviewConcern,
169
+ evidencePaths: Schema.Array(ChangedPath)
170
+ .check(Schema.isMinLength(1))
171
+ .check(Schema.isMaxLength(3)),
172
+ }) {}
75
173
 
76
174
  const UnitPaths = Schema.Array(ChangedPath)
77
175
  .check(Schema.isMinLength(1))
78
176
  .check(Schema.isMaxLength(MAX_UNIT_FILES));
79
177
 
80
- /** The child Agent input: one briefed unit of the changeset. */
178
+ const RiskCategories = Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6));
179
+ const Candidates = Schema.Array(ReviewCandidate).check(Schema.isMaxLength(MAX_UNIT_CANDIDATES));
180
+ const EvidenceShardIds = Schema.Array(ReviewEvidenceShardId)
181
+ .check(Schema.isMinLength(1))
182
+ .check(Schema.isMaxLength(MAX_UNIT_EVIDENCE_SHARDS));
183
+
184
+ /** Strict-object coordinator request for either discovery or verification. */
185
+ export class FileReviewRequest extends Schema.Class<FileReviewRequest>(
186
+ "@effect-agent/pr-review/FileReviewRequest",
187
+ )({
188
+ phase: ReviewWorkPhase,
189
+ workId: ReviewPassId,
190
+ unitId: ReviewUnitId,
191
+ paths: UnitPaths,
192
+ evidenceShardIds: EvidenceShardIds,
193
+ perspective: ReviewWorkPerspective,
194
+ riskCategories: RiskCategories,
195
+ /** Empty for discovery; the exact discovered set for unit verification. */
196
+ candidates: Candidates,
197
+ }) {}
198
+
199
+ /** One complete host-selected evidence shard supplied to a review child. */
200
+ export class FileReviewEvidence extends Schema.Class<FileReviewEvidence>(
201
+ "@effect-agent/pr-review/FileReviewEvidence",
202
+ )({
203
+ shardId: ReviewEvidenceShardId,
204
+ path: ChangedPath,
205
+ status: ChangedFileStatus,
206
+ reviewMode: Schema.Literals(["diff", "content", "unavailable"]),
207
+ ordinal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
208
+ total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
209
+ annotatedPatch: Schema.String.check(Schema.isMaxLength(MAX_PATCH_CHARS)),
210
+ }) {}
211
+
212
+ /** Host-prepared child input with complete bounded diff/content evidence. */
81
213
  export class FileReviewBrief extends Schema.Class<FileReviewBrief>(
82
214
  "@effect-agent/pr-review/FileReviewBrief",
83
215
  )({
216
+ phase: ReviewWorkPhase,
217
+ workId: ReviewPassId,
84
218
  unitId: ReviewUnitId,
85
219
  paths: UnitPaths,
86
- focus: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
220
+ evidenceShardIds: EvidenceShardIds,
221
+ perspective: ReviewWorkPerspective,
222
+ riskCategories: RiskCategories,
223
+ candidates: Candidates,
224
+ evidence: Schema.Array(FileReviewEvidence)
225
+ .check(Schema.isMinLength(1))
226
+ .check(Schema.isMaxLength(MAX_UNIT_EVIDENCE_SHARDS)),
87
227
  }) {}
88
228
 
89
- /** The child Agent output: the briefed unit's bounded findings and concerns. */
229
+ /** Child output; phase-inapplicable collections must be empty. */
90
230
  export class FileReviewReport extends Schema.Class<FileReviewReport>(
91
231
  "@effect-agent/pr-review/FileReviewReport",
92
232
  )({
233
+ phase: ReviewWorkPhase,
234
+ workId: ReviewPassId,
93
235
  unitId: ReviewUnitId,
94
236
  findings: Schema.Array(ReviewFinding).check(Schema.isMaxLength(MAX_CHILD_FINDINGS)),
95
- /** Unit-scoped concerns with no diff line to anchor to. */
96
- concerns: Schema.optionalKey(
97
- Schema.Array(ReviewConcern).check(Schema.isMaxLength(MAX_CHILD_CONCERNS)),
98
- ),
99
- /** One-sentence per-file change summaries for the merged walkthrough. */
100
- fileSummaries: Schema.optionalKey(
101
- Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
102
- ),
237
+ concerns: Schema.Array(DiscoveredConcern).check(Schema.isMaxLength(MAX_CHILD_CONCERNS)),
238
+ fileSummaries: Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
239
+ assessments: Schema.Array(CandidateAssessment).check(Schema.isMaxLength(MAX_UNIT_CANDIDATES)),
103
240
  }) {}
104
241
 
105
- /**
106
- * Guidance for delegated children must be static: child instructions are a
107
- * pure function of the brief, and the coordinator's mission never crosses the
108
- * delegation boundary (context isolation), so mission-dependent guidance
109
- * cannot be resolved for a child.
110
- */
242
+ /** Bounded coordinator-visible result with host-assigned candidate IDs. */
243
+ export class FileReviewUnitResult extends Schema.Class<FileReviewUnitResult>(
244
+ "@effect-agent/pr-review/FileReviewUnitResult",
245
+ )({
246
+ phase: ReviewWorkPhase,
247
+ workId: ReviewPassId,
248
+ unitId: ReviewUnitId,
249
+ candidates: Candidates,
250
+ fileSummaries: Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
251
+ assessments: Schema.Array(CandidateAssessment).check(Schema.isMaxLength(MAX_UNIT_CANDIDATES)),
252
+ }) {}
253
+
254
+ export class FileReviewUnitFailed extends Schema.TaggedError<FileReviewUnitFailed>()(
255
+ "FileReviewUnitFailed",
256
+ {
257
+ childErrorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
258
+ message: Schema.String.check(Schema.isMaxLength(400)),
259
+ },
260
+ ) {}
261
+
262
+ export class FileReviewWorkRejected extends Schema.TaggedError<FileReviewWorkRejected>()(
263
+ "FileReviewWorkRejected",
264
+ {
265
+ workId: ReviewPassId,
266
+ reason: Schema.NonEmptyString.check(Schema.isMaxLength(600)),
267
+ },
268
+ ) {}
269
+
270
+ export const FileReviewFailure = Schema.Union([FileReviewUnitFailed, FileReviewWorkRejected]);
271
+
111
272
  export interface FanOutInstructionOptions {
112
273
  readonly guidance?: string | ReadonlyArray<string> | undefined;
113
274
  }
@@ -120,115 +281,78 @@ const staticGuidanceLines = (
120
281
  return lines.filter((line) => line.length > 0);
121
282
  };
122
283
 
123
- /** Build the child file-reviewer instructions with optional static guidance. */
284
+ const evidenceInstructions = [
285
+ "The host placed complete bounded review evidence shards in the input evidence array. Treat every shard as required input; ordinal/total identifies multi-shard paths.",
286
+ "You have no tools and cannot roam outside this evidence. If it is insufficient for a candidate, reject or omit that candidate rather than guessing.",
287
+ "A diff marks new-version anchors as R<number>; only those lines may anchor findings. B/H content evidence is non-anchorable.",
288
+ ];
289
+
290
+ /** Discovery and verification instructions share one child definition. */
124
291
  export const makeFileReviewerInstructions =
125
292
  (options: FanOutInstructionOptions = {}) =>
126
- (brief: FileReviewBrief): string =>
127
- [
128
- `You are a code reviewer for one unit of a pull request: unit ${brief.unitId}, covering exactly these changed files: ${brief.paths.join(", ")}. Focus: ${brief.focus}.`,
293
+ (brief: FileReviewBrief): string => {
294
+ const common = [
295
+ `You are an attached review worker for ${brief.workId} in host-planned unit ${brief.unitId}: ${brief.paths.join(", ")}.`,
129
296
  ...staticGuidanceLines(options.guidance),
130
- "Work in this order:",
131
- "1. Call read_file_diff for every file in your unit. A normal diff marks new-version anchors as R<number>; only those numbers are valid startLine/endLine values. When GitHub omitted a diff, the tool may return bounded base/head content marked B/H instead. Review that content, but report its defects as non-anchored concerns because B/H lines cannot anchor GitHub comments. Never anchor a finding to a removed (-), B, or H line.",
132
- "2. Call read_file when you need surrounding context the diff does not show. ONLY files in the changeset are readable: a request for any other path (an import, a neighbor, a config) returns a failed result — do not retry it; reason from the diff instead and note the gap in your report when it matters.",
133
- "3. Review for real defects first: correctness, security, concurrency, resource leaks, error handling, API misuse. Style nits are least important. Do not praise; do not restate the diff.",
134
- "When the diff adds or changes a test, check that it can actually fail: a test that would still pass with the bug present is theatre, not coverage. The usual tell is a loose assertion standing where an exact one belongs — >= or a truthiness check over an expected value, or a snapshot that absorbs whatever it is handed.",
135
- "Drop bloat-shaped findings before reporting: defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies, just-in-case guards. A finding must be sound, correct, and worth acting on; prefer an explicit keep over an invented finding.",
136
- `4. For every file in your unit, write one factual sentence (<= ${MAX_WALKTHROUGH_SUMMARY_CHARS} chars) describing what changed in that file for a reader scanning the pull request, never a line-by-line restatement.`,
137
- `5. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"unitId": ${JSON.stringify(brief.unitId)}, "findings": [{"path": <string, a file in your unit>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "category": <OPTIONAL: "correctness" | "security" | "concurrency" | "performance" | "resources" | "error-handling" | "testing" | "maintainability" | "style" | "docs">, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], only for concerns about YOUR unit's files with no valid line anchor (a missing cleanup, a coverage gap the diff implies, sequencing the diff leaves open) — never duplicate a finding here>, "fileSummaries": <array, OPTIONAL: [{"path": <string, a file in your unit>, "summary": <string, the step-4 sentence>}], one entry per file in your unit>}.`,
138
- `Report at most ${MAX_CHILD_FINDINGS} findings and at most ${MAX_CHILD_CONCERNS} concerns; prefer the most important ones. An empty findings array is a valid report. Never report on files outside your unit. Line anchors you invent will be discarded, so copy R-numbers from read_file_diff output.`,
297
+ ...evidenceInstructions,
298
+ ];
299
+ if (brief.phase === "verification") {
300
+ return [
301
+ ...common,
302
+ "Independently verify every candidate in the input. You did not receive another reviewer's transcript or reasoning; use only the candidate claim and bounded evidence.",
303
+ "The evidence array contains the complete bounded unit, including neighboring changed code that may confirm or falsify a locally plausible claim.",
304
+ "For each candidate, try to falsify it first. Confirm only when the cited behavior is supported and actionable. Reject unsupported, speculative, duplicate, or non-actionable candidates.",
305
+ 'Return ONLY JSON with phase "verification", the exact workId/unitId, empty findings/concerns/fileSummaries arrays, and exactly one assessment per candidateId. Each assessment is {"candidateId": <exact id>, "disposition": <"confirmed" | "rejected">, "suggestion": <"committable" | "not-committable", present exactly when the candidate finding carries a suggestion>, "rationale": <bounded evidence-based reason>}. Never add or omit an id.',
306
+ 'Settle every carried suggestion independently of the claim: answer "committable" only when its text is the full replacement source for exactly lines startLine..endLine and nothing else — it compiles in context and preserves the finding\'s intent, never prose describing a change. Otherwise answer "not-committable"; the host then publishes the confirmed finding without its suggestion. Omit the assessment "suggestion" field for candidates without one.',
307
+ ].join("\n");
308
+ }
309
+ const focus =
310
+ brief.perspective === "risk-specialist"
311
+ ? brief.riskCategories.length > 0
312
+ ? `This is a fresh specialist discovery pass. Concentrate on these host-classified risks without relying on another pass: ${brief.riskCategories.join(", ")}.`
313
+ : "This is a fresh specialist discovery pass. The host found no keyword-classified category, so independently scrutinize authentication/authorization, security boundaries, durability, concurrency, credentials, and external side effects rather than treating classification silence as low risk."
314
+ : "This is the general discovery pass. Review broadly for correctness, security, concurrency, resource, API, and error-handling defects.";
315
+ return [
316
+ ...common,
317
+ focus,
318
+ "The discovery evidence array contains every complete shard in the unit. Review every entry and every shard of a multi-shard path. A later independent verifier, not you, decides which candidates publish.",
319
+ "When a non-anchored concern depends on one or more unit files, list 1-3 exact evidencePaths to bind the claim to scheduled evidence.",
320
+ `Return ONLY JSON with phase "discovery", the exact workId/unitId, up to ${MAX_CHILD_FINDINGS} findings, up to ${MAX_CHILD_CONCERNS} concerns shaped as {concern, evidencePaths}, one factual file summary per path (<= ${MAX_WALKTHROUGH_SUMMARY_CHARS} chars), and an empty assessments array. Empty candidate arrays are valid; do not invent defects.`,
321
+ 'Each finding is {"path": <a unit file path>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "category": <OPTIONAL problem-kind label>, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement source code for exactly lines startLine..endLine, ready to commit>}.',
322
+ 'Include "suggestion" only when you are confident the replacement compiles and preserves intent; its text must contain the full replacement source for every line in the range and nothing else — never prose describing the change, which belongs in "body".',
139
323
  ].join("\n");
324
+ };
140
325
 
141
326
  export const fileReviewerInstructions = makeFileReviewerInstructions();
142
327
 
143
- /** The default per-unit child execution bounds. */
328
+ export const FileReviewToolkit = Toolkit.empty;
329
+
330
+ /** Compatibility export: the evidence-only child has no handler requirements. */
331
+ export const FileReviewToolkitLayer = Layer.empty;
332
+
144
333
  export const defaultFileReviewerPolicy = AgentPolicy.make({
145
- maxTurns: 8,
334
+ maxTurns: 6,
146
335
  maxToolCalls: MAX_FILE_REVIEW_TOOL_CALLS,
147
336
  maxDuration: "6 minutes",
148
337
  toolConcurrency: 2,
149
- // Same rationale as the flat reviewer's bound: read refusals are
150
- // model-visible results, and one parallel batch of out-of-unit probes must
151
- // not kill the child before it has seen a single refusal.
152
- repeatedFailureLimit: 12,
338
+ repeatedFailureLimit: 6,
153
339
  tokenBudget: 200_000,
154
- // Bound one live prompt independently from cumulative usage. The engine
155
- // prunes old diff/file results before paying for a summary.
156
340
  contextTokenLimit: 150_000,
157
341
  toolResultBounds: ToolResultBounds.make({ maxBytes: REVIEW_TOOL_RESULT_MAX_BYTES }),
158
- // Typed exhaustion, deliberately NOT the final-answer soft landing: a
159
- // review is a coverage claim, and a child whose reads were rejected could
160
- // still emit schema-valid findings — laundering budget exhaustion into
161
- // "reviewed". Until host-owned evidence proves every mandatory
162
- // read_file_diff completed, an exhausted child fails typed and its unit
163
- // stays honestly unreviewed (containment turns that into result data
164
- // without failing the run).
342
+ // Discovery or verification that exhausts is unsettled work, never a
343
+ // schema-valid partial that can contribute to a green assurance claim.
165
344
  onExhaustion: "fail",
166
345
  });
167
346
 
168
- // ---------------------------------------------------------------------------
169
- // The delegation: one Effect AI Tool per review unit, with explicit
170
- // projections and finite bounds (SUB-009). `projectResult` is the
171
- // declassification boundary — the parent sees the child's bounded findings,
172
- // never its transcript or the diffs it read.
173
- // ---------------------------------------------------------------------------
174
-
175
- /** The model-decoded delegation parameters: which unit to review. */
176
- export class FileReviewRequest extends Schema.Class<FileReviewRequest>(
177
- "@effect-agent/pr-review/FileReviewRequest",
178
- )({
179
- unitId: ReviewUnitId,
180
- paths: UnitPaths,
181
- }) {}
182
-
183
- /** The bounded parent-visible result of one delegated unit review. */
184
- export class FileReviewUnitResult extends Schema.Class<FileReviewUnitResult>(
185
- "@effect-agent/pr-review/FileReviewUnitResult",
186
- )({
187
- unitId: ReviewUnitId,
188
- findings: Schema.Array(ReviewFinding).check(Schema.isMaxLength(MAX_CHILD_FINDINGS)),
189
- /** Unit-scoped concerns with no diff line to anchor to. */
190
- concerns: Schema.optionalKey(
191
- Schema.Array(ReviewConcern).check(Schema.isMaxLength(MAX_CHILD_CONCERNS)),
192
- ),
193
- /** One-sentence per-file change summaries for the merged walkthrough. */
194
- fileSummaries: Schema.optionalKey(
195
- Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
196
- ),
197
- }) {}
198
-
199
- /**
200
- * One unit's review failed: the child Run ended in a typed failure (policy
201
- * bound, output violation, model fault). The marker is bounded and carries no
202
- * child transcript content beyond the failure tag and message.
203
- */
204
- export class FileReviewUnitFailed extends Schema.TaggedError<FileReviewUnitFailed>()(
205
- "FileReviewUnitFailed",
206
- {
207
- childErrorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
208
- message: Schema.String.check(Schema.isMaxLength(400)),
209
- },
210
- ) {}
211
-
212
- /**
213
- * Finite per-invocation bounds (SUB-009), aligned with the child's own
214
- * AgentPolicy: the child's policy is the limit that trips typed; the
215
- * reservation mirrors it so parent-side accounting stays honest.
216
- */
217
347
  export const fileReviewPolicy = SubagentPolicy.make({
218
- maxChildren: MAX_REVIEW_UNITS,
219
- maxConcurrency: 3,
220
- maxTurns: 8,
348
+ maxChildren: MAX_REVIEW_CHILDREN,
349
+ maxConcurrency: 4,
350
+ maxTurns: 6,
221
351
  maxToolCalls: MAX_FILE_REVIEW_TOOL_CALLS,
222
352
  maxDuration: "6 minutes",
353
+ maxResultBytes: 256 * 1024,
223
354
  });
224
355
 
225
- const delegationDescription =
226
- "Delegate the review of one planned unit to a bounded file-reviewer child and return its line-anchored findings. Call it exactly once per unit from list_review_units; never retry a failed unit.";
227
-
228
- /**
229
- * Total mapping from every expected child Run failure to the declared unit
230
- * failure (SUB-028): the tag plus a bounded message, nothing else crosses.
231
- */
232
356
  export const mapFileReviewChildFailure = (failure: {
233
357
  readonly _tag: string;
234
358
  readonly message?: string;
@@ -238,22 +362,284 @@ export const mapFileReviewChildFailure = (failure: {
238
362
  message: (failure.message ?? "").slice(0, 400),
239
363
  });
240
364
 
241
- // ---------------------------------------------------------------------------
242
- // The coordinator's own tool: the deterministic unit plan over the changeset.
243
- // Grouping is host code (review-units.ts), not model prose, so fan-out shape
244
- // and budget honesty stay pinnable in tests.
245
- // ---------------------------------------------------------------------------
365
+ const sameStrings = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
366
+ left.length === right.length && left.every((value, index) => value === right[index]);
367
+
368
+ const rejectWork = (workId: string, reason: string) =>
369
+ FileReviewWorkRejected.make({ workId, reason });
370
+
371
+ /** Validate coordinator scheduling against the current deterministic plan. */
372
+ const prepareReviewBrief = (request: FileReviewRequest) =>
373
+ Effect.gen(function* () {
374
+ const source = yield* PullRequestSource;
375
+ const mapSourceFailure = (failure: PullRequestSourceFailure) =>
376
+ rejectWork(
377
+ request.workId,
378
+ `pull-request source ${failure.operation} failed: ${failure.reason}`.slice(0, 600),
379
+ );
380
+ const files = yield* source.changedFiles.pipe(Effect.mapError(mapSourceFailure));
381
+ const metadata = yield* source.metadata.pipe(Effect.mapError(mapSourceFailure));
382
+ const plan = planReviewUnits(files, { totalChangedFiles: metadata.totalChangedFiles });
383
+ const unit = plan.units.find((candidate) => candidate.unitId === request.unitId);
384
+ if (
385
+ unit === undefined ||
386
+ !sameStrings(request.paths, unit.paths) ||
387
+ !sameStrings(
388
+ request.evidenceShardIds,
389
+ unit.evidenceShards.map((shard) => shard.shardId),
390
+ )
391
+ ) {
392
+ return yield* rejectWork(request.workId, "request does not match a host-planned unit");
393
+ }
394
+
395
+ if (request.phase === "discovery") {
396
+ const pass = plan.discoveryPasses.find((candidate) => candidate.passId === request.workId);
397
+ if (
398
+ pass === undefined ||
399
+ pass.unitId !== request.unitId ||
400
+ !sameStrings(pass.paths, request.paths) ||
401
+ !sameStrings(pass.evidenceShardIds, request.evidenceShardIds) ||
402
+ pass.perspective !== request.perspective ||
403
+ !sameStrings(pass.riskCategories, request.riskCategories) ||
404
+ request.candidates.length !== 0
405
+ ) {
406
+ return yield* rejectWork(request.workId, "discovery request does not match the host plan");
407
+ }
408
+ } else {
409
+ if (
410
+ request.workId !== `${request.unitId}-verification` ||
411
+ request.perspective !== "candidate-verification" ||
412
+ !sameStrings(request.riskCategories, unit.riskCategories) ||
413
+ request.candidates.length === 0
414
+ ) {
415
+ return yield* rejectWork(
416
+ request.workId,
417
+ "verification request does not match the host-planned unit",
418
+ );
419
+ }
420
+ const candidateIds = new Set<string>();
421
+ const candidateSubjects = new Set<string>();
422
+ const allowed = new Set(unit.paths);
423
+ for (const candidate of request.candidates) {
424
+ const subjectKey = reviewCandidateSubjectKey(candidate);
425
+ if (
426
+ candidateIds.has(candidate.candidateId) ||
427
+ candidateSubjects.has(subjectKey) ||
428
+ candidate.unitId !== unit.unitId ||
429
+ candidate.evidencePaths.some((path) => !allowed.has(path)) ||
430
+ (candidate._tag === "FindingCandidate" && !allowed.has(candidate.finding.path))
431
+ ) {
432
+ return yield* rejectWork(
433
+ request.workId,
434
+ "verification candidates are duplicated or outside the planned unit",
435
+ );
436
+ }
437
+ candidateIds.add(candidate.candidateId);
438
+ candidateSubjects.add(subjectKey);
439
+ }
440
+ }
441
+
442
+ const byPath = new Map(files.map((file) => [file.path, file] as const));
443
+ const evidence: Array<FileReviewEvidence> = [];
444
+ for (const shard of unit.evidenceShards) {
445
+ const file = byPath.get(shard.path);
446
+ if (file === undefined) {
447
+ return yield* rejectWork(
448
+ request.workId,
449
+ `planned evidence path is unavailable: ${shard.path}`,
450
+ );
451
+ }
452
+ const chunks = fileReviewEvidenceChunks(file);
453
+ const chunk = chunks[shard.ordinal - 1];
454
+ if (
455
+ chunk === undefined ||
456
+ chunks.length !== shard.total ||
457
+ chunk.annotatedPatch.length !== shard.evidenceChars
458
+ ) {
459
+ return yield* rejectWork(
460
+ request.workId,
461
+ `planned evidence shard no longer matches source: ${shard.shardId}`,
462
+ );
463
+ }
464
+ evidence.push(
465
+ FileReviewEvidence.make({
466
+ shardId: shard.shardId,
467
+ path: shard.path,
468
+ status: file.status,
469
+ reviewMode: chunk.reviewMode,
470
+ ordinal: shard.ordinal,
471
+ total: shard.total,
472
+ annotatedPatch: chunk.annotatedPatch,
473
+ }),
474
+ );
475
+ }
476
+ return FileReviewBrief.make({ ...request, evidence });
477
+ });
478
+
479
+ const candidateOrdinal = (index: number): string => String(index + 1).padStart(3, "0");
480
+
481
+ const projectReviewResult = (
482
+ report: FileReviewReport,
483
+ context: { readonly budgetExhausted: boolean },
484
+ request: FileReviewRequest,
485
+ ) => {
486
+ if (context.budgetExhausted) {
487
+ return Effect.fail(
488
+ rejectWork(report.workId, "review work exhausted its budget before exact settlement"),
489
+ );
490
+ }
491
+ if (
492
+ report.phase !== request.phase ||
493
+ report.workId !== request.workId ||
494
+ report.unitId !== request.unitId
495
+ ) {
496
+ return Effect.fail(
497
+ rejectWork(request.workId, "review output identity does not match the scheduled request"),
498
+ );
499
+ }
500
+ if (report.phase === "verification") {
501
+ if (
502
+ report.findings.length > 0 ||
503
+ report.concerns.length > 0 ||
504
+ report.fileSummaries.length > 0
505
+ ) {
506
+ return Effect.fail(
507
+ rejectWork(report.workId, "verification output contained discovery-only fields"),
508
+ );
509
+ }
510
+ const expectedById = new Map(
511
+ request.candidates.map((candidate) => [candidate.candidateId, candidate] as const),
512
+ );
513
+ const assessedIds = new Set<string>();
514
+ for (const assessment of report.assessments) {
515
+ const candidate = expectedById.get(assessment.candidateId);
516
+ if (candidate === undefined || assessedIds.has(assessment.candidateId)) {
517
+ return Effect.fail(
518
+ rejectWork(report.workId, "verification output did not assess the exact candidate set"),
519
+ );
520
+ }
521
+ if (!assessmentSettlesSuggestionExactly(assessment, candidate)) {
522
+ return Effect.fail(
523
+ rejectWork(
524
+ report.workId,
525
+ "verification output did not settle suggestion publication exactly",
526
+ ),
527
+ );
528
+ }
529
+ assessedIds.add(assessment.candidateId);
530
+ }
531
+ if (assessedIds.size !== expectedById.size) {
532
+ return Effect.fail(
533
+ rejectWork(report.workId, "verification output did not assess the exact candidate set"),
534
+ );
535
+ }
536
+ return Effect.succeed(
537
+ FileReviewUnitResult.make({
538
+ phase: report.phase,
539
+ workId: report.workId,
540
+ unitId: report.unitId,
541
+ candidates: [],
542
+ fileSummaries: [],
543
+ assessments: report.assessments,
544
+ }),
545
+ );
546
+ }
547
+ if (report.assessments.length > 0) {
548
+ return Effect.fail(
549
+ rejectWork(report.workId, "discovery output contained verification-only assessments"),
550
+ );
551
+ }
552
+ const allowed = new Set(request.paths);
553
+ if (
554
+ report.findings.some((finding) => !allowed.has(finding.path)) ||
555
+ report.concerns.some((candidate) =>
556
+ candidate.evidencePaths.some((path) => !allowed.has(path)),
557
+ ) ||
558
+ report.fileSummaries.some((entry) => !allowed.has(entry.path))
559
+ ) {
560
+ return Effect.fail(
561
+ rejectWork(report.workId, "discovery output referenced evidence outside the scheduled unit"),
562
+ );
563
+ }
564
+ return Effect.gen(function* () {
565
+ const source = yield* PullRequestSource;
566
+ const mapSourceFailure = (failure: PullRequestSourceFailure) =>
567
+ rejectWork(
568
+ request.workId,
569
+ `pull-request source ${failure.operation} failed: ${failure.reason}`.slice(0, 600),
570
+ );
571
+ const files = yield* source.changedFiles.pipe(Effect.mapError(mapSourceFailure));
572
+ const metadata = yield* source.metadata.pipe(Effect.mapError(mapSourceFailure));
573
+ const anchorFiles = yield* source.anchorFiles.pipe(Effect.mapError(mapSourceFailure));
574
+ const unit = planReviewUnits(files, {
575
+ totalChangedFiles: metadata.totalChangedFiles,
576
+ }).units.find((candidate) => candidate.unitId === request.unitId);
577
+ if (unit === undefined) {
578
+ return yield* rejectWork(request.workId, "scheduled review unit is no longer available");
579
+ }
580
+ for (const finding of report.findings) {
581
+ const violation = anchorViolation(finding, anchorFiles);
582
+ if (violation !== undefined || !findingAnchorInUnitEvidence(finding, unit, files)) {
583
+ return yield* rejectWork(
584
+ request.workId,
585
+ `discovery finding has no valid anchor in its assigned evidence: ${violation ?? finding.path}`,
586
+ );
587
+ }
588
+ }
589
+ const findingCandidates = report.findings.map((finding, index) =>
590
+ FindingCandidate.make({
591
+ candidateId: `${request.workId}:finding:${candidateOrdinal(index)}`,
592
+ workId: request.workId,
593
+ unitId: request.unitId,
594
+ finding,
595
+ evidencePaths: [finding.path],
596
+ }),
597
+ );
598
+ const concernCandidates = report.concerns.map((candidate, index) =>
599
+ ConcernCandidate.make({
600
+ candidateId: `${request.workId}:concern:${candidateOrdinal(index)}`,
601
+ workId: request.workId,
602
+ unitId: request.unitId,
603
+ concern: candidate.concern,
604
+ evidencePaths: candidate.evidencePaths,
605
+ }),
606
+ );
607
+ return FileReviewUnitResult.make({
608
+ phase: report.phase,
609
+ workId: report.workId,
610
+ unitId: report.unitId,
611
+ candidates: [...findingCandidates, ...concernCandidates],
612
+ fileSummaries: report.fileSummaries,
613
+ assessments: [],
614
+ });
615
+ });
616
+ };
617
+
618
+ const delegationDescription =
619
+ "Run exactly one host-planned discovery or candidate-verification child. Copy every plan field and candidate verbatim; never retry failed work.";
620
+
621
+ const makeFileReviewDelegation = (child: ReturnType<typeof makeFileReviewerDefinition>) =>
622
+ Subagent.define("delegate_file_review", {
623
+ description: delegationDescription,
624
+ target: child,
625
+ parameters: FileReviewRequest,
626
+ success: FileReviewUnitResult,
627
+ failure: FileReviewFailure,
628
+ failureMode: "return",
629
+ prepareInput: prepareReviewBrief,
630
+ projectResult: projectReviewResult,
631
+ policy: fileReviewPolicy,
632
+ });
246
633
 
247
634
  export class ListReviewUnitsQuery extends Schema.Class<ListReviewUnitsQuery>(
248
635
  "@effect-agent/pr-review/ListReviewUnitsQuery",
249
636
  )({
250
- /** Explicit constant keeps the zero-choice operation compatible with strict provider schemas. */
251
637
  scope: Schema.Literal("all"),
252
638
  }) {}
253
639
 
254
640
  export const ListReviewUnits = Tool.make("list_review_units", {
255
641
  description:
256
- "List this pull request's changeset grouped into bounded review units (size-budgeted, directory-affine), plus the files no unit can cover.",
642
+ "List deterministic bounded review units, explicit risk categories, every required discovery pass, and paths the pipeline cannot cover.",
257
643
  parameters: ListReviewUnitsQuery,
258
644
  success: ReviewUnitPlan,
259
645
  failure: PullRequestSourceFailure,
@@ -273,64 +659,38 @@ export const FanOutCoordinatorToolkitLayer = FanOutCoordinatorToolkit.toLayer({
273
659
  }),
274
660
  });
275
661
 
276
- // ---------------------------------------------------------------------------
277
- // The coordinator Agent Definition: same mission input and CodeReview output
278
- // contract as the flat reviewer, so planPublication and anchor validation
279
- // apply unchanged.
280
- // ---------------------------------------------------------------------------
281
-
282
- /**
283
- * Build the coordinator's instructions. The same consumer guidance the
284
- * children receive is injected between the mission framing and the procedure
285
- * so the merged summary and verdict are shaped by the same review profile,
286
- * and the configured findings bound reaches the merge step instead of only
287
- * the host-side trim.
288
- */
289
662
  export const makeFanOutReviewInstructions =
290
663
  (options: FanOutInstructionOptions & { readonly maxFindings?: number | undefined } = {}) =>
291
664
  (mission: ReviewMission): string => {
292
665
  const maxFindings = clampMaxFindings(options.maxFindings);
293
666
  return [
294
- `You coordinate the review of pull request #${mission.number} ("${mission.title}") in ${mission.repository}, merging ${mission.headRef} into ${mission.baseRef}. It changes ${mission.changedFileCount} file(s).`,
295
- mission.body.length > 0
296
- ? `Author description:\n${mission.body}`
297
- : "The author provided no description.",
667
+ `You coordinate the bounded multi-pass review of pull request #${mission.number} ("${mission.title}") in ${mission.repository}.`,
668
+ mission.body.length > 0 ? `Author description:\n${mission.body}` : "No author description.",
298
669
  ...staticGuidanceLines(options.guidance),
299
- "Work in this order:",
300
- "1. Call list_review_units once to get the planned review units.",
301
- "2. Call delegate_file_review EXACTLY once per unit, passing each unit's unitId and paths verbatim. Prefer declaring all delegation calls in one batch. Never review files yourself and never invent units.",
302
- '3. A delegation result with "_tag" is a FAILED unit. Never retry it; instead your summary MUST name it honestly, e.g. "unit-002 unreviewed: AgentPolicyError". The plan\'s undiffablePaths and unassignedPaths must also be named as not reviewed when present.',
303
- `4. Merge the successful units' findings: drop duplicates sharing the same path and line range keeping the most severe, rank blocking > important > nit, and keep at most ${maxFindings} findings. Drop bloat-shaped findings during the merge defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies; children bias toward recommending changes, and a finding must be sound, correct, and worth acting on to survive.`,
304
- `5. Merge the units' concerns the same way: drop duplicates keeping the most severe, and keep at most ${MAX_CONCERNS}.`,
305
- "6. Merge the units' fileSummaries into one walkthrough: copy each entry verbatim, one entry per file, dropping duplicate paths.",
306
- '7. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"summary": <string, 1-3 paragraphs of overall assessment, including every unreviewed unit or file>, "verdict": <"approve" | "comment" | "request-changes">, "findings": [{"path": <string>, "startLine": <integer>, "endLine": <integer>, "severity": <"blocking" | "important" | "nit">, "category": <string, OPTIONAL>, "title": <string, <= 120 chars>, "body": <string>, "suggestion": <string, OPTIONAL>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], the merged unit concerns>, "walkthrough": <array, OPTIONAL: [{"path": <string>, "summary": <string>}], the merged fileSummaries>}. Copy findings (including "category" and "suggestion" when present), concerns, and walkthrough entries verbatim from the delegation results; never invent or edit anchors.',
307
- 'Use verdict "request-changes" only when at least one finding or concern is "blocking". An empty findings array with verdict "approve" is a valid review when every unit succeeded and found nothing.',
670
+ "1. Call list_review_units exactly once.",
671
+ '2. For EVERY discoveryPass, call delegate_file_review exactly once with phase "discovery", workId=passId, and the pass unitId/paths/evidenceShardIds/perspective/riskCategories verbatim; candidates must be []. Prefer one bounded parallel batch. Never retry.',
672
+ '3. Group candidates returned by all successful discovery passes by unit. Deterministically deduplicate byte-identical finding or concern payloads, retaining the first candidate in discoveryPass plan order. For every unit with at least one retained candidate, call delegate_file_review exactly once with phase "verification", workId "<unitId>-verification", perspective "candidate-verification", the unit paths/evidenceShardIds/riskCategories, and EVERY retained candidate copied byte-for-byte. Prefer one bounded parallel batch. Never retry.',
673
+ "4. Verification is authoritative: rejected candidates must not be reported. The host independently reconstructs publishable findings from exact confirmed assessments, so do not select, rewrite, downgrade, or invent findings.",
674
+ `5. Return ONLY CodeReview JSON. Write a concise summary of completed and failed stages. Set findings=[] and concerns=[]; the host injects exact confirmed candidates. Copy factual fileSummaries into walkthrough without invention. The host publication cap is ${maxFindings}.`,
675
+ "No configured pipeline can prove absence of defects. Describe settled work, never an exhaustive or defect-free review.",
308
676
  ].join("\n");
309
677
  };
310
678
 
311
679
  export const fanOutReviewInstructions = makeFanOutReviewInstructions();
312
680
 
313
- /** The default fan-out coordinator execution bounds. */
314
681
  export const defaultFanOutPolicy = AgentPolicy.make({
315
- maxTurns: 6,
316
- maxToolCalls: 1 + MAX_REVIEW_UNITS,
317
- maxDuration: "15 minutes",
318
- toolConcurrency: 3,
319
- // Contained unit failures (SUB-033) are ordinary successful Tool results,
320
- // so they no longer fold into the repeated-failure counter; the default
321
- // bound suffices.
682
+ maxTurns: 7,
683
+ maxToolCalls: 1 + MAX_REVIEW_CHILDREN,
684
+ maxDuration: "20 minutes",
685
+ toolConcurrency: 4,
322
686
  repeatedFailureLimit: 3,
323
- tokenBudget: 300_000,
324
- // Child reports can amplify the merge prompt; compact before the provider's
325
- // 200k-class window becomes the failure boundary.
687
+ tokenBudget: 400_000,
326
688
  contextTokenLimit: 150_000,
327
- // Budget soft landing (RUN-018): an exhausted coordinator merges what it
328
- // has into one best-effort review instead of discarding every child report.
689
+ // Coordinator exhaustion cannot become an assured result; exact stage
690
+ // settlement, not this final prose, determines assurance.
329
691
  onExhaustion: "final-answer",
330
692
  });
331
693
 
332
- /** Everything one fan-out configuration is made of, built as one unit so the
333
- * delegation always targets exactly the child definition that will run. */
334
694
  export interface FanOutReviewSuite {
335
695
  readonly child: ReturnType<typeof makeFileReviewerDefinition>;
336
696
  readonly parent: ReturnType<typeof makeFanOutReviewerDefinition>;
@@ -338,68 +698,22 @@ export interface FanOutReviewSuite {
338
698
  }
339
699
 
340
700
  const makeFileReviewerDefinition = (options: FanOutInstructionOptions = {}) =>
341
- Agent.define("pr-file-reviewer", {
701
+ Agent.define("pr-review-worker", {
342
702
  input: FileReviewBrief,
343
703
  output: FileReviewReport,
344
704
  instructions: makeFileReviewerInstructions(options),
345
705
  toolkit: FileReviewToolkit,
346
706
  policy: defaultFileReviewerPolicy,
347
707
  description:
348
- "Review one bounded unit of a pull request's changeset read-only and return line-anchored findings for exactly those files.",
349
- metadata: { deploymentClass: "E", surface: "read-only" },
350
- });
351
-
352
- /** Options for one coherent fan-out suite: shared guidance plus the merge bound. */
353
- export interface FanOutSuiteOptions extends FanOutInstructionOptions {
354
- readonly maxFindings?: number | undefined;
355
- }
356
-
357
- const makeFileReviewDelegation = (child: ReturnType<typeof makeFileReviewerDefinition>) =>
358
- Subagent.define("delegate_file_review", {
359
- description: delegationDescription,
360
- target: child,
361
- parameters: FileReviewRequest,
362
- success: FileReviewUnitResult,
363
- failure: FileReviewUnitFailed,
364
- // First-party containment (SUB-033): a failed unit is model-visible
365
- // result data instead of a parent-Run-fatal error, so the coordinator
366
- // reports it honestly and keeps reviewing the other units. This retires
367
- // the former same-name shadow-Tool workaround (FRICTION #7).
368
- failureMode: "return",
369
- prepareInput: (request) =>
370
- Effect.succeed(
371
- FileReviewBrief.make({
372
- unitId: request.unitId,
373
- paths: request.paths,
374
- focus: "defects-first: correctness, security, concurrency, resources, error handling",
375
- }),
376
- ),
377
- // The explicit declassification boundary (SUB-015): exactly the bounded
378
- // findings and concerns cross to the parent. Whether findings may anchor
379
- // anywhere is decided host-side by planPublication against the real diff.
380
- projectResult: (report) =>
381
- Effect.succeed(
382
- FileReviewUnitResult.make({
383
- unitId: report.unitId,
384
- findings: report.findings,
385
- ...(report.concerns !== undefined ? { concerns: report.concerns } : {}),
386
- ...(report.fileSummaries !== undefined ? { fileSummaries: report.fileSummaries } : {}),
387
- }),
388
- ),
389
- policy: fileReviewPolicy,
708
+ "Perform one bounded discovery or independent candidate-verification pass over host-supplied pull-request evidence.",
709
+ metadata: { deploymentClass: "E", surface: "read-only", stage: "discovery-verification" },
390
710
  });
391
711
 
392
- /**
393
- * The coordinator-facing delegation Tool: the delegation's own first-party
394
- * contained Tool plus the read-only execution class (the delegated child's
395
- * whole tool surface is read-only). Effect AI resolves handlers by Tool name,
396
- * so `SubagentRuntime.layer`'s handler serves this annotated copy unchanged.
397
- */
398
712
  const delegationToolFor = (delegation: ReturnType<typeof makeFileReviewDelegation>) =>
399
713
  delegation.tool.annotate(ToolExecutionClass, "readonly");
400
714
 
401
715
  const makeFanOutReviewerDefinition = (
402
- options: FanOutSuiteOptions,
716
+ options: FanOutInstructionOptions & { readonly maxFindings?: number | undefined },
403
717
  delegation: ReturnType<typeof makeFileReviewDelegation>,
404
718
  ) =>
405
719
  Agent.define("pr-fanout-reviewer", {
@@ -409,11 +723,19 @@ const makeFanOutReviewerDefinition = (
409
723
  toolkit: Toolkit.make(ListReviewUnits, delegationToolFor(delegation)),
410
724
  policy: defaultFanOutPolicy,
411
725
  description:
412
- "Coordinate one pull-request review by fanning bounded per-unit file reviews out to delegated children and merging their findings into one structured review.",
413
- metadata: { deploymentClass: "E", surface: "read-only", delegation: "S1-attached" },
726
+ "Coordinate deterministic general/specialist discovery and independent candidate verification over bounded review units.",
727
+ metadata: {
728
+ deploymentClass: "E",
729
+ surface: "read-only",
730
+ delegation: "S1-attached",
731
+ assurance: "multi-pass",
732
+ },
414
733
  });
415
734
 
416
- /** Build one coherent fan-out suite: child, coordinator, and delegation. */
735
+ export interface FanOutSuiteOptions extends FanOutInstructionOptions {
736
+ readonly maxFindings?: number | undefined;
737
+ }
738
+
417
739
  export const makeFanOutReviewSuite = (options: FanOutSuiteOptions = {}): FanOutReviewSuite => {
418
740
  const child = makeFileReviewerDefinition({ guidance: options.guidance });
419
741
  const delegation = makeFileReviewDelegation(child);
@@ -426,29 +748,13 @@ export const makeFanOutReviewSuite = (options: FanOutSuiteOptions = {}): FanOutR
426
748
 
427
749
  const defaultSuite = makeFanOutReviewSuite();
428
750
 
429
- /** The default child Agent Definition. */
430
751
  export const FileReviewer = defaultSuite.child;
431
-
432
- /** The default coordinator Agent Definition. */
433
752
  export const FanOutReviewer = defaultSuite.parent;
434
-
435
- /** The default delegation over the default child. */
436
753
  export const fileReviewDelegation = defaultSuite.delegation;
437
-
438
- /** The default coordinator-facing delegation Tool (first-party contained mode). */
439
754
  export const DelegateFileReview = delegationToolFor(fileReviewDelegation);
440
-
441
- /** The default coordinator Toolkit. */
442
755
  export const FanOutReviewToolkit = FanOutReviewer.toolkit;
443
-
444
- /**
445
- * The contained failure family the delegation can surface as result data
446
- * (SUB-033), derived from the delegation itself so the coverage decoder can
447
- * never diverge from what the runtime actually contains.
448
- */
449
756
  export const FileReviewDelegationFailure = fileReviewDelegation.containedFailure;
450
757
 
451
- /** Runtime wiring: one delegation plus one explicit child Binding. */
452
758
  export const fanOutHandlersLayerFor =
453
759
  (delegation: ReturnType<typeof makeFileReviewDelegation>) =>
454
760
  <Provider, ModelProvides, ModelRequires>(
@@ -466,5 +772,4 @@ export const fanOutHandlersLayerFor =
466
772
  mapChildFailure: mapFileReviewChildFailure,
467
773
  });
468
774
 
469
- /** Runtime wiring over the default delegation, mirroring the leaf example. */
470
775
  export const fanOutHandlersLayer = fanOutHandlersLayerFor(fileReviewDelegation);