@effect-agent/pr-review 0.1.0-beta.24 → 0.1.0-beta.26

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.
@@ -0,0 +1,415 @@
1
+ import { Context, DateTime, Effect, Layer, Schema } from "effect";
2
+
3
+ import { INLINE_FINDING_TITLE_PATTERN } from "./retirement.ts";
4
+ import {
5
+ adjudicationIdentity,
6
+ MAX_STORED_ADJUDICATIONS,
7
+ StoredAdjudication,
8
+ type AdjudicationDisposition,
9
+ type StoredReviewFinding,
10
+ } from "./review-state.ts";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Maintainer adjudication. GitHub reads stay behind ReviewAdjudicationHost;
14
+ // this module owns only the deterministic verb grammar, fail-closed
15
+ // authorization, later-wins resolution, and prompt-context rendering. Only an
16
+ // explicit, authorized `/adjudicate` verb adjudicates — free-text rebuttals
17
+ // are deliberately never parsed, because only an explicit verb is auditable
18
+ // and fail-closed (model output and third-party comments are untrusted
19
+ // input, AGENTS.md rule 11).
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const PositiveLine = Schema.Int.check(Schema.isGreaterThan(0));
23
+
24
+ /** Maximum authorized command candidates retained for one inline thread. */
25
+ export const MAX_THREAD_ADJUDICATION_COMMANDS = 100;
26
+
27
+ /** One reply or top-level comment observed through the adjudication host. */
28
+ export class AdjudicationComment extends Schema.Class<AdjudicationComment>(
29
+ "@effect-agent/pr-review/AdjudicationComment",
30
+ )({
31
+ body: Schema.String.check(Schema.isMaxLength(65_536)),
32
+ /** GitHub's author_association for the comment author, verbatim. */
33
+ authorAssociation: Schema.String.check(Schema.isMaxLength(40)),
34
+ authorLogin: Schema.NonEmptyString.check(Schema.isMaxLength(100)),
35
+ /** Creation time; a comment without one loses every later-wins tie. */
36
+ createdAt: Schema.NullOr(Schema.DateTimeUtc),
37
+ /** Stable zero-based order in the source listing, before thread grouping. */
38
+ sourceOrder: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
39
+ }) {}
40
+
41
+ /** One of the action's own inline finding threads, replies in creation order. */
42
+ export class AdjudicableThread extends Schema.Class<AdjudicableThread>(
43
+ "@effect-agent/pr-review/AdjudicableThread",
44
+ )({
45
+ path: Schema.NonEmptyString.check(Schema.isMaxLength(500)),
46
+ startLine: Schema.NullOr(PositiveLine),
47
+ endLine: Schema.NullOr(PositiveLine),
48
+ /** The root comment's body; its first line carries the finding title. */
49
+ rootBody: Schema.String.check(Schema.isMaxLength(65_536)),
50
+ replies: Schema.Array(AdjudicationComment).check(
51
+ Schema.isMaxLength(MAX_THREAD_ADJUDICATION_COMMANDS),
52
+ ),
53
+ }) {}
54
+
55
+ /** A GitHub adjudication read failed. */
56
+ export class ReviewAdjudicationFailure extends Schema.TaggedError<ReviewAdjudicationFailure>()(
57
+ "ReviewAdjudicationFailure",
58
+ {
59
+ operation: Schema.String,
60
+ reason: Schema.String,
61
+ },
62
+ ) {
63
+ override get message() {
64
+ return `Review adjudication operation '${this.operation}' failed: ${this.reason}`;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Host-side GitHub reads used by adjudication. Domain code never reaches into
70
+ * REST directly, and deterministic tests substitute this port. Both listings
71
+ * return comments in creation order.
72
+ */
73
+ export class ReviewAdjudicationHost extends Context.Service<
74
+ ReviewAdjudicationHost,
75
+ {
76
+ /** This action's own inline finding threads with their replies. */
77
+ readonly listFindingThreads: Effect.Effect<
78
+ ReadonlyArray<AdjudicableThread>,
79
+ ReviewAdjudicationFailure
80
+ >;
81
+ /** Top-level pull-request conversation comments. */
82
+ readonly listIssueComments: Effect.Effect<
83
+ ReadonlyArray<AdjudicationComment>,
84
+ ReviewAdjudicationFailure
85
+ >;
86
+ }
87
+ >()("@effect-agent/pr-review/ReviewAdjudicationHost") {}
88
+
89
+ /** Explicit program-edge adapter for runs that intentionally perform no host reads. */
90
+ export const noReviewAdjudicationHost = ReviewAdjudicationHost.of({
91
+ listFindingThreads: Effect.succeed([]),
92
+ listIssueComments: Effect.succeed([]),
93
+ });
94
+
95
+ /** Layer form of {@link noReviewAdjudicationHost}. */
96
+ export const noReviewAdjudicationHostLayer =
97
+ Layer.succeed(ReviewAdjudicationHost)(noReviewAdjudicationHost);
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Verb grammar. A body whose first line starts with `/adjudicate` is a
101
+ // command; a command that fails the grammar is malformed and ignored rather
102
+ // than guessed at. Fail-closed authorization: only OWNER, MEMBER, and
103
+ // COLLABORATOR authors may adjudicate.
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /** author_associations allowed to adjudicate; everything else is ignored. */
107
+ export const AUTHORIZED_ADJUDICATION_ASSOCIATIONS: ReadonlySet<string> = new Set([
108
+ "OWNER",
109
+ "MEMBER",
110
+ "COLLABORATOR",
111
+ ]);
112
+
113
+ const AdjudicationDispositionSchema = Schema.Literals(["accepted-risk", "refuted", "obsolete"]);
114
+
115
+ const THREAD_COMMAND_PATTERN = /^\/adjudicate[ \t]+([a-z-]+)[ \t]*(?::[ \t]*(.*\S))?[ \t]*$/;
116
+ const ISSUE_COMMAND_PATTERN =
117
+ /^\/adjudicate[ \t]+([a-z-]+)[ \t]+"([^"\n]+)"[ \t]*(?::[ \t]*(.*\S))?[ \t]*$/;
118
+
119
+ export interface ParsedAdjudicationCommand {
120
+ readonly disposition: AdjudicationDisposition;
121
+ /** Present only for the issue-comment grammar's quoted target title. */
122
+ readonly title?: string | undefined;
123
+ readonly reason?: string | undefined;
124
+ }
125
+
126
+ const firstLine = (body: string): string => (body.split("\n", 1)[0] ?? "").trim();
127
+
128
+ const boundedReason = (raw: string | undefined): string | undefined => {
129
+ if (raw === undefined) return undefined;
130
+ const trimmed = raw.trim().slice(0, 300);
131
+ return trimmed.length === 0 ? undefined : trimmed;
132
+ };
133
+
134
+ /**
135
+ * Parse one inline-thread reply: `/adjudicate <disposition>(: <reason>)?`.
136
+ * The thread itself names the target identity. Returns undefined for a
137
+ * non-command body and "malformed" for a command that fails the grammar.
138
+ */
139
+ export const parseThreadAdjudication = (
140
+ body: string,
141
+ ): ParsedAdjudicationCommand | "malformed" | undefined => {
142
+ const line = firstLine(body);
143
+ if (!line.startsWith("/adjudicate")) return undefined;
144
+ const match = THREAD_COMMAND_PATTERN.exec(line);
145
+ const disposition = match?.[1];
146
+ if (disposition === undefined || !Schema.is(AdjudicationDispositionSchema)(disposition)) {
147
+ return "malformed";
148
+ }
149
+ return { disposition, reason: boundedReason(match?.[2]) };
150
+ };
151
+
152
+ /**
153
+ * Parse one top-level PR comment:
154
+ * `/adjudicate <disposition> "<exact title>"(: <reason>)?`. The quoted title
155
+ * is required because the conversation names no finding thread; it targets
156
+ * the title-alone identity of an unanchored concern.
157
+ */
158
+ export const parseIssueAdjudication = (
159
+ body: string,
160
+ ): ParsedAdjudicationCommand | "malformed" | undefined => {
161
+ const line = firstLine(body);
162
+ if (!line.startsWith("/adjudicate")) return undefined;
163
+ const match = ISSUE_COMMAND_PATTERN.exec(line);
164
+ const disposition = match?.[1];
165
+ const title = match?.[2];
166
+ if (
167
+ disposition === undefined ||
168
+ !Schema.is(AdjudicationDispositionSchema)(disposition) ||
169
+ title === undefined ||
170
+ title.length > 120
171
+ ) {
172
+ return "malformed";
173
+ }
174
+ return { disposition, title, reason: boundedReason(match?.[3]) };
175
+ };
176
+
177
+ /** The finding identity an inline thread names, or undefined when unparsable. */
178
+ export const threadFindingTarget = (
179
+ thread: AdjudicableThread,
180
+ ):
181
+ | {
182
+ readonly path: string;
183
+ readonly startLine: number;
184
+ readonly endLine: number;
185
+ readonly title: string;
186
+ }
187
+ | undefined => {
188
+ if (thread.startLine === null || thread.endLine === null) return undefined;
189
+ const title = INLINE_FINDING_TITLE_PATTERN.exec(firstLine(thread.rootBody))?.[1];
190
+ if (title === undefined || title.length > 120) return undefined;
191
+ return {
192
+ path: thread.path,
193
+ startLine: thread.startLine,
194
+ endLine: thread.endLine,
195
+ title,
196
+ };
197
+ };
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Deterministic derivation: authorization, later-wins, bounded storage.
201
+ // ---------------------------------------------------------------------------
202
+
203
+ interface AdjudicationCandidate {
204
+ readonly adjudication: StoredAdjudication;
205
+ readonly epochMillis: number;
206
+ readonly sourceOrder: number;
207
+ }
208
+
209
+ export interface DerivedAdjudications {
210
+ readonly adjudications: ReadonlyArray<StoredAdjudication>;
211
+ /** Commands ignored fail-closed: unauthorized authors and malformed bodies. */
212
+ readonly ignored: ReadonlyArray<string>;
213
+ /** Later-wins winners dropped oldest-first at the storage bound. */
214
+ readonly droppedOldest: number;
215
+ }
216
+
217
+ /**
218
+ * Derive the standing adjudications from the host's listings. Every command
219
+ * is screened fail-closed (authorization, grammar, a parsable target); later
220
+ * adjudications of the same identity win by comment creation order; the
221
+ * result is capped at the ReviewState bound dropping the oldest winners.
222
+ */
223
+ export const deriveAdjudications = (input: {
224
+ readonly threads: ReadonlyArray<AdjudicableThread>;
225
+ readonly issueComments: ReadonlyArray<AdjudicationComment>;
226
+ }): DerivedAdjudications => {
227
+ const candidates: Array<AdjudicationCandidate> = [];
228
+ const ignored: Array<string> = [];
229
+ const admit = (
230
+ comment: AdjudicationComment,
231
+ command: ParsedAdjudicationCommand,
232
+ target: {
233
+ readonly path?: string | undefined;
234
+ readonly startLine?: number | undefined;
235
+ readonly endLine?: number | undefined;
236
+ readonly title: string;
237
+ },
238
+ ): void => {
239
+ candidates.push({
240
+ adjudication: StoredAdjudication.make({
241
+ ...(target.path === undefined ? {} : { path: target.path }),
242
+ ...(target.startLine === undefined ? {} : { startLine: target.startLine }),
243
+ ...(target.endLine === undefined ? {} : { endLine: target.endLine }),
244
+ title: target.title,
245
+ disposition: command.disposition,
246
+ ...(command.reason === undefined ? {} : { reason: command.reason }),
247
+ actor: comment.authorLogin,
248
+ }),
249
+ epochMillis: comment.createdAt === null ? -1 : DateTime.toEpochMillis(comment.createdAt),
250
+ sourceOrder: comment.sourceOrder,
251
+ });
252
+ };
253
+ const authorized = (comment: AdjudicationComment, surface: string): boolean => {
254
+ if (AUTHORIZED_ADJUDICATION_ASSOCIATIONS.has(comment.authorAssociation)) return true;
255
+ ignored.push(
256
+ `${surface}: unauthorized /adjudicate from @${comment.authorLogin} (${comment.authorAssociation})`,
257
+ );
258
+ return false;
259
+ };
260
+
261
+ for (const thread of input.threads) {
262
+ const target = threadFindingTarget(thread);
263
+ for (const reply of thread.replies) {
264
+ const command = parseThreadAdjudication(reply.body);
265
+ if (command === undefined) continue;
266
+ const surface = `inline thread ${thread.path}`;
267
+ if (command === "malformed") {
268
+ ignored.push(`${surface}: malformed /adjudicate command from @${reply.authorLogin}`);
269
+ continue;
270
+ }
271
+ if (!authorized(reply, surface)) continue;
272
+ if (target === undefined) {
273
+ ignored.push(`${surface}: thread root names no parsable finding title`);
274
+ continue;
275
+ }
276
+ admit(reply, command, target);
277
+ }
278
+ }
279
+ for (const comment of input.issueComments) {
280
+ const command = parseIssueAdjudication(comment.body);
281
+ if (command === undefined) continue;
282
+ const surface = "pull-request conversation";
283
+ if (command === "malformed") {
284
+ ignored.push(`${surface}: malformed /adjudicate command from @${comment.authorLogin}`);
285
+ continue;
286
+ }
287
+ if (!authorized(comment, surface)) continue;
288
+ if (command.title === undefined) {
289
+ ignored.push(`${surface}: /adjudicate without a quoted target title`);
290
+ continue;
291
+ }
292
+ admit(comment, command, { title: command.title });
293
+ }
294
+
295
+ const byIdentity = new Map<string, AdjudicationCandidate>();
296
+ const ordered = [...candidates].sort(
297
+ (left, right) => left.epochMillis - right.epochMillis || left.sourceOrder - right.sourceOrder,
298
+ );
299
+ for (const candidate of ordered) {
300
+ const identity = adjudicationIdentity(candidate.adjudication);
301
+ // Delete-then-set so a later adjudication also refreshes its recency for
302
+ // the oldest-first drop below.
303
+ byIdentity.delete(identity);
304
+ byIdentity.set(identity, candidate);
305
+ }
306
+ const winners = [...byIdentity.values()];
307
+ const droppedOldest = Math.max(0, winners.length - MAX_STORED_ADJUDICATIONS);
308
+ return {
309
+ adjudications: winners.slice(droppedOldest).map((candidate) => candidate.adjudication),
310
+ ignored,
311
+ droppedOldest,
312
+ };
313
+ };
314
+
315
+ /** Later-wins merge of stored prior adjudications with freshly derived ones. */
316
+ export const mergeAdjudications = (
317
+ prior: ReadonlyArray<StoredAdjudication>,
318
+ fresh: ReadonlyArray<StoredAdjudication>,
319
+ ): ReadonlyArray<StoredAdjudication> => {
320
+ const byIdentity = new Map<string, StoredAdjudication>();
321
+ for (const adjudication of [...prior, ...fresh]) {
322
+ const identity = adjudicationIdentity(adjudication);
323
+ byIdentity.delete(identity);
324
+ byIdentity.set(identity, adjudication);
325
+ }
326
+ const merged = [...byIdentity.values()];
327
+ return merged.slice(Math.max(0, merged.length - MAX_STORED_ADJUDICATIONS));
328
+ };
329
+
330
+ /**
331
+ * Collect the standing maintainer adjudications: freshly derived through the
332
+ * host, merged later-wins over the prior state's stored set. The host is a
333
+ * visible Effect requirement; program edges that intentionally perform no
334
+ * reads provide {@link noReviewAdjudicationHost}. Fail-open — any listing
335
+ * fault keeps the complete prior set and never fails the review, because NOT
336
+ * suppressing a finding is the conservative direction.
337
+ */
338
+ export const collectReviewAdjudications = Effect.fn("collectReviewAdjudications")(function* (
339
+ prior: ReadonlyArray<StoredAdjudication>,
340
+ ) {
341
+ const host = yield* ReviewAdjudicationHost;
342
+ const listings = yield* Effect.all({
343
+ threads: host.listFindingThreads,
344
+ issueComments: host.listIssueComments,
345
+ }).pipe(
346
+ Effect.catch((error) =>
347
+ Effect.logWarning(
348
+ `Could not collect adjudications from '${error.operation}': ${error.reason}; retaining stored adjudications unchanged.`,
349
+ ).pipe(Effect.as(undefined)),
350
+ ),
351
+ );
352
+ if (listings === undefined) return prior;
353
+ const derived = deriveAdjudications({
354
+ threads: listings.threads,
355
+ issueComments: listings.issueComments,
356
+ });
357
+ for (const note of derived.ignored) {
358
+ yield* Effect.logDebug(`Ignored adjudication command — ${note}`);
359
+ }
360
+ if (derived.droppedOldest > 0) {
361
+ yield* Effect.logWarning(
362
+ `Dropped ${derived.droppedOldest} oldest adjudication(s) over the ${MAX_STORED_ADJUDICATIONS}-entry bound.`,
363
+ );
364
+ }
365
+ return mergeAdjudications(prior, derived.adjudications);
366
+ });
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Prompt-context rendering: deterministic bounded lines the reviewer sees.
370
+ // ---------------------------------------------------------------------------
371
+
372
+ const lineRange = (startLine: number, endLine: number): string =>
373
+ `${startLine}${endLine === startLine ? "" : `-${endLine}`}`;
374
+
375
+ /** One adjudication as a bounded reviewer-prompt context line. */
376
+ export const renderAdjudicationContextLine = (adjudication: StoredAdjudication): string => {
377
+ const location =
378
+ adjudication.path !== undefined &&
379
+ adjudication.startLine !== undefined &&
380
+ adjudication.endLine !== undefined
381
+ ? `${adjudication.path}:${lineRange(adjudication.startLine, adjudication.endLine)}`
382
+ : "(unanchored)";
383
+ const reason = adjudication.reason === undefined ? "" : `: ${adjudication.reason}`;
384
+ return `${location} "${adjudication.title}" — ${adjudication.disposition} by @${adjudication.actor}${reason}`;
385
+ };
386
+
387
+ /** One prior-round finding as a bounded reviewer-prompt context line. */
388
+ export const renderPriorFindingContextLine = (finding: StoredReviewFinding): string =>
389
+ `${finding.path}:${lineRange(finding.startLine, finding.endLine)} [${finding.severity}] "${finding.title}" — ${finding.body.slice(0, 400)}`;
390
+
391
+ /** Prior-review context threaded into fan-out discovery briefs, per path. */
392
+ export interface PriorReviewContext {
393
+ /** Adjudicated identities; path-free entries apply to every unit. */
394
+ readonly adjudicated: ReadonlyArray<{
395
+ readonly path: string | undefined;
396
+ readonly line: string;
397
+ }>;
398
+ /** Prior-round findings whose paths are being re-reviewed. */
399
+ readonly priorFindings: ReadonlyArray<{ readonly path: string; readonly line: string }>;
400
+ }
401
+
402
+ /** Build the fan-out prior-review context from the resolved continuity data. */
403
+ export const buildPriorReviewContext = (
404
+ adjudications: ReadonlyArray<StoredAdjudication>,
405
+ priorFindingsOnScope: ReadonlyArray<StoredReviewFinding>,
406
+ ): PriorReviewContext => ({
407
+ adjudicated: adjudications.map((adjudication) => ({
408
+ path: adjudication.path,
409
+ line: renderAdjudicationContextLine(adjudication),
410
+ })),
411
+ priorFindings: priorFindingsOnScope.map((finding) => ({
412
+ path: finding.path,
413
+ line: renderPriorFindingContextLine(finding),
414
+ })),
415
+ });
@@ -19,37 +19,6 @@ import type { ReviewUnitPlan } from "./review-units.ts";
19
19
  // results; only the flat reviewer is assessed from its Run event trace here.
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
- export class FailedReviewUnit extends Schema.Class<FailedReviewUnit>(
23
- "@effect-agent/pr-review/FailedReviewUnit",
24
- )({
25
- unitId: Schema.NonEmptyString.check(Schema.isMaxLength(32)),
26
- errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
27
- }) {}
28
-
29
- /**
30
- * Compatibility diagnostic retained for callers that consumed the original
31
- * `coverage` field. New UI and state decisions use ReviewInputCoverage and
32
- * ReviewAssurance directly.
33
- */
34
- export class ReviewCoverage extends Schema.Class<ReviewCoverage>(
35
- "@effect-agent/pr-review/ReviewCoverage",
36
- )({
37
- status: Schema.Literals(["complete", "incomplete"]),
38
- requiredPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
39
- Schema.isMaxLength(300),
40
- ),
41
- reviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
42
- Schema.isMaxLength(300),
43
- ),
44
- unreviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
45
- Schema.isMaxLength(300),
46
- ),
47
- failedUnits: Schema.Array(FailedReviewUnit).check(Schema.isMaxLength(8)),
48
- reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
49
- Schema.isMaxLength(32),
50
- ),
51
- }) {}
52
-
53
22
  export class ReviewInputCoverage extends Schema.Class<ReviewInputCoverage>(
54
23
  "@effect-agent/pr-review/ReviewInputCoverage",
55
24
  )({
@@ -159,6 +128,47 @@ export const boundedListReason = (label: string, values: Iterable<string>): stri
159
128
  return rendered;
160
129
  };
161
130
 
131
+ export interface CarriedScope {
132
+ /** Carried paths a retry can actually settle (failed passes, overflow). */
133
+ readonly retryablePaths: ReadonlyArray<string>;
134
+ /** Carried paths no retry can settle (binaries, oversized files). */
135
+ readonly undiffablePaths: ReadonlyArray<string>;
136
+ /** Whether any incompleteness beyond the undiffable files exists. */
137
+ readonly retryableGap: boolean;
138
+ }
139
+
140
+ /**
141
+ * Split carried scope into paths a retry can settle and paths it never can.
142
+ * Undiffable files are a property of the pull request, not a transient
143
+ * reviewer-side failure: gate reasons and rendered callouts must never promise
144
+ * they are "retried automatically" — the honest instruction is to remove them
145
+ * from the pull request or exclude them with ignore globs.
146
+ */
147
+ export const splitCarriedScope = (input: {
148
+ readonly inputCoverage?: ReviewInputCoverage | undefined;
149
+ readonly assurance?: ReviewAssurance | undefined;
150
+ readonly unreviewedPaths?: ReadonlyArray<string> | undefined;
151
+ }): CarriedScope => {
152
+ const undiffable = new Set(input.inputCoverage?.undiffablePaths ?? []);
153
+ const retryablePaths = (input.unreviewedPaths ?? []).filter((path) => !undiffable.has(path));
154
+ const undiffablePaths = sortedUnique(undiffable);
155
+ // Every non-undiffable coverage gap (range truncation, capacity overflow,
156
+ // truncated or missing evidence, anchor surface) contributes its own reason
157
+ // line, so a lone reason alongside undiffable paths means the undiffable
158
+ // files are the entire gap.
159
+ const coverageGapBeyondUndiffable =
160
+ input.inputCoverage?.status === "incomplete" &&
161
+ input.inputCoverage.reasons.length > (undiffablePaths.length > 0 ? 1 : 0);
162
+ return {
163
+ retryablePaths,
164
+ undiffablePaths,
165
+ retryableGap:
166
+ input.assurance?.status === "incomplete" ||
167
+ retryablePaths.length > 0 ||
168
+ coverageGapBeyondUndiffable,
169
+ };
170
+ };
171
+
162
172
  const anchorSurfaceAdjusted = (
163
173
  inputCoverage: ReviewInputCoverage,
164
174
  anchorFiles: ReadonlyArray<ChangedFile>,
@@ -345,32 +355,3 @@ export const fanOutInputCoverage = (input: {
345
355
  input.totalAnchorFiles,
346
356
  );
347
357
  };
348
-
349
- /** Compatibility aggregate over the two precise claims. */
350
- export const compatibilityCoverage = (
351
- inputCoverage: ReviewInputCoverage,
352
- assurance: ReviewAssurance,
353
- ): ReviewCoverage => {
354
- const assuranceIncomplete = assurance.status === "incomplete";
355
- const failedUnits = new Map<string, FailedReviewUnit>();
356
- for (const pass of assurance.failedPasses) {
357
- const unitId = pass.workId.slice(0, "unit-000".length);
358
- if (!failedUnits.has(unitId)) {
359
- failedUnits.set(
360
- unitId,
361
- FailedReviewUnit.make({ unitId, errorTag: `${pass.stage}:${pass.errorTag}` }),
362
- );
363
- }
364
- }
365
- return ReviewCoverage.make({
366
- status: inputCoverage.status === "complete" && !assuranceIncomplete ? "complete" : "incomplete",
367
- requiredPaths: inputCoverage.requiredPaths,
368
- reviewedPaths: inputCoverage.assignedPaths,
369
- unreviewedPaths: sortedUnique([
370
- ...inputCoverage.partialPaths,
371
- ...inputCoverage.unassignedPaths,
372
- ]),
373
- failedUnits: [...failedUnits.values()].slice(0, 8),
374
- reasons: [...inputCoverage.reasons, ...(assuranceIncomplete ? assurance.reasons : [])],
375
- });
376
- };
@@ -11,6 +11,7 @@ import {
11
11
  } from "effect-agent";
12
12
  import { Toolkit } from "effect/unstable/ai";
13
13
 
14
+ import type { PriorReviewContext } from "./adjudication.ts";
14
15
  import { anchorViolation } from "./anchors.ts";
15
16
  import { boundedListReason, FailedReviewPass, ReviewAssurance } from "./coverage.ts";
16
17
  import { ChangedFileStatus, ChangedPath, type ChangedFile } from "./diff.ts";
@@ -174,8 +175,8 @@ export const confirmedFindingForPublication = (
174
175
  /**
175
176
  * Concern candidates need explicit paths internally to bind the claim to
176
177
  * scheduled evidence. The verifier receives the complete bounded unit so it
177
- * can use neighboring evidence to falsify the claim. The public ReviewConcern
178
- * remains path-free after the host confirms and projects it.
178
+ * can use neighboring evidence to falsify the claim. The host copies these
179
+ * validated paths onto a confirmed public concern for incremental continuity.
179
180
  */
180
181
  export class DiscoveredConcern extends Schema.Class<DiscoveredConcern>(
181
182
  "@effect-agent/pr-review/DiscoveredConcern",
@@ -190,6 +191,11 @@ const UnitPaths = Schema.Array(ChangedPath)
190
191
  .check(Schema.isMinLength(1))
191
192
  .check(Schema.isMaxLength(MAX_UNIT_FILES));
192
193
 
194
+ /** Bounded prior-review context lines injected into discovery instructions. */
195
+ const UnitContextLines = Schema.Array(Schema.String.check(Schema.isMaxLength(1_200))).check(
196
+ Schema.isMaxLength(20),
197
+ );
198
+
193
199
  const RiskCategories = Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6));
194
200
  const Candidates = Schema.Array(ReviewCandidate).check(Schema.isMaxLength(MAX_UNIT_CANDIDATES));
195
201
  const EvidenceShardIds = Schema.Array(ReviewEvidenceShardId)
@@ -225,6 +231,10 @@ export class FileReviewBrief extends Schema.Class<FileReviewBrief>(
225
231
  evidence: Schema.Array(FileReviewEvidence)
226
232
  .check(Schema.isMinLength(1))
227
233
  .check(Schema.isMaxLength(MAX_UNIT_EVIDENCE_SHARDS)),
234
+ /** Maintainer-adjudicated identities on this unit; do not re-raise. */
235
+ adjudicatedContext: Schema.optionalKey(UnitContextLines),
236
+ /** Prior-round findings on this unit's re-reviewed paths. */
237
+ priorFindingContext: Schema.optionalKey(UnitContextLines),
228
238
  }) {}
229
239
 
230
240
  /** Child output; phase-inapplicable collections must be empty. */
@@ -297,11 +307,25 @@ export const makeFileReviewerInstructions =
297
307
  ? `This is a fresh specialist discovery pass. Concentrate on these host-classified risks without relying on another pass: ${brief.riskCategories.join(", ")}.`
298
308
  : "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."
299
309
  : "This is the general discovery pass. Review broadly for correctness, security, concurrency, resource, API, and error-handling defects.";
310
+ const adjudicated = brief.adjudicatedContext ?? [];
311
+ const priorFindings = brief.priorFindingContext ?? [];
300
312
  return [
301
313
  ...common,
302
314
  focus,
315
+ ...(adjudicated.length === 0
316
+ ? []
317
+ : [
318
+ "A maintainer has adjudicated these previously raised items on this unit (disposition, reason). Do not re-raise them unless you have materially new evidence, and if you do, say explicitly what changed since the adjudication:",
319
+ ...adjudicated.map((line) => `- ${line}`),
320
+ ]),
321
+ ...(priorFindings.length === 0
322
+ ? []
323
+ : [
324
+ "A previous review round raised these findings on this unit's paths. For each, either confirm it still holds, state that it is fixed, or withdraw it; do not demand the opposite of that prior guidance without explicitly acknowledging the reversal:",
325
+ ...priorFindings.map((line) => `- ${line}`),
326
+ ]),
303
327
  "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.",
304
- "When a non-anchored concern depends on one or more unit files, list 1-3 exact evidencePaths to bind the claim to scheduled evidence.",
328
+ "Every non-anchored concern must list 1-3 exact evidencePaths to bind the claim to scheduled evidence. Report one root concern once; never split it into differently worded restatements.",
305
329
  `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.`,
306
330
  '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>}.',
307
331
  '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".',
@@ -388,6 +412,12 @@ export interface FanOutPipelineInput {
388
412
  readonly stages: ReadonlyArray<FailedReviewPass["stage"]>;
389
413
  }
390
414
  | undefined;
415
+ /**
416
+ * Adjudicated identities and prior-round findings injected as discovery
417
+ * context on the units whose paths they touch. Context only — they never
418
+ * enter candidates or publication.
419
+ */
420
+ readonly priorContext?: PriorReviewContext | undefined;
391
421
  }
392
422
 
393
423
  const candidateOrdinal = (index: number): string => String(index + 1).padStart(3, "0");
@@ -625,6 +655,19 @@ const reviewUnit = <Provider, ModelProvides, ModelRequires>(
625
655
  let turns = 0;
626
656
  let completedGeneralPasses = 0;
627
657
  let completedSpecialistPasses = 0;
658
+ // Discovery-only context: adjudicated identities (path-free entries apply
659
+ // to every unit) and prior-round findings on this unit's paths. The
660
+ // verifier stays unbiased — it judges only the candidate claims and the
661
+ // bounded evidence.
662
+ const unitPaths = new Set(unit.paths);
663
+ const adjudicatedContext = (input.priorContext?.adjudicated ?? [])
664
+ .filter((entry) => entry.path === undefined || unitPaths.has(entry.path))
665
+ .map((entry) => entry.line)
666
+ .slice(0, 20);
667
+ const priorFindingContext = (input.priorContext?.priorFindings ?? [])
668
+ .filter((entry) => unitPaths.has(entry.path))
669
+ .map((entry) => entry.line)
670
+ .slice(0, 20);
628
671
  for (const pass of passes) {
629
672
  const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
630
673
  const brief = FileReviewBrief.make({
@@ -637,6 +680,8 @@ const reviewUnit = <Provider, ModelProvides, ModelRequires>(
637
680
  riskCategories: pass.riskCategories,
638
681
  candidates: [],
639
682
  evidence,
683
+ ...(adjudicatedContext.length === 0 ? {} : { adjudicatedContext }),
684
+ ...(priorFindingContext.length === 0 ? {} : { priorFindingContext }),
640
685
  });
641
686
  const outcome = yield* runReviewPass(binding, brief, input.budget);
642
687
  if (outcome._tag === "failed") {
@@ -962,7 +1007,14 @@ export const runFanOutReview = <Provider, ModelProvides, ModelRequires>(
962
1007
  );
963
1008
  const concerns = rankAndDedupeConcerns(
964
1009
  confirmed.flatMap(({ candidate }) =>
965
- candidate._tag === "ConcernCandidate" ? [candidate.concern] : [],
1010
+ candidate._tag === "ConcernCandidate"
1011
+ ? [
1012
+ ReviewConcern.make({
1013
+ ...candidate.concern,
1014
+ evidencePaths: [...new Set(candidate.evidencePaths)].sort(),
1015
+ }),
1016
+ ]
1017
+ : [],
966
1018
  ),
967
1019
  );
968
1020
  const walkthrough = outcomes.flatMap((outcome) => outcome.walkthrough);