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

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.
@@ -10,13 +10,15 @@ import {
10
10
  import { type Tool } from "effect/unstable/ai";
11
11
 
12
12
  import {
13
- assessReviewPipeline,
13
+ assessFlatReview,
14
+ compatibilityCoverage,
15
+ fanOutInputCoverage,
14
16
  ReviewAssurance,
15
17
  ReviewCoverage,
16
18
  ReviewInputCoverage,
17
- type ReviewShape,
18
19
  } from "./coverage.ts";
19
20
  import type { ChangedFile } from "./diff.ts";
21
+ import { runFanOutReview, type FileReviewerBinding } from "./fan-out.ts";
20
22
  import { computeChangesetFingerprint } from "./fingerprint.ts";
21
23
  import { PublishedReview, ReviewPublisher } from "./github.ts";
22
24
  import { planPublication, ReviewPublicationPlan } from "./render.ts";
@@ -30,19 +32,26 @@ import {
30
32
  import {
31
33
  fromStoredConcern,
32
34
  fromStoredFinding,
35
+ MAX_STORED_UNREVIEWED_PATHS,
33
36
  ReviewExecutionContext,
34
37
  ReviewState,
35
38
  toStoredConcern,
36
39
  toStoredFinding,
37
40
  } from "./review-state.ts";
38
- import { rankAndDedupeFindings } from "./review-units.ts";
41
+ import { rankAndDedupeConcerns, rankAndDedupeFindings } from "./review-units.ts";
39
42
  import { PullRequestSource, type PullRequestMetadata } from "./source.ts";
40
43
 
41
44
  // ---------------------------------------------------------------------------
42
- // One review run, end to end: read the pull request, run the bounded agent,
43
- // validate the review against the real diff, then (optionally) publish.
44
- // Publication happens strictly AFTER the agent loop so no model turn can
45
- // observe or influence the mutation, and a failed run publishes nothing.
45
+ // One review run, end to end: read the pull request, run the bounded review
46
+ // (one flat agent, or the host-scheduled fan-out pipeline), validate the
47
+ // review against the real diff, then (optionally) publish. Publication
48
+ // happens strictly AFTER all model work so no model turn can observe or
49
+ // influence the mutation, and a failed run publishes nothing.
50
+ //
51
+ // Continuity is monotone: every completed run that can be signed advances the
52
+ // stored baseline, carrying genuinely-unsettled scope forward explicitly. A
53
+ // flaky pass therefore costs exactly its own scope on the next run — it can
54
+ // never freeze the baseline and reopen everything reviewed since.
46
55
  // ---------------------------------------------------------------------------
47
56
 
48
57
  /**
@@ -59,12 +68,9 @@ export const reviewBudgetLimits = UsageBudgetLimits.make({
59
68
  });
60
69
 
61
70
  /**
62
- * Run-level bounds for the fan-out coordinator. This budget observes only
63
- * the COORDINATOR'S own usage delegated children are bounded separately by
64
- * the delegation's `SubagentPolicy` reservation and the child definition's
65
- * own `AgentPolicy`, never silently by the parent's budget. The duration
66
- * ceiling is wider because delegation Tool Calls hold the parent turn open
67
- * while bounded children run.
71
+ * Run-level bounds for the fan-out pipeline. One budget observes EVERY child
72
+ * pass, so the ceiling covers bounded parallel discovery and verification
73
+ * plus the one-retry allowance.
68
74
  */
69
75
  export const fanOutReviewBudgetLimits = UsageBudgetLimits.make({
70
76
  maxInputTokens: 600_000,
@@ -87,23 +93,18 @@ export class ReviewRunOutcome extends Schema.Class<ReviewRunOutcome>(
87
93
  coverage: ReviewCoverage,
88
94
  /** Exact path/evidence assignment, distinct from semantic review work. */
89
95
  inputCoverage: ReviewInputCoverage,
90
- /** Settlement of configured discovery, specialist, and verification work. */
96
+ /** Settlement of scheduled discovery, specialist, and verification work. */
91
97
  assurance: ReviewAssurance,
98
+ /** Retryable scope this run could not settle; carried to the next run. */
99
+ unreviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
100
+ Schema.isMaxLength(300),
101
+ ),
92
102
  plan: ReviewPublicationPlan,
93
103
  published: Schema.optionalKey(PublishedReview),
94
- turns: Schema.Int.check(Schema.isGreaterThan(0)),
95
- /**
96
- * The run budget's observed usage. For the fan-out reviewer this observes
97
- * the COORDINATOR only — delegated children are bounded and accounted
98
- * separately by their reservations.
99
- */
104
+ /** Total settled model turns (all child passes for the fan-out pipeline). */
105
+ turns: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
106
+ /** The run budget's observed usage across the whole run. */
100
107
  usage: Schema.optionalKey(UsageTotals),
101
- /**
102
- * What `usage` observed: the whole run, or a fan-out coordinator only.
103
- * Absent when the caller declared no scope — consumers must not present
104
- * unscoped usage as whole-run totals.
105
- */
106
- usageScope: Schema.optionalKey(Schema.Literals(["run", "coordinator"])),
107
108
  reviewMode: Schema.optionalKey(Schema.Literals(["incremental", "full"])),
108
109
  reviewReason: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(1_000))),
109
110
  state: Schema.optionalKey(ReviewState),
@@ -114,7 +115,7 @@ export interface ExecuteReviewOptions {
114
115
  readonly post: boolean;
115
116
  /** Map the model's verdict onto APPROVE/REQUEST_CHANGES instead of COMMENT. */
116
117
  readonly applyVerdict: boolean;
117
- /** Run-level usage bounds; defaults to `reviewBudgetLimits`. */
118
+ /** Run-level usage bounds; defaults to the shape's packaged limits. */
118
119
  readonly limits?: UsageBudgetLimits | undefined;
119
120
  /**
120
121
  * Host-side findings bound (fail-closed backstop for the instruction-level
@@ -132,15 +133,6 @@ export interface ExecuteReviewOptions {
132
133
  readonly modelLabel?: string | undefined;
133
134
  /** Workflow-run URL rendered into the review footer. */
134
135
  readonly runUrl?: string | undefined;
135
- /**
136
- * What the run budget observes: the whole run, or a fan-out coordinator
137
- * only. Without a declared scope the footer omits usage entirely — this
138
- * generic path cannot know what a caller's binding shape observes, and an
139
- * unlabeled number would read as whole-run totals.
140
- */
141
- readonly usageScope?: "run" | "coordinator" | undefined;
142
- /** Host-owned coverage shape; defaults to the flat reviewer. */
143
- readonly reviewShape?: ReviewShape | undefined;
144
136
  }
145
137
 
146
138
  /** Build the mission one review run frames from the source's snapshot. */
@@ -173,109 +165,42 @@ export const enforceFindingsBound = (review: CodeReview, maxFindings: number): C
173
165
  const findingKey = (finding: ReviewFinding): string =>
174
166
  `${finding.path}\u0000${finding.startLine}\u0000${finding.endLine}\u0000${finding.severity}\u0000${finding.title}`;
175
167
 
176
- const severityRank: Record<ReviewConcern["severity"], number> = {
177
- blocking: 0,
178
- important: 1,
179
- nit: 2,
180
- };
181
-
182
- const rankAndDedupeConcerns = (
183
- concerns: ReadonlyArray<ReviewConcern>,
184
- ): ReadonlyArray<ReviewConcern> => {
185
- const byContent = new Map<string, ReviewConcern>();
186
- for (const concern of concerns) {
187
- const key = `${concern.title}\u0000${concern.body}`;
188
- const previous = byContent.get(key);
189
- if (
190
- previous === undefined ||
191
- severityRank[concern.severity] < severityRank[previous.severity]
192
- ) {
193
- byContent.set(key, concern);
194
- }
195
- }
196
- return [...byContent.values()]
197
- .sort((left, right) => severityRank[left.severity] - severityRank[right.severity])
198
- .slice(0, 10);
199
- };
168
+ /** One shape-specific review result, before the shared settlement tail. */
169
+ interface ReviewCore {
170
+ readonly review: CodeReview;
171
+ readonly inputCoverage: ReviewInputCoverage;
172
+ readonly assurance: ReviewAssurance;
173
+ readonly unreviewedPaths: ReadonlyArray<string>;
174
+ readonly turns: number;
175
+ }
200
176
 
201
177
  /**
202
- * Execute one review with any explicit Agent Binding whose contract is
203
- * `ReviewMission -> CodeReview` the flat reviewer or the fan-out
204
- * coordinator; the toolkit stays generic because publication only depends on
205
- * the shared output contract. The binding stays a parameter (D-027): tests
206
- * pass scripted models, hosts pass live provider bindings, and the model
207
- * Layer's requirements stay visible in this Effect's `R`.
178
+ * The shared settlement tail: carry unchanged prior scope, decide whether
179
+ * this run's continuity state can be signed, plan the exact publication, and
180
+ * (optionally) post it. Continuity requires only that the run COMPLETED with
181
+ * a trustworthy full-surface fingerprint never that every pass settled;
182
+ * unsettled scope travels inside the state instead of freezing it.
208
183
  */
209
- export const executeReview = <
210
- Instructions,
211
- Tools extends Record<string, Tool.Any>,
212
- Provider,
213
- ModelProvides,
214
- ModelRequires,
215
- >(
216
- binding: RuntimeBinding<
217
- typeof ReviewMission,
218
- typeof CodeReview,
219
- Instructions,
220
- Tools,
221
- Provider,
222
- ModelProvides,
223
- ModelRequires
224
- >,
184
+ const settleReviewRun = (
185
+ core: ReviewCore,
186
+ context: {
187
+ readonly metadata: PullRequestMetadata;
188
+ readonly files: ReadonlyArray<ChangedFile>;
189
+ readonly anchorFiles: ReadonlyArray<ChangedFile>;
190
+ readonly fingerprint: string | undefined;
191
+ readonly usage: UsageTotals | undefined;
192
+ },
225
193
  options: ExecuteReviewOptions,
226
194
  ) =>
227
195
  Effect.gen(function* () {
228
- const source = yield* PullRequestSource;
229
- const metadata = yield* source.metadata;
230
- const files = yield* source.changedFiles;
231
- const anchorFiles = yield* source.anchorFiles;
196
+ const { metadata, files, anchorFiles, fingerprint, usage } = context;
232
197
  const executionContext = Option.getOrUndefined(
233
198
  yield* Effect.serviceOption(ReviewExecutionContext),
234
199
  );
235
- const mission = buildReviewMission(metadata, files);
236
- const fullMission = buildReviewMission(metadata, anchorFiles);
237
- const fingerprint =
238
- options.signature === undefined
239
- ? undefined
240
- : yield* computeChangesetFingerprint(anchorFiles, options.signature(fullMission));
241
-
242
- const budget = yield* makeUsageBudget(options.limits ?? reviewBudgetLimits);
243
- const detached = yield* AgentRuntime.start(binding, mission, {
244
- budget: toRunBudgetHook(budget),
245
- estimateCostMicrousd: () => Effect.succeed(500),
246
- });
247
- const result = yield* detached.await;
248
- const events = yield* detached.events;
249
-
250
- // The engine validated the terminal JSON against the output schema; this
251
- // decode recovers the typed value on this side of the generic boundary.
252
- const decoded = yield* Schema.decodeUnknownEffect(CodeReview)(result.output);
200
+ const review = enforceFindingsBound(core.review, clampMaxFindings(options.maxFindings));
201
+ const { inputCoverage, assurance } = core;
202
+ const unreviewedPaths = [...new Set(core.unreviewedPaths)].sort();
253
203
  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.
265
- const verifiedReview =
266
- options.reviewShape !== "fan-out"
267
- ? decoded
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
- });
277
- const review = enforceFindingsBound(verifiedReview, clampMaxFindings(options.maxFindings));
278
- const usage = yield* budget.snapshot;
279
204
  const affectedPaths = new Set(
280
205
  executionContext?.affectedPaths ??
281
206
  files.flatMap((file) =>
@@ -315,16 +240,25 @@ export const executeReview = <
315
240
  const key = `${concern.title}\u0000${concern.body}`;
316
241
  return activeConcernKeys.has(key) && !currentConcernKeys.has(key);
317
242
  });
318
- const { assurance, coverage, inputCoverage } = pipeline;
243
+ const settled =
244
+ inputCoverage.status === "complete" &&
245
+ assurance.status !== "incomplete" &&
246
+ unreviewedPaths.length === 0;
247
+ // The fingerprint marker is standalone skip authority for fingerprint-only
248
+ // harnesses, so it is embedded only for a fully settled run.
249
+ const skipFingerprint = settled ? fingerprint : undefined;
250
+ const carriedScopeFits = unreviewedPaths.length <= MAX_STORED_UNREVIEWED_PATHS;
319
251
  const stateCandidate =
320
252
  executionContext !== undefined &&
321
- inputCoverage.status === "complete" &&
322
- assurance.status === "settled" &&
323
253
  fingerprint !== undefined &&
324
254
  metadata.baseSha !== undefined &&
255
+ // The fingerprint and stored baseline describe the FULL pull-request
256
+ // surface; a truncated anchor surface cannot make either claim.
257
+ anchorFiles.length >= metadata.totalChangedFiles &&
258
+ carriedScopeFits &&
325
259
  executionContext.stateAuthenticator?.status === "available"
326
260
  ? ReviewState.make({
327
- version: 1,
261
+ version: 2,
328
262
  repository: metadata.repository,
329
263
  pullRequestNumber: metadata.number,
330
264
  baseRef: metadata.baseRef,
@@ -336,6 +270,8 @@ export const executeReview = <
336
270
  reviewedPathCount: anchorFiles.length,
337
271
  unresolvedFindings: activeFindings.map(toStoredFinding),
338
272
  unresolvedConcerns: activeConcerns.map(toStoredConcern),
273
+ unreviewedPaths,
274
+ settled,
339
275
  lastReviewMode: executionContext.mode,
340
276
  })
341
277
  : undefined;
@@ -345,12 +281,12 @@ export const executeReview = <
345
281
  state: undefined,
346
282
  marker: undefined,
347
283
  notice:
348
- executionContext?.stateAuthenticator?.status === "unavailable" &&
349
- inputCoverage.status === "complete" &&
350
- assurance.status === "settled"
351
- ? (executionContext.stateAuthenticator.unavailableReason ??
352
- "authenticated continuity state is unavailable")
353
- : undefined,
284
+ executionContext !== undefined && !carriedScopeFits
285
+ ? `carried unreviewed scope (${unreviewedPaths.length} paths) exceeded the ${MAX_STORED_UNREVIEWED_PATHS}-path continuity bound`
286
+ : executionContext?.stateAuthenticator?.status === "unavailable"
287
+ ? (executionContext.stateAuthenticator.unavailableReason ??
288
+ "authenticated continuity state is unavailable")
289
+ : undefined,
354
290
  }
355
291
  : yield* executionContext.stateAuthenticator.render(stateCandidate).pipe(
356
292
  Effect.match({
@@ -374,14 +310,10 @@ export const executeReview = <
374
310
  modelLabel: options.modelLabel,
375
311
  runUrl: options.runUrl,
376
312
  usage,
377
- usageScope: options.usageScope,
378
- fingerprint:
379
- inputCoverage.status === "complete" && assurance.status === "settled"
380
- ? fingerprint
381
- : undefined,
382
- coverage,
313
+ fingerprint: skipFingerprint,
383
314
  inputCoverage,
384
315
  assurance,
316
+ unreviewedPaths,
385
317
  carriedFindings,
386
318
  carriedConcerns,
387
319
  reviewMode: executionContext?.mode,
@@ -392,44 +324,149 @@ export const executeReview = <
392
324
  stateMarker: continuity.marker,
393
325
  stateNotice: continuity.notice,
394
326
  });
395
-
396
- const scope =
397
- options.usageScope === undefined ? {} : ({ usageScope: options.usageScope } as const);
398
- if (!options.post) {
399
- return ReviewRunOutcome.make({
400
- review,
401
- activeFindings,
402
- activeConcerns,
403
- coverage,
404
- inputCoverage,
405
- assurance,
406
- plan,
407
- turns: result.turns,
408
- usage,
409
- ...scope,
410
- ...(executionContext === undefined
411
- ? {}
412
- : { reviewMode: executionContext.mode, reviewReason: executionContext.reason }),
413
- ...(continuity.state === undefined ? {} : { state: continuity.state }),
414
- });
415
- }
416
- const publisher = yield* ReviewPublisher;
417
- const published = yield* publisher.publish(plan);
418
- return ReviewRunOutcome.make({
327
+ const shared = {
419
328
  review,
420
329
  activeFindings,
421
330
  activeConcerns,
422
- coverage,
331
+ coverage: compatibilityCoverage(inputCoverage, assurance),
423
332
  inputCoverage,
424
333
  assurance,
334
+ unreviewedPaths,
425
335
  plan,
426
- published,
427
- turns: result.turns,
428
- usage,
429
- ...scope,
336
+ turns: core.turns,
337
+ ...(usage === undefined ? {} : { usage }),
430
338
  ...(executionContext === undefined
431
339
  ? {}
432
340
  : { reviewMode: executionContext.mode, reviewReason: executionContext.reason }),
433
341
  ...(continuity.state === undefined ? {} : { state: continuity.state }),
342
+ };
343
+ if (!options.post) return ReviewRunOutcome.make(shared);
344
+ const publisher = yield* ReviewPublisher;
345
+ const published = yield* publisher.publish(plan);
346
+ return ReviewRunOutcome.make({ ...shared, published });
347
+ });
348
+
349
+ /**
350
+ * Execute one flat review with any explicit Agent Binding whose contract is
351
+ * `ReviewMission -> CodeReview`. The binding stays a parameter (D-027): tests
352
+ * pass scripted models, hosts pass live provider bindings, and the model
353
+ * Layer's requirements stay visible in this Effect's `R`.
354
+ */
355
+ export const executeReview = <
356
+ Instructions,
357
+ Tools extends Record<string, Tool.Any>,
358
+ Provider,
359
+ ModelProvides,
360
+ ModelRequires,
361
+ >(
362
+ binding: RuntimeBinding<
363
+ typeof ReviewMission,
364
+ typeof CodeReview,
365
+ Instructions,
366
+ Tools,
367
+ Provider,
368
+ ModelProvides,
369
+ ModelRequires
370
+ >,
371
+ options: ExecuteReviewOptions,
372
+ ) =>
373
+ Effect.gen(function* () {
374
+ const source = yield* PullRequestSource;
375
+ const metadata = yield* source.metadata;
376
+ const files = yield* source.changedFiles;
377
+ const anchorFiles = yield* source.anchorFiles;
378
+ const executionContext = Option.getOrUndefined(
379
+ yield* Effect.serviceOption(ReviewExecutionContext),
380
+ );
381
+ const mission = buildReviewMission(metadata, files);
382
+ const fullMission = buildReviewMission(metadata, anchorFiles);
383
+ const fingerprint =
384
+ options.signature === undefined
385
+ ? undefined
386
+ : yield* computeChangesetFingerprint(anchorFiles, options.signature(fullMission));
387
+
388
+ const budget = yield* makeUsageBudget(options.limits ?? reviewBudgetLimits);
389
+ const detached = yield* AgentRuntime.start(binding, mission, {
390
+ budget: toRunBudgetHook(budget),
391
+ estimateCostMicrousd: () => Effect.succeed(500),
392
+ });
393
+ const result = yield* detached.await;
394
+ const events = yield* detached.events;
395
+
396
+ // The engine validated the terminal JSON against the output schema; this
397
+ // decode recovers the typed value on this side of the generic boundary.
398
+ const review = yield* Schema.decodeUnknownEffect(CodeReview)(result.output);
399
+ const assessment = assessFlatReview({
400
+ files,
401
+ totalFiles: executionContext?.totalFiles ?? metadata.totalChangedFiles,
402
+ anchorFiles,
403
+ totalAnchorFiles: metadata.totalChangedFiles,
404
+ events,
405
+ });
406
+ const usage = yield* budget.snapshot;
407
+ return yield* settleReviewRun(
408
+ {
409
+ review,
410
+ inputCoverage: assessment.inputCoverage,
411
+ assurance: assessment.assurance,
412
+ unreviewedPaths: assessment.unreviewedPaths,
413
+ turns: result.turns,
414
+ },
415
+ { metadata, files, anchorFiles, fingerprint, usage },
416
+ options,
417
+ );
418
+ });
419
+
420
+ /**
421
+ * Execute one host-scheduled fan-out review: deterministic planning,
422
+ * independent discovery and verification child passes with bounded retries,
423
+ * and a host-composed review from verifier-confirmed candidates only. One
424
+ * budget observes every child pass, so the reported usage is whole-run.
425
+ */
426
+ export const executeFanOutReview = <Provider, ModelProvides, ModelRequires>(
427
+ binding: FileReviewerBinding<Provider, ModelProvides, ModelRequires>,
428
+ options: ExecuteReviewOptions,
429
+ ) =>
430
+ Effect.gen(function* () {
431
+ const source = yield* PullRequestSource;
432
+ const metadata = yield* source.metadata;
433
+ const files = yield* source.changedFiles;
434
+ const anchorFiles = yield* source.anchorFiles;
435
+ const executionContext = Option.getOrUndefined(
436
+ yield* Effect.serviceOption(ReviewExecutionContext),
437
+ );
438
+ const fullMission = buildReviewMission(metadata, anchorFiles);
439
+ const fingerprint =
440
+ options.signature === undefined
441
+ ? undefined
442
+ : yield* computeChangesetFingerprint(anchorFiles, options.signature(fullMission));
443
+
444
+ const budget = yield* makeUsageBudget(options.limits ?? fanOutReviewBudgetLimits);
445
+ const totalFiles = executionContext?.totalFiles ?? metadata.totalChangedFiles;
446
+ const pipeline = yield* runFanOutReview(binding, {
447
+ files,
448
+ anchorFiles,
449
+ totalChangedFiles: totalFiles,
450
+ maxFindings: options.maxFindings,
451
+ budget: toRunBudgetHook(budget),
434
452
  });
453
+ const inputCoverage = fanOutInputCoverage({
454
+ plan: pipeline.plan,
455
+ files,
456
+ totalFiles,
457
+ anchorFiles,
458
+ totalAnchorFiles: metadata.totalChangedFiles,
459
+ });
460
+ const usage = yield* budget.snapshot;
461
+ return yield* settleReviewRun(
462
+ {
463
+ review: pipeline.review,
464
+ inputCoverage,
465
+ assurance: pipeline.assurance,
466
+ unreviewedPaths: pipeline.unreviewedPaths,
467
+ turns: pipeline.turns,
468
+ },
469
+ { metadata, files, anchorFiles, fingerprint, usage },
470
+ options,
471
+ );
435
472
  });
@@ -1,6 +1,6 @@
1
1
  import { Context, Effect, Schema } from "effect";
2
2
 
3
- import { ChangedFile } from "./diff.ts";
3
+ import type { ChangedFile } from "./diff.ts";
4
4
 
5
5
  // ---------------------------------------------------------------------------
6
6
  // The pull-request source port: everything the review tools may observe about