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

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.
Files changed (37) hide show
  1. package/README.md +83 -195
  2. package/dist/action.d.mts +27 -18
  3. package/dist/action.mjs +60 -39
  4. package/dist/action.mjs.map +1 -1
  5. package/dist/cli.mjs +3 -3
  6. package/dist/cli.mjs.map +1 -1
  7. package/dist/{fan-out-BJBTAYuh.d.mts → fan-out-CMEsbFLk.d.mts} +455 -177
  8. package/dist/{github-BbwYzNrC.mjs → github-NjgxGqwM.mjs} +2163 -1518
  9. package/dist/github-NjgxGqwM.mjs.map +1 -0
  10. package/dist/index.d.mts +30 -20
  11. package/dist/index.mjs +3 -3
  12. package/dist/{providers-NyP-4rS6.mjs → providers-CODZQCmL.mjs} +202 -80
  13. package/dist/providers-CODZQCmL.mjs.map +1 -0
  14. package/dist/testing.d.mts +3 -1
  15. package/dist/testing.mjs +3 -2
  16. package/dist/testing.mjs.map +1 -1
  17. package/package.json +2 -2
  18. package/src/action.ts +141 -78
  19. package/src/cli.ts +6 -1
  20. package/src/index.ts +1 -0
  21. package/src/internal/adjudication.ts +415 -0
  22. package/src/internal/coverage.ts +41 -60
  23. package/src/internal/factory.ts +4 -4
  24. package/src/internal/fan-out.ts +208 -14
  25. package/src/internal/fingerprint.ts +16 -10
  26. package/src/internal/fixtures.ts +6 -0
  27. package/src/internal/github-env.ts +9 -0
  28. package/src/internal/github.ts +243 -7
  29. package/src/internal/progress.ts +1 -1
  30. package/src/internal/render.ts +186 -42
  31. package/src/internal/retirement.ts +16 -17
  32. package/src/internal/review-agent.ts +39 -4
  33. package/src/internal/review-state.ts +315 -105
  34. package/src/internal/review-units.ts +10 -9
  35. package/src/internal/run.ts +197 -63
  36. package/dist/github-BbwYzNrC.mjs.map +0 -1
  37. package/dist/providers-NyP-4rS6.mjs.map +0 -1
@@ -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";
@@ -130,8 +139,9 @@ const GitHubReviewCommentWire = Schema.Struct({
130
139
  node_id: Schema.String,
131
140
  path: Schema.String,
132
141
  body: Schema.String,
133
- line: Schema.NullOr(Schema.Int),
134
- original_line: Schema.NullOr(Schema.Int),
142
+ // Outdated comments omit `line` entirely instead of sending null.
143
+ line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
144
+ original_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
135
145
  start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
136
146
  original_start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
137
147
  });
@@ -578,8 +588,11 @@ export const gitHubReviewRetirementHostLayer: Layer.Layer<
578
588
  }).pipe(
579
589
  Effect.map((comments) =>
580
590
  comments.map((comment) => {
581
- const endLine = comment.line ?? comment.original_line;
582
- const startLine = comment.start_line ?? comment.original_start_line ?? endLine;
591
+ const positiveLine = (value: number | null | undefined): number | null =>
592
+ value !== undefined && value !== null && value > 0 ? value : null;
593
+ const endLine = positiveLine(comment.line ?? comment.original_line);
594
+ const startLine =
595
+ positiveLine(comment.start_line ?? comment.original_start_line) ?? endLine;
583
596
  return RetirableReviewComment.make({
584
597
  nodeId: comment.node_id,
585
598
  path: comment.path,
@@ -638,6 +651,204 @@ export const gitHubReviewRetirementHostLayer: Layer.Layer<
638
651
  }),
639
652
  );
640
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
+
641
852
  // --- Prior reviews (fingerprint deduplication) ---------------------------------
642
853
 
643
854
  /** Reading the pull request's previously posted reviews failed. */
@@ -672,6 +883,15 @@ export class PriorReviews extends Context.Service<
672
883
  baseSha: string,
673
884
  headSha: string,
674
885
  ) => Effect.Effect<ReviewHeadComparison, PriorReviewLookupFailure>;
886
+ /**
887
+ * Two-dot tree comparison (`base..head`). Used when the reviewed head is
888
+ * not a git ancestor so a rebase or amend can still name the paths whose
889
+ * blob contents actually changed.
890
+ */
891
+ readonly compareTrees: (
892
+ baseSha: string,
893
+ headSha: string,
894
+ ) => Effect.Effect<ReviewHeadComparison, PriorReviewLookupFailure>;
675
895
  }
676
896
  >()("@effect-agent/pr-review/PriorReviews") {}
677
897
 
@@ -775,12 +995,12 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
775
995
  }
776
996
  return { latestFingerprint: latest, latestState };
777
997
  }).pipe(Effect.provideService(HttpClient.HttpClient, client));
778
- const compareHeads = (baseSha: string, headSha: string) =>
998
+ const compareCommits = (baseSha: string, headSha: string, separator: "..." | "..") =>
779
999
  Effect.gen(function* () {
780
1000
  const response = yield* HttpClient.execute(
781
1001
  withCommonHeaders(
782
1002
  HttpClientRequest.get(
783
- `${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(headSha)}`,
1003
+ `${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}${separator}${encodeURIComponent(headSha)}`,
784
1004
  ).pipe(HttpClientRequest.acceptJson),
785
1005
  target.token,
786
1006
  ),
@@ -803,6 +1023,21 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
803
1023
  truncated: files.length >= MAX_CHANGED_FILES,
804
1024
  });
805
1025
  }).pipe(Effect.provideService(HttpClient.HttpClient, client));
1026
+ const compareTrees = (baseSha: string, headSha: string) =>
1027
+ compareCommits(baseSha, headSha, "..").pipe(
1028
+ Effect.map((comparison) =>
1029
+ ReviewHeadComparison.make({
1030
+ // This is a content snapshot, not a lineage claim. Selection
1031
+ // intersects these files with the current PR path set.
1032
+ status: comparison.status === "identical" ? "identical" : "ahead",
1033
+ baseSha,
1034
+ headSha,
1035
+ mergeBaseSha: baseSha,
1036
+ files: comparison.files,
1037
+ truncated: comparison.truncated,
1038
+ }),
1039
+ ),
1040
+ );
806
1041
  return PriorReviews.of({
807
1042
  latestFingerprint: readMarkers(Option.none()).pipe(
808
1043
  Effect.map((markers) => markers.latestFingerprint),
@@ -813,7 +1048,8 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
813
1048
  Effect.map((markers) => markers.latestState),
814
1049
  );
815
1050
  }),
816
- compareHeads,
1051
+ compareHeads: (baseSha, headSha) => compareCommits(baseSha, headSha, "..."),
1052
+ compareTrees,
817
1053
  });
818
1054
  }),
819
1055
  );
@@ -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.