@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.
@@ -1,12 +1,14 @@
1
1
  import { Config, Effect, FileSystem, Layer, Option, Schema } from "effect";
2
2
  import type { HttpClient } from "effect/unstable/http";
3
3
 
4
+ import type { ReviewAdjudicationHost } from "./adjudication.ts";
4
5
  import type { PriorReviews, ReviewPublisher } from "./github.ts";
5
6
  import {
6
7
  DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN,
7
8
  GitHubReviewTarget,
8
9
  gitHubPriorReviewsLayer,
9
10
  gitHubPullRequestSourceLayer,
11
+ gitHubReviewAdjudicationHostLayer,
10
12
  gitHubReviewPublisherLayer,
11
13
  gitHubReviewRetirementHostLayer,
12
14
  } from "./github.ts";
@@ -116,6 +118,7 @@ export const gitHubReviewLayers = (
116
118
  | ReviewPublisher
117
119
  | PriorReviews
118
120
  | ReviewRetirementHost
121
+ | ReviewAdjudicationHost
119
122
  | ReviewProgressReporter,
120
123
  Config.ConfigError,
121
124
  HttpClient.HttpClient
@@ -144,11 +147,17 @@ export const gitHubReviewLayers = (
144
147
  token,
145
148
  reviewAuthorLogin,
146
149
  });
150
+ const adjudicationHostLayer = gitHubReviewAdjudicationHostLayer.pipe(
151
+ Layer.provide(targetLayer),
152
+ );
147
153
  return Layer.mergeAll(
148
154
  gitHubPullRequestSourceLayer.pipe(Layer.provide(targetLayer)),
149
155
  gitHubReviewPublisherLayer.pipe(Layer.provide(targetLayer)),
150
156
  gitHubPriorReviewsLayer.pipe(Layer.provide(targetLayer)),
151
157
  gitHubReviewRetirementHostLayer.pipe(Layer.provide(targetLayer)),
158
+ // Adjudication is an unconditional run dependency, so the public
159
+ // GitHub bundle owns its target-bound live host explicitly.
160
+ adjudicationHostLayer,
152
161
  gitHubReviewProgressLayer.pipe(Layer.provide(targetLayer)),
153
162
  );
154
163
  }),
@@ -2,6 +2,15 @@ import type { Redacted } from "effect";
2
2
  import { Context, DateTime, Effect, Layer, Option, Schema } from "effect";
3
3
  import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
4
 
5
+ import {
6
+ AdjudicableThread,
7
+ AdjudicationComment,
8
+ AUTHORIZED_ADJUDICATION_ASSOCIATIONS,
9
+ MAX_THREAD_ADJUDICATION_COMMANDS,
10
+ parseThreadAdjudication,
11
+ ReviewAdjudicationFailure,
12
+ ReviewAdjudicationHost,
13
+ } from "./adjudication.ts";
5
14
  import { ChangedFile } from "./diff.ts";
6
15
  import { extractFingerprint } from "./fingerprint.ts";
7
16
  import type { ReviewPublicationPlan } from "./render.ts";
@@ -642,6 +651,204 @@ export const gitHubReviewRetirementHostLayer: Layer.Layer<
642
651
  }),
643
652
  );
644
653
 
654
+ // --- Live ReviewAdjudicationHost ----------------------------------------------
655
+
656
+ const MAX_ADJUDICATION_PAGES = 5;
657
+
658
+ const GitHubThreadCommentWire = Schema.Struct({
659
+ id: Schema.Int,
660
+ in_reply_to_id: Schema.optionalKey(Schema.NullOr(Schema.Int)),
661
+ path: Schema.String,
662
+ body: Schema.String,
663
+ author_association: Schema.optionalKey(Schema.NullOr(Schema.String)),
664
+ user: Schema.NullOr(Schema.Struct({ login: Schema.String })),
665
+ created_at: Schema.optionalKey(Schema.NullOr(Schema.String)),
666
+ line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
667
+ original_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
668
+ start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
669
+ original_start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
670
+ });
671
+ const GitHubThreadCommentsPageWire = Schema.Array(GitHubThreadCommentWire);
672
+
673
+ const GitHubIssueCommentWire = Schema.Struct({
674
+ body: Schema.optionalKey(Schema.NullOr(Schema.String)),
675
+ author_association: Schema.optionalKey(Schema.NullOr(Schema.String)),
676
+ user: Schema.NullOr(Schema.Struct({ login: Schema.String })),
677
+ created_at: Schema.optionalKey(Schema.NullOr(Schema.String)),
678
+ });
679
+ const GitHubIssueCommentsPageWire = Schema.Array(GitHubIssueCommentWire);
680
+
681
+ const toAdjudicationComment = (
682
+ wire: {
683
+ readonly body?: string | null | undefined;
684
+ readonly author_association?: string | null | undefined;
685
+ readonly user: { readonly login: string } | null;
686
+ readonly created_at?: string | null | undefined;
687
+ },
688
+ sourceOrder: number,
689
+ ): AdjudicationComment | undefined => {
690
+ // A comment without an attributable author cannot authorize anything —
691
+ // skip it fail-closed rather than inventing an actor.
692
+ const login = wire.user?.login;
693
+ if (login === undefined || login.length === 0) return undefined;
694
+ return AdjudicationComment.make({
695
+ body: (wire.body ?? "").slice(0, 65_536),
696
+ authorAssociation: (wire.author_association ?? "NONE").slice(0, 40),
697
+ authorLogin: login.slice(0, 100),
698
+ createdAt: parseGitHubSubmittedAt(wire.created_at ?? null),
699
+ sourceOrder,
700
+ });
701
+ };
702
+
703
+ /**
704
+ * GitHub-backed host reads for maintainer adjudication, installed by
705
+ * `gitHubReviewLayers` in `github-env.ts` at the public composition root: this action's own
706
+ * inline finding threads (roots authored by the configured review author)
707
+ * with their replies, and the pull request's top-level conversation comments.
708
+ * Both listings are creation-ordered.
709
+ */
710
+ export const gitHubReviewAdjudicationHostLayer: Layer.Layer<
711
+ ReviewAdjudicationHost,
712
+ never,
713
+ GitHubReviewTarget | HttpClient.HttpClient
714
+ > = Layer.effect(ReviewAdjudicationHost)(
715
+ Effect.gen(function* () {
716
+ const target = yield* GitHubReviewTarget;
717
+ const client = yield* HttpClient.HttpClient;
718
+ const reviewAuthorLogin = (
719
+ target.reviewAuthorLogin ?? DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN
720
+ ).toLowerCase();
721
+ const asAdjudicationFailure =
722
+ (operation: string) =>
723
+ (error: { readonly _tag: string; readonly message?: string }): ReviewAdjudicationFailure =>
724
+ ReviewAdjudicationFailure.make({
725
+ operation,
726
+ reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
727
+ });
728
+ const decodeAdjudication = <S extends Schema.Top>(schema: S, operation: string) => {
729
+ const decode = Schema.decodeUnknownEffect(schema);
730
+ return (response: HttpClientResponse.HttpClientResponse) =>
731
+ response.json.pipe(
732
+ Effect.mapError(asAdjudicationFailure(operation)),
733
+ Effect.flatMap((body) =>
734
+ decode(body).pipe(Effect.mapError(asAdjudicationFailure(operation))),
735
+ ),
736
+ );
737
+ };
738
+ const listPaged = <A>(input: {
739
+ readonly operation: string;
740
+ readonly url: string;
741
+ readonly decode: (
742
+ response: HttpClientResponse.HttpClientResponse,
743
+ ) => Effect.Effect<ReadonlyArray<A>, ReviewAdjudicationFailure>;
744
+ }) =>
745
+ Effect.gen(function* () {
746
+ const values: Array<A> = [];
747
+ const perPage = 100;
748
+ for (let page = 1; page <= MAX_ADJUDICATION_PAGES; page += 1) {
749
+ const response = yield* client
750
+ .execute(
751
+ withCommonHeaders(
752
+ HttpClientRequest.get(input.url).pipe(
753
+ HttpClientRequest.acceptJson,
754
+ HttpClientRequest.setUrlParams({
755
+ per_page: String(perPage),
756
+ page: String(page),
757
+ sort: "created",
758
+ direction: "asc",
759
+ }),
760
+ ),
761
+ target.token,
762
+ ),
763
+ )
764
+ .pipe(
765
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
766
+ Effect.mapError(asAdjudicationFailure(input.operation)),
767
+ );
768
+ const pageValues = yield* input.decode(response);
769
+ values.push(...pageValues);
770
+ if (pageValues.length < perPage) return values;
771
+ }
772
+ return yield* ReviewAdjudicationFailure.make({
773
+ operation: input.operation,
774
+ reason: `history exceeds the bounded ${MAX_ADJUDICATION_PAGES * 100}-item lookup`,
775
+ });
776
+ });
777
+
778
+ const listFindingThreads = Effect.gen(function* () {
779
+ const wires = yield* listPaged({
780
+ operation: "listReviewCommentsForAdjudication",
781
+ url: `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}/comments`,
782
+ decode: decodeAdjudication(
783
+ GitHubThreadCommentsPageWire,
784
+ "listReviewCommentsForAdjudication",
785
+ ),
786
+ });
787
+ const positiveLine = (value: number | null | undefined): number | null =>
788
+ value !== undefined && value !== null && value > 0 ? value : null;
789
+ const threads = new Map<
790
+ number,
791
+ { readonly root: (typeof wires)[number]; readonly replies: Array<AdjudicationComment> }
792
+ >();
793
+ for (const wire of wires) {
794
+ if (wire.in_reply_to_id !== undefined && wire.in_reply_to_id !== null) continue;
795
+ // Only this action's own finding threads can be adjudicated inline.
796
+ if (wire.user?.login.toLowerCase() !== reviewAuthorLogin) continue;
797
+ threads.set(wire.id, { root: wire, replies: [] });
798
+ }
799
+ for (const [sourceOrder, wire] of wires.entries()) {
800
+ if (wire.in_reply_to_id === undefined || wire.in_reply_to_id === null) continue;
801
+ const thread = threads.get(wire.in_reply_to_id);
802
+ if (thread === undefined) continue;
803
+ const reply = toAdjudicationComment(wire, sourceOrder);
804
+ if (reply === undefined) continue;
805
+ const command = parseThreadAdjudication(reply.body);
806
+ if (command === undefined) continue;
807
+ if (!AUTHORIZED_ADJUDICATION_ASSOCIATIONS.has(reply.authorAssociation)) {
808
+ yield* Effect.logDebug(
809
+ `Ignored inline adjudication command from @${reply.authorLogin} (${reply.authorAssociation}).`,
810
+ );
811
+ continue;
812
+ }
813
+ if (thread.replies.length >= MAX_THREAD_ADJUDICATION_COMMANDS) {
814
+ return yield* ReviewAdjudicationFailure.make({
815
+ operation: "listReviewCommentsForAdjudication",
816
+ reason: `inline thread ${wire.in_reply_to_id} exceeds the bounded ${MAX_THREAD_ADJUDICATION_COMMANDS}-command adjudication lookup`,
817
+ });
818
+ }
819
+ thread.replies.push(reply);
820
+ }
821
+ return [...threads.values()]
822
+ .filter((thread) => thread.root.path.length > 0 && thread.root.path.length <= 500)
823
+ .map(({ root, replies }) => {
824
+ const endLine = positiveLine(root.line ?? root.original_line);
825
+ const startLine = positiveLine(root.start_line ?? root.original_start_line) ?? endLine;
826
+ return AdjudicableThread.make({
827
+ path: root.path,
828
+ startLine,
829
+ endLine,
830
+ rootBody: root.body.slice(0, 65_536),
831
+ replies,
832
+ });
833
+ });
834
+ });
835
+
836
+ const listIssueComments = Effect.gen(function* () {
837
+ const wires = yield* listPaged({
838
+ operation: "listIssueCommentsForAdjudication",
839
+ url: `${target.apiUrl}/repos/${target.repository}/issues/${target.number}/comments`,
840
+ decode: decodeAdjudication(GitHubIssueCommentsPageWire, "listIssueCommentsForAdjudication"),
841
+ });
842
+ return wires.flatMap((wire, sourceOrder) => {
843
+ const comment = toAdjudicationComment(wire, sourceOrder);
844
+ return comment === undefined ? [] : [comment];
845
+ });
846
+ });
847
+
848
+ return ReviewAdjudicationHost.of({ listFindingThreads, listIssueComments });
849
+ }),
850
+ );
851
+
645
852
  // --- Prior reviews (fingerprint deduplication) ---------------------------------
646
853
 
647
854
  /** Reading the pull request's previously posted reviews failed. */
@@ -133,7 +133,7 @@ const settleCallout = (info: ReviewProgressSettle): string => {
133
133
  case "success":
134
134
  return `> ✅ **Code review posted** — verdict \`${info.verdict}\`, ${info.inlineComments} inline comment(s), nothing blocking.`;
135
135
  case "blocking":
136
- return `> 🛑 **Code review posted** blocking findings; the check fails until they are addressed.`;
136
+ return `> 🛑 **Code review posted:** blocking review items; the check fails until they are addressed.`;
137
137
  case "incomplete":
138
138
  return `> ⚠️ **Code review posted** — input coverage or configured review assurance is incomplete, so the check fails.`;
139
139
  }
@@ -2,6 +2,7 @@ import { Schema } from "effect";
2
2
 
3
3
  import { anchorViolation } from "./anchors.ts";
4
4
  export { anchorViolation } from "./anchors.ts";
5
+ import { splitCarriedScope } from "./coverage.ts";
5
6
  import type { ReviewAssurance, ReviewInputCoverage } from "./coverage.ts";
6
7
  import type { ChangedFile } from "./diff.ts";
7
8
  import { renderFingerprintMarker } from "./fingerprint.ts";
@@ -11,7 +12,7 @@ import {
11
12
  type ReviewConcern,
12
13
  type WalkthroughEntry,
13
14
  } from "./review-agent.ts";
14
- import type { ReviewScopeMode, ReviewStateMarker } from "./review-state.ts";
15
+ import type { ReviewScopeMode, ReviewStateMarker, StoredAdjudication } from "./review-state.ts";
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Publication planning: pure, deterministic, and fail-closed. Model output is
@@ -185,25 +186,86 @@ const renderDemoted = (finding: ReviewFinding, reason: string): string => {
185
186
  const countNoun = (count: number, noun: string): string =>
186
187
  `${count} ${noun}${count === 1 ? "" : "s"}`;
187
188
 
188
- /** The validated finding + concern severities, tallied for callout and event. */
189
+ interface SeverityTally {
190
+ readonly blocking: number;
191
+ readonly important: number;
192
+ readonly nit: number;
193
+ readonly total: number;
194
+ }
195
+
196
+ interface ReviewItemCounts {
197
+ readonly findings: SeverityTally;
198
+ readonly concerns: SeverityTally;
199
+ readonly carriedFindings: SeverityTally;
200
+ readonly carriedConcerns: SeverityTally;
201
+ readonly total: SeverityTally;
202
+ }
203
+
204
+ const tallySeverities = (
205
+ items: ReadonlyArray<{ readonly severity: ReviewFinding["severity"] }>,
206
+ ): SeverityTally => {
207
+ const blocking = items.filter((item) => item.severity === "blocking").length;
208
+ const important = items.filter((item) => item.severity === "important").length;
209
+ const nit = items.length - blocking - important;
210
+ return { blocking, important, nit, total: items.length };
211
+ };
212
+
213
+ /** The validated finding + concern severities, kept separate by provenance. */
189
214
  const severityCounts = (
190
215
  review: CodeReview,
191
216
  carriedFindings: ReadonlyArray<ReviewFinding> = [],
192
217
  carriedConcerns: ReadonlyArray<ReviewConcern> = [],
193
- ) => {
194
- const severities = [
195
- ...review.findings.map((finding) => finding.severity),
196
- ...(review.concerns ?? []).map((concern) => concern.severity),
197
- ...carriedFindings.map((finding) => finding.severity),
198
- ...carriedConcerns.map((concern) => concern.severity),
199
- ];
218
+ ): ReviewItemCounts => {
219
+ const findings = tallySeverities(review.findings);
220
+ const concerns = tallySeverities(review.concerns ?? []);
221
+ const priorFindings = tallySeverities(carriedFindings);
222
+ const priorConcerns = tallySeverities(carriedConcerns);
200
223
  return {
201
- blocking: severities.filter((severity) => severity === "blocking").length,
202
- important: severities.filter((severity) => severity === "important").length,
203
- total: severities.length,
224
+ findings,
225
+ concerns,
226
+ carriedFindings: priorFindings,
227
+ carriedConcerns: priorConcerns,
228
+ total: {
229
+ blocking:
230
+ findings.blocking + concerns.blocking + priorFindings.blocking + priorConcerns.blocking,
231
+ important:
232
+ findings.important + concerns.important + priorFindings.important + priorConcerns.important,
233
+ nit: findings.nit + concerns.nit + priorFindings.nit + priorConcerns.nit,
234
+ total: findings.total + concerns.total + priorFindings.total + priorConcerns.total,
235
+ },
204
236
  };
205
237
  };
206
238
 
239
+ const joinItemCounts = (items: ReadonlyArray<string>): string =>
240
+ items.length <= 1
241
+ ? (items[0] ?? "none")
242
+ : items.length === 2
243
+ ? `${items[0]} and ${items[1]}`
244
+ : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
245
+
246
+ const severityItemParts = (
247
+ counts: ReviewItemCounts,
248
+ severity: keyof Pick<SeverityTally, "blocking" | "important" | "nit">,
249
+ ): ReadonlyArray<string> => [
250
+ ...(counts.findings[severity] === 0
251
+ ? []
252
+ : [countNoun(counts.findings[severity], `${severity} finding`)]),
253
+ ...(counts.concerns[severity] === 0
254
+ ? []
255
+ : [countNoun(counts.concerns[severity], `${severity} concern`)]),
256
+ ...(counts.carriedFindings[severity] === 0
257
+ ? []
258
+ : [countNoun(counts.carriedFindings[severity], `carried ${severity} finding`)]),
259
+ ...(counts.carriedConcerns[severity] === 0
260
+ ? []
261
+ : [countNoun(counts.carriedConcerns[severity], `carried ${severity} concern`)]),
262
+ ];
263
+
264
+ const renderSeverityItems = (
265
+ counts: ReviewItemCounts,
266
+ severity: keyof Pick<SeverityTally, "blocking" | "important" | "nit">,
267
+ ): string => joinItemCounts(severityItemParts(counts, severity));
268
+
207
269
  /**
208
270
  * The opening callout: the review's overall tier, derived HOST-SIDE from the
209
271
  * validated severities (never from model prose), described by what GitHub
@@ -223,37 +285,73 @@ const renderVerdictCallout = (
223
285
  const counts = severityCounts(review, options.carriedFindings, options.carriedConcerns);
224
286
  // Code findings outrank machinery gaps: a blocking finding is the
225
287
  // actionable signal, and unsettled reviewer-side work is carried forward.
226
- if (counts.blocking > 0) {
227
- return `> [!CAUTION]\n> ${countNoun(counts.blocking, "blocking finding")} do not merge before addressing ${counts.blocking === 1 ? "it" : "them"}.`;
288
+ if (counts.total.blocking > 0) {
289
+ return `> [!CAUTION]\n> ${renderSeverityItems(counts, "blocking")}. Do not merge before addressing ${counts.total.blocking === 1 ? "it" : "them"}.`;
228
290
  }
229
- const carried = options.unreviewedPaths?.length ?? 0;
230
291
  if (
231
292
  options.inputCoverage?.status === "incomplete" ||
232
293
  options.assurance?.status === "incomplete"
233
294
  ) {
295
+ const scope = splitCarriedScope(options);
296
+ const undiffableCount = scope.undiffablePaths.length;
297
+ const undiffableNote =
298
+ undiffableCount > 0
299
+ ? ` ${countNoun(undiffableCount, "unreviewable file")} (binary or oversized) ${undiffableCount === 1 ? "has" : "have"} no diff a retry could settle — remove ${undiffableCount === 1 ? "it" : "them"} from the pull request or exclude ${undiffableCount === 1 ? "it" : "them"} with ignore globs.`
300
+ : "";
301
+ // Undiffable-only gaps are NOT reviewer-side and DO ask for a change (to
302
+ // the changeset or the ignore configuration), so they get their own
303
+ // banner instead of the carried-forward retry promise.
304
+ if (!scope.retryableGap) {
305
+ return `> [!WARNING]\n>${undiffableNote} The check reports "incomplete" while ${undiffableCount === 1 ? "it remains" : "they remain"} part of the pull request.`;
306
+ }
307
+ const retryableCount = scope.retryablePaths.length;
234
308
  const carriedNote =
235
- carried > 0
236
- ? ` ${countNoun(carried, "affected path")} ${carried === 1 ? "is" : "are"} carried forward and retried automatically on the next run.`
309
+ retryableCount > 0
310
+ ? ` ${countNoun(retryableCount, "affected path")} ${retryableCount === 1 ? "is" : "are"} carried forward and retried automatically on the next run.`
237
311
  : "";
238
- return `> [!WARNING]\n> Review infrastructure did not settle a reviewer-side gap, NOT a request to change code.${carriedNote} The check reports "incomplete" until a run settles.`;
312
+ return `> [!WARNING]\n> Review infrastructure did not settle. This is a reviewer-side gap, NOT a request to change code.${carriedNote}${undiffableNote} The check reports "incomplete" until a run settles.`;
239
313
  }
240
- if (counts.important > 0) {
241
- return `> [!IMPORTANT]\n> ${countNoun(counts.important, "important finding")} to address before merging.`;
314
+ if (counts.total.important > 0) {
315
+ return `> [!IMPORTANT]\n> ${renderSeverityItems(counts, "important")} to address before merging.`;
242
316
  }
243
- if (counts.total > 0) {
244
- return "> ℹ️ Minor suggestions only — mergeable as-is.";
317
+ if (counts.total.total > 0) {
318
+ return `> ℹ️ ${renderSeverityItems(counts, "nit")}; mergeable as-is.`;
245
319
  }
246
320
  return review.verdict === "approve"
247
321
  ? "> ✅ No issues found."
248
- : "> ℹ️ No findings see the summary.";
322
+ : "> ℹ️ No review items. See the summary.";
249
323
  };
250
324
 
251
325
  const renderConcern = (concern: ReviewConcern): string =>
252
- [`### ${severityEmoji[concern.severity]} ${concern.title}`, "", concern.body].join("\n");
326
+ [
327
+ `### ${severityEmoji[concern.severity]} ${concern.title}`,
328
+ ...(concern.evidencePaths === undefined
329
+ ? []
330
+ : ["", `_Affected paths: ${concern.evidencePaths.map((path) => `\`${path}\``).join(", ")}_`]),
331
+ "",
332
+ concern.body,
333
+ ].join("\n");
253
334
 
254
335
  const renderCarriedFinding = (finding: ReviewFinding): string =>
255
336
  `- \`${finding.path}:${finding.startLine}${finding.endLine === finding.startLine ? "" : `-${finding.endLine}`}\` **[${findingLabel(finding)}] ${finding.title}** — ${finding.body}`;
256
337
 
338
+ /**
339
+ * One adjudicated identity: its location (absent for unanchored concerns),
340
+ * title, maintainer disposition, actor, and the optional reason. Rendered in
341
+ * the collapsed adjudicated section so the audit distinguishes fixed,
342
+ * adjudicated, and still-open items.
343
+ */
344
+ const renderAdjudicated = (adjudication: StoredAdjudication): string => {
345
+ const location =
346
+ adjudication.path !== undefined &&
347
+ adjudication.startLine !== undefined &&
348
+ adjudication.endLine !== undefined
349
+ ? `\`${adjudication.path}:${adjudication.startLine}${adjudication.endLine === adjudication.startLine ? "" : `-${adjudication.endLine}`}\` `
350
+ : "";
351
+ const reason = adjudication.reason === undefined ? "" : `: ${adjudication.reason}`;
352
+ return `- ${location}${adjudication.title} — ${adjudication.disposition} by @${adjudication.actor}${reason}`;
353
+ };
354
+
257
355
  /**
258
356
  * Validate the model's walkthrough against the real changeset: entries whose
259
357
  * path is not a changed file are dropped (the walkthrough analogue of anchor
@@ -311,7 +409,7 @@ export const estimateReviewEffort = (
311
409
  const renderReviewStats = (
312
410
  files: ReadonlyArray<ChangedFile>,
313
411
  totalChangedFiles: number,
314
- counts: { readonly blocking: number; readonly important: number; readonly total: number },
412
+ counts: ReviewItemCounts,
315
413
  ): string => {
316
414
  const additions = files.reduce((total, file) => total + file.additions, 0);
317
415
  const deletions = files.reduce((total, file) => total + file.deletions, 0);
@@ -319,17 +417,16 @@ const renderReviewStats = (
319
417
  files.length < totalChangedFiles
320
418
  ? `${files.length} of ${totalChangedFiles} files`
321
419
  : countNoun(files.length, "file");
322
- const nits = counts.total - counts.blocking - counts.important;
323
420
  const tally =
324
- counts.total === 0
421
+ counts.total.total === 0
325
422
  ? "none"
326
- : [
327
- ...(counts.blocking > 0 ? [`${counts.blocking} blocking`] : []),
328
- ...(counts.important > 0 ? [`${counts.important} important`] : []),
329
- ...(nits > 0 ? [`${nits} nit`] : []),
330
- ].join(", ");
423
+ : joinItemCounts(
424
+ (["blocking", "important", "nit"] as const).flatMap((severity) =>
425
+ severityItemParts(counts, severity),
426
+ ),
427
+ );
331
428
  const effort = estimateReviewEffort(files);
332
- return `**Changeset:** ${fileCount} (+${additions} / −${deletions}) · **Findings:** ${tally} · **Review effort:** ${effort.score}/5 (${effort.label})`;
429
+ return `**Changeset:** ${fileCount} (+${additions} / −${deletions}) · **Review items:** ${tally} · **Review effort:** ${effort.score}/5 (${effort.label})`;
333
430
  };
334
431
 
335
432
  /** HTML comments must not contain `--`; interpolated values are sanitized. */
@@ -409,6 +506,12 @@ export const planPublication = (
409
506
  /** Unchanged unresolved items carried from the prior reviewed baseline. */
410
507
  readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
411
508
  readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
509
+ /**
510
+ * Standing maintainer adjudications. The caller excludes their identities
511
+ * from the review, the carried items, and every severity count; this
512
+ * planner only renders them as the collapsed adjudicated section.
513
+ */
514
+ readonly adjudications?: ReadonlyArray<StoredAdjudication> | undefined;
412
515
  /** Selected review scope, made visible whenever orchestration chose it. */
413
516
  readonly reviewMode?: ReviewScopeMode | undefined;
414
517
  readonly reviewReason?: string | undefined;
@@ -475,6 +578,7 @@ export const planPublication = (
475
578
  omitted: number,
476
579
  walkthroughKept: boolean,
477
580
  promptsKept: boolean,
581
+ adjudicationsKept: boolean,
478
582
  ): string => {
479
583
  const carriedFindings = options.carriedFindings ?? [];
480
584
  const carriedConcerns = options.carriedConcerns ?? [];
@@ -544,12 +648,33 @@ export const planPublication = (
544
648
  );
545
649
  }
546
650
  if (carriedConcerns.length > 0) {
547
- parts.push("", "### Unresolved concerns carried to the final audit");
651
+ parts.push(
652
+ "",
653
+ `### Unresolved concerns from unchanged paths (${carriedConcerns.length})`,
654
+ "",
655
+ "These concerns were reported in an earlier review and were not reverified in this incremental pass. They remain active because none of their affected paths changed.",
656
+ );
548
657
  for (const concern of carriedConcerns) parts.push("", renderConcern(concern));
549
658
  }
550
659
  for (const concern of sortedConcerns.slice(0, concernsKept)) {
551
660
  parts.push("", renderConcern(concern));
552
661
  }
662
+ const adjudications = options.adjudications ?? [];
663
+ if (adjudications.length > 0) {
664
+ parts.push(
665
+ "",
666
+ adjudicationsKept
667
+ ? [
668
+ "<details>",
669
+ `<summary>Adjudicated (${adjudications.length})</summary>`,
670
+ "",
671
+ ...adjudications.map(renderAdjudicated),
672
+ "",
673
+ "</details>",
674
+ ].join("\n")
675
+ : "⚠️ Adjudicated section omitted — the body exceeded GitHub's review size cap.",
676
+ );
677
+ }
553
678
  if (files.length < options.totalChangedFiles) {
554
679
  parts.push(
555
680
  "",
@@ -607,9 +732,9 @@ export const planPublication = (
607
732
  options.inputCoverage?.status === "incomplete" || options.assurance?.status === "incomplete";
608
733
  const event: ReviewEvent = !options.applyVerdict
609
734
  ? "COMMENT"
610
- : counts.blocking > 0
735
+ : counts.total.blocking > 0
611
736
  ? "REQUEST_CHANGES"
612
- : review.verdict === "approve" && counts.important === 0 && !unclean
737
+ : review.verdict === "approve" && counts.total.important === 0 && !unclean
613
738
  ? "APPROVE"
614
739
  : "COMMENT";
615
740
 
@@ -631,20 +756,30 @@ export const planPublication = (
631
756
  const headBudget = 60_000 - tail.length - 1;
632
757
 
633
758
  // Shed whole trailing items — the derivative consolidated prompt first,
634
- // then the informational walkthrough, then demoted bullets (they already
635
- // failed validation), then concerns — instead of slicing markdown
636
- // mid-block. Every omission is announced, and `plan.demoted` keeps the full
637
- // data regardless.
759
+ // then the informational walkthrough, then the adjudicated record, then
760
+ // demoted bullets (they already failed validation), then concerns — instead
761
+ // of slicing markdown mid-block. Every omission is announced, and
762
+ // `plan.demoted` keeps the full data regardless.
763
+ const adjudicationCount = options.adjudications?.length ?? 0;
638
764
  let concernsKept = sortedConcerns.length;
639
765
  let demotedKept = sortedDemoted.length;
640
766
  let omitted = 0;
641
767
  let walkthroughKept = true;
642
768
  let promptsKept = true;
643
- let head = renderHead(concernsKept, demotedKept, omitted, walkthroughKept, promptsKept);
769
+ let adjudicationsKept = true;
770
+ let head = renderHead(
771
+ concernsKept,
772
+ demotedKept,
773
+ omitted,
774
+ walkthroughKept,
775
+ promptsKept,
776
+ adjudicationsKept,
777
+ );
644
778
  while (
645
779
  head.length > headBudget &&
646
780
  ((promptsKept && consolidatedPromptWanted) ||
647
781
  (walkthroughKept && walkthrough.length > 0) ||
782
+ (adjudicationsKept && adjudicationCount > 0) ||
648
783
  demotedKept > 0 ||
649
784
  concernsKept > 0)
650
785
  ) {
@@ -652,6 +787,8 @@ export const planPublication = (
652
787
  promptsKept = false;
653
788
  } else if (walkthroughKept && walkthrough.length > 0) {
654
789
  walkthroughKept = false;
790
+ } else if (adjudicationsKept && adjudicationCount > 0) {
791
+ adjudicationsKept = false;
655
792
  } else if (demotedKept > 0) {
656
793
  demotedKept -= 1;
657
794
  omitted += 1;
@@ -659,7 +796,14 @@ export const planPublication = (
659
796
  concernsKept -= 1;
660
797
  omitted += 1;
661
798
  }
662
- head = renderHead(concernsKept, demotedKept, omitted, walkthroughKept, promptsKept);
799
+ head = renderHead(
800
+ concernsKept,
801
+ demotedKept,
802
+ omitted,
803
+ walkthroughKept,
804
+ promptsKept,
805
+ adjudicationsKept,
806
+ );
663
807
  }
664
808
  // Last resort for a pathological summary; unreachable while the CodeReview
665
809
  // schema caps the summary well below the budget.