@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
@@ -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";
@@ -25,6 +26,7 @@ import {
25
26
  WalkthroughEntry,
26
27
  } from "./review-agent.ts";
27
28
  import {
29
+ MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS,
28
30
  MAX_REVIEW_UNITS,
29
31
  MAX_UNIT_EVIDENCE_SHARDS,
30
32
  MAX_UNIT_FILES,
@@ -32,13 +34,13 @@ import {
32
34
  planReviewUnits,
33
35
  rankAndDedupeConcerns,
34
36
  rankAndDedupeFindings,
37
+ ReviewDiscoveryPass,
35
38
  ReviewEvidenceShardId,
36
39
  ReviewPassId,
37
40
  ReviewRiskCategory,
41
+ ReviewUnit,
38
42
  ReviewUnitId,
39
- type ReviewDiscoveryPass,
40
- type ReviewUnit,
41
- type ReviewUnitPlan,
43
+ ReviewUnitPlan,
42
44
  } from "./review-units.ts";
43
45
 
44
46
  // ---------------------------------------------------------------------------
@@ -173,8 +175,8 @@ export const confirmedFindingForPublication = (
173
175
  /**
174
176
  * Concern candidates need explicit paths internally to bind the claim to
175
177
  * scheduled evidence. The verifier receives the complete bounded unit so it
176
- * can use neighboring evidence to falsify the claim. The public ReviewConcern
177
- * 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.
178
180
  */
179
181
  export class DiscoveredConcern extends Schema.Class<DiscoveredConcern>(
180
182
  "@effect-agent/pr-review/DiscoveredConcern",
@@ -189,6 +191,11 @@ const UnitPaths = Schema.Array(ChangedPath)
189
191
  .check(Schema.isMinLength(1))
190
192
  .check(Schema.isMaxLength(MAX_UNIT_FILES));
191
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
+
192
199
  const RiskCategories = Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6));
193
200
  const Candidates = Schema.Array(ReviewCandidate).check(Schema.isMaxLength(MAX_UNIT_CANDIDATES));
194
201
  const EvidenceShardIds = Schema.Array(ReviewEvidenceShardId)
@@ -224,6 +231,10 @@ export class FileReviewBrief extends Schema.Class<FileReviewBrief>(
224
231
  evidence: Schema.Array(FileReviewEvidence)
225
232
  .check(Schema.isMinLength(1))
226
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),
227
238
  }) {}
228
239
 
229
240
  /** Child output; phase-inapplicable collections must be empty. */
@@ -296,11 +307,25 @@ export const makeFileReviewerInstructions =
296
307
  ? `This is a fresh specialist discovery pass. Concentrate on these host-classified risks without relying on another pass: ${brief.riskCategories.join(", ")}.`
297
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."
298
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 ?? [];
299
312
  return [
300
313
  ...common,
301
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
+ ]),
302
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.",
303
- "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.",
304
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.`,
305
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>}.',
306
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".',
@@ -361,6 +386,11 @@ export interface FanOutPipelineOutcome {
361
386
  readonly plan: ReviewUnitPlan;
362
387
  /** Paths of units with an unsettled pass — retryable scope for the next run. */
363
388
  readonly unreviewedPaths: ReadonlyArray<string>;
389
+ /** Failed stages paired with the leftover paths they still own. */
390
+ readonly unreviewedPasses: ReadonlyArray<{
391
+ readonly stage: FailedReviewPass["stage"];
392
+ readonly paths: ReadonlyArray<string>;
393
+ }>;
364
394
  /** Total settled child turns across every scheduled pass. */
365
395
  readonly turns: number;
366
396
  }
@@ -372,6 +402,22 @@ export interface FanOutPipelineInput {
372
402
  readonly maxFindings?: number | undefined;
373
403
  /** Shared run budget observed by every child pass. */
374
404
  readonly budget?: RunBudgetHook<BudgetExceeded | BudgetAdapterError> | undefined;
405
+ /**
406
+ * Unchanged leftover paths from a prior failed pass. Those units retry only
407
+ * the recorded stages — no second general discovery on files nobody touched.
408
+ */
409
+ readonly retry?:
410
+ | {
411
+ readonly paths: ReadonlyArray<string>;
412
+ readonly stages: ReadonlyArray<FailedReviewPass["stage"]>;
413
+ }
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;
375
421
  }
376
422
 
377
423
  const candidateOrdinal = (index: number): string => String(index + 1).padStart(3, "0");
@@ -587,6 +633,10 @@ interface UnitReviewOutcome {
587
633
  readonly requiredVerificationPasses: number;
588
634
  readonly completedVerificationPasses: number;
589
635
  readonly unreviewedPaths: ReadonlyArray<string>;
636
+ readonly unreviewedPasses: ReadonlyArray<{
637
+ readonly stage: FailedReviewPass["stage"];
638
+ readonly paths: ReadonlyArray<string>;
639
+ }>;
590
640
  }
591
641
 
592
642
  const reviewUnit = <Provider, ModelProvides, ModelRequires>(
@@ -605,6 +655,19 @@ const reviewUnit = <Provider, ModelProvides, ModelRequires>(
605
655
  let turns = 0;
606
656
  let completedGeneralPasses = 0;
607
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);
608
671
  for (const pass of passes) {
609
672
  const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
610
673
  const brief = FileReviewBrief.make({
@@ -617,6 +680,8 @@ const reviewUnit = <Provider, ModelProvides, ModelRequires>(
617
680
  riskCategories: pass.riskCategories,
618
681
  candidates: [],
619
682
  evidence,
683
+ ...(adjudicatedContext.length === 0 ? {} : { adjudicatedContext }),
684
+ ...(priorFindingContext.length === 0 ? {} : { priorFindingContext }),
620
685
  });
621
686
  const outcome = yield* runReviewPass(binding, brief, input.budget);
622
687
  if (outcome._tag === "failed") {
@@ -697,6 +762,10 @@ const reviewUnit = <Provider, ModelProvides, ModelRequires>(
697
762
  requiredVerificationPasses,
698
763
  completedVerificationPasses,
699
764
  unreviewedPaths: failedPasses.length > 0 ? unit.paths : [],
765
+ unreviewedPasses: failedPasses.map((pass) => ({
766
+ stage: pass.stage,
767
+ paths: unit.paths,
768
+ })),
700
769
  } satisfies UnitReviewOutcome;
701
770
  });
702
771
 
@@ -737,6 +806,121 @@ const composeSummary = (plan: ReviewUnitPlan, assurance: ReviewAssurance): strin
737
806
  return parts.join(" ").slice(0, 4_000);
738
807
  };
739
808
 
809
+ const remapPlanUnitIds = (plan: ReviewUnitPlan, offset: number): ReviewUnitPlan => {
810
+ if (offset === 0) return plan;
811
+ const units = plan.units.map((unit, index) =>
812
+ ReviewUnit.make({
813
+ ...unit,
814
+ unitId: `unit-${String(offset + index + 1).padStart(3, "0")}`,
815
+ }),
816
+ );
817
+ const mappedIds = new Map<string, string>();
818
+ for (const [index, unit] of plan.units.entries()) {
819
+ const remapped = units[index];
820
+ if (remapped !== undefined) {
821
+ mappedIds.set(unit.unitId, remapped.unitId);
822
+ }
823
+ }
824
+ return ReviewUnitPlan.make({
825
+ ...plan,
826
+ units,
827
+ discoveryPasses: plan.discoveryPasses.map((pass) => {
828
+ const unitId = mappedIds.get(pass.unitId) ?? pass.unitId;
829
+ return ReviewDiscoveryPass.make({
830
+ ...pass,
831
+ unitId,
832
+ passId: `${unitId}${pass.passId.slice(pass.unitId.length)}`,
833
+ });
834
+ }),
835
+ });
836
+ };
837
+
838
+ const scheduleFanOutWork = (
839
+ input: FanOutPipelineInput,
840
+ ): {
841
+ readonly plan: ReviewUnitPlan;
842
+ readonly passesByUnit: Map<string, ReadonlyArray<ReviewDiscoveryPass>>;
843
+ readonly overflowRetryPaths: ReadonlyArray<string>;
844
+ } => {
845
+ const retryPathSet = new Set(input.retry?.paths ?? []);
846
+ const retryStages = new Set(input.retry?.stages ?? []);
847
+ if (retryPathSet.size === 0) {
848
+ const plan = planReviewUnits(input.files, { totalChangedFiles: input.totalChangedFiles });
849
+ const passesByUnit = new Map<string, Array<ReviewDiscoveryPass>>();
850
+ for (const pass of plan.discoveryPasses) {
851
+ const passes = passesByUnit.get(pass.unitId) ?? [];
852
+ passes.push(pass);
853
+ passesByUnit.set(pass.unitId, passes);
854
+ }
855
+ return { plan, passesByUnit, overflowRetryPaths: [] };
856
+ }
857
+ const freshFiles = input.files.filter((file) => !retryPathSet.has(file.path));
858
+ const retryFiles = input.files.filter((file) => retryPathSet.has(file.path));
859
+ const freshPlan = planReviewUnits(freshFiles, { totalChangedFiles: input.totalChangedFiles });
860
+ const retryPlan = remapPlanUnitIds(
861
+ planReviewUnits(retryFiles, { totalChangedFiles: input.totalChangedFiles }),
862
+ freshPlan.units.length,
863
+ );
864
+ const acceptedFresh = freshPlan.units.slice(0, MAX_REVIEW_UNITS);
865
+ const acceptedRetry = retryPlan.units.slice(
866
+ 0,
867
+ Math.max(0, MAX_REVIEW_UNITS - acceptedFresh.length),
868
+ );
869
+ const overflowRetryPaths = retryPlan.units
870
+ .slice(acceptedRetry.length)
871
+ .flatMap((unit) => [...unit.paths]);
872
+ const retryPassFilter = (pass: ReviewDiscoveryPass): boolean => {
873
+ const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
874
+ return retryStages.has(stage);
875
+ };
876
+ const acceptedRetryIds = new Set(acceptedRetry.map((unit) => unit.unitId));
877
+ let retryPasses = retryPlan.discoveryPasses.filter(
878
+ (pass) => acceptedRetryIds.has(pass.unitId) && retryPassFilter(pass),
879
+ );
880
+ if (
881
+ retryStages.has("verification") &&
882
+ !retryStages.has("discovery") &&
883
+ !retryStages.has("specialist")
884
+ ) {
885
+ retryPasses = retryPlan.discoveryPasses.filter((pass) => acceptedRetryIds.has(pass.unitId));
886
+ }
887
+ const acceptedFreshIds = new Set(acceptedFresh.map((unit) => unit.unitId));
888
+ const freshPasses = freshPlan.discoveryPasses.filter((pass) => acceptedFreshIds.has(pass.unitId));
889
+ const discoveryPasses = [...freshPasses, ...retryPasses];
890
+ const plan = ReviewUnitPlan.make({
891
+ totalFiles: input.files.length,
892
+ truncated: freshPlan.truncated || retryPlan.truncated,
893
+ units: [...acceptedFresh, ...acceptedRetry],
894
+ discoveryPasses,
895
+ undiffablePaths: [
896
+ ...new Set([...freshPlan.undiffablePaths, ...retryPlan.undiffablePaths]),
897
+ ].sort(),
898
+ partialEvidencePaths: [
899
+ ...new Set([...freshPlan.partialEvidencePaths, ...retryPlan.partialEvidencePaths]),
900
+ ].sort(),
901
+ unassignedEvidenceShardCount:
902
+ freshPlan.unassignedEvidenceShardCount + retryPlan.unassignedEvidenceShardCount,
903
+ unassignedEvidenceShardIds: [
904
+ ...freshPlan.unassignedEvidenceShardIds,
905
+ ...retryPlan.unassignedEvidenceShardIds,
906
+ ].slice(0, MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS),
907
+ unassignedPaths: [
908
+ ...new Set([
909
+ ...freshPlan.unassignedPaths,
910
+ ...retryPlan.unassignedPaths,
911
+ ...overflowRetryPaths,
912
+ ]),
913
+ ].sort(),
914
+ });
915
+ const passesByUnit = new Map<string, Array<ReviewDiscoveryPass>>();
916
+ for (const pass of discoveryPasses) {
917
+ const passes = passesByUnit.get(pass.unitId) ?? [];
918
+ passes.push(pass);
919
+ passesByUnit.set(pass.unitId, passes);
920
+ }
921
+ return { plan, passesByUnit, overflowRetryPaths };
922
+ };
923
+
740
924
  /**
741
925
  * Run the complete host-scheduled fan-out pipeline over one selected
742
926
  * changeset snapshot: plan, independent discovery, exact verification, and a
@@ -748,13 +932,7 @@ export const runFanOutReview = <Provider, ModelProvides, ModelRequires>(
748
932
  input: FanOutPipelineInput,
749
933
  ) =>
750
934
  Effect.gen(function* () {
751
- const plan = planReviewUnits(input.files, { totalChangedFiles: input.totalChangedFiles });
752
- const passesByUnit = new Map<string, Array<ReviewDiscoveryPass>>();
753
- for (const pass of plan.discoveryPasses) {
754
- const passes = passesByUnit.get(pass.unitId) ?? [];
755
- passes.push(pass);
756
- passesByUnit.set(pass.unitId, passes);
757
- }
935
+ const { plan, passesByUnit, overflowRetryPaths } = scheduleFanOutWork(input);
758
936
  const outcomes = yield* Effect.forEach(
759
937
  plan.units,
760
938
  (unit) => reviewUnit(binding, unit, passesByUnit.get(unit.unitId) ?? [], input),
@@ -829,7 +1007,14 @@ export const runFanOutReview = <Provider, ModelProvides, ModelRequires>(
829
1007
  );
830
1008
  const concerns = rankAndDedupeConcerns(
831
1009
  confirmed.flatMap(({ candidate }) =>
832
- 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
+ : [],
833
1018
  ),
834
1019
  );
835
1020
  const walkthrough = outcomes.flatMap((outcome) => outcome.walkthrough);
@@ -865,6 +1050,15 @@ export const runFanOutReview = <Provider, ModelProvides, ModelRequires>(
865
1050
  ...plan.undiffablePaths,
866
1051
  ]),
867
1052
  ].sort(),
1053
+ unreviewedPasses: [
1054
+ ...outcomes.flatMap((outcome) => outcome.unreviewedPasses),
1055
+ ...(overflowRetryPaths.length === 0
1056
+ ? []
1057
+ : (input.retry?.stages.length
1058
+ ? input.retry.stages
1059
+ : (["discovery", "specialist", "verification"] as const)
1060
+ ).map((stage) => ({ stage, paths: overflowRetryPaths }))),
1061
+ ],
868
1062
  turns: outcomes.reduce((total, outcome) => total + outcome.turns, 0),
869
1063
  } satisfies FanOutPipelineOutcome;
870
1064
  });
@@ -1,4 +1,4 @@
1
- import { Effect } from "effect";
1
+ import { Crypto, Effect, Encoding } from "effect";
2
2
 
3
3
  import type { ChangedFile } from "./diff.ts";
4
4
 
@@ -36,14 +36,14 @@ export const extractFingerprint = (body: string): string | undefined => {
36
36
  return last;
37
37
  };
38
38
 
39
- /** WebCrypto SHA-256; unavailable crypto is a defect, not an expected failure. */
40
- const sha256Hex = (text: string): Effect.Effect<string> =>
41
- Effect.promise(async () => {
42
- const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
43
- return Array.from(new Uint8Array(digest))
44
- .map((byte) => byte.toString(16).padStart(2, "0"))
45
- .join("");
46
- });
39
+ /** SHA-256 via `Crypto.Crypto`; unavailable crypto is a defect, not an expected failure. */
40
+ const sha256Hex = Effect.fn("sha256Hex")(function* (
41
+ text: string,
42
+ ): Effect.fn.Return<string, never, Crypto.Crypto> {
43
+ const crypto = yield* Crypto.Crypto;
44
+ const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe(Effect.orDie);
45
+ return Encoding.encodeHex(digest);
46
+ });
47
47
 
48
48
  const FIELD = "\u0000";
49
49
  const RECORD = "\u0001";
@@ -80,4 +80,10 @@ const canonicalChangeset = (files: ReadonlyArray<ChangedFile>): string =>
80
80
  export const computeChangesetFingerprint = (
81
81
  files: ReadonlyArray<ChangedFile>,
82
82
  signature: string,
83
- ): Effect.Effect<string> => sha256Hex(`${canonicalChangeset(files)}${SECTION}${signature}`);
83
+ ): Effect.Effect<string, never, Crypto.Crypto> =>
84
+ sha256Hex(`${canonicalChangeset(files)}${SECTION}${signature}`);
85
+
86
+ /** Profile fingerprints are SHA-256 over configuration-only signatures. */
87
+ export const computeProfileFingerprint = (
88
+ signature: string,
89
+ ): Effect.Effect<string, never, Crypto.Crypto> => sha256Hex(signature);
@@ -120,6 +120,7 @@ export const staticPriorReviews = (
120
120
  options: {
121
121
  readonly state?: Option.Option<ReviewState> | undefined;
122
122
  readonly comparison?: ReviewHeadComparison | undefined;
123
+ readonly treeComparison?: ReviewHeadComparison | undefined;
123
124
  } = {},
124
125
  ): PriorReviews["Service"] =>
125
126
  PriorReviews.of({
@@ -129,6 +130,10 @@ export const staticPriorReviews = (
129
130
  options.comparison === undefined
130
131
  ? Effect.fail(PriorReviewLookupFailure.make({ reason: "no fixture comparison" }))
131
132
  : Effect.succeed(options.comparison),
133
+ compareTrees: () =>
134
+ options.treeComparison === undefined
135
+ ? Effect.fail(PriorReviewLookupFailure.make({ reason: "no fixture tree comparison" }))
136
+ : Effect.succeed(options.treeComparison),
132
137
  });
133
138
 
134
139
  /** Layer form for consumers whose Effect explicitly requires `PriorReviews`. */
@@ -137,6 +142,7 @@ export const staticPriorReviewsLayer = (
137
142
  options: {
138
143
  readonly state?: Option.Option<ReviewState> | undefined;
139
144
  readonly comparison?: ReviewHeadComparison | undefined;
145
+ readonly treeComparison?: ReviewHeadComparison | undefined;
140
146
  } = {},
141
147
  ): Layer.Layer<PriorReviews> =>
142
148
  Layer.succeed(PriorReviews)(staticPriorReviews(fingerprint, options));
@@ -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
  }),