@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.
@@ -169,18 +169,6 @@ const annotatePatch = (patch) => {
169
169
  return output.join("\n");
170
170
  };
171
171
  //#endregion
172
- //#region src/internal/anchors.ts
173
- /** Why a finding cannot anchor to the current new-version diff, if any. */
174
- const anchorViolation = (finding, files) => {
175
- const file = files.find((candidate) => candidate.path === finding.path);
176
- if (file === void 0) return "path is not part of the changeset";
177
- if (file.patch === void 0) return "file has no anchorable textual diff";
178
- if (finding.endLine < finding.startLine) return "endLine precedes startLine";
179
- if (finding.endLine - finding.startLine + 1 > 100) return "range is implausibly large";
180
- const anchors = commentableLines(file.patch);
181
- for (let line = finding.startLine; line <= finding.endLine; line += 1) if (!anchors.has(line)) return `line ${line} is not part of the diff`;
182
- };
183
- //#endregion
184
172
  //#region src/internal/source.ts
185
173
  /** Reading a file head version larger than this is refused, never truncated silently. */
186
174
  const MAX_FILE_CHARS = 2e5;
@@ -442,6 +430,8 @@ const ReviewToolkitLayer = ReviewToolkit.toLayer({
442
430
  read_file_diff: readFileDiffHandler,
443
431
  read_file: readFileHandler
444
432
  });
433
+ /** Bounded prior-review context lines injected into reviewer instructions. */
434
+ const ReviewContextLines = Schema.Array(Schema.String.check(Schema.isMaxLength(1200))).check(Schema.isMaxLength(20));
445
435
  var ReviewMission = class extends Schema.Class("@effect-agent/pr-review/ReviewMission")({
446
436
  repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
447
437
  number: Schema.Int.check(Schema.isGreaterThan(0)),
@@ -449,7 +439,19 @@ var ReviewMission = class extends Schema.Class("@effect-agent/pr-review/ReviewMi
449
439
  body: Schema.String.check(Schema.isMaxLength(2e4)),
450
440
  baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
451
441
  headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
452
- changedFileCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
442
+ changedFileCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
443
+ /**
444
+ * Maintainer-adjudicated identities rendered as bounded context lines; the
445
+ * reviewer must not re-raise them without materially new evidence. Absent
446
+ * from fingerprint missions so an adjudication never invalidates the
447
+ * skip-unchanged authority.
448
+ */
449
+ adjudicatedContext: Schema.optionalKey(ReviewContextLines),
450
+ /**
451
+ * Prior-round findings on re-reviewed scope, rendered as bounded context
452
+ * lines; each must be confirmed, declared fixed, or explicitly withdrawn.
453
+ */
454
+ priorFindingContext: Schema.optionalKey(ReviewContextLines)
453
455
  }) {};
454
456
  const FindingSeverity = Schema.Literals([
455
457
  "blocking",
@@ -494,10 +496,14 @@ const ReviewVerdict = Schema.Literals([
494
496
  * A concern with no diff line to anchor to: a missing deletion or cleanup,
495
497
  * rollout or migration sequencing, a coverage gap the diff implies but does
496
498
  * not add, or a scope question only the author can answer. Rendered as a
497
- * review-body section never as an inline comment, so it needs no anchor and
498
- * is never demoted.
499
+ * review-body section instead of an inline comment. `evidencePaths` binds the
500
+ * concern to changed files so a later incremental review can invalidate and
501
+ * recheck it when any supporting path changes. It remains optional only for
502
+ * decoding review output and continuity state written before path binding was
503
+ * introduced; a pathless concern cannot authorize incremental continuity.
499
504
  */
500
505
  var ReviewConcern = class extends Schema.Class("@effect-agent/pr-review/ReviewConcern")({
506
+ evidencePaths: Schema.optionalKey(Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3))),
501
507
  severity: FindingSeverity,
502
508
  title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
503
509
  body: Schema.NonEmptyString.check(Schema.isMaxLength(2e3))
@@ -537,6 +543,8 @@ const makeReviewInstructions = (options = {}) => (mission) => {
537
543
  `You are a senior code reviewer for pull request #${mission.number} ("${mission.title}") in ${mission.repository}, merging ${mission.headRef} into ${mission.baseRef}. It changes ${mission.changedFileCount} file(s).`,
538
544
  mission.body.length > 0 ? `Author description:\n${mission.body}` : "The author provided no description.",
539
545
  ...resolveGuidance(options.guidance, mission),
546
+ ...mission.adjudicatedContext === void 0 || mission.adjudicatedContext.length === 0 ? [] : ["A maintainer has adjudicated these previously raised review items (disposition, reason). Do not re-raise them unless you have materially new evidence, and if you do, say explicitly what changed since the adjudication:", ...mission.adjudicatedContext.map((line) => `- ${line}`)],
547
+ ...mission.priorFindingContext === void 0 || mission.priorFindingContext.length === 0 ? [] : ["Your previous review raised these findings on the scope you are re-reviewing. For each, either confirm it still holds, state that it is fixed, or withdraw it; do not demand the opposite of your own prior guidance without explicitly acknowledging the reversal:", ...mission.priorFindingContext.map((line) => `- ${line}`)],
540
548
  "Work in this order:",
541
549
  "1. Call list_changed_files once to see the selected input scope. In incremental reviews it is deliberately a subset of the pull request's full diff (totalFiles counts the whole pull request); omitted paths belong to settled prior scope or explicit host exclusions, not to this run.",
542
550
  "2. Call read_file_diff for every listed file. A normal diff marks new-version anchors as R<number>; only those numbers are valid startLine/endLine values. When GitHub omitted a diff, the tool may return bounded base/head content marked B/H instead. Review that content, but report its defects as non-anchored concerns because B/H lines cannot anchor GitHub comments. Never anchor a finding to a removed (-), B, or H line.",
@@ -545,9 +553,9 @@ const makeReviewInstructions = (options = {}) => (mission) => {
545
553
  "When the diff adds or changes a test, check that it can actually fail: a test that would still pass with the bug present is theatre, not coverage. The usual tell is a loose assertion standing where an exact one belongs — >= or a truthiness check over an expected value, or a snapshot that absorbs whatever it is handed.",
546
554
  "Go shallow only when the diff has no behavioral surface at all: doc typos, formatting, lockfile or generated-code regeneration, a mechanical rename. Line count is not the signal — a one-line change to auth, money, SQL, a comparison operator, or a config default is not trivial.",
547
555
  "Drop bloat-shaped findings before reporting: defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies, just-in-case guards. A finding must be sound, correct, and worth acting on; prefer an explicit keep over an invented finding.",
548
- "5. After collecting anchored findings, deliberately scan for concerns with NO line to point at: deletion or cleanup plans for code the diff replaces, rollout or migration sequencing, coverage gaps the diff implies but does not add, scope questions only the author can answer. Report each as a \"concern\", never as a finding with an invented anchor; report none when none exist.",
556
+ "5. After collecting anchored findings, deliberately scan for concerns with NO line to point at: deletion or cleanup plans for code the diff replaces, rollout or migration sequencing, coverage gaps the diff implies but does not add, scope questions only the author can answer. Report each as a \"concern\", never as a finding with an invented anchor. Every concern must list 1-3 exact changed evidencePaths that support it so later incremental reviews can recheck it when those files change. Report none when none exist, and never split one root concern into differently worded restatements.",
549
557
  `6. Write a walkthrough: for every file whose evidence you examined, one factual sentence (<= 240 chars) describing what changed in that file — written for a reader scanning the pull request, never restating the diff line by line. Use only paths from list_changed_files; invented paths are dropped.`,
550
- "7. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {\"summary\": <string, 1-3 paragraphs of overall assessment>, \"verdict\": <\"approve\" | \"comment\" | \"request-changes\">, \"findings\": [{\"path\": <string, a changed file path>, \"startLine\": <integer, an R-marked new-file line>, \"endLine\": <integer, >= startLine, same file, R-marked>, \"severity\": <\"blocking\" | \"important\" | \"nit\">, \"category\": <OPTIONAL: \"correctness\" | \"security\" | \"concurrency\" | \"performance\" | \"resources\" | \"error-handling\" | \"testing\" | \"maintainability\" | \"style\" | \"docs\">, \"title\": <string, <= 120 chars>, \"body\": <string, why it matters and what to do>, \"suggestion\": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], \"concerns\": <array, OPTIONAL: [{\"severity\": <\"blocking\" | \"important\" | \"nit\">, \"title\": <string, <= 120 chars>, \"body\": <string>}], only for step-5 concerns with no valid anchor — never duplicate a finding here>, \"walkthrough\": <array, OPTIONAL: [{\"path\": <string, a changed file path>, \"summary\": <string, the step-6 sentence>}], one entry per reviewed file>}.",
558
+ "7. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {\"summary\": <string, 1-3 paragraphs of overall assessment>, \"verdict\": <\"approve\" | \"comment\" | \"request-changes\">, \"findings\": [{\"path\": <string, a changed file path>, \"startLine\": <integer, an R-marked new-file line>, \"endLine\": <integer, >= startLine, same file, R-marked>, \"severity\": <\"blocking\" | \"important\" | \"nit\">, \"category\": <OPTIONAL: \"correctness\" | \"security\" | \"concurrency\" | \"performance\" | \"resources\" | \"error-handling\" | \"testing\" | \"maintainability\" | \"style\" | \"docs\">, \"title\": <string, <= 120 chars>, \"body\": <string, why it matters and what to do>, \"suggestion\": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], \"concerns\": <array, OPTIONAL: [{\"evidencePaths\": <array of 1-3 exact changed file paths>, \"severity\": <\"blocking\" | \"important\" | \"nit\">, \"title\": <string, <= 120 chars>, \"body\": <string>}], only for step-5 concerns with no valid anchor — never duplicate a finding here>, \"walkthrough\": <array, OPTIONAL: [{\"path\": <string, a changed file path>, \"summary\": <string, the step-6 sentence>}], one entry per reviewed file>}.",
551
559
  `Report at most ${maxFindings} findings and at most 10 concerns; prefer the most important ones. An empty findings array with verdict "approve" is a valid review. Include "suggestion" only when you are confident the replacement compiles and preserves intent; its text must contain the full replacement for every line in the range and nothing else.`,
552
560
  "Use verdict \"request-changes\" only when at least one finding or concern is \"blocking\". Line anchors you invent will be discarded, so copy R-numbers from read_file_diff output."
553
561
  ].join("\n");
@@ -579,1764 +587,2108 @@ const PullRequestReviewer = Agent.define("pr-reviewer", {
579
587
  }
580
588
  });
581
589
  //#endregion
582
- //#region src/internal/coverage.ts
583
- var FailedReviewUnit = class extends Schema.Class("@effect-agent/pr-review/FailedReviewUnit")({
584
- unitId: Schema.NonEmptyString.check(Schema.isMaxLength(32)),
585
- errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256))
590
+ //#region src/internal/review-state.ts
591
+ const ReviewMode = Schema.Literals(["incremental", "final"]);
592
+ const ReviewScopeMode = Schema.Literals(["incremental", "full"]);
593
+ const GitCommitSha = Schema.NonEmptyString.check(Schema.isMaxLength(64), Schema.isPattern(/^[0-9a-f]{40,64}$/));
594
+ const Fingerprint = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/));
595
+ const StoredText = Schema.NonEmptyString.check(Schema.isMaxLength(800));
596
+ /** A compact unresolved finding suitable for the bounded review-body marker. */
597
+ var StoredReviewFinding = class extends Schema.Class("@effect-agent/pr-review/StoredReviewFinding")({
598
+ path: ChangedPath,
599
+ startLine: Schema.Int.check(Schema.isGreaterThan(0)),
600
+ endLine: Schema.Int.check(Schema.isGreaterThan(0)),
601
+ severity: FindingSeverity,
602
+ title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
603
+ body: StoredText
586
604
  }) {};
605
+ /** A compact unresolved non-anchored concern with its invalidation paths. */
606
+ var StoredReviewConcern = class extends Schema.Class("@effect-agent/pr-review/StoredReviewConcern")({
607
+ /** Absent only on legacy state written before concern path binding. */
608
+ evidencePaths: Schema.optionalKey(Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3))),
609
+ severity: FindingSeverity,
610
+ title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
611
+ body: StoredText
612
+ }) {};
613
+ /** How a maintainer settled a previously raised finding or concern. */
614
+ const AdjudicationDisposition = Schema.Literals([
615
+ "accepted-risk",
616
+ "refuted",
617
+ "obsolete"
618
+ ]);
619
+ /** The adjudications bound carried by the ReviewState schema. */
620
+ const MAX_STORED_ADJUDICATIONS = 20;
587
621
  /**
588
- * Compatibility diagnostic retained for callers that consumed the original
589
- * `coverage` field. New UI and state decisions use ReviewInputCoverage and
590
- * ReviewAssurance directly.
622
+ * One maintainer adjudication of a finding or concern identity. Anchored
623
+ * findings carry their full location identity; unanchored concerns are
624
+ * identified by title alone, so the location fields stay absent.
591
625
  */
592
- var ReviewCoverage = class extends Schema.Class("@effect-agent/pr-review/ReviewCoverage")({
593
- status: Schema.Literals(["complete", "incomplete"]),
594
- requiredPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
595
- reviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
596
- unreviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
597
- failedUnits: Schema.Array(FailedReviewUnit).check(Schema.isMaxLength(8)),
598
- reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1e3))).check(Schema.isMaxLength(32))
626
+ const StoredAdjudicationFields = Schema.Struct({
627
+ path: Schema.optionalKey(ChangedPath),
628
+ startLine: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))),
629
+ endLine: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))),
630
+ title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
631
+ disposition: AdjudicationDisposition,
632
+ reason: Schema.optionalKey(Schema.NonEmptyString.check(Schema.isMaxLength(300))),
633
+ /** GitHub login of the maintainer whose comment adjudicated the identity. */
634
+ actor: Schema.NonEmptyString.check(Schema.isMaxLength(100))
635
+ }).check(Schema.makeFilter((adjudication) => {
636
+ const locationParts = [
637
+ adjudication.path,
638
+ adjudication.startLine,
639
+ adjudication.endLine
640
+ ].filter((part) => part !== void 0).length;
641
+ return locationParts === 0 || locationParts === 3 ? void 0 : "path, startLine, and endLine must be either all present or all absent";
642
+ }, { title: "adjudication locations are complete or unanchored" }));
643
+ var StoredAdjudication = class extends Schema.Class("@effect-agent/pr-review/StoredAdjudication")(StoredAdjudicationFields) {};
644
+ /**
645
+ * The one finding-identity composition shared by retirement, adjudication,
646
+ * and settlement. A tagged JSON tuple keeps anchored findings in a namespace
647
+ * disjoint from title-only concerns and remains unambiguous even when
648
+ * untrusted path or title text contains delimiter characters.
649
+ */
650
+ const findingIdentity = (finding) => JSON.stringify([
651
+ "finding",
652
+ finding.path,
653
+ finding.startLine,
654
+ finding.endLine,
655
+ finding.title
656
+ ]);
657
+ /** The disjoint title-only identity namespace for unanchored concerns. */
658
+ const concernIdentity = (concern) => JSON.stringify(["concern", concern.title]);
659
+ /**
660
+ * An adjudication's identity: the shared finding identity when anchored, the
661
+ * disjoint concern identity when unanchored.
662
+ */
663
+ const adjudicationIdentity = (adjudication) => adjudication.path !== void 0 && adjudication.startLine !== void 0 && adjudication.endLine !== void 0 ? findingIdentity({
664
+ path: adjudication.path,
665
+ startLine: adjudication.startLine,
666
+ endLine: adjudication.endLine,
667
+ title: adjudication.title
668
+ }) : concernIdentity(adjudication);
669
+ /** The carried-scope bound; a run that cannot fit its leftovers publishes no state. */
670
+ const MAX_STORED_UNREVIEWED_PATHS = 100;
671
+ /** Failed-pass records stored beside the leftover paths; one per unit stage. */
672
+ const MAX_STORED_UNREVIEWED_PASSES = 24;
673
+ /** Stages a leftover path may need retried without a second general discovery. */
674
+ const UnreviewedStage = Schema.Literals([
675
+ "discovery",
676
+ "specialist",
677
+ "verification"
678
+ ]);
679
+ /** One failed fan-out pass whose paths should be retried, not rediscovered. */
680
+ var StoredUnreviewedPass = class extends Schema.Class("@effect-agent/pr-review/StoredUnreviewedPass")({
681
+ stage: UnreviewedStage,
682
+ paths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12))
599
683
  }) {};
600
- var ReviewInputCoverage = class extends Schema.Class("@effect-agent/pr-review/ReviewInputCoverage")({
601
- status: Schema.Literals(["complete", "incomplete"]),
602
- requiredPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
603
- assignedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
604
- /** Assigned paths whose model-visible diff was truncated by the evidence bound. */
605
- partialPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
606
- unassignedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
684
+ /**
685
+ * Versioned state embedded after EVERY completed run that can be signed. The
686
+ * head plus full-scope fingerprint forms an incremental baseline; an absent
687
+ * unresolved item never means the path is defect-free. `unreviewedPaths`
688
+ * carries retryable review gaps (failed passes) forward so the next
689
+ * incremental run re-reviews exactly them plus the new delta — the baseline
690
+ * advances monotonically instead of freezing on one flaky pass and reopening
691
+ * the whole post-baseline scope. Storing hundreds of path strings separately
692
+ * would not fit GitHub's bounded review body in the worst case.
693
+ */
694
+ var ReviewState = class extends Schema.Class("@effect-agent/pr-review/ReviewState")({
695
+ version: Schema.Literal(1),
696
+ repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
697
+ pullRequestNumber: Schema.Int.check(Schema.isGreaterThan(0)),
698
+ baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
699
+ baseSha: GitCommitSha,
700
+ headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
701
+ reviewedHeadSha: GitCommitSha,
702
+ profileFingerprint: Fingerprint,
703
+ settledScopeFingerprint: Fingerprint,
704
+ reviewedPathCount: Schema.Int.check(Schema.isBetween({
705
+ minimum: 0,
706
+ maximum: 300
707
+ })),
708
+ unresolvedFindings: Schema.Array(StoredReviewFinding).check(Schema.isMaxLength(20)),
709
+ unresolvedConcerns: Schema.Array(StoredReviewConcern).check(Schema.isMaxLength(10)),
710
+ /** Retryable review gaps carried into the next incremental run's scope. */
711
+ unreviewedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(100)),
712
+ /** Which failed pass produced those leftovers. */
713
+ unreviewedPasses: Schema.Array(StoredUnreviewedPass).check(Schema.isMaxLength(24)),
607
714
  /**
608
- * Paths with neither a textual diff nor bounded base/head text (binaries,
609
- * oversized files). Fail-closed: they keep the status incomplete for as
610
- * long as they are part of the pull request — an unreviewable change must
611
- * never authorize a green check. Exclude them deliberately with ignore
612
- * globs when that is intended.
715
+ * True only when the producing run had complete input coverage, no
716
+ * unsettled pass, and nothing carried. Skip-unchanged authority: an
717
+ * unchanged patch may skip re-review only over a settled state.
613
718
  */
614
- undiffablePaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
615
- reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1e3))).check(Schema.isMaxLength(20))
719
+ settled: Schema.Boolean,
720
+ lastReviewMode: ReviewScopeMode,
721
+ /**
722
+ * Maintainer adjudications standing against this pull request. optionalKey
723
+ * so state markers signed before the field existed still decode.
724
+ */
725
+ adjudications: Schema.optionalKey(Schema.Array(StoredAdjudication).check(Schema.isMaxLength(20)))
616
726
  }) {};
617
- var FailedReviewPass = class extends Schema.Class("@effect-agent/pr-review/FailedReviewPass")({
618
- workId: Schema.NonEmptyString.check(Schema.isMaxLength(96)),
619
- stage: Schema.Literals([
620
- "discovery",
621
- "specialist",
622
- "verification"
623
- ]),
624
- errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256))
727
+ const toStoredFinding = (finding) => StoredReviewFinding.make({
728
+ path: finding.path,
729
+ startLine: finding.startLine,
730
+ endLine: finding.endLine,
731
+ severity: finding.severity,
732
+ title: finding.title,
733
+ body: finding.body.slice(0, 800)
734
+ });
735
+ const fromStoredFinding = (finding) => ReviewFinding.make({
736
+ path: finding.path,
737
+ startLine: finding.startLine,
738
+ endLine: finding.endLine,
739
+ severity: finding.severity,
740
+ title: finding.title,
741
+ body: finding.body
742
+ });
743
+ const toStoredConcern = (concern) => StoredReviewConcern.make({
744
+ ...concern.evidencePaths === void 0 ? {} : { evidencePaths: concern.evidencePaths },
745
+ severity: concern.severity,
746
+ title: concern.title,
747
+ body: concern.body.slice(0, 800)
748
+ });
749
+ const fromStoredConcern = (concern) => ReviewConcern.make({
750
+ ...concern.evidencePaths === void 0 ? {} : { evidencePaths: concern.evidencePaths },
751
+ severity: concern.severity,
752
+ title: concern.title,
753
+ body: concern.body
754
+ });
755
+ const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v1:";
756
+ const STATE_MARKER_SUFFIX = " -->";
757
+ const STATE_MARKER_PATTERN = /(?:^|\n)<!-- effect-agent-pr-review state-v1:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
758
+ const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-v1\0";
759
+ const MAX_REVIEW_STATE_MARKER_CHARS = 24e3;
760
+ const ReviewStateMarker = Schema.NonEmptyString.check(Schema.isMaxLength(MAX_REVIEW_STATE_MARKER_CHARS), Schema.isPattern(/^<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/)).pipe(Schema.brand("@effect-agent/pr-review/ReviewStateMarker"));
761
+ var ReviewStateAuthenticationFailure = class extends Schema.TaggedError()("ReviewStateAuthenticationFailure", {
762
+ operation: Schema.Literals(["sign", "verify"]),
763
+ reason: Schema.NonEmptyString.check(Schema.isMaxLength(2048))
625
764
  }) {};
626
- /**
627
- * Settlement of scheduled review work. `incomplete` means reviewer-side work
628
- * failed after its bounded retry — a machinery gap that is carried forward and
629
- * retried on the next run, never a statement about the code under review.
630
- * `unverified` is the flat reviewer's honest constant: one pass with no
631
- * independent verifier is neither settled assurance nor a failure.
632
- */
633
- var ReviewAssurance = class extends Schema.Class("@effect-agent/pr-review/ReviewAssurance")({
634
- status: Schema.Literals([
635
- "settled",
636
- "incomplete",
637
- "unverified"
638
- ]),
639
- requiredGeneralDiscoveryPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
640
- completedGeneralDiscoveryPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
641
- requiredSpecialistPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
642
- completedSpecialistPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
643
- requiredVerificationPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
644
- completedVerificationPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
645
- discoveredCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
646
- confirmedCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
647
- rejectedCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
648
- unsettledCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
649
- /** Discovery claims discarded for anchors/paths outside their assigned evidence. */
650
- discardedInvalidFindings: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
651
- failedPasses: Schema.Array(FailedReviewPass).check(Schema.isMaxLength(64)),
652
- reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1e3))).check(Schema.isMaxLength(32))
765
+ var ReviewStateMarkerTooLarge = class extends Schema.TaggedError()("ReviewStateMarkerTooLarge", {
766
+ observedChars: Schema.Int.check(Schema.isGreaterThan(0)),
767
+ maximumChars: Schema.Int.check(Schema.isGreaterThan(0))
653
768
  }) {};
654
- const toolTrace = (events) => {
655
- const declared = /* @__PURE__ */ new Map();
656
- const succeeded = /* @__PURE__ */ new Map();
657
- const failed = /* @__PURE__ */ new Map();
658
- for (const event of events) {
659
- if (event._tag === "ToolCallDeclared") declared.set(event.toolCallId, event);
660
- if (event._tag === "ToolCallSucceeded") succeeded.set(event.toolCallId, event);
661
- if (event._tag === "ToolCallFailed") failed.set(event.toolCallId, event);
662
- }
663
- return {
664
- declared,
665
- succeeded,
666
- failed
667
- };
668
- };
669
- const sortedUnique = (values) => [...new Set(values)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
670
- /** Render a bounded, deterministic "label (n): a, b, … (+k more)" reason line. */
671
- const boundedListReason = (label, values) => {
672
- const items = sortedUnique(values);
673
- let rendered = `${label} (${items.length}): `;
674
- for (let index = 0; index < items.length; index += 1) {
675
- const item = items[index] ?? "";
676
- const separator = index === 0 ? "" : ", ";
677
- const omitted = items.length - index - 1;
678
- const suffix = omitted === 0 ? "" : ` … (+${omitted} more)`;
679
- if (`${rendered}${separator}${item}${suffix}`.length > 1e3) {
680
- const omission = `… (+${items.length - index} more)`;
681
- return `${rendered.slice(0, 1e3 - omission.length)}${omission}`;
682
- }
683
- rendered = `${rendered}${separator}${item}`;
684
- }
685
- return rendered;
686
- };
687
- const anchorSurfaceAdjusted = (inputCoverage, anchorFiles, totalAnchorFiles) => anchorFiles.length >= totalAnchorFiles ? inputCoverage : ReviewInputCoverage.make({
688
- ...inputCoverage,
689
- status: "incomplete",
690
- reasons: [...inputCoverage.reasons, `full pull-request anchor surface exposed ${anchorFiles.length} of ${totalAnchorFiles} required files`]
769
+ var ReviewStateAuthenticator = class extends Context.Service()("@effect-agent/pr-review/ReviewStateAuthenticator") {};
770
+ const authenticationFailure = (operation, cause) => ReviewStateAuthenticationFailure.make({
771
+ operation,
772
+ reason: String(cause).slice(0, 2048)
691
773
  });
692
- /** The flat reviewer's honest constant assurance: one pass, no verifier. */
693
- const flatAssurance = () => ReviewAssurance.make({
694
- status: "unverified",
695
- requiredGeneralDiscoveryPasses: 1,
696
- completedGeneralDiscoveryPasses: 1,
697
- requiredSpecialistPasses: 0,
698
- completedSpecialistPasses: 0,
699
- requiredVerificationPasses: 0,
700
- completedVerificationPasses: 0,
701
- discoveredCandidates: 0,
702
- confirmedCandidates: 0,
703
- rejectedCandidates: 0,
704
- unsettledCandidates: 0,
705
- discardedInvalidFindings: 0,
706
- failedPasses: [],
707
- reasons: ["flat review has no independent candidate-verification pass; use the fan-out pipeline for a settled assurance result"]
774
+ const hmacKey = (secret, operation) => Effect.tryPromise({
775
+ try: () => globalThis.crypto.subtle.importKey("raw", new TextEncoder().encode(Redacted.value(secret)), {
776
+ name: "HMAC",
777
+ hash: "SHA-256"
778
+ }, false, ["sign", "verify"]),
779
+ catch: (cause) => authenticationFailure(operation, cause)
708
780
  });
709
- /**
710
- * Assess one settled flat run from its Run event trace: which required paths
711
- * received successful bounded diff evidence. This observes tool INPUT
712
- * assignment only the host cannot know which evidence the model weighed.
713
- */
714
- const assessFlatReview = (input) => {
715
- const trace = toolTrace(input.events);
716
- const requiredPaths = sortedUnique(input.files.map((file) => file.path));
717
- const assigned = /* @__PURE__ */ new Set();
718
- const partial = /* @__PURE__ */ new Set();
719
- const failedPaths = /* @__PURE__ */ new Set();
720
- for (const [toolCallId, declaration] of trace.declared) {
721
- if (declaration.toolName !== "read_file_diff") continue;
722
- const query = Schema.decodeUnknownOption(FileDiffQuery)(declaration.parameters);
723
- if (Option.isNone(query)) continue;
724
- const success = trace.succeeded.get(toolCallId);
725
- if (success !== void 0) {
726
- assigned.add(query.value.path);
727
- const view = Schema.decodeUnknownOption(FileDiffView)(success.result);
728
- if (Option.isSome(view) && view.value.truncated) partial.add(query.value.path);
729
- }
730
- if (trace.failed.has(toolCallId)) failedPaths.add(query.value.path);
781
+ const signatureBytes = (signature) => {
782
+ const pairs = signature.match(/../g) ?? [];
783
+ const buffer = new ArrayBuffer(pairs.length);
784
+ const bytes = new Uint8Array(buffer);
785
+ for (let index = 0; index < pairs.length; index += 1) bytes[index] = Number.parseInt(pairs[index] ?? "", 16);
786
+ return buffer;
787
+ };
788
+ /** Validated WebCrypto adapter selected at the Action composition root. */
789
+ const webCryptoReviewStateAuthenticatorLayer = (secret) => Layer.succeed(ReviewStateAuthenticator)(ReviewStateAuthenticator.of({
790
+ status: "available",
791
+ unavailableReason: void 0,
792
+ render: (state) => Effect.gen(function* () {
793
+ const json = yield* Schema.encodeUnknownEffect(Schema.fromJsonString(ReviewState))(state).pipe(Effect.mapError((cause) => authenticationFailure("sign", cause)));
794
+ const payload = Encoding.encodeBase64(json);
795
+ const message = new TextEncoder().encode(`${STATE_SIGNATURE_DOMAIN}${payload}`);
796
+ const key = yield* hmacKey(secret, "sign");
797
+ const signature = yield* Effect.tryPromise({
798
+ try: () => globalThis.crypto.subtle.sign("HMAC", key, message),
799
+ catch: (cause) => authenticationFailure("sign", cause)
800
+ });
801
+ const hex = Array.from(new Uint8Array(signature)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
802
+ const marker = `${STATE_MARKER_PREFIX}${payload}.${hex}${STATE_MARKER_SUFFIX}`;
803
+ if (marker.length > 24e3) return yield* ReviewStateMarkerTooLarge.make({
804
+ observedChars: marker.length,
805
+ maximumChars: MAX_REVIEW_STATE_MARKER_CHARS
806
+ });
807
+ return yield* Schema.decodeUnknownEffect(ReviewStateMarker)(marker).pipe(Effect.mapError((cause) => authenticationFailure("sign", cause)));
808
+ }),
809
+ extract: (body) => {
810
+ if (body.length > 6e4) return Effect.succeed(Option.none());
811
+ const match = STATE_MARKER_PATTERN.exec(body);
812
+ const payload = match?.[1];
813
+ const signature = match?.[2];
814
+ if (payload === void 0 || signature === void 0) return Effect.succeed(Option.none());
815
+ const marker = `${STATE_MARKER_PREFIX}${payload}.${signature}${STATE_MARKER_SUFFIX}`;
816
+ if (!Schema.is(ReviewStateMarker)(marker)) return Effect.succeed(Option.none());
817
+ const json = Result.getOrUndefined(Encoding.decodeBase64String(payload));
818
+ if (json === void 0) return Effect.succeed(Option.none());
819
+ const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(ReviewState))(json);
820
+ if (Option.isNone(decoded)) return Effect.succeed(Option.none());
821
+ const message = new TextEncoder().encode(`${STATE_SIGNATURE_DOMAIN}${payload}`);
822
+ return Effect.gen(function* () {
823
+ const key = yield* hmacKey(secret, "verify");
824
+ return (yield* Effect.tryPromise({
825
+ try: () => globalThis.crypto.subtle.verify("HMAC", key, signatureBytes(signature), message),
826
+ catch: (cause) => authenticationFailure("verify", cause)
827
+ })) ? Option.some(decoded.value) : Option.none();
828
+ });
731
829
  }
732
- const undiffable = new Set(input.files.filter((file) => !isReviewableFile(file)).map((file) => file.path));
733
- const unassigned = requiredPaths.filter((path) => !undiffable.has(path) && (!assigned.has(path) || failedPaths.has(path)));
734
- const reasons = [];
735
- if (input.files.length < input.totalFiles) reasons.push(`review range exposed ${input.files.length} of ${input.totalFiles} required files`);
736
- if (undiffable.size > 0) reasons.push(boundedListReason("required paths have no reviewable diff or bounded text", undiffable));
737
- if (failedPaths.size > 0) reasons.push(boundedListReason("diff reads failed", failedPaths));
738
- if (partial.size > 0) reasons.push(boundedListReason("model-visible diff evidence was truncated", partial));
739
- if (unassigned.length > 0) reasons.push(boundedListReason("required paths received no successful diff input", unassigned));
740
- return {
741
- inputCoverage: anchorSurfaceAdjusted(ReviewInputCoverage.make({
742
- status: reasons.length === 0 ? "complete" : "incomplete",
743
- requiredPaths,
744
- assignedPaths: sortedUnique(assigned),
745
- partialPaths: sortedUnique(partial),
746
- unassignedPaths: sortedUnique(unassigned),
747
- undiffablePaths: sortedUnique(undiffable),
748
- reasons
749
- }), input.anchorFiles, input.totalAnchorFiles),
750
- assurance: flatAssurance(),
751
- unreviewedPaths: sortedUnique([...unassigned, ...undiffable])
752
- };
830
+ }));
831
+ /** Explicit no-state implementation for hosts without a stable authentication secret. */
832
+ const unavailableReviewStateAuthenticatorLayer = (reason) => {
833
+ const safeReason = reason === "" ? "review-state authentication is unavailable" : reason;
834
+ return Layer.succeed(ReviewStateAuthenticator)(ReviewStateAuthenticator.of({
835
+ status: "unavailable",
836
+ unavailableReason: safeReason.slice(0, 1e3),
837
+ render: () => Effect.fail(ReviewStateAuthenticationFailure.make({
838
+ operation: "sign",
839
+ reason: safeReason.slice(0, 2048)
840
+ })),
841
+ extract: () => Effect.succeed(Option.none())
842
+ }));
753
843
  };
844
+ /** The bounded result of GitHub's previous-head...current-head comparison. */
845
+ var ReviewHeadComparison = class extends Schema.Class("@effect-agent/pr-review/ReviewHeadComparison")({
846
+ status: Schema.Literals([
847
+ "ahead",
848
+ "behind",
849
+ "diverged",
850
+ "identical"
851
+ ]),
852
+ baseSha: GitCommitSha,
853
+ headSha: GitCommitSha,
854
+ mergeBaseSha: GitCommitSha,
855
+ files: Schema.Array(ChangedFile).check(Schema.isMaxLength(300)),
856
+ /** GitHub caps compare-file payloads at 300; equality is conservatively truncated. */
857
+ truncated: Schema.Boolean
858
+ }) {};
859
+ const fullReviewSelection = (input) => ({
860
+ mode: "full",
861
+ reason: input.reason,
862
+ files: input.files,
863
+ affectedPaths: input.files.flatMap((file) => file.previousPath === void 0 ? [file.path] : [file.path, file.previousPath]),
864
+ retryPaths: [],
865
+ retryStages: [],
866
+ totalFiles: input.totalFiles,
867
+ baselineSha: void 0,
868
+ priorState: void 0,
869
+ profileFingerprint: input.profileFingerprint
870
+ });
871
+ /** Three-dot lineage from the reviewed head to the current head is usable. */
872
+ const isLineageAncestor = (comparison, priorState, currentHeadSha) => comparison.baseSha === priorState.reviewedHeadSha && comparison.headSha === currentHeadSha && comparison.mergeBaseSha === priorState.reviewedHeadSha && !comparison.truncated && (comparison.status === "ahead" || comparison.status === "identical");
754
873
  /**
755
- * Input coverage of one host-scheduled fan-out plan: which required paths the
756
- * bounded plan actually assigned complete evidence for. Capacity overflow and
757
- * undiffable paths are both real gaps; the pipeline carries them so the check
758
- * stays fail-closed until they are reviewed, removed, or explicitly ignored.
874
+ * Validate that persisted state belongs to this exact PR/base lineage and the
875
+ * same review profile. A mismatch is a full-review reason, never an error that
876
+ * silently suppresses review work.
759
877
  */
760
- const fanOutInputCoverage = (input) => {
761
- const plan = input.plan;
762
- const assignedPaths = sortedUnique(plan.units.flatMap((unit) => unit.paths));
763
- const unassignedPaths = sortedUnique(plan.unassignedPaths);
764
- const reasons = [];
765
- if (plan.truncated) reasons.push(`review range exposed ${input.files.length} of ${input.totalFiles} required files`);
766
- if (plan.undiffablePaths.length > 0) reasons.push(boundedListReason("required paths have no reviewable diff or bounded text", plan.undiffablePaths));
767
- if (plan.partialEvidencePaths.length > 0) reasons.push(boundedListReason("fan-out capacity left some deterministic evidence shards unassigned", plan.partialEvidencePaths));
768
- if (plan.unassignedEvidenceShardCount > 0) {
769
- reasons.push(`${plan.unassignedEvidenceShardCount} deterministic evidence shard(s) exceeded fan-out capacity`);
770
- reasons.push(boundedListReason(`unassigned evidence shard identifier sample (${plan.unassignedEvidenceShardIds.length} of ${plan.unassignedEvidenceShardCount})`, plan.unassignedEvidenceShardIds));
878
+ const validateReviewState = (state, current, profileFingerprint) => {
879
+ if (state.repository !== current.repository || state.pullRequestNumber !== current.number) return "stored state belongs to a different pull request";
880
+ if (current.baseSha === void 0) return "the current base commit is unavailable";
881
+ if (state.baseRef !== current.baseRef) return "the pull request base ref changed";
882
+ if (state.headRef !== current.headRef) return "the pull request head ref changed";
883
+ if (state.profileFingerprint !== profileFingerprint) return "the reviewer profile or model configuration changed";
884
+ if (state.unresolvedConcerns.some((concern) => concern.evidencePaths === void 0)) return "stored concerns predate affected-path tracking";
885
+ };
886
+ const filePaths = (file) => file.previousPath === void 0 ? [file.path] : [file.path, file.previousPath];
887
+ const incrementalFromDelta = (input) => {
888
+ const currentPaths = new Set(input.fullFiles.flatMap(filePaths));
889
+ const affectedPaths = /* @__PURE__ */ new Set([...input.deltaFiles.flatMap(filePaths), ...input.extraAffectedPaths ?? []]);
890
+ const initialAffectedCount = affectedPaths.size;
891
+ let expanded = true;
892
+ while (expanded) {
893
+ expanded = false;
894
+ for (const concern of input.priorState.unresolvedConcerns) {
895
+ const paths = concern.evidencePaths ?? [];
896
+ if (!paths.some((path) => affectedPaths.has(path))) continue;
897
+ for (const path of paths) if (!affectedPaths.has(path)) {
898
+ affectedPaths.add(path);
899
+ expanded = true;
900
+ }
901
+ }
771
902
  }
772
- if (plan.unassignedPaths.length > 0) reasons.push(boundedListReason("fan-out capacity left paths unassigned", plan.unassignedPaths));
773
- return anchorSurfaceAdjusted(ReviewInputCoverage.make({
774
- status: reasons.length === 0 ? "complete" : "incomplete",
775
- requiredPaths: sortedUnique(input.files.map((file) => file.path)),
776
- assignedPaths,
777
- partialPaths: plan.partialEvidencePaths,
778
- unassignedPaths,
779
- undiffablePaths: sortedUnique(plan.undiffablePaths),
780
- reasons
781
- }), input.anchorFiles, input.totalAnchorFiles);
903
+ const selectedByPath = /* @__PURE__ */ new Map();
904
+ for (const file of input.deltaFiles) if (currentPaths.has(file.path) || file.previousPath !== void 0 && currentPaths.has(file.previousPath)) selectedByPath.set(file.path, file);
905
+ const carriedPaths = input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path));
906
+ const retryOnly = /* @__PURE__ */ new Set();
907
+ const retryStages = /* @__PURE__ */ new Set();
908
+ for (const path of carriedPaths) {
909
+ if (affectedPaths.has(path)) continue;
910
+ retryOnly.add(path);
911
+ for (const pass of input.priorState.unreviewedPasses) if (pass.paths.includes(path)) retryStages.add(pass.stage);
912
+ }
913
+ if (retryStages.has("verification") && !retryStages.has("discovery") && !retryStages.has("specialist")) {
914
+ retryStages.add("discovery");
915
+ retryStages.add("specialist");
916
+ }
917
+ if (retryOnly.size > 0 && retryStages.size === 0) {
918
+ for (const path of retryOnly) affectedPaths.add(path);
919
+ retryStages.add("discovery");
920
+ retryStages.add("specialist");
921
+ retryStages.add("verification");
922
+ }
923
+ for (const file of input.fullFiles) if (affectedPaths.has(file.path) || file.previousPath !== void 0 && affectedPaths.has(file.previousPath) || retryOnly.has(file.path) || file.previousPath !== void 0 && retryOnly.has(file.previousPath)) selectedByPath.set(file.path, file);
924
+ const selectedFiles = [...selectedByPath.values()].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
925
+ const leftoverCount = [...retryOnly].filter((path) => !affectedPaths.has(path)).length;
926
+ const carriedReason = leftoverCount > 0 ? `; retrying ${leftoverCount} unchanged leftover path(s) without rediscovery` : carriedPaths.length > 0 ? `; retrying ${carriedPaths.length} carried unreviewed path(s)` : "";
927
+ const concernPathCount = affectedPaths.size - initialAffectedCount;
928
+ const concernReason = concernPathCount === 0 ? "" : `; reopening ${concernPathCount} related concern path(s) for context`;
929
+ return {
930
+ mode: "incremental",
931
+ reason: `${input.reason}${carriedReason}${concernReason}`,
932
+ files: selectedFiles,
933
+ affectedPaths: [...affectedPaths].sort(),
934
+ retryPaths: [...retryOnly].filter((path) => !affectedPaths.has(path)).sort(),
935
+ retryStages: [...retryStages].sort(),
936
+ totalFiles: selectedFiles.length,
937
+ baselineSha: input.priorState.reviewedHeadSha,
938
+ priorState: input.priorState,
939
+ profileFingerprint: input.profileFingerprint
940
+ };
782
941
  };
783
- /** Compatibility aggregate over the two precise claims. */
784
- const compatibilityCoverage = (inputCoverage, assurance) => {
785
- const assuranceIncomplete = assurance.status === "incomplete";
786
- const failedUnits = /* @__PURE__ */ new Map();
787
- for (const pass of assurance.failedPasses) {
788
- const unitId = pass.workId.slice(0, 8);
789
- if (!failedUnits.has(unitId)) failedUnits.set(unitId, FailedReviewUnit.make({
790
- unitId,
791
- errorTag: `${pass.stage}:${pass.errorTag}`
792
- }));
942
+ /** Pure, deterministic range selection with conservative full-review fallbacks. */
943
+ const selectReviewRange = (input) => {
944
+ const full = (reason) => fullReviewSelection({
945
+ reason,
946
+ files: input.fullFiles,
947
+ totalFiles: input.current.totalChangedFiles,
948
+ profileFingerprint: input.profileFingerprint
949
+ });
950
+ if (input.requestedMode === "final") return full("explicit final full-diff audit requested");
951
+ if (input.lookupFailure !== void 0) return full(`stored review state could not be recovered: ${input.lookupFailure}`);
952
+ if (input.priorState === void 0) return full("no compatible stored review state was found");
953
+ const invalid = validateReviewState(input.priorState, input.current, input.profileFingerprint);
954
+ if (invalid !== void 0) return full(invalid);
955
+ const comparison = input.comparison;
956
+ if (comparison !== void 0 && isLineageAncestor(comparison, input.priorState, input.current.headSha)) {
957
+ const extraAffected = [];
958
+ let baseReason = "";
959
+ if (input.priorState.baseSha !== input.current.baseSha) {
960
+ const baseComparison = input.baseComparison;
961
+ if (baseComparison === void 0) return full("the pull request base changed and its lineage comparison was unavailable");
962
+ if (baseComparison.baseSha !== input.priorState.baseSha || baseComparison.headSha !== input.current.baseSha || baseComparison.mergeBaseSha !== input.priorState.baseSha || baseComparison.status !== "ahead" && baseComparison.status !== "identical" || baseComparison.truncated) return full("the pull request base changed materially or exceeded the comparison bound");
963
+ for (const file of baseComparison.files) extraAffected.push(...filePaths(file));
964
+ baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
965
+ }
966
+ return incrementalFromDelta({
967
+ current: input.current,
968
+ fullFiles: input.fullFiles,
969
+ profileFingerprint: input.profileFingerprint,
970
+ priorState: input.priorState,
971
+ deltaFiles: comparison.files,
972
+ extraAffectedPaths: extraAffected,
973
+ reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`
974
+ });
793
975
  }
794
- return ReviewCoverage.make({
795
- status: inputCoverage.status === "complete" && !assuranceIncomplete ? "complete" : "incomplete",
796
- requiredPaths: inputCoverage.requiredPaths,
797
- reviewedPaths: inputCoverage.assignedPaths,
798
- unreviewedPaths: sortedUnique([...inputCoverage.partialPaths, ...inputCoverage.unassignedPaths]),
799
- failedUnits: [...failedUnits.values()].slice(0, 8),
800
- reasons: [...inputCoverage.reasons, ...assuranceIncomplete ? assurance.reasons : []]
976
+ const contentComparison = input.contentComparison;
977
+ if (contentComparison !== void 0 && !contentComparison.truncated) return incrementalFromDelta({
978
+ current: input.current,
979
+ fullFiles: input.fullFiles,
980
+ profileFingerprint: input.profileFingerprint,
981
+ priorState: input.priorState,
982
+ deltaFiles: contentComparison.files,
983
+ reason: `rewritten history; contents changed since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}`
801
984
  });
985
+ if (comparison === void 0) return full("the incremental head comparison was unavailable");
986
+ if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
987
+ return full("the prior reviewed head is not an ancestor of the current head");
802
988
  };
803
- //#endregion
804
- //#region src/internal/review-units.ts
805
- /** The delegation fan-out bound: one parent Run spawns at most this many children. */
806
- const MAX_REVIEW_UNITS = 8;
807
- /** A unit never carries more files than this, regardless of their size. */
808
- const MAX_UNIT_FILES = 12;
809
- /** Compatibility export; complete evidence chars now own unit packing. */
810
- const UNIT_CHANGED_LINE_BUDGET = 800;
989
+ /** Per-run context consumed by orchestration and publication, not by the model. */
990
+ var ReviewExecutionContext = class extends Context.Service()("@effect-agent/pr-review/ReviewExecutionContext") {};
811
991
  /**
812
- * Bound the complete model-visible evidence assigned to one child. This is a
813
- * character bound rather than a token estimate because it is deterministic,
814
- * provider-independent, and enforced before any model call.
992
+ * Explicit direct-run adapter for callers that intentionally review the full
993
+ * source without authenticated incremental continuity.
815
994
  */
816
- const UNIT_EVIDENCE_CHAR_BUDGET = 24e4;
817
- /** Maximum complete evidence shards placed in one child brief. */
818
- const MAX_UNIT_EVIDENCE_SHARDS = 12;
995
+ const fullReviewExecutionContextLayer = (reason) => Layer.effect(ReviewExecutionContext, Effect.gen(function* () {
996
+ const source = yield* PullRequestSource;
997
+ const [metadata, files] = yield* Effect.all([source.metadata, source.changedFiles]);
998
+ return fullReviewSelection({
999
+ reason,
1000
+ files,
1001
+ totalFiles: metadata.totalChangedFiles
1002
+ });
1003
+ }));
819
1004
  /**
820
- * Keep overflow diagnostics bounded to one plan's total assignment capacity.
821
- * The plan separately records the exact overflow count and every affected
822
- * path, so identifiers are a deterministic diagnostic sample rather than the
823
- * authority for whether input coverage is complete.
1005
+ * Decorate the full source with the selected review range. Full anchor files
1006
+ * remain available to host-side publication validation; model tools see only
1007
+ * the selected delta and may read head context only for that delta's paths.
824
1008
  */
825
- const MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS = 96;
826
- /** @deprecated Use `MAX_PATCH_CHARS`; this is now the per-shard bound. */
827
- const MAX_FILE_EVIDENCE_CHARS = MAX_PATCH_CHARS;
828
- /** The merged review never exceeds the `CodeReview` findings bound. */
829
- const MAX_MERGED_FINDINGS = 20;
830
- const ReviewUnitId = Schema.NonEmptyString.check(Schema.isMaxLength(32));
831
- /** High-risk surfaces that receive an explicit specialist focus label. */
832
- const ReviewRiskCategory = Schema.Literals([
833
- "authentication-authorization",
834
- "security-boundary",
835
- "persistence-durability",
836
- "concurrency",
837
- "credential-handling",
838
- "external-side-effects"
839
- ]);
840
- const ReviewDiscoveryPerspective = Schema.Literals(["general", "risk-specialist"]);
841
- const ReviewPassId = Schema.NonEmptyString.check(Schema.isMaxLength(64));
842
- const ReviewEvidenceShardId = Schema.NonEmptyString.check(Schema.isMaxLength(32));
843
- /** One complete bounded slice of a changed path's model-visible evidence. */
844
- var ReviewEvidenceShard = class extends Schema.Class("@effect-agent/pr-review/ReviewEvidenceShard")({
845
- shardId: ReviewEvidenceShardId,
846
- path: ChangedPath,
847
- ordinal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
848
- total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
849
- evidenceChars: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).check(Schema.isLessThanOrEqualTo(MAX_PATCH_CHARS))
850
- }) {};
851
- const EvidenceShardIds$1 = Schema.Array(ReviewEvidenceShardId).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12));
852
- /** One required, independently scoped discovery attempt. */
853
- var ReviewDiscoveryPass = class extends Schema.Class("@effect-agent/pr-review/ReviewDiscoveryPass")({
854
- passId: ReviewPassId,
855
- unitId: ReviewUnitId,
856
- paths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12)),
857
- evidenceShardIds: EvidenceShardIds$1,
858
- perspective: ReviewDiscoveryPerspective,
859
- /** Empty for the general pass; explicit deterministic focus for specialists. */
860
- riskCategories: Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6))
861
- }) {};
862
- /** One bounded slice of the changeset delegated to one child reviewer. */
863
- var ReviewUnit = class extends Schema.Class("@effect-agent/pr-review/ReviewUnit")({
864
- unitId: ReviewUnitId,
865
- paths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12)),
866
- evidenceShards: Schema.Array(ReviewEvidenceShard).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12)),
867
- /** additions + deletions across the unit's files, for honest sizing. */
868
- changedLines: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
869
- /** Complete model-visible diff/content evidence assigned to each child. */
870
- evidenceChars: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).check(Schema.isLessThanOrEqualTo(UNIT_EVIDENCE_CHAR_BUDGET)),
871
- /** Host-classified focus labels for the unit's redundant specialist pass. */
872
- riskCategories: Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6))
1009
+ const selectedPullRequestSourceLayer = (selection) => Layer.effect(PullRequestSource)(Effect.gen(function* () {
1010
+ const source = yield* PullRequestSource;
1011
+ const selectedPaths = new Set(selection.files.map((file) => file.path));
1012
+ const selectedFiles = source.changedFiles.pipe(Effect.map((fullFiles) => {
1013
+ const fullByPath = new Map(fullFiles.map((file) => [file.path, file]));
1014
+ return selection.files.map((file) => {
1015
+ if (file.patch !== void 0) return file;
1016
+ const full = fullByPath.get(file.path);
1017
+ return full === void 0 ? file : ChangedFile.make({
1018
+ ...file,
1019
+ ...full.reviewBaseContent === void 0 ? {} : { reviewBaseContent: full.reviewBaseContent },
1020
+ ...full.reviewHeadContent === void 0 ? {} : { reviewHeadContent: full.reviewHeadContent }
1021
+ });
1022
+ });
1023
+ }));
1024
+ return PullRequestSource.of({
1025
+ metadata: source.metadata,
1026
+ changedFiles: selectedFiles,
1027
+ anchorFiles: source.anchorFiles,
1028
+ readFile: (path) => selectedPaths.has(path) ? source.readFile(path) : Effect.fail(ReviewInputViolation.make({
1029
+ input: path,
1030
+ reason: "Path is outside this incremental review range."
1031
+ }))
1032
+ });
1033
+ }));
1034
+ /** Build the full-surface mission used only to resolve profile guidance. */
1035
+ const buildProfileMission = (metadata, files) => ReviewMission.make({
1036
+ repository: metadata.repository,
1037
+ number: metadata.number,
1038
+ title: metadata.title,
1039
+ body: metadata.body,
1040
+ baseRef: metadata.baseRef,
1041
+ headRef: metadata.headRef,
1042
+ changedFileCount: files.length
1043
+ });
1044
+ //#endregion
1045
+ //#region src/internal/retirement.ts
1046
+ const PositiveLine$1 = Schema.Int.check(Schema.isGreaterThan(0));
1047
+ /** One previously posted review as observed through the retirement host. */
1048
+ var RetirableReview = class extends Schema.Class("@effect-agent/pr-review/RetirableReview")({
1049
+ reviewId: Schema.Int.check(Schema.isGreaterThan(0)),
1050
+ body: Schema.String.check(Schema.isMaxLength(6e4)),
1051
+ commitSha: Schema.NonEmptyString.check(Schema.isMaxLength(64)),
1052
+ authorNodeId: Schema.NullOr(Schema.NonEmptyString.check(Schema.isMaxLength(200))),
1053
+ submittedAt: Schema.NullOr(Schema.DateTimeUtc)
873
1054
  }) {};
874
- /** The complete deterministic fan-out plan over one changeset. */
875
- var ReviewUnitPlan = class extends Schema.Class("@effect-agent/pr-review/ReviewUnitPlan")({
876
- totalFiles: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
877
- /** True when the source returned fewer files than the pull request has. */
878
- truncated: Schema.Boolean,
879
- units: Schema.Array(ReviewUnit).check(Schema.isMaxLength(8)),
880
- /** Exact discovery calls the coordinator must make. */
881
- discoveryPasses: Schema.Array(ReviewDiscoveryPass).check(Schema.isMaxLength(16)),
882
- /** Changed files with neither a textual diff nor bounded base/head text. */
883
- undiffablePaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
884
- /** Assigned paths with one or more evidence shards beyond plan capacity. */
885
- partialEvidencePaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
886
- /** Exact number of shards beyond the bounded unit capacity. */
887
- unassignedEvidenceShardCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
888
- /** Bounded deterministic prefix of the unassigned shard identifiers. */
889
- unassignedEvidenceShardIds: Schema.Array(ReviewEvidenceShardId).check(Schema.isMaxLength(96)),
890
- /**
891
- * Diffable files beyond the fan-out capacity (MAX_REVIEW_UNITS units of
892
- * MAX_UNIT_FILES files). Never silently dropped: the coordinator must name
893
- * them as unreviewed in its summary.
894
- */
895
- unassignedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300))
1055
+ /** One inline comment attached to a previously posted review. */
1056
+ var RetirableReviewComment = class extends Schema.Class("@effect-agent/pr-review/RetirableReviewComment")({
1057
+ nodeId: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
1058
+ path: Schema.NonEmptyString.check(Schema.isMaxLength(500)),
1059
+ startLine: Schema.NullOr(PositiveLine$1),
1060
+ endLine: Schema.NullOr(PositiveLine$1),
1061
+ body: Schema.String.check(Schema.isMaxLength(65536))
896
1062
  }) {};
897
- const riskRules = [
898
- {
899
- category: "authentication-authorization",
900
- patterns: [
901
- /auth/,
902
- /authoriz/,
903
- /permission/,
904
- /principal/,
905
- /access[-_ ]?control/,
906
- /role\b/
907
- ]
908
- },
909
- {
910
- category: "security-boundary",
911
- patterns: [
912
- /security/,
913
- /sandbox/,
914
- /untrusted/,
915
- /schema\.decode/,
916
- /validation/,
917
- /injection/,
918
- /csrf/,
919
- /xss/,
920
- /path traversal/
921
- ]
922
- },
923
- {
924
- category: "persistence-durability",
925
- patterns: [
926
- /durab/,
927
- /persist/,
928
- /storage/,
929
- /database/,
930
- /\bsql\b/,
931
- /journal/,
932
- /ledger/,
933
- /checkpoint/,
934
- /migration/,
935
- /transaction/
936
- ]
937
- },
938
- {
939
- category: "concurrency",
940
- patterns: [
941
- /concurr/,
942
- /semaphore/,
943
- /\bfiber/,
944
- /race/,
945
- /mutex/,
946
- /\block\b/,
947
- /queue/,
948
- /parallel/,
949
- /interrupt/
950
- ]
951
- },
952
- {
953
- category: "credential-handling",
954
- patterns: [
955
- /credential/,
956
- /secret/,
957
- /password/,
958
- /api[-_ ]?key/,
959
- /bearer/,
960
- /hmac/,
961
- /signature/
962
- ]
963
- },
964
- {
965
- category: "external-side-effects",
966
- patterns: [
967
- /publish/,
968
- /webhook/,
969
- /github/,
970
- /fetch\(/,
971
- /http/,
972
- /send[-_ ]?(email|message)/,
973
- /write[-_ ]?(file|record)/,
974
- /delete/,
975
- /mutation/,
976
- /side[-_ ]?effect/,
977
- /spawn/,
978
- /exec/
979
- ]
1063
+ /** A GitHub retirement read or mutation failed. */
1064
+ var ReviewRetirementFailure = class extends Schema.TaggedError()("ReviewRetirementFailure", {
1065
+ operation: Schema.String,
1066
+ reason: Schema.String
1067
+ }) {
1068
+ get message() {
1069
+ return `Review retirement operation '${this.operation}' failed: ${this.reason}`;
980
1070
  }
981
- ];
1071
+ };
982
1072
  /**
983
- * Deterministic host policy for specialist assignment. It intentionally
984
- * favors false positives: an extra bounded pass costs work, while a missed
985
- * high-risk classification removes redundancy. This is not a claim that the
986
- * keyword policy recognizes every semantically risky change.
1073
+ * Host-side GitHub operations used by retirement. Domain code never reaches
1074
+ * into REST or GraphQL directly, and deterministic tests substitute this port.
987
1075
  */
988
- const classifyReviewRisks = (file) => {
989
- const text = [
990
- file.path,
991
- file.previousPath ?? "",
992
- file.patch ?? "",
993
- file.reviewBaseContent ?? "",
994
- file.reviewHeadContent ?? ""
995
- ].join("\n").toLowerCase();
996
- return riskRules.filter((rule) => rule.patterns.some((pattern) => pattern.test(text))).map((rule) => rule.category);
997
- };
1076
+ var ReviewRetirementHost = class extends Context.Service()("@effect-agent/pr-review/ReviewRetirementHost") {};
1077
+ /** Observable cosmetic work completed by one fail-open retirement pass. */
1078
+ var ReviewRetirementReport = class extends Schema.Class("@effect-agent/pr-review/ReviewRetirementReport")({
1079
+ reviewsRetired: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1080
+ findingsResolved: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1081
+ commentsMinimized: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1082
+ failures: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
1083
+ }) {};
1084
+ const REVIEW_METADATA_PATTERN = /<!-- effect-agent-pr-review metadata\n[\s\S]*?\n-->/g;
1085
+ const FINGERPRINT_PATTERN = /<!-- effect-agent-pr-review fingerprint=sha256:[0-9a-f]{64} -->/g;
1086
+ const STATE_PATTERN = /<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->/g;
1087
+ const RETIRED_ORIGINAL_PATTERN = /<!-- effect-agent-pr-review retired-original:start -->\n([\s\S]*?)\n<!-- effect-agent-pr-review retired-original:end -->/;
1088
+ const MACHINE_COMMENT_PATTERN = new RegExp(`${REVIEW_METADATA_PATTERN.source}|${FINGERPRINT_PATTERN.source}|${STATE_PATTERN.source}`, "g");
1089
+ const VERDICT_CALLOUT_PATTERN = /^(?:> \[!(?:CAUTION|IMPORTANT)\]\n> [^\n]*(?:\n> [^\n]*)*|> (?:ℹ️|✅)[^\n]*)\n*/;
998
1090
  /**
999
- * Whether every claimed finding anchor was present in the exact bounded
1000
- * evidence shards assigned to one unit. This is stricter than checking the
1001
- * full pull-request diff when an oversized path spans multiple units.
1091
+ * The first line of every inline finding comment this package posts. Shared
1092
+ * with adjudication so both parse the identical title shape.
1002
1093
  */
1003
- const findingAnchorInUnitEvidence = (finding, unit, files) => {
1004
- const file = files.find((candidate) => candidate.path === finding.path);
1005
- if (file?.patch === void 0 || finding.endLine < finding.startLine) return false;
1006
- const assignedOrdinals = new Set(unit.evidenceShards.filter((shard) => shard.path === finding.path).map((shard) => shard.ordinal));
1007
- const visibleLines = /* @__PURE__ */ new Set();
1008
- const chunks = fileReviewEvidenceChunks(file);
1009
- for (let index = 0; index < chunks.length; index += 1) {
1010
- if (!assignedOrdinals.has(index + 1)) continue;
1011
- for (const line of chunks[index]?.annotatedPatch.split("\n") ?? []) {
1012
- const match = /^R(\d+) /.exec(line);
1013
- if (match?.[1] !== void 0) visibleLines.add(Number(match[1]));
1014
- }
1015
- }
1016
- for (let line = finding.startLine; line <= finding.endLine; line += 1) if (!visibleLines.has(line)) return false;
1017
- return true;
1018
- };
1019
- const uniquePaths = (shards) => [...new Set(shards.map(({ shard }) => shard.path))];
1020
- const plannedEvidenceShards = (files) => {
1021
- const planned = [];
1022
- let shardIndex = 0;
1023
- for (const file of files) {
1024
- const chunks = fileReviewEvidenceChunks(file);
1025
- for (let index = 0; index < chunks.length; index += 1) {
1026
- const chunk = chunks[index];
1027
- if (chunk === void 0) continue;
1028
- shardIndex += 1;
1029
- planned.push({
1030
- shard: ReviewEvidenceShard.make({
1031
- shardId: `shard-${String(shardIndex).padStart(4, "0")}`,
1032
- path: file.path,
1033
- ordinal: index + 1,
1034
- total: chunks.length,
1035
- evidenceChars: chunk.annotatedPatch.length
1036
- }),
1037
- file,
1038
- changedLines: index === 0 ? file.additions + file.deletions : 0
1039
- });
1040
- }
1041
- }
1042
- return planned;
1094
+ const INLINE_FINDING_TITLE_PATTERN = /^\*\*\[(?:🛑 blocking|⚠️ important|💅 nit) · [a-z-]+\] ([^\n]+)\*\*$/;
1095
+ const MAX_REVIEW_BODY_CHARS = 6e4;
1096
+ /** The host-authored metadata marker is the authority gate for any edit. */
1097
+ const hasReviewMetadataMarker = (body) => /<!-- effect-agent-pr-review metadata\n/.test(body);
1098
+ const machineComments = (body) => Array.from(body.matchAll(MACHINE_COMMENT_PATTERN), (match) => match[0]);
1099
+ const originalVisibleBody = (body) => {
1100
+ const retired = RETIRED_ORIGINAL_PATTERN.exec(body)?.[1];
1101
+ if (retired !== void 0) return retired;
1102
+ return body.replace(MACHINE_COMMENT_PATTERN, "").trim().replace(VERDICT_CALLOUT_PATTERN, "");
1103
+ };
1104
+ const findingLocation = (finding) => `${finding.path}:${finding.startLine}${finding.endLine === finding.startLine ? "" : `-${finding.endLine}`}`;
1105
+ const renderRetiredBody = (input) => {
1106
+ const shortSha = input.currentState.reviewedHeadSha.slice(0, 7);
1107
+ const comments = machineComments(input.priorBody);
1108
+ const original = originalVisibleBody(input.priorBody);
1109
+ const resolved = input.resolvedFindings.length === 0 ? [] : [
1110
+ "### Findings resolved by later review",
1111
+ "",
1112
+ ...input.resolvedFindings.map((finding) => `- \`${findingLocation(finding)}\` ~~${finding.title}~~ · resolved at \`${shortSha}\``),
1113
+ ""
1114
+ ];
1115
+ const prefix = [
1116
+ `> ℹ️ Superseded ${input.resolvedFindings.length} of ${input.priorState.unresolvedFindings.length} findings resolved at \`${shortSha}\`; see [the latest review](${input.currentReviewUrl}).`,
1117
+ "",
1118
+ "<details>",
1119
+ "<summary>Previous review details</summary>",
1120
+ "",
1121
+ ...resolved,
1122
+ "<!-- effect-agent-pr-review retired-original:start -->"
1123
+ ];
1124
+ const suffix = [
1125
+ "<!-- effect-agent-pr-review retired-original:end -->",
1126
+ "",
1127
+ "</details>",
1128
+ ...comments.length === 0 ? [] : ["", ...comments]
1129
+ ];
1130
+ const render = (visible) => [
1131
+ ...prefix,
1132
+ visible,
1133
+ ...suffix
1134
+ ].join("\n");
1135
+ if (render(original).length <= MAX_REVIEW_BODY_CHARS) return render(original);
1136
+ const truncationNotice = "\n\n_Original review content truncated during retirement._";
1137
+ const budget = Math.max(0, MAX_REVIEW_BODY_CHARS - render(truncationNotice).length);
1138
+ return render(`${original.slice(0, budget)}${truncationNotice}`);
1139
+ };
1140
+ /** Compute one prior review's resolved subset and deterministic retired body. */
1141
+ const decideReviewRetirement = (input) => {
1142
+ const current = new Set(input.currentState.unresolvedFindings.map(findingIdentity));
1143
+ const adjudicated = new Set((input.currentState.adjudications ?? []).map((entry) => adjudicationIdentity(entry)));
1144
+ const resolvedFindings = input.priorState.unresolvedFindings.filter((finding) => !current.has(findingIdentity(finding)) && !adjudicated.has(findingIdentity(finding)));
1145
+ return {
1146
+ body: renderRetiredBody({
1147
+ ...input,
1148
+ resolvedFindings
1149
+ }),
1150
+ resolvedFindings,
1151
+ priorFindingCount: input.priorState.unresolvedFindings.length
1152
+ };
1153
+ };
1154
+ const inlineCommentIdentity = (comment) => {
1155
+ if (comment.startLine === null || comment.endLine === null) return void 0;
1156
+ const firstLine = comment.body.split("\n", 1)[0] ?? "";
1157
+ const title = INLINE_FINDING_TITLE_PATTERN.exec(firstLine)?.[1];
1158
+ return title === void 0 ? void 0 : findingIdentity({
1159
+ path: comment.path,
1160
+ startLine: comment.startLine,
1161
+ endLine: comment.endLine,
1162
+ title
1163
+ });
1164
+ };
1165
+ const failOpen = (effect, fallback, message) => effect.pipe(Effect.catch((error) => Effect.logWarning(`${message}: ${String(error)}`).pipe(Effect.as(fallback))));
1166
+ const isStrictlyOlderReview = (review, input) => {
1167
+ if (review.submittedAt === null) return false;
1168
+ const submittedAt = DateTime.toEpochMillis(review.submittedAt);
1169
+ const currentSubmittedAt = DateTime.toEpochMillis(input.currentSubmittedAt);
1170
+ return submittedAt < currentSubmittedAt || submittedAt === currentSubmittedAt && review.reviewId < input.currentReviewId;
1043
1171
  };
1044
- const unitOf = (index, shards) => ReviewUnit.make({
1045
- unitId: `unit-${String(index + 1).padStart(3, "0")}`,
1046
- paths: uniquePaths(shards),
1047
- evidenceShards: shards.map(({ shard }) => shard),
1048
- changedLines: shards.reduce((total, shard) => total + shard.changedLines, 0),
1049
- evidenceChars: shards.reduce((total, { shard }) => total + shard.evidenceChars, 0),
1050
- riskCategories: [...new Set(shards.flatMap(({ file }) => classifyReviewRisks(file)))]
1051
- });
1052
- const discoveryPassesFor = (units) => units.flatMap((unit) => [ReviewDiscoveryPass.make({
1053
- passId: `${unit.unitId}-general`,
1054
- unitId: unit.unitId,
1055
- paths: unit.paths,
1056
- evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
1057
- perspective: "general",
1058
- riskCategories: []
1059
- }), ReviewDiscoveryPass.make({
1060
- passId: `${unit.unitId}-specialist`,
1061
- unitId: unit.unitId,
1062
- paths: unit.paths,
1063
- evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
1064
- perspective: "risk-specialist",
1065
- riskCategories: unit.riskCategories
1066
- })]);
1067
1172
  /**
1068
- * Group the changeset into at most `MAX_REVIEW_UNITS` review units.
1069
- *
1070
- * Deterministic by construction: files are ordered by path (so files sharing
1071
- * a directory become neighbors — directory affinity without a heuristic),
1072
- * then split into complete line-bounded evidence shards and packed greedily
1073
- * under the hard evidence and per-unit shard bounds. Capacity is finite and
1074
- * explicit:
1075
- *
1076
- * - files without a textual diff are still delegated when the source
1077
- * recovered complete bounded UTF-8 base/head content. Findings from that
1078
- * evidence cannot anchor inline and are reported as concerns;
1079
- * - files with neither form of textual evidence surface in
1080
- * `undiffablePaths` instead of laundering missing coverage;
1081
- * - an oversized path spans as many deterministic shards and units as needed;
1082
- * - shards beyond `MAX_REVIEW_UNITS` full units surface explicitly, so a path
1083
- * is partial only when finite plan capacity is genuinely exhausted.
1173
+ * Retire every marker-bearing prior review against the newest posted state.
1174
+ * Every lookup, edit, and minimization is isolated: retirement is cosmetic
1175
+ * and can never change the run or check outcome.
1084
1176
  */
1085
- const planReviewUnits = (files, options) => {
1086
- const ordered = [...files].sort((left, right) => left.path < right.path ? -1 : 1);
1087
- const reviewable = ordered.filter(isReviewableFile);
1088
- const undiffable = ordered.filter((file) => !isReviewableFile(file));
1089
- const shards = plannedEvidenceShards(reviewable);
1090
- const groups = [];
1091
- const unassigned = [];
1092
- let current = [];
1093
- let currentEvidenceChars = 0;
1094
- for (const shard of shards) {
1095
- const nextPaths = /* @__PURE__ */ new Set([...uniquePaths(current), shard.shard.path]);
1096
- if (current.length >= 12 || nextPaths.size > 12 || current.length > 0 && currentEvidenceChars + shard.shard.evidenceChars > 24e4) {
1097
- groups.push(current);
1098
- current = [];
1099
- currentEvidenceChars = 0;
1100
- }
1101
- if (groups.length >= 8) {
1102
- unassigned.push(shard);
1177
+ const retireStaleReviews = Effect.fn("retireStaleReviews")(function* (input) {
1178
+ const host = yield* ReviewRetirementHost;
1179
+ const authenticator = yield* ReviewStateAuthenticator;
1180
+ if (authenticator.status !== "available") {
1181
+ yield* Effect.logWarning("Skipping stale-review retirement because authenticated review state is unavailable.");
1182
+ return ReviewRetirementReport.make({
1183
+ reviewsRetired: 0,
1184
+ findingsResolved: 0,
1185
+ commentsMinimized: 0,
1186
+ failures: 0
1187
+ });
1188
+ }
1189
+ let failures = 0;
1190
+ let reviewsRetired = 0;
1191
+ let findingsResolved = 0;
1192
+ let commentsMinimized = 0;
1193
+ const reviews = yield* failOpen(host.listReviews, void 0, "Could not list prior reviews");
1194
+ if (reviews === void 0) return ReviewRetirementReport.make({
1195
+ reviewsRetired,
1196
+ findingsResolved,
1197
+ commentsMinimized,
1198
+ failures: 1
1199
+ });
1200
+ for (const review of reviews) {
1201
+ if (review.authorNodeId !== input.currentAuthorNodeId || !isStrictlyOlderReview(review, input) || !hasReviewMetadataMarker(review.body)) continue;
1202
+ const priorState = yield* failOpen(authenticator.extract(review.body), Option.none(), `Could not authenticate prior review ${review.reviewId}`);
1203
+ if (Option.isNone(priorState)) continue;
1204
+ const decision = decideReviewRetirement({
1205
+ priorBody: review.body,
1206
+ priorState: priorState.value,
1207
+ currentState: input.currentState,
1208
+ currentReviewUrl: input.currentReviewUrl
1209
+ });
1210
+ if (yield* failOpen(host.updateBody(review.reviewId, decision.body).pipe(Effect.as(true)), false, `Could not retire prior review ${review.reviewId}`)) {
1211
+ reviewsRetired += 1;
1212
+ findingsResolved += decision.resolvedFindings.length;
1213
+ } else failures += 1;
1214
+ if (decision.resolvedFindings.length === 0) continue;
1215
+ const comments = yield* failOpen(host.listComments(review.reviewId), void 0, `Could not list inline comments for prior review ${review.reviewId}`);
1216
+ if (comments === void 0) {
1217
+ failures += 1;
1103
1218
  continue;
1104
1219
  }
1105
- current.push(shard);
1106
- currentEvidenceChars += shard.shard.evidenceChars;
1220
+ const resolved = new Set(decision.resolvedFindings.map(findingIdentity));
1221
+ for (const comment of comments) {
1222
+ const identity = inlineCommentIdentity(comment);
1223
+ if (identity === void 0 || !resolved.has(identity)) continue;
1224
+ if (yield* failOpen(host.minimizeComment(comment.nodeId).pipe(Effect.as(true)), false, `Could not minimize resolved inline comment ${comment.nodeId}`)) commentsMinimized += 1;
1225
+ else failures += 1;
1226
+ }
1107
1227
  }
1108
- if (current.length > 0 && groups.length < 8) groups.push(current);
1109
- const units = groups.map((group, index) => unitOf(index, group));
1110
- const assignedShardIds = new Set(units.flatMap((unit) => unit.evidenceShards.map((shard) => shard.shardId)));
1111
- const assignedPaths = new Set(shards.filter(({ shard }) => assignedShardIds.has(shard.shardId)).map(({ shard }) => shard.path));
1112
- const unassignedPathsWithEvidence = new Set(unassigned.map(({ shard }) => shard.path));
1113
- return ReviewUnitPlan.make({
1114
- totalFiles: files.length,
1115
- truncated: files.length < options.totalChangedFiles,
1116
- units,
1117
- discoveryPasses: discoveryPassesFor(units),
1118
- undiffablePaths: undiffable.map((file) => file.path),
1119
- partialEvidencePaths: [...unassignedPathsWithEvidence].filter((path) => assignedPaths.has(path)),
1120
- unassignedEvidenceShardCount: unassigned.length,
1121
- unassignedEvidenceShardIds: unassigned.slice(0, 96).map(({ shard }) => shard.shardId),
1122
- unassignedPaths: [...unassignedPathsWithEvidence].filter((path) => !assignedPaths.has(path))
1228
+ return ReviewRetirementReport.make({
1229
+ reviewsRetired,
1230
+ findingsResolved,
1231
+ commentsMinimized,
1232
+ failures
1123
1233
  });
1234
+ });
1235
+ //#endregion
1236
+ //#region src/internal/adjudication.ts
1237
+ const PositiveLine = Schema.Int.check(Schema.isGreaterThan(0));
1238
+ /** Maximum authorized command candidates retained for one inline thread. */
1239
+ const MAX_THREAD_ADJUDICATION_COMMANDS = 100;
1240
+ /** One reply or top-level comment observed through the adjudication host. */
1241
+ var AdjudicationComment = class extends Schema.Class("@effect-agent/pr-review/AdjudicationComment")({
1242
+ body: Schema.String.check(Schema.isMaxLength(65536)),
1243
+ /** GitHub's author_association for the comment author, verbatim. */
1244
+ authorAssociation: Schema.String.check(Schema.isMaxLength(40)),
1245
+ authorLogin: Schema.NonEmptyString.check(Schema.isMaxLength(100)),
1246
+ /** Creation time; a comment without one loses every later-wins tie. */
1247
+ createdAt: Schema.NullOr(Schema.DateTimeUtc),
1248
+ /** Stable zero-based order in the source listing, before thread grouping. */
1249
+ sourceOrder: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
1250
+ }) {};
1251
+ /** One of the action's own inline finding threads, replies in creation order. */
1252
+ var AdjudicableThread = class extends Schema.Class("@effect-agent/pr-review/AdjudicableThread")({
1253
+ path: Schema.NonEmptyString.check(Schema.isMaxLength(500)),
1254
+ startLine: Schema.NullOr(PositiveLine),
1255
+ endLine: Schema.NullOr(PositiveLine),
1256
+ /** The root comment's body; its first line carries the finding title. */
1257
+ rootBody: Schema.String.check(Schema.isMaxLength(65536)),
1258
+ replies: Schema.Array(AdjudicationComment).check(Schema.isMaxLength(100))
1259
+ }) {};
1260
+ /** A GitHub adjudication read failed. */
1261
+ var ReviewAdjudicationFailure = class extends Schema.TaggedError()("ReviewAdjudicationFailure", {
1262
+ operation: Schema.String,
1263
+ reason: Schema.String
1264
+ }) {
1265
+ get message() {
1266
+ return `Review adjudication operation '${this.operation}' failed: ${this.reason}`;
1267
+ }
1124
1268
  };
1125
- const severityRank = {
1126
- blocking: 0,
1127
- important: 1,
1128
- nit: 2
1269
+ /**
1270
+ * Host-side GitHub reads used by adjudication. Domain code never reaches into
1271
+ * REST directly, and deterministic tests substitute this port. Both listings
1272
+ * return comments in creation order.
1273
+ */
1274
+ var ReviewAdjudicationHost = class extends Context.Service()("@effect-agent/pr-review/ReviewAdjudicationHost") {};
1275
+ /** Explicit program-edge adapter for runs that intentionally perform no host reads. */
1276
+ const noReviewAdjudicationHost = ReviewAdjudicationHost.of({
1277
+ listFindingThreads: Effect.succeed([]),
1278
+ listIssueComments: Effect.succeed([])
1279
+ });
1280
+ /** Layer form of {@link noReviewAdjudicationHost}. */
1281
+ const noReviewAdjudicationHostLayer = Layer.succeed(ReviewAdjudicationHost)(noReviewAdjudicationHost);
1282
+ /** author_associations allowed to adjudicate; everything else is ignored. */
1283
+ const AUTHORIZED_ADJUDICATION_ASSOCIATIONS = /* @__PURE__ */ new Set([
1284
+ "OWNER",
1285
+ "MEMBER",
1286
+ "COLLABORATOR"
1287
+ ]);
1288
+ const AdjudicationDispositionSchema = Schema.Literals([
1289
+ "accepted-risk",
1290
+ "refuted",
1291
+ "obsolete"
1292
+ ]);
1293
+ const THREAD_COMMAND_PATTERN = /^\/adjudicate[ \t]+([a-z-]+)[ \t]*(?::[ \t]*(.*\S))?[ \t]*$/;
1294
+ const ISSUE_COMMAND_PATTERN = /^\/adjudicate[ \t]+([a-z-]+)[ \t]+"([^"\n]+)"[ \t]*(?::[ \t]*(.*\S))?[ \t]*$/;
1295
+ const firstLine = (body) => (body.split("\n", 1)[0] ?? "").trim();
1296
+ const boundedReason = (raw) => {
1297
+ if (raw === void 0) return void 0;
1298
+ const trimmed = raw.trim().slice(0, 300);
1299
+ return trimmed.length === 0 ? void 0 : trimmed;
1129
1300
  };
1130
- const anchorKey = (finding) => `${finding.path} ${finding.startLine} ${finding.endLine}`;
1131
1301
  /**
1132
- * Merge the children's findings into one bounded, deterministic list: dedupe
1133
- * findings sharing an anchor (path + line range) keeping the most severe —
1134
- * and, at equal severity, the first in declaration order then rank by
1135
- * severity, path, and line, and cap at the `CodeReview` findings bound.
1136
- * This is the merge policy the coordinator's instructions state in prose;
1137
- * pinning it here keeps the policy itself deterministic and testable.
1302
+ * Parse one inline-thread reply: `/adjudicate <disposition>(: <reason>)?`.
1303
+ * The thread itself names the target identity. Returns undefined for a
1304
+ * non-command body and "malformed" for a command that fails the grammar.
1138
1305
  */
1139
- const rankAndDedupeFindings = (findings) => {
1140
- const byAnchor = /* @__PURE__ */ new Map();
1141
- for (const finding of findings) {
1142
- const key = anchorKey(finding);
1143
- const existing = byAnchor.get(key);
1144
- if (existing === void 0 || severityRank[finding.severity] < severityRank[existing.severity]) byAnchor.set(key, finding);
1145
- }
1146
- return [...byAnchor.values()].sort((left, right) => {
1147
- const bySeverity = severityRank[left.severity] - severityRank[right.severity];
1148
- if (bySeverity !== 0) return bySeverity;
1149
- if (left.path !== right.path) return left.path < right.path ? -1 : 1;
1150
- return left.startLine - right.startLine;
1151
- }).slice(0, 20);
1306
+ const parseThreadAdjudication = (body) => {
1307
+ const line = firstLine(body);
1308
+ if (!line.startsWith("/adjudicate")) return void 0;
1309
+ const match = THREAD_COMMAND_PATTERN.exec(line);
1310
+ const disposition = match?.[1];
1311
+ if (disposition === void 0 || !Schema.is(AdjudicationDispositionSchema)(disposition)) return "malformed";
1312
+ return {
1313
+ disposition,
1314
+ reason: boundedReason(match?.[2])
1315
+ };
1152
1316
  };
1153
1317
  /**
1154
- * The concern analogue of `rankAndDedupeFindings`: dedupe by exact content
1155
- * keeping the most severe duplicate, rank by severity, and cap at the
1156
- * `CodeReview` concerns bound.
1318
+ * Parse one top-level PR comment:
1319
+ * `/adjudicate <disposition> "<exact title>"(: <reason>)?`. The quoted title
1320
+ * is required because the conversation names no finding thread; it targets
1321
+ * the title-alone identity of an unanchored concern.
1157
1322
  */
1158
- const rankAndDedupeConcerns = (concerns) => {
1159
- const byContent = /* @__PURE__ */ new Map();
1160
- for (const concern of concerns) {
1161
- const key = `${concern.title}\u0000${concern.body}`;
1162
- const previous = byContent.get(key);
1163
- if (previous === void 0 || severityRank[concern.severity] < severityRank[previous.severity]) byContent.set(key, concern);
1323
+ const parseIssueAdjudication = (body) => {
1324
+ const line = firstLine(body);
1325
+ if (!line.startsWith("/adjudicate")) return void 0;
1326
+ const match = ISSUE_COMMAND_PATTERN.exec(line);
1327
+ const disposition = match?.[1];
1328
+ const title = match?.[2];
1329
+ if (disposition === void 0 || !Schema.is(AdjudicationDispositionSchema)(disposition) || title === void 0 || title.length > 120) return "malformed";
1330
+ return {
1331
+ disposition,
1332
+ title,
1333
+ reason: boundedReason(match?.[3])
1334
+ };
1335
+ };
1336
+ /** The finding identity an inline thread names, or undefined when unparsable. */
1337
+ const threadFindingTarget = (thread) => {
1338
+ if (thread.startLine === null || thread.endLine === null) return void 0;
1339
+ const title = INLINE_FINDING_TITLE_PATTERN.exec(firstLine(thread.rootBody))?.[1];
1340
+ if (title === void 0 || title.length > 120) return void 0;
1341
+ return {
1342
+ path: thread.path,
1343
+ startLine: thread.startLine,
1344
+ endLine: thread.endLine,
1345
+ title
1346
+ };
1347
+ };
1348
+ /**
1349
+ * Derive the standing adjudications from the host's listings. Every command
1350
+ * is screened fail-closed (authorization, grammar, a parsable target); later
1351
+ * adjudications of the same identity win by comment creation order; the
1352
+ * result is capped at the ReviewState bound dropping the oldest winners.
1353
+ */
1354
+ const deriveAdjudications = (input) => {
1355
+ const candidates = [];
1356
+ const ignored = [];
1357
+ const admit = (comment, command, target) => {
1358
+ candidates.push({
1359
+ adjudication: StoredAdjudication.make({
1360
+ ...target.path === void 0 ? {} : { path: target.path },
1361
+ ...target.startLine === void 0 ? {} : { startLine: target.startLine },
1362
+ ...target.endLine === void 0 ? {} : { endLine: target.endLine },
1363
+ title: target.title,
1364
+ disposition: command.disposition,
1365
+ ...command.reason === void 0 ? {} : { reason: command.reason },
1366
+ actor: comment.authorLogin
1367
+ }),
1368
+ epochMillis: comment.createdAt === null ? -1 : DateTime.toEpochMillis(comment.createdAt),
1369
+ sourceOrder: comment.sourceOrder
1370
+ });
1371
+ };
1372
+ const authorized = (comment, surface) => {
1373
+ if (AUTHORIZED_ADJUDICATION_ASSOCIATIONS.has(comment.authorAssociation)) return true;
1374
+ ignored.push(`${surface}: unauthorized /adjudicate from @${comment.authorLogin} (${comment.authorAssociation})`);
1375
+ return false;
1376
+ };
1377
+ for (const thread of input.threads) {
1378
+ const target = threadFindingTarget(thread);
1379
+ for (const reply of thread.replies) {
1380
+ const command = parseThreadAdjudication(reply.body);
1381
+ if (command === void 0) continue;
1382
+ const surface = `inline thread ${thread.path}`;
1383
+ if (command === "malformed") {
1384
+ ignored.push(`${surface}: malformed /adjudicate command from @${reply.authorLogin}`);
1385
+ continue;
1386
+ }
1387
+ if (!authorized(reply, surface)) continue;
1388
+ if (target === void 0) {
1389
+ ignored.push(`${surface}: thread root names no parsable finding title`);
1390
+ continue;
1391
+ }
1392
+ admit(reply, command, target);
1393
+ }
1164
1394
  }
1165
- return [...byContent.values()].sort((left, right) => severityRank[left.severity] - severityRank[right.severity]).slice(0, 10);
1395
+ for (const comment of input.issueComments) {
1396
+ const command = parseIssueAdjudication(comment.body);
1397
+ if (command === void 0) continue;
1398
+ const surface = "pull-request conversation";
1399
+ if (command === "malformed") {
1400
+ ignored.push(`${surface}: malformed /adjudicate command from @${comment.authorLogin}`);
1401
+ continue;
1402
+ }
1403
+ if (!authorized(comment, surface)) continue;
1404
+ if (command.title === void 0) {
1405
+ ignored.push(`${surface}: /adjudicate without a quoted target title`);
1406
+ continue;
1407
+ }
1408
+ admit(comment, command, { title: command.title });
1409
+ }
1410
+ const byIdentity = /* @__PURE__ */ new Map();
1411
+ const ordered = [...candidates].sort((left, right) => left.epochMillis - right.epochMillis || left.sourceOrder - right.sourceOrder);
1412
+ for (const candidate of ordered) {
1413
+ const identity = adjudicationIdentity(candidate.adjudication);
1414
+ byIdentity.delete(identity);
1415
+ byIdentity.set(identity, candidate);
1416
+ }
1417
+ const winners = [...byIdentity.values()];
1418
+ const droppedOldest = Math.max(0, winners.length - 20);
1419
+ return {
1420
+ adjudications: winners.slice(droppedOldest).map((candidate) => candidate.adjudication),
1421
+ ignored,
1422
+ droppedOldest
1423
+ };
1424
+ };
1425
+ /** Later-wins merge of stored prior adjudications with freshly derived ones. */
1426
+ const mergeAdjudications = (prior, fresh) => {
1427
+ const byIdentity = /* @__PURE__ */ new Map();
1428
+ for (const adjudication of [...prior, ...fresh]) {
1429
+ const identity = adjudicationIdentity(adjudication);
1430
+ byIdentity.delete(identity);
1431
+ byIdentity.set(identity, adjudication);
1432
+ }
1433
+ const merged = [...byIdentity.values()];
1434
+ return merged.slice(Math.max(0, merged.length - 20));
1166
1435
  };
1167
- //#endregion
1168
- //#region src/internal/fan-out.ts
1169
- /** One discovery pass returns at most this many anchored candidates. */
1170
- const MAX_CHILD_FINDINGS = 6;
1171
- /** One discovery pass returns at most this many non-anchored candidates. */
1172
- const MAX_CHILD_CONCERNS = 3;
1173
- /** Every unit receives independent general and specialist discovery passes. */
1174
- const MAX_UNIT_CANDIDATES = 18;
1175
1436
  /**
1176
- * General + specialist discovery for every unit, then one verifier per unit.
1177
- * The one-retry budget doubles the worst-case child Run count, but the
1178
- * schedule itself never exceeds this bound.
1437
+ * Collect the standing maintainer adjudications: freshly derived through the
1438
+ * host, merged later-wins over the prior state's stored set. The host is a
1439
+ * visible Effect requirement; program edges that intentionally perform no
1440
+ * reads provide {@link noReviewAdjudicationHost}. Fail-open — any listing
1441
+ * fault keeps the complete prior set and never fails the review, because NOT
1442
+ * suppressing a finding is the conservative direction.
1179
1443
  */
1180
- const MAX_REVIEW_CHILDREN = 24;
1181
- /** Bounded structured concurrency across units; passes inside a unit are sequential. */
1182
- const REVIEW_UNIT_CONCURRENCY = 4;
1183
- /** Structural minimum for a child that exposes no tools. */
1184
- const MAX_FILE_REVIEW_TOOL_CALLS = 1;
1185
- const ReviewWorkPhase = Schema.Literals(["discovery", "verification"]);
1186
- const ReviewWorkPerspective = Schema.Literals([
1187
- "general",
1188
- "risk-specialist",
1189
- "candidate-verification"
1190
- ]);
1191
- const ReviewCandidateId = Schema.NonEmptyString.check(Schema.isMaxLength(96));
1192
- var FindingCandidate = class extends Schema.TaggedClass()("FindingCandidate", {
1193
- candidateId: ReviewCandidateId,
1194
- workId: ReviewPassId,
1195
- unitId: ReviewUnitId,
1196
- finding: ReviewFinding,
1197
- evidencePaths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(1))
1198
- }) {};
1199
- var ConcernCandidate = class extends Schema.TaggedClass()("ConcernCandidate", {
1200
- candidateId: ReviewCandidateId,
1201
- workId: ReviewPassId,
1202
- unitId: ReviewUnitId,
1203
- concern: ReviewConcern,
1204
- evidencePaths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3))
1205
- }) {};
1206
- const ReviewCandidate = Schema.Union([FindingCandidate, ConcernCandidate]);
1207
- /** Deterministic host equivalence for claims repeated across discovery passes. */
1208
- const reviewCandidateSubjectKey = (candidate) => candidate._tag === "FindingCandidate" ? `finding:${JSON.stringify(Schema.encodeSync(ReviewFinding)(candidate.finding))}` : `concern:${JSON.stringify(Schema.encodeSync(ReviewConcern)(candidate.concern))}`;
1209
- var CandidateAssessment = class extends Schema.Class("@effect-agent/pr-review/CandidateAssessment")({
1210
- candidateId: ReviewCandidateId,
1211
- disposition: Schema.Literals(["confirmed", "rejected"]),
1444
+ const collectReviewAdjudications = Effect.fn("collectReviewAdjudications")(function* (prior) {
1445
+ const host = yield* ReviewAdjudicationHost;
1446
+ const listings = yield* Effect.all({
1447
+ threads: host.listFindingThreads,
1448
+ issueComments: host.listIssueComments
1449
+ }).pipe(Effect.catch((error) => Effect.logWarning(`Could not collect adjudications from '${error.operation}': ${error.reason}; retaining stored adjudications unchanged.`).pipe(Effect.as(void 0))));
1450
+ if (listings === void 0) return prior;
1451
+ const derived = deriveAdjudications({
1452
+ threads: listings.threads,
1453
+ issueComments: listings.issueComments
1454
+ });
1455
+ for (const note of derived.ignored) yield* Effect.logDebug(`Ignored adjudication command — ${note}`);
1456
+ if (derived.droppedOldest > 0) yield* Effect.logWarning(`Dropped ${derived.droppedOldest} oldest adjudication(s) over the 20-entry bound.`);
1457
+ return mergeAdjudications(prior, derived.adjudications);
1458
+ });
1459
+ const lineRange = (startLine, endLine) => `${startLine}${endLine === startLine ? "" : `-${endLine}`}`;
1460
+ /** One adjudication as a bounded reviewer-prompt context line. */
1461
+ const renderAdjudicationContextLine = (adjudication) => {
1462
+ const location = adjudication.path !== void 0 && adjudication.startLine !== void 0 && adjudication.endLine !== void 0 ? `${adjudication.path}:${lineRange(adjudication.startLine, adjudication.endLine)}` : "(unanchored)";
1463
+ const reason = adjudication.reason === void 0 ? "" : `: ${adjudication.reason}`;
1464
+ return `${location} "${adjudication.title}" — ${adjudication.disposition} by @${adjudication.actor}${reason}`;
1465
+ };
1466
+ /** One prior-round finding as a bounded reviewer-prompt context line. */
1467
+ const renderPriorFindingContextLine = (finding) => `${finding.path}:${lineRange(finding.startLine, finding.endLine)} [${finding.severity}] "${finding.title}" — ${finding.body.slice(0, 400)}`;
1468
+ /** Build the fan-out prior-review context from the resolved continuity data. */
1469
+ const buildPriorReviewContext = (adjudications, priorFindingsOnScope) => ({
1470
+ adjudicated: adjudications.map((adjudication) => ({
1471
+ path: adjudication.path,
1472
+ line: renderAdjudicationContextLine(adjudication)
1473
+ })),
1474
+ priorFindings: priorFindingsOnScope.map((finding) => ({
1475
+ path: finding.path,
1476
+ line: renderPriorFindingContextLine(finding)
1477
+ }))
1478
+ });
1479
+ //#endregion
1480
+ //#region src/internal/anchors.ts
1481
+ /** Why a finding cannot anchor to the current new-version diff, if any. */
1482
+ const anchorViolation = (finding, files) => {
1483
+ const file = files.find((candidate) => candidate.path === finding.path);
1484
+ if (file === void 0) return "path is not part of the changeset";
1485
+ if (file.patch === void 0) return "file has no anchorable textual diff";
1486
+ if (finding.endLine < finding.startLine) return "endLine precedes startLine";
1487
+ if (finding.endLine - finding.startLine + 1 > 100) return "range is implausibly large";
1488
+ const anchors = commentableLines(file.patch);
1489
+ for (let line = finding.startLine; line <= finding.endLine; line += 1) if (!anchors.has(line)) return `line ${line} is not part of the diff`;
1490
+ };
1491
+ //#endregion
1492
+ //#region src/internal/coverage.ts
1493
+ var ReviewInputCoverage = class extends Schema.Class("@effect-agent/pr-review/ReviewInputCoverage")({
1494
+ status: Schema.Literals(["complete", "incomplete"]),
1495
+ requiredPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
1496
+ assignedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
1497
+ /** Assigned paths whose model-visible diff was truncated by the evidence bound. */
1498
+ partialPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
1499
+ unassignedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
1212
1500
  /**
1213
- * Exact suggestion settlement: required when the candidate finding carries
1214
- * a suggestion, forbidden otherwise. Untrusted child output cannot publish
1215
- * a GitHub replacement block by prompt compliance alonethe host keeps a
1216
- * confirmed finding's suggestion only on an exact "committable" settlement.
1501
+ * Paths with neither a textual diff nor bounded base/head text (binaries,
1502
+ * oversized files). Fail-closed: they keep the status incomplete for as
1503
+ * long as they are part of the pull request an unreviewable change must
1504
+ * never authorize a green check. Exclude them deliberately with ignore
1505
+ * globs when that is intended.
1217
1506
  */
1218
- suggestion: Schema.optionalKey(Schema.Literals(["committable", "not-committable"]).annotate({ description: "Required exactly when the candidate finding carries a suggestion: \"committable\" only when its text is the full replacement source for exactly lines startLine..endLine and nothing else. Forbidden for candidates without a suggestion." })),
1219
- rationale: Schema.NonEmptyString.check(Schema.isMaxLength(600))
1507
+ undiffablePaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(Schema.isMaxLength(300)),
1508
+ reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1e3))).check(Schema.isMaxLength(20))
1509
+ }) {};
1510
+ var FailedReviewPass = class extends Schema.Class("@effect-agent/pr-review/FailedReviewPass")({
1511
+ workId: Schema.NonEmptyString.check(Schema.isMaxLength(96)),
1512
+ stage: Schema.Literals([
1513
+ "discovery",
1514
+ "specialist",
1515
+ "verification"
1516
+ ]),
1517
+ errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256))
1220
1518
  }) {};
1221
1519
  /**
1222
- * Exact suggestion settlement shape: a carried suggestion must be settled and
1223
- * nothing else may be. A verification report that violates it is treated as a
1224
- * misbehaving pass and retried within the pass budget.
1520
+ * Settlement of scheduled review work. `incomplete` means reviewer-side work
1521
+ * failed after its bounded retry a machinery gap that is carried forward and
1522
+ * retried on the next run, never a statement about the code under review.
1523
+ * `unverified` is the flat reviewer's honest constant: one pass with no
1524
+ * independent verifier is neither settled assurance nor a failure.
1225
1525
  */
1226
- const assessmentSettlesSuggestionExactly = (assessment, candidate) => candidate._tag === "FindingCandidate" && candidate.finding.suggestion !== void 0 ? assessment.suggestion !== void 0 : assessment.suggestion === void 0;
1526
+ var ReviewAssurance = class extends Schema.Class("@effect-agent/pr-review/ReviewAssurance")({
1527
+ status: Schema.Literals([
1528
+ "settled",
1529
+ "incomplete",
1530
+ "unverified"
1531
+ ]),
1532
+ requiredGeneralDiscoveryPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1533
+ completedGeneralDiscoveryPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1534
+ requiredSpecialistPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1535
+ completedSpecialistPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1536
+ requiredVerificationPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1537
+ completedVerificationPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1538
+ discoveredCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1539
+ confirmedCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1540
+ rejectedCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1541
+ unsettledCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1542
+ /** Discovery claims discarded for anchors/paths outside their assigned evidence. */
1543
+ discardedInvalidFindings: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1544
+ failedPasses: Schema.Array(FailedReviewPass).check(Schema.isMaxLength(64)),
1545
+ reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1e3))).check(Schema.isMaxLength(32))
1546
+ }) {};
1547
+ const toolTrace = (events) => {
1548
+ const declared = /* @__PURE__ */ new Map();
1549
+ const succeeded = /* @__PURE__ */ new Map();
1550
+ const failed = /* @__PURE__ */ new Map();
1551
+ for (const event of events) {
1552
+ if (event._tag === "ToolCallDeclared") declared.set(event.toolCallId, event);
1553
+ if (event._tag === "ToolCallSucceeded") succeeded.set(event.toolCallId, event);
1554
+ if (event._tag === "ToolCallFailed") failed.set(event.toolCallId, event);
1555
+ }
1556
+ return {
1557
+ declared,
1558
+ succeeded,
1559
+ failed
1560
+ };
1561
+ };
1562
+ const sortedUnique = (values) => [...new Set(values)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
1563
+ /** Render a bounded, deterministic "label (n): a, b, … (+k more)" reason line. */
1564
+ const boundedListReason = (label, values) => {
1565
+ const items = sortedUnique(values);
1566
+ let rendered = `${label} (${items.length}): `;
1567
+ for (let index = 0; index < items.length; index += 1) {
1568
+ const item = items[index] ?? "";
1569
+ const separator = index === 0 ? "" : ", ";
1570
+ const omitted = items.length - index - 1;
1571
+ const suffix = omitted === 0 ? "" : ` … (+${omitted} more)`;
1572
+ if (`${rendered}${separator}${item}${suffix}`.length > 1e3) {
1573
+ const omission = `… (+${items.length - index} more)`;
1574
+ return `${rendered.slice(0, 1e3 - omission.length)}${omission}`;
1575
+ }
1576
+ rendered = `${rendered}${separator}${item}`;
1577
+ }
1578
+ return rendered;
1579
+ };
1227
1580
  /**
1228
- * Fail-closed publication of a confirmed finding: only an exact "committable"
1229
- * settlement keeps the suggestion; anything else publishes the finding with
1230
- * the suggestion stripped so unverified text can never become a one-click
1231
- * GitHub replacement block.
1581
+ * Split carried scope into paths a retry can settle and paths it never can.
1582
+ * Undiffable files are a property of the pull request, not a transient
1583
+ * reviewer-side failure: gate reasons and rendered callouts must never promise
1584
+ * they are "retried automatically" — the honest instruction is to remove them
1585
+ * from the pull request or exclude them with ignore globs.
1232
1586
  */
1233
- const confirmedFindingForPublication = (assessment, candidate) => {
1234
- if (candidate.finding.suggestion === void 0 || assessment.suggestion === "committable") return candidate.finding;
1235
- const { suggestion: _stripped, ...finding } = candidate.finding;
1236
- return ReviewFinding.make(finding);
1587
+ const splitCarriedScope = (input) => {
1588
+ const undiffable = new Set(input.inputCoverage?.undiffablePaths ?? []);
1589
+ const retryablePaths = (input.unreviewedPaths ?? []).filter((path) => !undiffable.has(path));
1590
+ const undiffablePaths = sortedUnique(undiffable);
1591
+ const coverageGapBeyondUndiffable = input.inputCoverage?.status === "incomplete" && input.inputCoverage.reasons.length > (undiffablePaths.length > 0 ? 1 : 0);
1592
+ return {
1593
+ retryablePaths,
1594
+ undiffablePaths,
1595
+ retryableGap: input.assurance?.status === "incomplete" || retryablePaths.length > 0 || coverageGapBeyondUndiffable
1596
+ };
1237
1597
  };
1598
+ const anchorSurfaceAdjusted = (inputCoverage, anchorFiles, totalAnchorFiles) => anchorFiles.length >= totalAnchorFiles ? inputCoverage : ReviewInputCoverage.make({
1599
+ ...inputCoverage,
1600
+ status: "incomplete",
1601
+ reasons: [...inputCoverage.reasons, `full pull-request anchor surface exposed ${anchorFiles.length} of ${totalAnchorFiles} required files`]
1602
+ });
1603
+ /** The flat reviewer's honest constant assurance: one pass, no verifier. */
1604
+ const flatAssurance = () => ReviewAssurance.make({
1605
+ status: "unverified",
1606
+ requiredGeneralDiscoveryPasses: 1,
1607
+ completedGeneralDiscoveryPasses: 1,
1608
+ requiredSpecialistPasses: 0,
1609
+ completedSpecialistPasses: 0,
1610
+ requiredVerificationPasses: 0,
1611
+ completedVerificationPasses: 0,
1612
+ discoveredCandidates: 0,
1613
+ confirmedCandidates: 0,
1614
+ rejectedCandidates: 0,
1615
+ unsettledCandidates: 0,
1616
+ discardedInvalidFindings: 0,
1617
+ failedPasses: [],
1618
+ reasons: ["flat review has no independent candidate-verification pass; use the fan-out pipeline for a settled assurance result"]
1619
+ });
1238
1620
  /**
1239
- * Concern candidates need explicit paths internally to bind the claim to
1240
- * scheduled evidence. The verifier receives the complete bounded unit so it
1241
- * can use neighboring evidence to falsify the claim. The public ReviewConcern
1242
- * remains path-free after the host confirms and projects it.
1621
+ * Assess one settled flat run from its Run event trace: which required paths
1622
+ * received successful bounded diff evidence. This observes tool INPUT
1623
+ * assignment only the host cannot know which evidence the model weighed.
1243
1624
  */
1244
- var DiscoveredConcern = class extends Schema.Class("@effect-agent/pr-review/DiscoveredConcern")({
1245
- concern: ReviewConcern,
1246
- evidencePaths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3))
1247
- }) {};
1248
- const UnitPaths = Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12));
1249
- const RiskCategories = Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6));
1250
- const Candidates = Schema.Array(ReviewCandidate).check(Schema.isMaxLength(18));
1251
- const EvidenceShardIds = Schema.Array(ReviewEvidenceShardId).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12));
1252
- /** One complete host-selected evidence shard supplied to a review child. */
1253
- var FileReviewEvidence = class extends Schema.Class("@effect-agent/pr-review/FileReviewEvidence")({
1254
- shardId: ReviewEvidenceShardId,
1255
- path: ChangedPath,
1256
- status: ChangedFileStatus,
1257
- reviewMode: Schema.Literals([
1258
- "diff",
1259
- "content",
1260
- "unavailable"
1261
- ]),
1262
- ordinal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
1263
- total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
1264
- annotatedPatch: Schema.String.check(Schema.isMaxLength(MAX_PATCH_CHARS))
1265
- }) {};
1266
- /** Host-prepared child input with complete bounded diff/content evidence. */
1267
- var FileReviewBrief = class extends Schema.Class("@effect-agent/pr-review/FileReviewBrief")({
1268
- phase: ReviewWorkPhase,
1269
- workId: ReviewPassId,
1270
- unitId: ReviewUnitId,
1271
- paths: UnitPaths,
1272
- evidenceShardIds: EvidenceShardIds,
1273
- perspective: ReviewWorkPerspective,
1274
- riskCategories: RiskCategories,
1275
- /** Empty for discovery; the exact discovered set for unit verification. */
1276
- candidates: Candidates,
1277
- evidence: Schema.Array(FileReviewEvidence).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12))
1278
- }) {};
1279
- /** Child output; phase-inapplicable collections must be empty. */
1280
- var FileReviewReport = class extends Schema.Class("@effect-agent/pr-review/FileReviewReport")({
1281
- phase: ReviewWorkPhase,
1282
- workId: ReviewPassId,
1283
- unitId: ReviewUnitId,
1284
- findings: Schema.Array(ReviewFinding).check(Schema.isMaxLength(6)),
1285
- concerns: Schema.Array(DiscoveredConcern).check(Schema.isMaxLength(3)),
1286
- fileSummaries: Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(12)),
1287
- assessments: Schema.Array(CandidateAssessment).check(Schema.isMaxLength(18))
1288
- }) {};
1625
+ const assessFlatReview = (input) => {
1626
+ const trace = toolTrace(input.events);
1627
+ const requiredPaths = sortedUnique(input.files.map((file) => file.path));
1628
+ const assigned = /* @__PURE__ */ new Set();
1629
+ const partial = /* @__PURE__ */ new Set();
1630
+ const failedPaths = /* @__PURE__ */ new Set();
1631
+ for (const [toolCallId, declaration] of trace.declared) {
1632
+ if (declaration.toolName !== "read_file_diff") continue;
1633
+ const query = Schema.decodeUnknownOption(FileDiffQuery)(declaration.parameters);
1634
+ if (Option.isNone(query)) continue;
1635
+ const success = trace.succeeded.get(toolCallId);
1636
+ if (success !== void 0) {
1637
+ assigned.add(query.value.path);
1638
+ const view = Schema.decodeUnknownOption(FileDiffView)(success.result);
1639
+ if (Option.isSome(view) && view.value.truncated) partial.add(query.value.path);
1640
+ }
1641
+ if (trace.failed.has(toolCallId)) failedPaths.add(query.value.path);
1642
+ }
1643
+ const undiffable = new Set(input.files.filter((file) => !isReviewableFile(file)).map((file) => file.path));
1644
+ const unassigned = requiredPaths.filter((path) => !undiffable.has(path) && (!assigned.has(path) || failedPaths.has(path)));
1645
+ const reasons = [];
1646
+ if (input.files.length < input.totalFiles) reasons.push(`review range exposed ${input.files.length} of ${input.totalFiles} required files`);
1647
+ if (undiffable.size > 0) reasons.push(boundedListReason("required paths have no reviewable diff or bounded text", undiffable));
1648
+ if (failedPaths.size > 0) reasons.push(boundedListReason("diff reads failed", failedPaths));
1649
+ if (partial.size > 0) reasons.push(boundedListReason("model-visible diff evidence was truncated", partial));
1650
+ if (unassigned.length > 0) reasons.push(boundedListReason("required paths received no successful diff input", unassigned));
1651
+ return {
1652
+ inputCoverage: anchorSurfaceAdjusted(ReviewInputCoverage.make({
1653
+ status: reasons.length === 0 ? "complete" : "incomplete",
1654
+ requiredPaths,
1655
+ assignedPaths: sortedUnique(assigned),
1656
+ partialPaths: sortedUnique(partial),
1657
+ unassignedPaths: sortedUnique(unassigned),
1658
+ undiffablePaths: sortedUnique(undiffable),
1659
+ reasons
1660
+ }), input.anchorFiles, input.totalAnchorFiles),
1661
+ assurance: flatAssurance(),
1662
+ unreviewedPaths: sortedUnique([...unassigned, ...undiffable])
1663
+ };
1664
+ };
1289
1665
  /**
1290
- * A structurally valid child report that does not answer the scheduled pass:
1291
- * wrong identity, phase-inapplicable fields, or an inexact assessment set.
1292
- * Retried once like any other pass fault, because it is model misbehavior,
1293
- * not evidence about the code under review.
1666
+ * Input coverage of one host-scheduled fan-out plan: which required paths the
1667
+ * bounded plan actually assigned complete evidence for. Capacity overflow and
1668
+ * undiffable paths are both real gaps; the pipeline carries them so the check
1669
+ * stays fail-closed until they are reviewed, removed, or explicitly ignored.
1294
1670
  */
1295
- var ReviewPassMisbehaved = class extends Schema.TaggedError()("ReviewPassMisbehaved", {
1296
- workId: ReviewPassId,
1297
- reason: Schema.NonEmptyString.check(Schema.isMaxLength(600))
1298
- }) {};
1299
- const staticGuidanceLines = (guidance) => {
1300
- if (guidance === void 0) return [];
1301
- return (typeof guidance === "string" ? [guidance] : guidance).filter((line) => line.length > 0);
1302
- };
1303
- const evidenceInstructions = [
1304
- "The host placed complete bounded review evidence shards in the input evidence array. Treat every shard as required input; ordinal/total identifies multi-shard paths.",
1305
- "You have no tools and cannot roam outside this evidence. If it is insufficient for a candidate, reject or omit that candidate rather than guessing.",
1306
- "A diff marks new-version anchors as R<number>; only those lines may anchor findings. B/H content evidence is non-anchorable."
1307
- ];
1308
- /** Discovery and verification instructions share one child definition. */
1309
- const makeFileReviewerInstructions = (options = {}) => (brief) => {
1310
- const common = [
1311
- `You are an attached review worker for ${brief.workId} in host-planned unit ${brief.unitId}: ${brief.paths.join(", ")}.`,
1312
- ...staticGuidanceLines(options.guidance),
1313
- ...evidenceInstructions
1314
- ];
1315
- if (brief.phase === "verification") return [
1316
- ...common,
1317
- "Independently verify every candidate in the input. You did not receive another reviewer's transcript or reasoning; use only the candidate claim and bounded evidence.",
1318
- "The evidence array contains the complete bounded unit, including neighboring changed code that may confirm or falsify a locally plausible claim.",
1319
- "For each candidate, try to falsify it first. Confirm only when the cited behavior is supported and actionable. Reject unsupported, speculative, duplicate, or non-actionable candidates.",
1320
- "Return ONLY JSON with phase \"verification\", the exact workId/unitId, empty findings/concerns/fileSummaries arrays, and exactly one assessment per candidateId. Each assessment is {\"candidateId\": <exact id>, \"disposition\": <\"confirmed\" | \"rejected\">, \"suggestion\": <\"committable\" | \"not-committable\", present exactly when the candidate finding carries a suggestion>, \"rationale\": <bounded evidence-based reason>}. Never add or omit an id.",
1321
- "Settle every carried suggestion independently of the claim: answer \"committable\" only when its text is the full replacement source for exactly lines startLine..endLine and nothing else — it compiles in context and preserves the finding's intent, never prose describing a change. Otherwise answer \"not-committable\"; the host then publishes the confirmed finding without its suggestion. Omit the assessment \"suggestion\" field for candidates without one."
1322
- ].join("\n");
1323
- const focus = brief.perspective === "risk-specialist" ? brief.riskCategories.length > 0 ? `This is a fresh specialist discovery pass. Concentrate on these host-classified risks without relying on another pass: ${brief.riskCategories.join(", ")}.` : "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." : "This is the general discovery pass. Review broadly for correctness, security, concurrency, resource, API, and error-handling defects.";
1324
- return [
1325
- ...common,
1326
- focus,
1327
- "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.",
1328
- "When a non-anchored concern depends on one or more unit files, list 1-3 exact evidencePaths to bind the claim to scheduled evidence.",
1329
- `Return ONLY JSON with phase "discovery", the exact workId/unitId, up to 6 findings, up to 3 concerns shaped as {concern, evidencePaths}, one factual file summary per path (<= 240 chars), and an empty assessments array. Empty candidate arrays are valid; do not invent defects.`,
1330
- "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>}.",
1331
- "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\"."
1332
- ].join("\n");
1333
- };
1334
- const fileReviewerInstructions = makeFileReviewerInstructions();
1335
- const FileReviewToolkit = Toolkit.empty;
1336
- const defaultFileReviewerPolicy = AgentPolicy.make({
1337
- maxTurns: 6,
1338
- maxToolCalls: 1,
1339
- maxDuration: "6 minutes",
1340
- toolConcurrency: 2,
1341
- repeatedFailureLimit: 6,
1342
- tokenBudget: 2e5,
1343
- contextTokenLimit: 15e4,
1344
- toolResultBounds: ToolResultBounds.make({ maxBytes: REVIEW_TOOL_RESULT_MAX_BYTES }),
1345
- onExhaustion: "fail"
1346
- });
1347
- const makeFileReviewerDefinition = (options = {}) => Agent.define("pr-review-worker", {
1348
- input: FileReviewBrief,
1349
- output: FileReviewReport,
1350
- instructions: makeFileReviewerInstructions(options),
1351
- toolkit: FileReviewToolkit,
1352
- policy: defaultFileReviewerPolicy,
1353
- description: "Perform one bounded discovery or independent candidate-verification pass over host-supplied pull-request evidence.",
1354
- metadata: {
1355
- deploymentClass: "E",
1356
- surface: "read-only",
1357
- stage: "discovery-verification"
1358
- }
1359
- });
1360
- const FileReviewer = makeFileReviewerDefinition();
1361
- const candidateOrdinal = (index) => String(index + 1).padStart(3, "0");
1362
- /** Rebuild one unit's complete evidence from the same snapshot the plan used. */
1363
- const unitEvidence = (unit, files) => Effect.gen(function* () {
1364
- const byPath = new Map(files.map((file) => [file.path, file]));
1365
- const evidence = [];
1366
- for (const shard of unit.evidenceShards) {
1367
- const file = byPath.get(shard.path);
1368
- const chunk = file === void 0 ? void 0 : fileReviewEvidenceChunks(file)[shard.ordinal - 1];
1369
- if (file === void 0 || chunk === void 0) return yield* Effect.die(/* @__PURE__ */ new Error(`planned evidence shard has no source: ${shard.shardId} (${shard.path})`));
1370
- evidence.push(FileReviewEvidence.make({
1371
- shardId: shard.shardId,
1372
- path: shard.path,
1373
- status: file.status,
1374
- reviewMode: chunk.reviewMode,
1375
- ordinal: shard.ordinal,
1376
- total: shard.total,
1377
- annotatedPatch: chunk.annotatedPatch
1378
- }));
1379
- }
1380
- return evidence;
1381
- });
1382
- const misbehaved = (workId, reason) => ReviewPassMisbehaved.make({
1383
- workId,
1384
- reason: reason.slice(0, 600)
1385
- });
1386
- /** Validate that a verification report assesses exactly the scheduled candidates. */
1387
- const validateVerificationReport = (brief, report) => {
1388
- if (report.findings.length > 0 || report.concerns.length > 0 || report.fileSummaries.length > 0) return misbehaved(brief.workId, "verification output contained discovery-only fields");
1389
- const expectedById = new Map(brief.candidates.map((candidate) => [candidate.candidateId, candidate]));
1390
- const assessedIds = /* @__PURE__ */ new Set();
1391
- for (const assessment of report.assessments) {
1392
- const candidate = expectedById.get(assessment.candidateId);
1393
- if (candidate === void 0 || assessedIds.has(assessment.candidateId)) return misbehaved(brief.workId, "verification output did not assess the exact candidate set");
1394
- if (!assessmentSettlesSuggestionExactly(assessment, candidate)) return misbehaved(brief.workId, "verification output did not settle suggestion publication exactly");
1395
- assessedIds.add(assessment.candidateId);
1671
+ const fanOutInputCoverage = (input) => {
1672
+ const plan = input.plan;
1673
+ const assignedPaths = sortedUnique(plan.units.flatMap((unit) => unit.paths));
1674
+ const unassignedPaths = sortedUnique(plan.unassignedPaths);
1675
+ const reasons = [];
1676
+ if (plan.truncated) reasons.push(`review range exposed ${input.files.length} of ${input.totalFiles} required files`);
1677
+ if (plan.undiffablePaths.length > 0) reasons.push(boundedListReason("required paths have no reviewable diff or bounded text", plan.undiffablePaths));
1678
+ if (plan.partialEvidencePaths.length > 0) reasons.push(boundedListReason("fan-out capacity left some deterministic evidence shards unassigned", plan.partialEvidencePaths));
1679
+ if (plan.unassignedEvidenceShardCount > 0) {
1680
+ reasons.push(`${plan.unassignedEvidenceShardCount} deterministic evidence shard(s) exceeded fan-out capacity`);
1681
+ reasons.push(boundedListReason(`unassigned evidence shard identifier sample (${plan.unassignedEvidenceShardIds.length} of ${plan.unassignedEvidenceShardCount})`, plan.unassignedEvidenceShardIds));
1396
1682
  }
1397
- if (assessedIds.size !== expectedById.size) return misbehaved(brief.workId, "verification output did not assess the exact candidate set");
1683
+ if (plan.unassignedPaths.length > 0) reasons.push(boundedListReason("fan-out capacity left paths unassigned", plan.unassignedPaths));
1684
+ return anchorSurfaceAdjusted(ReviewInputCoverage.make({
1685
+ status: reasons.length === 0 ? "complete" : "incomplete",
1686
+ requiredPaths: sortedUnique(input.files.map((file) => file.path)),
1687
+ assignedPaths,
1688
+ partialPaths: plan.partialEvidencePaths,
1689
+ unassignedPaths,
1690
+ undiffablePaths: sortedUnique(plan.undiffablePaths),
1691
+ reasons
1692
+ }), input.anchorFiles, input.totalAnchorFiles);
1398
1693
  };
1694
+ //#endregion
1695
+ //#region src/internal/review-units.ts
1696
+ /** The delegation fan-out bound: one parent Run spawns at most this many children. */
1697
+ const MAX_REVIEW_UNITS = 8;
1698
+ /** A unit never carries more files than this, regardless of their size. */
1699
+ const MAX_UNIT_FILES = 12;
1399
1700
  /**
1400
- * Run one scheduled pass: execute the child, decode its report, and enforce
1401
- * the pass contract. Any typed fault child failure, malformed or misdirected
1402
- * output is retried once; budget exhaustion is terminal because a retry
1403
- * would fail the same way. The settled outcome is a value either way, so one
1404
- * flaky pass can never fail the whole pipeline.
1701
+ * Bound the complete model-visible evidence assigned to one child. This is a
1702
+ * character bound rather than a token estimate because it is deterministic,
1703
+ * provider-independent, and enforced before any model call.
1405
1704
  */
1406
- const runReviewPass = (binding, brief, budget) => Effect.gen(function* () {
1407
- const result = yield* AgentRuntime.run(binding, brief, {
1408
- ...budget === void 0 ? {} : { budget },
1409
- estimateCostMicrousd: () => Effect.succeed(500)
1410
- });
1411
- const report = yield* Schema.decodeUnknownEffect(FileReviewReport)(result.output).pipe(Effect.mapError((error) => misbehaved(brief.workId, `child report failed to decode: ${error.message}`)));
1412
- if (report.phase !== brief.phase || report.workId !== brief.workId || report.unitId !== brief.unitId) return yield* misbehaved(brief.workId, "child report identity does not match the scheduled pass");
1413
- if (brief.phase === "verification") {
1414
- const violation = validateVerificationReport(brief, report);
1415
- if (violation !== void 0) return yield* violation;
1416
- } else if (report.assessments.length > 0) return yield* misbehaved(brief.workId, "discovery output contained verification-only assessments");
1417
- return {
1418
- report,
1419
- turns: result.turns
1420
- };
1421
- }).pipe(Effect.scoped, Effect.retry({
1422
- times: 1,
1423
- while: (error) => error._tag !== "BudgetExceeded"
1424
- }), Effect.map((settled) => ({
1425
- _tag: "settled",
1426
- ...settled
1427
- })), Effect.catch((error) => Effect.succeed({
1428
- _tag: "failed",
1429
- errorTag: String(error._tag).slice(0, 256)
1430
- })));
1705
+ const UNIT_EVIDENCE_CHAR_BUDGET = 24e4;
1706
+ /** Maximum complete evidence shards placed in one child brief. */
1707
+ const MAX_UNIT_EVIDENCE_SHARDS = 12;
1431
1708
  /**
1432
- * Keep only findings anchored inside the pass's exact assigned evidence and
1433
- * concerns bound to unit paths. Everything else is discarded and counted
1434
- * an invalid anchor invalidates one claim, never the pass that produced it.
1709
+ * Keep overflow diagnostics bounded to one plan's total assignment capacity.
1710
+ * The plan separately records the exact overflow count and every affected
1711
+ * path, so identifiers are a deterministic diagnostic sample rather than the
1712
+ * authority for whether input coverage is complete.
1435
1713
  */
1436
- const harvestDiscovery = (pass, unit, files, anchorFiles, report) => {
1437
- const allowed = new Set(pass.paths);
1438
- let discarded = 0;
1439
- const keptFindings = [];
1440
- for (const finding of report.findings) {
1441
- if (!allowed.has(finding.path) || anchorViolation(finding, anchorFiles) !== void 0 || !findingAnchorInUnitEvidence(finding, unit, files)) {
1442
- discarded += 1;
1443
- continue;
1444
- }
1445
- keptFindings.push(finding);
1446
- }
1447
- const keptConcerns = [];
1448
- for (const candidate of report.concerns) {
1449
- if (candidate.evidencePaths.some((path) => !allowed.has(path))) {
1450
- discarded += 1;
1451
- continue;
1452
- }
1453
- keptConcerns.push(candidate);
1714
+ const MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS = 96;
1715
+ /** The merged review never exceeds the `CodeReview` findings bound. */
1716
+ const MAX_MERGED_FINDINGS = 20;
1717
+ const ReviewUnitId = Schema.NonEmptyString.check(Schema.isMaxLength(32));
1718
+ /** High-risk surfaces that receive an explicit specialist focus label. */
1719
+ const ReviewRiskCategory = Schema.Literals([
1720
+ "authentication-authorization",
1721
+ "security-boundary",
1722
+ "persistence-durability",
1723
+ "concurrency",
1724
+ "credential-handling",
1725
+ "external-side-effects"
1726
+ ]);
1727
+ const ReviewDiscoveryPerspective = Schema.Literals(["general", "risk-specialist"]);
1728
+ const ReviewPassId = Schema.NonEmptyString.check(Schema.isMaxLength(64));
1729
+ const ReviewEvidenceShardId = Schema.NonEmptyString.check(Schema.isMaxLength(32));
1730
+ /** One complete bounded slice of a changed path's model-visible evidence. */
1731
+ var ReviewEvidenceShard = class extends Schema.Class("@effect-agent/pr-review/ReviewEvidenceShard")({
1732
+ shardId: ReviewEvidenceShardId,
1733
+ path: ChangedPath,
1734
+ ordinal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
1735
+ total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
1736
+ evidenceChars: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).check(Schema.isLessThanOrEqualTo(MAX_PATCH_CHARS))
1737
+ }) {};
1738
+ const EvidenceShardIds$1 = Schema.Array(ReviewEvidenceShardId).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12));
1739
+ /** One required, independently scoped discovery attempt. */
1740
+ var ReviewDiscoveryPass = class extends Schema.Class("@effect-agent/pr-review/ReviewDiscoveryPass")({
1741
+ passId: ReviewPassId,
1742
+ unitId: ReviewUnitId,
1743
+ paths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12)),
1744
+ evidenceShardIds: EvidenceShardIds$1,
1745
+ perspective: ReviewDiscoveryPerspective,
1746
+ /** Empty for the general pass; explicit deterministic focus for specialists. */
1747
+ riskCategories: Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6))
1748
+ }) {};
1749
+ /** One bounded slice of the changeset delegated to one child reviewer. */
1750
+ var ReviewUnit = class extends Schema.Class("@effect-agent/pr-review/ReviewUnit")({
1751
+ unitId: ReviewUnitId,
1752
+ paths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12)),
1753
+ evidenceShards: Schema.Array(ReviewEvidenceShard).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12)),
1754
+ /** additions + deletions across the unit's files, for honest sizing. */
1755
+ changedLines: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1756
+ /** Complete model-visible diff/content evidence assigned to each child. */
1757
+ evidenceChars: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).check(Schema.isLessThanOrEqualTo(UNIT_EVIDENCE_CHAR_BUDGET)),
1758
+ /** Host-classified focus labels for the unit's redundant specialist pass. */
1759
+ riskCategories: Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6))
1760
+ }) {};
1761
+ /** The complete deterministic fan-out plan over one changeset. */
1762
+ var ReviewUnitPlan = class extends Schema.Class("@effect-agent/pr-review/ReviewUnitPlan")({
1763
+ totalFiles: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1764
+ /** True when the source returned fewer files than the pull request has. */
1765
+ truncated: Schema.Boolean,
1766
+ units: Schema.Array(ReviewUnit).check(Schema.isMaxLength(8)),
1767
+ /** Exact discovery calls the coordinator must make. */
1768
+ discoveryPasses: Schema.Array(ReviewDiscoveryPass).check(Schema.isMaxLength(16)),
1769
+ /** Changed files with neither a textual diff nor bounded base/head text. */
1770
+ undiffablePaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
1771
+ /** Assigned paths with one or more evidence shards beyond plan capacity. */
1772
+ partialEvidencePaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
1773
+ /** Exact number of shards beyond the bounded unit capacity. */
1774
+ unassignedEvidenceShardCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
1775
+ /** Bounded deterministic prefix of the unassigned shard identifiers. */
1776
+ unassignedEvidenceShardIds: Schema.Array(ReviewEvidenceShardId).check(Schema.isMaxLength(96)),
1777
+ /**
1778
+ * Diffable files beyond the fan-out capacity (MAX_REVIEW_UNITS units of
1779
+ * MAX_UNIT_FILES files). Never silently dropped: the coordinator must name
1780
+ * them as unreviewed in its summary.
1781
+ */
1782
+ unassignedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300))
1783
+ }) {};
1784
+ const riskRules = [
1785
+ {
1786
+ category: "authentication-authorization",
1787
+ patterns: [
1788
+ /auth/,
1789
+ /authoriz/,
1790
+ /permission/,
1791
+ /principal/,
1792
+ /access[-_ ]?control/,
1793
+ /role\b/
1794
+ ]
1795
+ },
1796
+ {
1797
+ category: "security-boundary",
1798
+ patterns: [
1799
+ /security/,
1800
+ /sandbox/,
1801
+ /untrusted/,
1802
+ /schema\.decode/,
1803
+ /validation/,
1804
+ /injection/,
1805
+ /csrf/,
1806
+ /xss/,
1807
+ /path traversal/
1808
+ ]
1809
+ },
1810
+ {
1811
+ category: "persistence-durability",
1812
+ patterns: [
1813
+ /durab/,
1814
+ /persist/,
1815
+ /storage/,
1816
+ /database/,
1817
+ /\bsql\b/,
1818
+ /journal/,
1819
+ /ledger/,
1820
+ /checkpoint/,
1821
+ /migration/,
1822
+ /transaction/
1823
+ ]
1824
+ },
1825
+ {
1826
+ category: "concurrency",
1827
+ patterns: [
1828
+ /concurr/,
1829
+ /semaphore/,
1830
+ /\bfiber/,
1831
+ /race/,
1832
+ /mutex/,
1833
+ /\block\b/,
1834
+ /queue/,
1835
+ /parallel/,
1836
+ /interrupt/
1837
+ ]
1838
+ },
1839
+ {
1840
+ category: "credential-handling",
1841
+ patterns: [
1842
+ /credential/,
1843
+ /secret/,
1844
+ /password/,
1845
+ /api[-_ ]?key/,
1846
+ /bearer/,
1847
+ /hmac/,
1848
+ /signature/
1849
+ ]
1850
+ },
1851
+ {
1852
+ category: "external-side-effects",
1853
+ patterns: [
1854
+ /publish/,
1855
+ /webhook/,
1856
+ /github/,
1857
+ /fetch\(/,
1858
+ /http/,
1859
+ /send[-_ ]?(email|message)/,
1860
+ /write[-_ ]?(file|record)/,
1861
+ /delete/,
1862
+ /mutation/,
1863
+ /side[-_ ]?effect/,
1864
+ /spawn/,
1865
+ /exec/
1866
+ ]
1454
1867
  }
1455
- return {
1456
- candidates: [...keptFindings.map((finding, index) => FindingCandidate.make({
1457
- candidateId: `${pass.passId}:finding:${candidateOrdinal(index)}`,
1458
- workId: pass.passId,
1459
- unitId: pass.unitId,
1460
- finding,
1461
- evidencePaths: [finding.path]
1462
- })), ...keptConcerns.map((candidate, index) => ConcernCandidate.make({
1463
- candidateId: `${pass.passId}:concern:${candidateOrdinal(index)}`,
1464
- workId: pass.passId,
1465
- unitId: pass.unitId,
1466
- concern: candidate.concern,
1467
- evidencePaths: candidate.evidencePaths
1468
- }))],
1469
- fileSummaries: report.fileSummaries.filter((entry) => allowed.has(entry.path)),
1470
- discarded
1471
- };
1868
+ ];
1869
+ /**
1870
+ * Deterministic host policy for specialist assignment. It intentionally
1871
+ * favors false positives: an extra bounded pass costs work, while a missed
1872
+ * high-risk classification removes redundancy. This is not a claim that the
1873
+ * keyword policy recognizes every semantically risky change.
1874
+ */
1875
+ const classifyReviewRisks = (file) => {
1876
+ const text = [
1877
+ file.path,
1878
+ file.previousPath ?? "",
1879
+ file.patch ?? "",
1880
+ file.reviewBaseContent ?? "",
1881
+ file.reviewHeadContent ?? ""
1882
+ ].join("\n").toLowerCase();
1883
+ return riskRules.filter((rule) => rule.patterns.some((pattern) => pattern.test(text))).map((rule) => rule.category);
1472
1884
  };
1473
- const reviewUnit = (binding, unit, passes, input) => Effect.gen(function* () {
1474
- const evidence = yield* unitEvidence(unit, input.files);
1475
- const failedPasses = [];
1476
- const candidates = [];
1477
- const subjects = /* @__PURE__ */ new Set();
1478
- const walkthrough = [];
1479
- let discardedFindings = 0;
1480
- let turns = 0;
1481
- let completedGeneralPasses = 0;
1482
- let completedSpecialistPasses = 0;
1483
- for (const pass of passes) {
1484
- const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
1485
- const brief = FileReviewBrief.make({
1486
- phase: "discovery",
1487
- workId: pass.passId,
1488
- unitId: pass.unitId,
1489
- paths: pass.paths,
1490
- evidenceShardIds: pass.evidenceShardIds,
1491
- perspective: pass.perspective,
1492
- riskCategories: pass.riskCategories,
1493
- candidates: [],
1494
- evidence
1495
- });
1496
- const outcome = yield* runReviewPass(binding, brief, input.budget);
1497
- if (outcome._tag === "failed") {
1498
- failedPasses.push(FailedReviewPass.make({
1499
- workId: pass.passId,
1500
- stage,
1501
- errorTag: outcome.errorTag
1502
- }));
1503
- continue;
1504
- }
1505
- turns += outcome.turns;
1506
- if (stage === "specialist") completedSpecialistPasses += 1;
1507
- else completedGeneralPasses += 1;
1508
- const harvest = harvestDiscovery(pass, unit, input.files, input.anchorFiles, outcome.report);
1509
- discardedFindings += harvest.discarded;
1510
- if (pass.perspective === "general") walkthrough.push(...harvest.fileSummaries);
1511
- for (const candidate of harvest.candidates) {
1512
- const subject = reviewCandidateSubjectKey(candidate);
1513
- if (subjects.has(subject)) continue;
1514
- subjects.add(subject);
1515
- candidates.push(candidate);
1516
- }
1517
- }
1518
- const confirmed = [];
1519
- let rejectedCandidates = 0;
1520
- let unsettledCandidates = 0;
1521
- let completedVerificationPasses = 0;
1522
- const requiredVerificationPasses = candidates.length > 0 ? 1 : 0;
1523
- if (candidates.length > 0) {
1524
- const workId = `${unit.unitId}-verification`;
1525
- const brief = FileReviewBrief.make({
1526
- phase: "verification",
1527
- workId,
1528
- unitId: unit.unitId,
1529
- paths: unit.paths,
1530
- evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
1531
- perspective: "candidate-verification",
1532
- riskCategories: unit.riskCategories,
1533
- candidates,
1534
- evidence
1535
- });
1536
- const outcome = yield* runReviewPass(binding, brief, input.budget);
1537
- if (outcome._tag === "failed") {
1538
- unsettledCandidates = candidates.length;
1539
- failedPasses.push(FailedReviewPass.make({
1540
- workId,
1541
- stage: "verification",
1542
- errorTag: outcome.errorTag
1543
- }));
1544
- } else {
1545
- turns += outcome.turns;
1546
- completedVerificationPasses = 1;
1547
- const byId = new Map(candidates.map((candidate) => [candidate.candidateId, candidate]));
1548
- for (const assessment of outcome.report.assessments) {
1549
- const candidate = byId.get(assessment.candidateId);
1550
- if (candidate === void 0) continue;
1551
- if (assessment.disposition === "confirmed") confirmed.push({
1552
- assessment,
1553
- candidate
1554
- });
1555
- else rejectedCandidates += 1;
1556
- }
1885
+ /**
1886
+ * Whether every claimed finding anchor was present in the exact bounded
1887
+ * evidence shards assigned to one unit. This is stricter than checking the
1888
+ * full pull-request diff when an oversized path spans multiple units.
1889
+ */
1890
+ const findingAnchorInUnitEvidence = (finding, unit, files) => {
1891
+ const file = files.find((candidate) => candidate.path === finding.path);
1892
+ if (file?.patch === void 0 || finding.endLine < finding.startLine) return false;
1893
+ const assignedOrdinals = new Set(unit.evidenceShards.filter((shard) => shard.path === finding.path).map((shard) => shard.ordinal));
1894
+ const visibleLines = /* @__PURE__ */ new Set();
1895
+ const chunks = fileReviewEvidenceChunks(file);
1896
+ for (let index = 0; index < chunks.length; index += 1) {
1897
+ if (!assignedOrdinals.has(index + 1)) continue;
1898
+ for (const line of chunks[index]?.annotatedPatch.split("\n") ?? []) {
1899
+ const match = /^R(\d+) /.exec(line);
1900
+ if (match?.[1] !== void 0) visibleLines.add(Number(match[1]));
1557
1901
  }
1558
1902
  }
1559
- return {
1560
- failedPasses,
1561
- discoveredCandidates: candidates.length,
1562
- confirmed,
1563
- rejectedCandidates,
1564
- unsettledCandidates,
1565
- discardedFindings,
1566
- walkthrough,
1567
- turns,
1568
- completedGeneralPasses,
1569
- completedSpecialistPasses,
1570
- requiredVerificationPasses,
1571
- completedVerificationPasses,
1572
- unreviewedPaths: failedPasses.length > 0 ? unit.paths : [],
1573
- unreviewedPasses: failedPasses.map((pass) => ({
1574
- stage: pass.stage,
1575
- paths: unit.paths
1576
- }))
1577
- };
1578
- });
1579
- const countNoun = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
1580
- const composeSummary = (plan, assurance) => {
1581
- const requiredDiscovery = assurance.requiredGeneralDiscoveryPasses + assurance.requiredSpecialistPasses;
1582
- const completedDiscovery = assurance.completedGeneralDiscoveryPasses + assurance.completedSpecialistPasses;
1583
- const parts = [`Reviewed ${countNoun(plan.totalFiles, "changed file")} across ${countNoun(plan.units.length, "bounded unit")}: ${completedDiscovery}/${requiredDiscovery} discovery and ${assurance.completedVerificationPasses}/${assurance.requiredVerificationPasses} verification pass(es) settled; ${assurance.confirmedCandidates} of ${countNoun(assurance.discoveredCandidates, "discovered candidate")} confirmed by independent verification.`];
1584
- if (assurance.failedPasses.length > 0) parts.push(`${countNoun(assurance.failedPasses.length, "pass")} did not settle; the affected paths are carried forward and retried on the next run. This is a reviewer-side gap, not a code defect.`);
1585
- if (assurance.discardedInvalidFindings > 0) parts.push(`${countNoun(assurance.discardedInvalidFindings, "candidate")} discarded for anchors or paths outside the assigned evidence.`);
1586
- if (plan.undiffablePaths.length > 0) parts.push(`${countNoun(plan.undiffablePaths.length, "path")} had no reviewable textual evidence and keep input coverage incomplete; exclude such paths with ignore globs when that is intended.`);
1587
- if (plan.unassignedPaths.length > 0 || plan.unassignedEvidenceShardCount > 0) parts.push("The changeset exceeded the bounded fan-out capacity; unassigned scope is reported under input coverage.");
1588
- parts.push("No configured pipeline can prove absence of defects; this describes settled work only.");
1589
- return parts.join(" ").slice(0, 4e3);
1903
+ for (let line = finding.startLine; line <= finding.endLine; line += 1) if (!visibleLines.has(line)) return false;
1904
+ return true;
1590
1905
  };
1591
- const remapPlanUnitIds = (plan, offset) => {
1592
- if (offset === 0) return plan;
1593
- const units = plan.units.map((unit, index) => ReviewUnit.make({
1594
- ...unit,
1595
- unitId: `unit-${String(offset + index + 1).padStart(3, "0")}`
1596
- }));
1597
- const mappedIds = /* @__PURE__ */ new Map();
1598
- for (const [index, unit] of plan.units.entries()) {
1599
- const remapped = units[index];
1600
- if (remapped !== void 0) mappedIds.set(unit.unitId, remapped.unitId);
1601
- }
1602
- return ReviewUnitPlan.make({
1603
- ...plan,
1604
- units,
1605
- discoveryPasses: plan.discoveryPasses.map((pass) => {
1606
- const unitId = mappedIds.get(pass.unitId) ?? pass.unitId;
1607
- return ReviewDiscoveryPass.make({
1608
- ...pass,
1609
- unitId,
1610
- passId: `${unitId}${pass.passId.slice(pass.unitId.length)}`
1906
+ const uniquePaths = (shards) => [...new Set(shards.map(({ shard }) => shard.path))];
1907
+ const plannedEvidenceShards = (files) => {
1908
+ const planned = [];
1909
+ let shardIndex = 0;
1910
+ for (const file of files) {
1911
+ const chunks = fileReviewEvidenceChunks(file);
1912
+ for (let index = 0; index < chunks.length; index += 1) {
1913
+ const chunk = chunks[index];
1914
+ if (chunk === void 0) continue;
1915
+ shardIndex += 1;
1916
+ planned.push({
1917
+ shard: ReviewEvidenceShard.make({
1918
+ shardId: `shard-${String(shardIndex).padStart(4, "0")}`,
1919
+ path: file.path,
1920
+ ordinal: index + 1,
1921
+ total: chunks.length,
1922
+ evidenceChars: chunk.annotatedPatch.length
1923
+ }),
1924
+ file,
1925
+ changedLines: index === 0 ? file.additions + file.deletions : 0
1611
1926
  });
1612
- })
1613
- });
1614
- };
1615
- const scheduleFanOutWork = (input) => {
1616
- const retryPathSet = new Set(input.retry?.paths ?? []);
1617
- const retryStages = new Set(input.retry?.stages ?? []);
1618
- if (retryPathSet.size === 0) {
1619
- const plan = planReviewUnits(input.files, { totalChangedFiles: input.totalChangedFiles });
1620
- const passesByUnit = /* @__PURE__ */ new Map();
1621
- for (const pass of plan.discoveryPasses) {
1622
- const passes = passesByUnit.get(pass.unitId) ?? [];
1623
- passes.push(pass);
1624
- passesByUnit.set(pass.unitId, passes);
1625
1927
  }
1626
- return {
1627
- plan,
1628
- passesByUnit,
1629
- overflowRetryPaths: []
1630
- };
1631
1928
  }
1632
- const freshFiles = input.files.filter((file) => !retryPathSet.has(file.path));
1633
- const retryFiles = input.files.filter((file) => retryPathSet.has(file.path));
1634
- const freshPlan = planReviewUnits(freshFiles, { totalChangedFiles: input.totalChangedFiles });
1635
- const retryPlan = remapPlanUnitIds(planReviewUnits(retryFiles, { totalChangedFiles: input.totalChangedFiles }), freshPlan.units.length);
1636
- const acceptedFresh = freshPlan.units.slice(0, 8);
1637
- const acceptedRetry = retryPlan.units.slice(0, Math.max(0, 8 - acceptedFresh.length));
1638
- const overflowRetryPaths = retryPlan.units.slice(acceptedRetry.length).flatMap((unit) => [...unit.paths]);
1639
- const retryPassFilter = (pass) => {
1640
- const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
1641
- return retryStages.has(stage);
1642
- };
1643
- const acceptedRetryIds = new Set(acceptedRetry.map((unit) => unit.unitId));
1644
- let retryPasses = retryPlan.discoveryPasses.filter((pass) => acceptedRetryIds.has(pass.unitId) && retryPassFilter(pass));
1645
- if (retryStages.has("verification") && !retryStages.has("discovery") && !retryStages.has("specialist")) retryPasses = retryPlan.discoveryPasses.filter((pass) => acceptedRetryIds.has(pass.unitId));
1646
- const acceptedFreshIds = new Set(acceptedFresh.map((unit) => unit.unitId));
1647
- const discoveryPasses = [...freshPlan.discoveryPasses.filter((pass) => acceptedFreshIds.has(pass.unitId)), ...retryPasses];
1648
- const plan = ReviewUnitPlan.make({
1649
- totalFiles: input.files.length,
1650
- truncated: freshPlan.truncated || retryPlan.truncated,
1651
- units: [...acceptedFresh, ...acceptedRetry],
1652
- discoveryPasses,
1653
- undiffablePaths: [.../* @__PURE__ */ new Set([...freshPlan.undiffablePaths, ...retryPlan.undiffablePaths])].sort(),
1654
- partialEvidencePaths: [.../* @__PURE__ */ new Set([...freshPlan.partialEvidencePaths, ...retryPlan.partialEvidencePaths])].sort(),
1655
- unassignedEvidenceShardCount: freshPlan.unassignedEvidenceShardCount + retryPlan.unassignedEvidenceShardCount,
1656
- unassignedEvidenceShardIds: [...freshPlan.unassignedEvidenceShardIds, ...retryPlan.unassignedEvidenceShardIds].slice(0, 96),
1657
- unassignedPaths: [.../* @__PURE__ */ new Set([
1658
- ...freshPlan.unassignedPaths,
1659
- ...retryPlan.unassignedPaths,
1660
- ...overflowRetryPaths
1661
- ])].sort()
1929
+ return planned;
1930
+ };
1931
+ const unitOf = (index, shards) => ReviewUnit.make({
1932
+ unitId: `unit-${String(index + 1).padStart(3, "0")}`,
1933
+ paths: uniquePaths(shards),
1934
+ evidenceShards: shards.map(({ shard }) => shard),
1935
+ changedLines: shards.reduce((total, shard) => total + shard.changedLines, 0),
1936
+ evidenceChars: shards.reduce((total, { shard }) => total + shard.evidenceChars, 0),
1937
+ riskCategories: [...new Set(shards.flatMap(({ file }) => classifyReviewRisks(file)))]
1938
+ });
1939
+ const discoveryPassesFor = (units) => units.flatMap((unit) => [ReviewDiscoveryPass.make({
1940
+ passId: `${unit.unitId}-general`,
1941
+ unitId: unit.unitId,
1942
+ paths: unit.paths,
1943
+ evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
1944
+ perspective: "general",
1945
+ riskCategories: []
1946
+ }), ReviewDiscoveryPass.make({
1947
+ passId: `${unit.unitId}-specialist`,
1948
+ unitId: unit.unitId,
1949
+ paths: unit.paths,
1950
+ evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
1951
+ perspective: "risk-specialist",
1952
+ riskCategories: unit.riskCategories
1953
+ })]);
1954
+ /**
1955
+ * Group the changeset into at most `MAX_REVIEW_UNITS` review units.
1956
+ *
1957
+ * Deterministic by construction: files are ordered by path (so files sharing
1958
+ * a directory become neighbors — directory affinity without a heuristic),
1959
+ * then split into complete line-bounded evidence shards and packed greedily
1960
+ * under the hard evidence and per-unit shard bounds. Capacity is finite and
1961
+ * explicit:
1962
+ *
1963
+ * - files without a textual diff are still delegated when the source
1964
+ * recovered complete bounded UTF-8 base/head content. Findings from that
1965
+ * evidence cannot anchor inline and are reported as concerns;
1966
+ * - files with neither form of textual evidence surface in
1967
+ * `undiffablePaths` instead of laundering missing coverage;
1968
+ * - an oversized path spans as many deterministic shards and units as needed;
1969
+ * - shards beyond `MAX_REVIEW_UNITS` full units surface explicitly, so a path
1970
+ * is partial only when finite plan capacity is genuinely exhausted.
1971
+ */
1972
+ const planReviewUnits = (files, options) => {
1973
+ const ordered = [...files].sort((left, right) => left.path < right.path ? -1 : 1);
1974
+ const reviewable = ordered.filter(isReviewableFile);
1975
+ const undiffable = ordered.filter((file) => !isReviewableFile(file));
1976
+ const shards = plannedEvidenceShards(reviewable);
1977
+ const groups = [];
1978
+ const unassigned = [];
1979
+ let current = [];
1980
+ let currentEvidenceChars = 0;
1981
+ for (const shard of shards) {
1982
+ const nextPaths = /* @__PURE__ */ new Set([...uniquePaths(current), shard.shard.path]);
1983
+ if (current.length >= 12 || nextPaths.size > 12 || current.length > 0 && currentEvidenceChars + shard.shard.evidenceChars > 24e4) {
1984
+ groups.push(current);
1985
+ current = [];
1986
+ currentEvidenceChars = 0;
1987
+ }
1988
+ if (groups.length >= 8) {
1989
+ unassigned.push(shard);
1990
+ continue;
1991
+ }
1992
+ current.push(shard);
1993
+ currentEvidenceChars += shard.shard.evidenceChars;
1994
+ }
1995
+ if (current.length > 0 && groups.length < 8) groups.push(current);
1996
+ const units = groups.map((group, index) => unitOf(index, group));
1997
+ const assignedShardIds = new Set(units.flatMap((unit) => unit.evidenceShards.map((shard) => shard.shardId)));
1998
+ const assignedPaths = new Set(shards.filter(({ shard }) => assignedShardIds.has(shard.shardId)).map(({ shard }) => shard.path));
1999
+ const unassignedPathsWithEvidence = new Set(unassigned.map(({ shard }) => shard.path));
2000
+ return ReviewUnitPlan.make({
2001
+ totalFiles: files.length,
2002
+ truncated: files.length < options.totalChangedFiles,
2003
+ units,
2004
+ discoveryPasses: discoveryPassesFor(units),
2005
+ undiffablePaths: undiffable.map((file) => file.path),
2006
+ partialEvidencePaths: [...unassignedPathsWithEvidence].filter((path) => assignedPaths.has(path)),
2007
+ unassignedEvidenceShardCount: unassigned.length,
2008
+ unassignedEvidenceShardIds: unassigned.slice(0, 96).map(({ shard }) => shard.shardId),
2009
+ unassignedPaths: [...unassignedPathsWithEvidence].filter((path) => !assignedPaths.has(path))
1662
2010
  });
1663
- const passesByUnit = /* @__PURE__ */ new Map();
1664
- for (const pass of discoveryPasses) {
1665
- const passes = passesByUnit.get(pass.unitId) ?? [];
1666
- passes.push(pass);
1667
- passesByUnit.set(pass.unitId, passes);
2011
+ };
2012
+ const severityRank = {
2013
+ blocking: 0,
2014
+ important: 1,
2015
+ nit: 2
2016
+ };
2017
+ const anchorKey = (finding) => `${finding.path} ${finding.startLine} ${finding.endLine}`;
2018
+ /**
2019
+ * Merge the children's findings into one bounded, deterministic list: dedupe
2020
+ * findings sharing an anchor (path + line range) keeping the most severe —
2021
+ * and, at equal severity, the first in declaration order — then rank by
2022
+ * severity, path, and line, and cap at the `CodeReview` findings bound.
2023
+ * This is the merge policy the coordinator's instructions state in prose;
2024
+ * pinning it here keeps the policy itself deterministic and testable.
2025
+ */
2026
+ const rankAndDedupeFindings = (findings) => {
2027
+ const byAnchor = /* @__PURE__ */ new Map();
2028
+ for (const finding of findings) {
2029
+ const key = anchorKey(finding);
2030
+ const existing = byAnchor.get(key);
2031
+ if (existing === void 0 || severityRank[finding.severity] < severityRank[existing.severity]) byAnchor.set(key, finding);
1668
2032
  }
1669
- return {
1670
- plan,
1671
- passesByUnit,
1672
- overflowRetryPaths
1673
- };
2033
+ return [...byAnchor.values()].sort((left, right) => {
2034
+ const bySeverity = severityRank[left.severity] - severityRank[right.severity];
2035
+ if (bySeverity !== 0) return bySeverity;
2036
+ if (left.path !== right.path) return left.path < right.path ? -1 : 1;
2037
+ return left.startLine - right.startLine;
2038
+ }).slice(0, 20);
1674
2039
  };
1675
2040
  /**
1676
- * Run the complete host-scheduled fan-out pipeline over one selected
1677
- * changeset snapshot: plan, independent discovery, exact verification, and a
1678
- * deterministic host-composed CodeReview from verifier-confirmed candidates
1679
- * only. The verdict is derived from confirmed severities, never model prose.
2041
+ * Stable identity for one concern. The paths are part of the claim: identical
2042
+ * prose about two independent files must not collapse into one item.
1680
2043
  */
1681
- const runFanOutReview = (binding, input) => Effect.gen(function* () {
1682
- const { plan, passesByUnit, overflowRetryPaths } = scheduleFanOutWork(input);
1683
- const outcomes = yield* Effect.forEach(plan.units, (unit) => reviewUnit(binding, unit, passesByUnit.get(unit.unitId) ?? [], input), { concurrency: 4 });
1684
- const failedPasses = outcomes.flatMap((outcome) => outcome.failedPasses);
1685
- const unsettledCandidates = outcomes.reduce((total, outcome) => total + outcome.unsettledCandidates, 0);
1686
- const reasons = [];
1687
- if (failedPasses.length > 0) reasons.push(boundedListReason("configured review passes did not settle", failedPasses.map((pass) => `${pass.workId} (${pass.errorTag})`)));
1688
- if (unsettledCandidates > 0) reasons.push(`${unsettledCandidates} discovered candidate(s) did not receive exact verification`);
1689
- const requiredSpecialistPasses = plan.discoveryPasses.filter((pass) => pass.perspective === "risk-specialist").length;
1690
- const confirmed = outcomes.flatMap((outcome) => outcome.confirmed);
1691
- const assurance = ReviewAssurance.make({
1692
- status: reasons.length === 0 ? "settled" : "incomplete",
1693
- requiredGeneralDiscoveryPasses: plan.discoveryPasses.length - requiredSpecialistPasses,
1694
- completedGeneralDiscoveryPasses: outcomes.reduce((total, outcome) => total + outcome.completedGeneralPasses, 0),
1695
- requiredSpecialistPasses,
1696
- completedSpecialistPasses: outcomes.reduce((total, outcome) => total + outcome.completedSpecialistPasses, 0),
1697
- requiredVerificationPasses: outcomes.reduce((total, outcome) => total + outcome.requiredVerificationPasses, 0),
1698
- completedVerificationPasses: outcomes.reduce((total, outcome) => total + outcome.completedVerificationPasses, 0),
1699
- discoveredCandidates: outcomes.reduce((total, outcome) => total + outcome.discoveredCandidates, 0),
1700
- confirmedCandidates: confirmed.length,
1701
- rejectedCandidates: outcomes.reduce((total, outcome) => total + outcome.rejectedCandidates, 0),
1702
- unsettledCandidates,
1703
- discardedInvalidFindings: outcomes.reduce((total, outcome) => total + outcome.discardedFindings, 0),
1704
- failedPasses,
1705
- reasons
1706
- });
1707
- const findings = rankAndDedupeFindings(confirmed.flatMap(({ assessment, candidate }) => candidate._tag === "FindingCandidate" ? [confirmedFindingForPublication(assessment, candidate)] : []));
1708
- const concerns = rankAndDedupeConcerns(confirmed.flatMap(({ candidate }) => candidate._tag === "ConcernCandidate" ? [candidate.concern] : []));
1709
- const walkthrough = outcomes.flatMap((outcome) => outcome.walkthrough);
1710
- const blocking = findings.some((finding) => finding.severity === "blocking") || concerns.some((concern) => concern.severity === "blocking");
1711
- return {
1712
- review: CodeReview.make({
1713
- summary: composeSummary(plan, assurance),
1714
- verdict: blocking ? "request-changes" : findings.length > 0 || concerns.length > 0 ? "comment" : "approve",
1715
- findings,
1716
- ...concerns.length === 0 ? {} : { concerns },
1717
- ...walkthrough.length === 0 ? {} : { walkthrough }
1718
- }),
1719
- assurance,
1720
- plan,
1721
- unreviewedPaths: [.../* @__PURE__ */ new Set([
1722
- ...outcomes.flatMap((outcome) => outcome.unreviewedPaths),
1723
- ...plan.unassignedPaths,
1724
- ...plan.partialEvidencePaths,
1725
- ...plan.undiffablePaths
1726
- ])].sort(),
1727
- unreviewedPasses: [...outcomes.flatMap((outcome) => outcome.unreviewedPasses), ...overflowRetryPaths.length === 0 ? [] : (input.retry?.stages.length ? input.retry.stages : [
1728
- "discovery",
1729
- "specialist",
1730
- "verification"
1731
- ]).map((stage) => ({
1732
- stage,
1733
- paths: overflowRetryPaths
1734
- }))],
1735
- turns: outcomes.reduce((total, outcome) => total + outcome.turns, 0)
1736
- };
1737
- });
1738
- //#endregion
1739
- //#region src/internal/fingerprint.ts
1740
- const MARKER_PREFIX = "<!-- effect-agent-pr-review fingerprint=sha256:";
1741
- const MARKER_SUFFIX = " -->";
1742
- const MARKER_PATTERN = /<!-- effect-agent-pr-review fingerprint=sha256:([0-9a-f]{64}) -->/g;
1743
- /** Render the invisible review-body marker for one fingerprint. */
1744
- const renderFingerprintMarker = (fingerprint) => `${MARKER_PREFIX}${fingerprint}${MARKER_SUFFIX}`;
1745
- /** The rendered marker length is fixed; publication reserves room for it. */
1746
- const FINGERPRINT_MARKER_LENGTH = renderFingerprintMarker("0".repeat(64)).length;
1747
- /** Extract the last fingerprint marker in one review body, if any. */
1748
- const extractFingerprint = (body) => {
1749
- let last;
1750
- for (const match of body.matchAll(MARKER_PATTERN)) last = match[1];
1751
- return last;
2044
+ const reviewConcernKey = (concern) => `${(concern.evidencePaths ?? []).join("\0")}\u0001${concern.title}\u0000${concern.body}`;
2045
+ /**
2046
+ * The concern analogue of `rankAndDedupeFindings`: dedupe by exact scoped
2047
+ * content keeping the most severe duplicate, rank by severity, and cap at the
2048
+ * `CodeReview` concerns bound.
2049
+ */
2050
+ const rankAndDedupeConcerns = (concerns) => {
2051
+ const byContent = /* @__PURE__ */ new Map();
2052
+ for (const concern of concerns) {
2053
+ const key = reviewConcernKey(concern);
2054
+ const previous = byContent.get(key);
2055
+ if (previous === void 0 || severityRank[concern.severity] < severityRank[previous.severity]) byContent.set(key, concern);
2056
+ }
2057
+ return [...byContent.values()].sort((left, right) => severityRank[left.severity] - severityRank[right.severity]).slice(0, 10);
1752
2058
  };
1753
- /** SHA-256 via `Crypto.Crypto`; unavailable crypto is a defect, not an expected failure. */
1754
- const sha256Hex = Effect.fn("sha256Hex")(function* (text) {
1755
- const digest = yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(text)).pipe(Effect.orDie);
1756
- return Encoding.encodeHex(digest);
1757
- });
1758
- const FIELD = "\0";
1759
- const RECORD = "";
1760
- const SECTION = "";
2059
+ //#endregion
2060
+ //#region src/internal/fan-out.ts
2061
+ /** One discovery pass returns at most this many anchored candidates. */
2062
+ const MAX_CHILD_FINDINGS = 6;
2063
+ /** One discovery pass returns at most this many non-anchored candidates. */
2064
+ const MAX_CHILD_CONCERNS = 3;
2065
+ /** Every unit receives independent general and specialist discovery passes. */
2066
+ const MAX_UNIT_CANDIDATES = 18;
1761
2067
  /**
1762
- * Unified-diff hunk coordinates describe where a patch applies, not what it
1763
- * changes. A content-equivalent rebase can shift both coordinates while
1764
- * leaving every context/addition/deletion line unchanged, so exclude only
1765
- * those coordinates from the canonical patch representation.
2068
+ * General + specialist discovery for every unit, then one verifier per unit.
2069
+ * The one-retry budget doubles the worst-case child Run count, but the
2070
+ * schedule itself never exceeds this bound.
1766
2071
  */
1767
- const canonicalPatch = (patch) => patch.replace(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/gm, "@@ -_ +_ @@");
2072
+ const MAX_REVIEW_CHILDREN = 24;
2073
+ /** Bounded structured concurrency across units; passes inside a unit are sequential. */
2074
+ const REVIEW_UNIT_CONCURRENCY = 4;
2075
+ /** Structural minimum for a child that exposes no tools. */
2076
+ const MAX_FILE_REVIEW_TOOL_CALLS = 1;
2077
+ const ReviewWorkPhase = Schema.Literals(["discovery", "verification"]);
2078
+ const ReviewWorkPerspective = Schema.Literals([
2079
+ "general",
2080
+ "risk-specialist",
2081
+ "candidate-verification"
2082
+ ]);
2083
+ const ReviewCandidateId = Schema.NonEmptyString.check(Schema.isMaxLength(96));
2084
+ var FindingCandidate = class extends Schema.TaggedClass()("FindingCandidate", {
2085
+ candidateId: ReviewCandidateId,
2086
+ workId: ReviewPassId,
2087
+ unitId: ReviewUnitId,
2088
+ finding: ReviewFinding,
2089
+ evidencePaths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(1))
2090
+ }) {};
2091
+ var ConcernCandidate = class extends Schema.TaggedClass()("ConcernCandidate", {
2092
+ candidateId: ReviewCandidateId,
2093
+ workId: ReviewPassId,
2094
+ unitId: ReviewUnitId,
2095
+ concern: ReviewConcern,
2096
+ evidencePaths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3))
2097
+ }) {};
2098
+ const ReviewCandidate = Schema.Union([FindingCandidate, ConcernCandidate]);
2099
+ /** Deterministic host equivalence for claims repeated across discovery passes. */
2100
+ const reviewCandidateSubjectKey = (candidate) => candidate._tag === "FindingCandidate" ? `finding:${JSON.stringify(Schema.encodeSync(ReviewFinding)(candidate.finding))}` : `concern:${JSON.stringify(Schema.encodeSync(ReviewConcern)(candidate.concern))}`;
2101
+ var CandidateAssessment = class extends Schema.Class("@effect-agent/pr-review/CandidateAssessment")({
2102
+ candidateId: ReviewCandidateId,
2103
+ disposition: Schema.Literals(["confirmed", "rejected"]),
2104
+ /**
2105
+ * Exact suggestion settlement: required when the candidate finding carries
2106
+ * a suggestion, forbidden otherwise. Untrusted child output cannot publish
2107
+ * a GitHub replacement block by prompt compliance alone — the host keeps a
2108
+ * confirmed finding's suggestion only on an exact "committable" settlement.
2109
+ */
2110
+ suggestion: Schema.optionalKey(Schema.Literals(["committable", "not-committable"]).annotate({ description: "Required exactly when the candidate finding carries a suggestion: \"committable\" only when its text is the full replacement source for exactly lines startLine..endLine and nothing else. Forbidden for candidates without a suggestion." })),
2111
+ rationale: Schema.NonEmptyString.check(Schema.isMaxLength(600))
2112
+ }) {};
1768
2113
  /**
1769
- * Canonical changeset encoding: sorted by path so provider ordering never
1770
- * matters, with every review-relevant field of every file.
2114
+ * Exact suggestion settlement shape: a carried suggestion must be settled and
2115
+ * nothing else may be. A verification report that violates it is treated as a
2116
+ * misbehaving pass and retried within the pass budget.
1771
2117
  */
1772
- const canonicalChangeset = (files) => files.map((file) => `${file.path}${FIELD}${file.status}${FIELD}${String(file.additions)}${FIELD}${String(file.deletions)}${FIELD}${file.patch === void 0 ? "" : canonicalPatch(file.patch)}${FIELD}${file.reviewBaseContent ?? ""}${FIELD}${file.reviewHeadContent ?? ""}`).sort().join(RECORD);
2118
+ const assessmentSettlesSuggestionExactly = (assessment, candidate) => candidate._tag === "FindingCandidate" && candidate.finding.suggestion !== void 0 ? assessment.suggestion !== void 0 : assessment.suggestion === void 0;
1773
2119
  /**
1774
- * Fingerprint one review's complete input surface: the (already
1775
- * ignore-filtered) changeset plus the caller's prompt signature the
1776
- * rendered instructions and any review-shaping options the instructions do
1777
- * not carry.
2120
+ * Fail-closed publication of a confirmed finding: only an exact "committable"
2121
+ * settlement keeps the suggestion; anything else publishes the finding with
2122
+ * the suggestion stripped so unverified text can never become a one-click
2123
+ * GitHub replacement block.
1778
2124
  */
1779
- const computeChangesetFingerprint = (files, signature) => sha256Hex(`${canonicalChangeset(files)}${SECTION}${signature}`);
1780
- /** Profile fingerprints are SHA-256 over configuration-only signatures. */
1781
- const computeProfileFingerprint = (signature) => sha256Hex(signature);
1782
- //#endregion
1783
- //#region src/internal/review-state.ts
1784
- const ReviewMode = Schema.Literals(["incremental", "final"]);
1785
- const ReviewScopeMode = Schema.Literals(["incremental", "full"]);
1786
- const GitCommitSha = Schema.NonEmptyString.check(Schema.isMaxLength(64), Schema.isPattern(/^[0-9a-f]{40,64}$/));
1787
- const Fingerprint = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/));
1788
- const StoredText = Schema.NonEmptyString.check(Schema.isMaxLength(800));
1789
- /** A compact unresolved finding suitable for the bounded review-body marker. */
1790
- var StoredReviewFinding = class extends Schema.Class("@effect-agent/pr-review/StoredReviewFinding")({
2125
+ const confirmedFindingForPublication = (assessment, candidate) => {
2126
+ if (candidate.finding.suggestion === void 0 || assessment.suggestion === "committable") return candidate.finding;
2127
+ const { suggestion: _stripped, ...finding } = candidate.finding;
2128
+ return ReviewFinding.make(finding);
2129
+ };
2130
+ /**
2131
+ * Concern candidates need explicit paths internally to bind the claim to
2132
+ * scheduled evidence. The verifier receives the complete bounded unit so it
2133
+ * can use neighboring evidence to falsify the claim. The host copies these
2134
+ * validated paths onto a confirmed public concern for incremental continuity.
2135
+ */
2136
+ var DiscoveredConcern = class extends Schema.Class("@effect-agent/pr-review/DiscoveredConcern")({
2137
+ concern: ReviewConcern,
2138
+ evidencePaths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3))
2139
+ }) {};
2140
+ const UnitPaths = Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12));
2141
+ /** Bounded prior-review context lines injected into discovery instructions. */
2142
+ const UnitContextLines = Schema.Array(Schema.String.check(Schema.isMaxLength(1200))).check(Schema.isMaxLength(20));
2143
+ const RiskCategories = Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6));
2144
+ const Candidates = Schema.Array(ReviewCandidate).check(Schema.isMaxLength(18));
2145
+ const EvidenceShardIds = Schema.Array(ReviewEvidenceShardId).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12));
2146
+ /** One complete host-selected evidence shard supplied to a review child. */
2147
+ var FileReviewEvidence = class extends Schema.Class("@effect-agent/pr-review/FileReviewEvidence")({
2148
+ shardId: ReviewEvidenceShardId,
1791
2149
  path: ChangedPath,
1792
- startLine: Schema.Int.check(Schema.isGreaterThan(0)),
1793
- endLine: Schema.Int.check(Schema.isGreaterThan(0)),
1794
- severity: FindingSeverity,
1795
- title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
1796
- body: StoredText
2150
+ status: ChangedFileStatus,
2151
+ reviewMode: Schema.Literals([
2152
+ "diff",
2153
+ "content",
2154
+ "unavailable"
2155
+ ]),
2156
+ ordinal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
2157
+ total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
2158
+ annotatedPatch: Schema.String.check(Schema.isMaxLength(MAX_PATCH_CHARS))
1797
2159
  }) {};
1798
- /** A compact unresolved non-anchored concern carried until a final audit. */
1799
- var StoredReviewConcern = class extends Schema.Class("@effect-agent/pr-review/StoredReviewConcern")({
1800
- severity: FindingSeverity,
1801
- title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
1802
- body: StoredText
2160
+ /** Host-prepared child input with complete bounded diff/content evidence. */
2161
+ var FileReviewBrief = class extends Schema.Class("@effect-agent/pr-review/FileReviewBrief")({
2162
+ phase: ReviewWorkPhase,
2163
+ workId: ReviewPassId,
2164
+ unitId: ReviewUnitId,
2165
+ paths: UnitPaths,
2166
+ evidenceShardIds: EvidenceShardIds,
2167
+ perspective: ReviewWorkPerspective,
2168
+ riskCategories: RiskCategories,
2169
+ /** Empty for discovery; the exact discovered set for unit verification. */
2170
+ candidates: Candidates,
2171
+ evidence: Schema.Array(FileReviewEvidence).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12)),
2172
+ /** Maintainer-adjudicated identities on this unit; do not re-raise. */
2173
+ adjudicatedContext: Schema.optionalKey(UnitContextLines),
2174
+ /** Prior-round findings on this unit's re-reviewed paths. */
2175
+ priorFindingContext: Schema.optionalKey(UnitContextLines)
1803
2176
  }) {};
1804
- /** The carried-scope bound; a run that cannot fit its leftovers publishes no state. */
1805
- const MAX_STORED_UNREVIEWED_PATHS = 100;
1806
- /** Failed-pass records stored beside the leftover paths; one per unit stage. */
1807
- const MAX_STORED_UNREVIEWED_PASSES = 24;
1808
- /** Stages a leftover path may need retried without a second general discovery. */
1809
- const UnreviewedStage = Schema.Literals([
1810
- "discovery",
1811
- "specialist",
1812
- "verification"
1813
- ]);
1814
- /** One failed fan-out pass whose paths should be retried, not rediscovered. */
1815
- var StoredUnreviewedPass = class extends Schema.Class("@effect-agent/pr-review/StoredUnreviewedPass")({
1816
- stage: UnreviewedStage,
1817
- paths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12))
2177
+ /** Child output; phase-inapplicable collections must be empty. */
2178
+ var FileReviewReport = class extends Schema.Class("@effect-agent/pr-review/FileReviewReport")({
2179
+ phase: ReviewWorkPhase,
2180
+ workId: ReviewPassId,
2181
+ unitId: ReviewUnitId,
2182
+ findings: Schema.Array(ReviewFinding).check(Schema.isMaxLength(6)),
2183
+ concerns: Schema.Array(DiscoveredConcern).check(Schema.isMaxLength(3)),
2184
+ fileSummaries: Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(12)),
2185
+ assessments: Schema.Array(CandidateAssessment).check(Schema.isMaxLength(18))
1818
2186
  }) {};
1819
2187
  /**
1820
- * Versioned state embedded after EVERY completed run that can be signed. The
1821
- * head plus full-scope fingerprint forms an incremental baseline; an absent
1822
- * unresolved item never means the path is defect-free. `unreviewedPaths`
1823
- * carries retryable review gaps (failed passes) forward so the next
1824
- * incremental run re-reviews exactly them plus the new delta — the baseline
1825
- * advances monotonically instead of freezing on one flaky pass and reopening
1826
- * the whole post-baseline scope. The `acceptedScopeFingerprint` name is
1827
- * retained for wire compatibility. Storing hundreds of path strings
1828
- * separately would not fit GitHub's bounded review body in the worst case.
2188
+ * A structurally valid child report that does not answer the scheduled pass:
2189
+ * wrong identity, phase-inapplicable fields, or an inexact assessment set.
2190
+ * Retried once like any other pass fault, because it is model misbehavior,
2191
+ * not evidence about the code under review.
1829
2192
  */
1830
- var ReviewState = class extends Schema.Class("@effect-agent/pr-review/ReviewState")({
1831
- version: Schema.Literal(2),
1832
- repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
1833
- pullRequestNumber: Schema.Int.check(Schema.isGreaterThan(0)),
1834
- baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
1835
- baseSha: GitCommitSha,
1836
- headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
1837
- reviewedHeadSha: GitCommitSha,
1838
- profileFingerprint: Fingerprint,
1839
- acceptedScopeFingerprint: Fingerprint,
1840
- reviewedPathCount: Schema.Int.check(Schema.isBetween({
1841
- minimum: 0,
1842
- maximum: 300
1843
- })),
1844
- unresolvedFindings: Schema.Array(StoredReviewFinding).check(Schema.isMaxLength(20)),
1845
- unresolvedConcerns: Schema.Array(StoredReviewConcern).check(Schema.isMaxLength(10)),
1846
- /** Retryable review gaps carried into the next incremental run's scope. */
1847
- unreviewedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(100)),
1848
- /**
1849
- * Which failed pass produced those leftovers. Absent on state-v2 markers
1850
- * written before this field existed; those leftovers still re-enter scope
1851
- * but cannot skip rediscovery. Present (including empty) on new markers.
1852
- */
1853
- unreviewedPasses: Schema.optionalKey(Schema.Array(StoredUnreviewedPass).check(Schema.isMaxLength(24))),
1854
- /**
1855
- * True only when the producing run had complete input coverage, no
1856
- * unsettled pass, and nothing carried. Skip-unchanged authority: an
1857
- * unchanged patch may skip re-review only over a settled state.
1858
- */
1859
- settled: Schema.Boolean,
1860
- lastReviewMode: ReviewScopeMode
1861
- }) {};
1862
- const toStoredFinding = (finding) => StoredReviewFinding.make({
1863
- path: finding.path,
1864
- startLine: finding.startLine,
1865
- endLine: finding.endLine,
1866
- severity: finding.severity,
1867
- title: finding.title,
1868
- body: finding.body.slice(0, 800)
1869
- });
1870
- const fromStoredFinding = (finding) => ReviewFinding.make({
1871
- path: finding.path,
1872
- startLine: finding.startLine,
1873
- endLine: finding.endLine,
1874
- severity: finding.severity,
1875
- title: finding.title,
1876
- body: finding.body
1877
- });
1878
- const toStoredConcern = (concern) => StoredReviewConcern.make({
1879
- severity: concern.severity,
1880
- title: concern.title,
1881
- body: concern.body.slice(0, 800)
1882
- });
1883
- const fromStoredConcern = (concern) => ReviewConcern.make({
1884
- severity: concern.severity,
1885
- title: concern.title,
1886
- body: concern.body
1887
- });
1888
- const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v2:";
1889
- const STATE_MARKER_SUFFIX = " -->";
1890
- const STATE_MARKER_PATTERN = /(?:^|\n)<!-- effect-agent-pr-review state-v2:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
1891
- const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-v2\0";
1892
- const MAX_REVIEW_STATE_MARKER_CHARS = 24e3;
1893
- const ReviewStateMarker = Schema.NonEmptyString.check(Schema.isMaxLength(MAX_REVIEW_STATE_MARKER_CHARS), Schema.isPattern(/^<!-- effect-agent-pr-review state-v2:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/)).pipe(Schema.brand("@effect-agent/pr-review/ReviewStateMarker"));
1894
- var ReviewStateAuthenticationFailure = class extends Schema.TaggedError()("ReviewStateAuthenticationFailure", {
1895
- operation: Schema.Literals(["sign", "verify"]),
1896
- reason: Schema.NonEmptyString.check(Schema.isMaxLength(2048))
1897
- }) {};
1898
- var ReviewStateMarkerTooLarge = class extends Schema.TaggedError()("ReviewStateMarkerTooLarge", {
1899
- observedChars: Schema.Int.check(Schema.isGreaterThan(0)),
1900
- maximumChars: Schema.Int.check(Schema.isGreaterThan(0))
2193
+ var ReviewPassMisbehaved = class extends Schema.TaggedError()("ReviewPassMisbehaved", {
2194
+ workId: ReviewPassId,
2195
+ reason: Schema.NonEmptyString.check(Schema.isMaxLength(600))
1901
2196
  }) {};
1902
- var ReviewStateAuthenticator = class extends Context.Service()("@effect-agent/pr-review/ReviewStateAuthenticator") {};
1903
- const authenticationFailure = (operation, cause) => ReviewStateAuthenticationFailure.make({
1904
- operation,
1905
- reason: String(cause).slice(0, 2048)
2197
+ const staticGuidanceLines = (guidance) => {
2198
+ if (guidance === void 0) return [];
2199
+ return (typeof guidance === "string" ? [guidance] : guidance).filter((line) => line.length > 0);
2200
+ };
2201
+ const evidenceInstructions = [
2202
+ "The host placed complete bounded review evidence shards in the input evidence array. Treat every shard as required input; ordinal/total identifies multi-shard paths.",
2203
+ "You have no tools and cannot roam outside this evidence. If it is insufficient for a candidate, reject or omit that candidate rather than guessing.",
2204
+ "A diff marks new-version anchors as R<number>; only those lines may anchor findings. B/H content evidence is non-anchorable."
2205
+ ];
2206
+ /** Discovery and verification instructions share one child definition. */
2207
+ const makeFileReviewerInstructions = (options = {}) => (brief) => {
2208
+ const common = [
2209
+ `You are an attached review worker for ${brief.workId} in host-planned unit ${brief.unitId}: ${brief.paths.join(", ")}.`,
2210
+ ...staticGuidanceLines(options.guidance),
2211
+ ...evidenceInstructions
2212
+ ];
2213
+ if (brief.phase === "verification") return [
2214
+ ...common,
2215
+ "Independently verify every candidate in the input. You did not receive another reviewer's transcript or reasoning; use only the candidate claim and bounded evidence.",
2216
+ "The evidence array contains the complete bounded unit, including neighboring changed code that may confirm or falsify a locally plausible claim.",
2217
+ "For each candidate, try to falsify it first. Confirm only when the cited behavior is supported and actionable. Reject unsupported, speculative, duplicate, or non-actionable candidates.",
2218
+ "Return ONLY JSON with phase \"verification\", the exact workId/unitId, empty findings/concerns/fileSummaries arrays, and exactly one assessment per candidateId. Each assessment is {\"candidateId\": <exact id>, \"disposition\": <\"confirmed\" | \"rejected\">, \"suggestion\": <\"committable\" | \"not-committable\", present exactly when the candidate finding carries a suggestion>, \"rationale\": <bounded evidence-based reason>}. Never add or omit an id.",
2219
+ "Settle every carried suggestion independently of the claim: answer \"committable\" only when its text is the full replacement source for exactly lines startLine..endLine and nothing else — it compiles in context and preserves the finding's intent, never prose describing a change. Otherwise answer \"not-committable\"; the host then publishes the confirmed finding without its suggestion. Omit the assessment \"suggestion\" field for candidates without one."
2220
+ ].join("\n");
2221
+ const focus = brief.perspective === "risk-specialist" ? brief.riskCategories.length > 0 ? `This is a fresh specialist discovery pass. Concentrate on these host-classified risks without relying on another pass: ${brief.riskCategories.join(", ")}.` : "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." : "This is the general discovery pass. Review broadly for correctness, security, concurrency, resource, API, and error-handling defects.";
2222
+ const adjudicated = brief.adjudicatedContext ?? [];
2223
+ const priorFindings = brief.priorFindingContext ?? [];
2224
+ return [
2225
+ ...common,
2226
+ focus,
2227
+ ...adjudicated.length === 0 ? [] : ["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:", ...adjudicated.map((line) => `- ${line}`)],
2228
+ ...priorFindings.length === 0 ? [] : ["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:", ...priorFindings.map((line) => `- ${line}`)],
2229
+ "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.",
2230
+ "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.",
2231
+ `Return ONLY JSON with phase "discovery", the exact workId/unitId, up to 6 findings, up to 3 concerns shaped as {concern, evidencePaths}, one factual file summary per path (<= 240 chars), and an empty assessments array. Empty candidate arrays are valid; do not invent defects.`,
2232
+ "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>}.",
2233
+ "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\"."
2234
+ ].join("\n");
2235
+ };
2236
+ const fileReviewerInstructions = makeFileReviewerInstructions();
2237
+ const FileReviewToolkit = Toolkit.empty;
2238
+ const defaultFileReviewerPolicy = AgentPolicy.make({
2239
+ maxTurns: 6,
2240
+ maxToolCalls: 1,
2241
+ maxDuration: "6 minutes",
2242
+ toolConcurrency: 2,
2243
+ repeatedFailureLimit: 6,
2244
+ tokenBudget: 2e5,
2245
+ contextTokenLimit: 15e4,
2246
+ toolResultBounds: ToolResultBounds.make({ maxBytes: REVIEW_TOOL_RESULT_MAX_BYTES }),
2247
+ onExhaustion: "fail"
1906
2248
  });
1907
- const hmacKey = (secret, operation) => Effect.tryPromise({
1908
- try: () => globalThis.crypto.subtle.importKey("raw", new TextEncoder().encode(Redacted.value(secret)), {
1909
- name: "HMAC",
1910
- hash: "SHA-256"
1911
- }, false, ["sign", "verify"]),
1912
- catch: (cause) => authenticationFailure(operation, cause)
2249
+ const makeFileReviewerDefinition = (options = {}) => Agent.define("pr-review-worker", {
2250
+ input: FileReviewBrief,
2251
+ output: FileReviewReport,
2252
+ instructions: makeFileReviewerInstructions(options),
2253
+ toolkit: FileReviewToolkit,
2254
+ policy: defaultFileReviewerPolicy,
2255
+ description: "Perform one bounded discovery or independent candidate-verification pass over host-supplied pull-request evidence.",
2256
+ metadata: {
2257
+ deploymentClass: "E",
2258
+ surface: "read-only",
2259
+ stage: "discovery-verification"
2260
+ }
1913
2261
  });
1914
- const signatureBytes = (signature) => {
1915
- const pairs = signature.match(/../g) ?? [];
1916
- const buffer = new ArrayBuffer(pairs.length);
1917
- const bytes = new Uint8Array(buffer);
1918
- for (let index = 0; index < pairs.length; index += 1) bytes[index] = Number.parseInt(pairs[index] ?? "", 16);
1919
- return buffer;
1920
- };
1921
- /** Validated WebCrypto adapter selected at the Action composition root. */
1922
- const webCryptoReviewStateAuthenticatorLayer = (secret) => Layer.succeed(ReviewStateAuthenticator)(ReviewStateAuthenticator.of({
1923
- status: "available",
1924
- unavailableReason: void 0,
1925
- render: (state) => Effect.gen(function* () {
1926
- const json = yield* Schema.encodeUnknownEffect(Schema.fromJsonString(ReviewState))(state).pipe(Effect.mapError((cause) => authenticationFailure("sign", cause)));
1927
- const payload = Encoding.encodeBase64(json);
1928
- const message = new TextEncoder().encode(`${STATE_SIGNATURE_DOMAIN}${payload}`);
1929
- const key = yield* hmacKey(secret, "sign");
1930
- const signature = yield* Effect.tryPromise({
1931
- try: () => globalThis.crypto.subtle.sign("HMAC", key, message),
1932
- catch: (cause) => authenticationFailure("sign", cause)
1933
- });
1934
- const hex = Array.from(new Uint8Array(signature)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
1935
- const marker = `${STATE_MARKER_PREFIX}${payload}.${hex}${STATE_MARKER_SUFFIX}`;
1936
- if (marker.length > 24e3) return yield* ReviewStateMarkerTooLarge.make({
1937
- observedChars: marker.length,
1938
- maximumChars: MAX_REVIEW_STATE_MARKER_CHARS
1939
- });
1940
- return yield* Schema.decodeUnknownEffect(ReviewStateMarker)(marker).pipe(Effect.mapError((cause) => authenticationFailure("sign", cause)));
1941
- }),
1942
- extract: (body) => {
1943
- if (body.length > 6e4) return Effect.succeed(Option.none());
1944
- const match = STATE_MARKER_PATTERN.exec(body);
1945
- const payload = match?.[1];
1946
- const signature = match?.[2];
1947
- if (payload === void 0 || signature === void 0) return Effect.succeed(Option.none());
1948
- const marker = `${STATE_MARKER_PREFIX}${payload}.${signature}${STATE_MARKER_SUFFIX}`;
1949
- if (!Schema.is(ReviewStateMarker)(marker)) return Effect.succeed(Option.none());
1950
- const json = Result.getOrUndefined(Encoding.decodeBase64String(payload));
1951
- if (json === void 0) return Effect.succeed(Option.none());
1952
- const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(ReviewState))(json);
1953
- if (Option.isNone(decoded)) return Effect.succeed(Option.none());
1954
- const message = new TextEncoder().encode(`${STATE_SIGNATURE_DOMAIN}${payload}`);
1955
- return Effect.gen(function* () {
1956
- const key = yield* hmacKey(secret, "verify");
1957
- return (yield* Effect.tryPromise({
1958
- try: () => globalThis.crypto.subtle.verify("HMAC", key, signatureBytes(signature), message),
1959
- catch: (cause) => authenticationFailure("verify", cause)
1960
- })) ? Option.some(decoded.value) : Option.none();
1961
- });
2262
+ const FileReviewer = makeFileReviewerDefinition();
2263
+ const candidateOrdinal = (index) => String(index + 1).padStart(3, "0");
2264
+ /** Rebuild one unit's complete evidence from the same snapshot the plan used. */
2265
+ const unitEvidence = (unit, files) => Effect.gen(function* () {
2266
+ const byPath = new Map(files.map((file) => [file.path, file]));
2267
+ const evidence = [];
2268
+ for (const shard of unit.evidenceShards) {
2269
+ const file = byPath.get(shard.path);
2270
+ const chunk = file === void 0 ? void 0 : fileReviewEvidenceChunks(file)[shard.ordinal - 1];
2271
+ if (file === void 0 || chunk === void 0) return yield* Effect.die(/* @__PURE__ */ new Error(`planned evidence shard has no source: ${shard.shardId} (${shard.path})`));
2272
+ evidence.push(FileReviewEvidence.make({
2273
+ shardId: shard.shardId,
2274
+ path: shard.path,
2275
+ status: file.status,
2276
+ reviewMode: chunk.reviewMode,
2277
+ ordinal: shard.ordinal,
2278
+ total: shard.total,
2279
+ annotatedPatch: chunk.annotatedPatch
2280
+ }));
1962
2281
  }
1963
- }));
1964
- /** Explicit no-state implementation for hosts without a stable authentication secret. */
1965
- const unavailableReviewStateAuthenticatorLayer = (reason) => {
1966
- const safeReason = reason === "" ? "review-state authentication is unavailable" : reason;
1967
- return Layer.succeed(ReviewStateAuthenticator)(ReviewStateAuthenticator.of({
1968
- status: "unavailable",
1969
- unavailableReason: safeReason.slice(0, 1e3),
1970
- render: () => Effect.fail(ReviewStateAuthenticationFailure.make({
1971
- operation: "sign",
1972
- reason: safeReason.slice(0, 2048)
1973
- })),
1974
- extract: () => Effect.succeed(Option.none())
1975
- }));
1976
- };
1977
- /** The bounded result of GitHub's previous-head...current-head comparison. */
1978
- var ReviewHeadComparison = class extends Schema.Class("@effect-agent/pr-review/ReviewHeadComparison")({
1979
- status: Schema.Literals([
1980
- "ahead",
1981
- "behind",
1982
- "diverged",
1983
- "identical"
1984
- ]),
1985
- baseSha: GitCommitSha,
1986
- headSha: GitCommitSha,
1987
- mergeBaseSha: GitCommitSha,
1988
- files: Schema.Array(ChangedFile).check(Schema.isMaxLength(300)),
1989
- /** GitHub caps compare-file payloads at 300; equality is conservatively truncated. */
1990
- truncated: Schema.Boolean
1991
- }) {};
1992
- const fullSelection = (input) => ({
1993
- mode: "full",
1994
- reason: input.reason,
1995
- files: input.files,
1996
- affectedPaths: input.files.flatMap((file) => file.previousPath === void 0 ? [file.path] : [file.path, file.previousPath]),
1997
- retryPaths: [],
1998
- retryStages: [],
1999
- totalFiles: input.totalFiles,
2000
- baselineSha: void 0,
2001
- priorState: void 0,
2002
- profileFingerprint: input.profileFingerprint
2282
+ return evidence;
2003
2283
  });
2004
- /** Three-dot lineage from the reviewed head to the current head is usable. */
2005
- const isLineageAncestor = (comparison, priorState, currentHeadSha) => comparison.baseSha === priorState.reviewedHeadSha && comparison.headSha === currentHeadSha && comparison.mergeBaseSha === priorState.reviewedHeadSha && !comparison.truncated && (comparison.status === "ahead" || comparison.status === "identical");
2284
+ const misbehaved = (workId, reason) => ReviewPassMisbehaved.make({
2285
+ workId,
2286
+ reason: reason.slice(0, 600)
2287
+ });
2288
+ /** Validate that a verification report assesses exactly the scheduled candidates. */
2289
+ const validateVerificationReport = (brief, report) => {
2290
+ if (report.findings.length > 0 || report.concerns.length > 0 || report.fileSummaries.length > 0) return misbehaved(brief.workId, "verification output contained discovery-only fields");
2291
+ const expectedById = new Map(brief.candidates.map((candidate) => [candidate.candidateId, candidate]));
2292
+ const assessedIds = /* @__PURE__ */ new Set();
2293
+ for (const assessment of report.assessments) {
2294
+ const candidate = expectedById.get(assessment.candidateId);
2295
+ if (candidate === void 0 || assessedIds.has(assessment.candidateId)) return misbehaved(brief.workId, "verification output did not assess the exact candidate set");
2296
+ if (!assessmentSettlesSuggestionExactly(assessment, candidate)) return misbehaved(brief.workId, "verification output did not settle suggestion publication exactly");
2297
+ assessedIds.add(assessment.candidateId);
2298
+ }
2299
+ if (assessedIds.size !== expectedById.size) return misbehaved(brief.workId, "verification output did not assess the exact candidate set");
2300
+ };
2006
2301
  /**
2007
- * Validate that persisted state belongs to this exact PR/base lineage and the
2008
- * same review profile. A mismatch is a full-review reason, never an error that
2009
- * silently suppresses review work.
2302
+ * Run one scheduled pass: execute the child, decode its report, and enforce
2303
+ * the pass contract. Any typed fault child failure, malformed or misdirected
2304
+ * output is retried once; budget exhaustion is terminal because a retry
2305
+ * would fail the same way. The settled outcome is a value either way, so one
2306
+ * flaky pass can never fail the whole pipeline.
2010
2307
  */
2011
- const validateReviewState = (state, current, profileFingerprint) => {
2012
- if (state.repository !== current.repository || state.pullRequestNumber !== current.number) return "stored state belongs to a different pull request";
2013
- if (current.baseSha === void 0) return "the current base commit is unavailable";
2014
- if (state.baseRef !== current.baseRef) return "the pull request base ref changed";
2015
- if (state.headRef !== current.headRef) return "the pull request head ref changed";
2016
- if (state.profileFingerprint !== profileFingerprint) return "the reviewer profile or model configuration changed";
2017
- };
2018
- const filePaths = (file) => file.previousPath === void 0 ? [file.path] : [file.path, file.previousPath];
2019
- const incrementalFromDelta = (input) => {
2020
- const currentPaths = new Set(input.fullFiles.flatMap(filePaths));
2021
- const affectedPaths = /* @__PURE__ */ new Set([...input.deltaFiles.flatMap(filePaths), ...input.extraAffectedPaths ?? []]);
2022
- const selectedByPath = /* @__PURE__ */ new Map();
2023
- for (const file of input.deltaFiles) if (currentPaths.has(file.path) || file.previousPath !== void 0 && currentPaths.has(file.previousPath)) selectedByPath.set(file.path, file);
2024
- const carriedPaths = input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path));
2025
- const surgical = input.priorState.unreviewedPasses !== void 0;
2026
- const retryOnly = /* @__PURE__ */ new Set();
2027
- const retryStages = /* @__PURE__ */ new Set();
2028
- for (const path of carriedPaths) {
2029
- if (affectedPaths.has(path)) continue;
2030
- retryOnly.add(path);
2031
- if (surgical) {
2032
- for (const pass of input.priorState.unreviewedPasses ?? []) if (pass.paths.includes(path)) retryStages.add(pass.stage);
2033
- } else affectedPaths.add(path);
2034
- }
2035
- if (surgical && retryStages.has("verification") && !retryStages.has("discovery") && !retryStages.has("specialist")) {
2036
- retryStages.add("discovery");
2037
- retryStages.add("specialist");
2038
- }
2039
- if (surgical && retryOnly.size > 0 && retryStages.size === 0) {
2040
- for (const path of retryOnly) affectedPaths.add(path);
2041
- retryStages.add("discovery");
2042
- retryStages.add("specialist");
2043
- retryStages.add("verification");
2308
+ const runReviewPass = (binding, brief, budget) => Effect.gen(function* () {
2309
+ const result = yield* AgentRuntime.run(binding, brief, {
2310
+ ...budget === void 0 ? {} : { budget },
2311
+ estimateCostMicrousd: () => Effect.succeed(500)
2312
+ });
2313
+ const report = yield* Schema.decodeUnknownEffect(FileReviewReport)(result.output).pipe(Effect.mapError((error) => misbehaved(brief.workId, `child report failed to decode: ${error.message}`)));
2314
+ if (report.phase !== brief.phase || report.workId !== brief.workId || report.unitId !== brief.unitId) return yield* misbehaved(brief.workId, "child report identity does not match the scheduled pass");
2315
+ if (brief.phase === "verification") {
2316
+ const violation = validateVerificationReport(brief, report);
2317
+ if (violation !== void 0) return yield* violation;
2318
+ } else if (report.assessments.length > 0) return yield* misbehaved(brief.workId, "discovery output contained verification-only assessments");
2319
+ return {
2320
+ report,
2321
+ turns: result.turns
2322
+ };
2323
+ }).pipe(Effect.scoped, Effect.retry({
2324
+ times: 1,
2325
+ while: (error) => error._tag !== "BudgetExceeded"
2326
+ }), Effect.map((settled) => ({
2327
+ _tag: "settled",
2328
+ ...settled
2329
+ })), Effect.catch((error) => Effect.succeed({
2330
+ _tag: "failed",
2331
+ errorTag: String(error._tag).slice(0, 256)
2332
+ })));
2333
+ /**
2334
+ * Keep only findings anchored inside the pass's exact assigned evidence and
2335
+ * concerns bound to unit paths. Everything else is discarded and counted —
2336
+ * an invalid anchor invalidates one claim, never the pass that produced it.
2337
+ */
2338
+ const harvestDiscovery = (pass, unit, files, anchorFiles, report) => {
2339
+ const allowed = new Set(pass.paths);
2340
+ let discarded = 0;
2341
+ const keptFindings = [];
2342
+ for (const finding of report.findings) {
2343
+ if (!allowed.has(finding.path) || anchorViolation(finding, anchorFiles) !== void 0 || !findingAnchorInUnitEvidence(finding, unit, files)) {
2344
+ discarded += 1;
2345
+ continue;
2346
+ }
2347
+ keptFindings.push(finding);
2044
2348
  }
2045
- if (carriedPaths.length > 0 || (input.extraAffectedPaths?.length ?? 0) > 0) {
2046
- for (const file of input.fullFiles) if (affectedPaths.has(file.path) || file.previousPath !== void 0 && affectedPaths.has(file.previousPath) || retryOnly.has(file.path) || file.previousPath !== void 0 && retryOnly.has(file.previousPath)) selectedByPath.set(file.path, file);
2349
+ const keptConcerns = [];
2350
+ for (const candidate of report.concerns) {
2351
+ if (candidate.evidencePaths.some((path) => !allowed.has(path))) {
2352
+ discarded += 1;
2353
+ continue;
2354
+ }
2355
+ keptConcerns.push(candidate);
2047
2356
  }
2048
- const selectedFiles = [...selectedByPath.values()].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
2049
- const leftoverCount = [...retryOnly].filter((path) => !affectedPaths.has(path)).length;
2050
- const carriedReason = leftoverCount > 0 && surgical ? `; retrying ${leftoverCount} unchanged leftover path(s) without rediscovery` : carriedPaths.length > 0 ? `; retrying ${carriedPaths.length} carried unreviewed path(s)` : "";
2051
2357
  return {
2052
- mode: "incremental",
2053
- reason: `${input.reason}${carriedReason}`,
2054
- files: selectedFiles,
2055
- affectedPaths: [...affectedPaths].sort(),
2056
- retryPaths: [...retryOnly].filter((path) => !affectedPaths.has(path)).sort(),
2057
- retryStages: [...retryStages].sort(),
2058
- totalFiles: selectedFiles.length,
2059
- baselineSha: input.priorState.reviewedHeadSha,
2060
- priorState: input.priorState,
2061
- profileFingerprint: input.profileFingerprint
2358
+ candidates: [...keptFindings.map((finding, index) => FindingCandidate.make({
2359
+ candidateId: `${pass.passId}:finding:${candidateOrdinal(index)}`,
2360
+ workId: pass.passId,
2361
+ unitId: pass.unitId,
2362
+ finding,
2363
+ evidencePaths: [finding.path]
2364
+ })), ...keptConcerns.map((candidate, index) => ConcernCandidate.make({
2365
+ candidateId: `${pass.passId}:concern:${candidateOrdinal(index)}`,
2366
+ workId: pass.passId,
2367
+ unitId: pass.unitId,
2368
+ concern: candidate.concern,
2369
+ evidencePaths: candidate.evidencePaths
2370
+ }))],
2371
+ fileSummaries: report.fileSummaries.filter((entry) => allowed.has(entry.path)),
2372
+ discarded
2062
2373
  };
2063
2374
  };
2064
- /** Pure, deterministic range selection with conservative full-review fallbacks. */
2065
- const selectReviewRange = (input) => {
2066
- const full = (reason) => fullSelection({
2067
- reason,
2068
- files: input.fullFiles,
2069
- totalFiles: input.current.totalChangedFiles,
2070
- profileFingerprint: input.profileFingerprint
2071
- });
2072
- if (input.requestedMode === "final") return full("explicit final full-diff audit requested");
2073
- if (input.lookupFailure !== void 0) return full(`stored review state could not be recovered: ${input.lookupFailure}`);
2074
- if (input.priorState === void 0) return full("no compatible stored review state was found");
2075
- const invalid = validateReviewState(input.priorState, input.current, input.profileFingerprint);
2076
- if (invalid !== void 0) return full(invalid);
2077
- const comparison = input.comparison;
2078
- if (comparison !== void 0 && isLineageAncestor(comparison, input.priorState, input.current.headSha)) {
2079
- const extraAffected = [];
2080
- let baseReason = "";
2081
- if (input.priorState.baseSha !== input.current.baseSha) {
2082
- const baseComparison = input.baseComparison;
2083
- if (baseComparison === void 0) return full("the pull request base changed and its lineage comparison was unavailable");
2084
- if (baseComparison.baseSha !== input.priorState.baseSha || baseComparison.headSha !== input.current.baseSha || baseComparison.mergeBaseSha !== input.priorState.baseSha || baseComparison.status !== "ahead" && baseComparison.status !== "identical" || baseComparison.truncated) return full("the pull request base changed materially or exceeded the comparison bound");
2085
- for (const file of baseComparison.files) extraAffected.push(...filePaths(file));
2086
- baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
2375
+ const reviewUnit = (binding, unit, passes, input) => Effect.gen(function* () {
2376
+ const evidence = yield* unitEvidence(unit, input.files);
2377
+ const failedPasses = [];
2378
+ const candidates = [];
2379
+ const subjects = /* @__PURE__ */ new Set();
2380
+ const walkthrough = [];
2381
+ let discardedFindings = 0;
2382
+ let turns = 0;
2383
+ let completedGeneralPasses = 0;
2384
+ let completedSpecialistPasses = 0;
2385
+ const unitPaths = new Set(unit.paths);
2386
+ const adjudicatedContext = (input.priorContext?.adjudicated ?? []).filter((entry) => entry.path === void 0 || unitPaths.has(entry.path)).map((entry) => entry.line).slice(0, 20);
2387
+ const priorFindingContext = (input.priorContext?.priorFindings ?? []).filter((entry) => unitPaths.has(entry.path)).map((entry) => entry.line).slice(0, 20);
2388
+ for (const pass of passes) {
2389
+ const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
2390
+ const brief = FileReviewBrief.make({
2391
+ phase: "discovery",
2392
+ workId: pass.passId,
2393
+ unitId: pass.unitId,
2394
+ paths: pass.paths,
2395
+ evidenceShardIds: pass.evidenceShardIds,
2396
+ perspective: pass.perspective,
2397
+ riskCategories: pass.riskCategories,
2398
+ candidates: [],
2399
+ evidence,
2400
+ ...adjudicatedContext.length === 0 ? {} : { adjudicatedContext },
2401
+ ...priorFindingContext.length === 0 ? {} : { priorFindingContext }
2402
+ });
2403
+ const outcome = yield* runReviewPass(binding, brief, input.budget);
2404
+ if (outcome._tag === "failed") {
2405
+ failedPasses.push(FailedReviewPass.make({
2406
+ workId: pass.passId,
2407
+ stage,
2408
+ errorTag: outcome.errorTag
2409
+ }));
2410
+ continue;
2087
2411
  }
2088
- return incrementalFromDelta({
2089
- current: input.current,
2090
- fullFiles: input.fullFiles,
2091
- profileFingerprint: input.profileFingerprint,
2092
- priorState: input.priorState,
2093
- deltaFiles: comparison.files,
2094
- extraAffectedPaths: extraAffected,
2095
- reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`
2412
+ turns += outcome.turns;
2413
+ if (stage === "specialist") completedSpecialistPasses += 1;
2414
+ else completedGeneralPasses += 1;
2415
+ const harvest = harvestDiscovery(pass, unit, input.files, input.anchorFiles, outcome.report);
2416
+ discardedFindings += harvest.discarded;
2417
+ if (pass.perspective === "general") walkthrough.push(...harvest.fileSummaries);
2418
+ for (const candidate of harvest.candidates) {
2419
+ const subject = reviewCandidateSubjectKey(candidate);
2420
+ if (subjects.has(subject)) continue;
2421
+ subjects.add(subject);
2422
+ candidates.push(candidate);
2423
+ }
2424
+ }
2425
+ const confirmed = [];
2426
+ let rejectedCandidates = 0;
2427
+ let unsettledCandidates = 0;
2428
+ let completedVerificationPasses = 0;
2429
+ const requiredVerificationPasses = candidates.length > 0 ? 1 : 0;
2430
+ if (candidates.length > 0) {
2431
+ const workId = `${unit.unitId}-verification`;
2432
+ const brief = FileReviewBrief.make({
2433
+ phase: "verification",
2434
+ workId,
2435
+ unitId: unit.unitId,
2436
+ paths: unit.paths,
2437
+ evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
2438
+ perspective: "candidate-verification",
2439
+ riskCategories: unit.riskCategories,
2440
+ candidates,
2441
+ evidence
2096
2442
  });
2443
+ const outcome = yield* runReviewPass(binding, brief, input.budget);
2444
+ if (outcome._tag === "failed") {
2445
+ unsettledCandidates = candidates.length;
2446
+ failedPasses.push(FailedReviewPass.make({
2447
+ workId,
2448
+ stage: "verification",
2449
+ errorTag: outcome.errorTag
2450
+ }));
2451
+ } else {
2452
+ turns += outcome.turns;
2453
+ completedVerificationPasses = 1;
2454
+ const byId = new Map(candidates.map((candidate) => [candidate.candidateId, candidate]));
2455
+ for (const assessment of outcome.report.assessments) {
2456
+ const candidate = byId.get(assessment.candidateId);
2457
+ if (candidate === void 0) continue;
2458
+ if (assessment.disposition === "confirmed") confirmed.push({
2459
+ assessment,
2460
+ candidate
2461
+ });
2462
+ else rejectedCandidates += 1;
2463
+ }
2464
+ }
2097
2465
  }
2098
- const contentComparison = input.contentComparison;
2099
- if (contentComparison !== void 0 && !contentComparison.truncated) return incrementalFromDelta({
2100
- current: input.current,
2101
- fullFiles: input.fullFiles,
2102
- profileFingerprint: input.profileFingerprint,
2103
- priorState: input.priorState,
2104
- deltaFiles: contentComparison.files,
2105
- reason: `rewritten history; contents changed since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}`
2106
- });
2107
- if (comparison === void 0) return full("the incremental head comparison was unavailable");
2108
- if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
2109
- return full("the prior reviewed head is not an ancestor of the current head");
2110
- };
2111
- /** Per-run context consumed by orchestration and publication, not by the model. */
2112
- var ReviewExecutionContext = class extends Context.Service()("@effect-agent/pr-review/ReviewExecutionContext") {};
2113
- /**
2114
- * Decorate the full source with the selected review range. Full anchor files
2115
- * remain available to host-side publication validation; model tools see only
2116
- * the selected delta and may read head context only for that delta's paths.
2117
- */
2118
- const selectedPullRequestSourceLayer = (selection) => Layer.effect(PullRequestSource)(Effect.gen(function* () {
2119
- const source = yield* PullRequestSource;
2120
- const selectedPaths = new Set(selection.files.map((file) => file.path));
2121
- const selectedFiles = source.changedFiles.pipe(Effect.map((fullFiles) => {
2122
- const fullByPath = new Map(fullFiles.map((file) => [file.path, file]));
2123
- return selection.files.map((file) => {
2124
- if (file.patch !== void 0) return file;
2125
- const full = fullByPath.get(file.path);
2126
- return full === void 0 ? file : ChangedFile.make({
2127
- ...file,
2128
- ...full.reviewBaseContent === void 0 ? {} : { reviewBaseContent: full.reviewBaseContent },
2129
- ...full.reviewHeadContent === void 0 ? {} : { reviewHeadContent: full.reviewHeadContent }
2130
- });
2131
- });
2132
- }));
2133
- return PullRequestSource.of({
2134
- metadata: source.metadata,
2135
- changedFiles: selectedFiles,
2136
- anchorFiles: source.anchorFiles,
2137
- readFile: (path) => selectedPaths.has(path) ? source.readFile(path) : Effect.fail(ReviewInputViolation.make({
2138
- input: path,
2139
- reason: "Path is outside this incremental review range."
2466
+ return {
2467
+ failedPasses,
2468
+ discoveredCandidates: candidates.length,
2469
+ confirmed,
2470
+ rejectedCandidates,
2471
+ unsettledCandidates,
2472
+ discardedFindings,
2473
+ walkthrough,
2474
+ turns,
2475
+ completedGeneralPasses,
2476
+ completedSpecialistPasses,
2477
+ requiredVerificationPasses,
2478
+ completedVerificationPasses,
2479
+ unreviewedPaths: failedPasses.length > 0 ? unit.paths : [],
2480
+ unreviewedPasses: failedPasses.map((pass) => ({
2481
+ stage: pass.stage,
2482
+ paths: unit.paths
2140
2483
  }))
2141
- });
2142
- }));
2143
- /** Build the full-surface mission used only to resolve profile guidance. */
2144
- const buildProfileMission = (metadata, files) => ReviewMission.make({
2145
- repository: metadata.repository,
2146
- number: metadata.number,
2147
- title: metadata.title,
2148
- body: metadata.body,
2149
- baseRef: metadata.baseRef,
2150
- headRef: metadata.headRef,
2151
- changedFileCount: files.length
2484
+ };
2152
2485
  });
2153
- //#endregion
2154
- //#region src/internal/retirement.ts
2155
- const PositiveLine = Schema.Int.check(Schema.isGreaterThan(0));
2156
- /** One previously posted review as observed through the retirement host. */
2157
- var RetirableReview = class extends Schema.Class("@effect-agent/pr-review/RetirableReview")({
2158
- reviewId: Schema.Int.check(Schema.isGreaterThan(0)),
2159
- body: Schema.String.check(Schema.isMaxLength(6e4)),
2160
- commitSha: Schema.NonEmptyString.check(Schema.isMaxLength(64)),
2161
- authorNodeId: Schema.NullOr(Schema.NonEmptyString.check(Schema.isMaxLength(200))),
2162
- submittedAt: Schema.NullOr(Schema.DateTimeUtc)
2163
- }) {};
2164
- /** One inline comment attached to a previously posted review. */
2165
- var RetirableReviewComment = class extends Schema.Class("@effect-agent/pr-review/RetirableReviewComment")({
2166
- nodeId: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
2167
- path: Schema.NonEmptyString.check(Schema.isMaxLength(500)),
2168
- startLine: Schema.NullOr(PositiveLine),
2169
- endLine: Schema.NullOr(PositiveLine),
2170
- body: Schema.String.check(Schema.isMaxLength(65536))
2171
- }) {};
2172
- /** A GitHub retirement read or mutation failed. */
2173
- var ReviewRetirementFailure = class extends Schema.TaggedError()("ReviewRetirementFailure", {
2174
- operation: Schema.String,
2175
- reason: Schema.String
2176
- }) {
2177
- get message() {
2178
- return `Review retirement operation '${this.operation}' failed: ${this.reason}`;
2179
- }
2180
- };
2181
- /**
2182
- * Host-side GitHub operations used by retirement. Domain code never reaches
2183
- * into REST or GraphQL directly, and deterministic tests substitute this port.
2184
- */
2185
- var ReviewRetirementHost = class extends Context.Service()("@effect-agent/pr-review/ReviewRetirementHost") {};
2186
- /** Observable cosmetic work completed by one fail-open retirement pass. */
2187
- var ReviewRetirementReport = class extends Schema.Class("@effect-agent/pr-review/ReviewRetirementReport")({
2188
- reviewsRetired: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
2189
- findingsResolved: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
2190
- commentsMinimized: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
2191
- failures: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
2192
- }) {};
2193
- const findingIdentity = (finding) => `${finding.path}\u0000${finding.startLine}\u0000${finding.endLine}\u0000${finding.title}`;
2194
- const REVIEW_METADATA_PATTERN = /<!-- effect-agent-pr-review metadata\n[\s\S]*?\n-->/g;
2195
- const FINGERPRINT_PATTERN = /<!-- effect-agent-pr-review fingerprint=sha256:[0-9a-f]{64} -->/g;
2196
- const STATE_PATTERN = /<!-- effect-agent-pr-review state-v\d+:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->/g;
2197
- const RETIRED_ORIGINAL_PATTERN = /<!-- effect-agent-pr-review retired-original:start -->\n([\s\S]*?)\n<!-- effect-agent-pr-review retired-original:end -->/;
2198
- const MACHINE_COMMENT_PATTERN = new RegExp(`${REVIEW_METADATA_PATTERN.source}|${FINGERPRINT_PATTERN.source}|${STATE_PATTERN.source}`, "g");
2199
- const VERDICT_CALLOUT_PATTERN = /^(?:> \[!(?:CAUTION|IMPORTANT)\]\n> [^\n]*(?:\n> [^\n]*)*|> (?:ℹ️|✅)[^\n]*)\n*/;
2200
- const INLINE_FINDING_TITLE_PATTERN = /^\*\*\[(?:🛑 blocking|⚠️ important|💅 nit)(?: · [a-z-]+)?\] ([^\n]+)\*\*$/;
2201
- const MAX_REVIEW_BODY_CHARS = 6e4;
2202
- /** The host-authored metadata marker is the authority gate for any edit. */
2203
- const hasReviewMetadataMarker = (body) => /<!-- effect-agent-pr-review metadata\n/.test(body);
2204
- const machineComments = (body) => Array.from(body.matchAll(MACHINE_COMMENT_PATTERN), (match) => match[0]);
2205
- const originalVisibleBody = (body) => {
2206
- const retired = RETIRED_ORIGINAL_PATTERN.exec(body)?.[1];
2207
- if (retired !== void 0) return retired;
2208
- return body.replace(MACHINE_COMMENT_PATTERN, "").trim().replace(VERDICT_CALLOUT_PATTERN, "");
2486
+ const countNoun = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
2487
+ const composeSummary = (plan, assurance) => {
2488
+ const requiredDiscovery = assurance.requiredGeneralDiscoveryPasses + assurance.requiredSpecialistPasses;
2489
+ const completedDiscovery = assurance.completedGeneralDiscoveryPasses + assurance.completedSpecialistPasses;
2490
+ const parts = [`Reviewed ${countNoun(plan.totalFiles, "changed file")} across ${countNoun(plan.units.length, "bounded unit")}: ${completedDiscovery}/${requiredDiscovery} discovery and ${assurance.completedVerificationPasses}/${assurance.requiredVerificationPasses} verification pass(es) settled; ${assurance.confirmedCandidates} of ${countNoun(assurance.discoveredCandidates, "discovered candidate")} confirmed by independent verification.`];
2491
+ if (assurance.failedPasses.length > 0) parts.push(`${countNoun(assurance.failedPasses.length, "pass")} did not settle; the affected paths are carried forward and retried on the next run. This is a reviewer-side gap, not a code defect.`);
2492
+ if (assurance.discardedInvalidFindings > 0) parts.push(`${countNoun(assurance.discardedInvalidFindings, "candidate")} discarded for anchors or paths outside the assigned evidence.`);
2493
+ if (plan.undiffablePaths.length > 0) parts.push(`${countNoun(plan.undiffablePaths.length, "path")} had no reviewable textual evidence and keep input coverage incomplete; exclude such paths with ignore globs when that is intended.`);
2494
+ if (plan.unassignedPaths.length > 0 || plan.unassignedEvidenceShardCount > 0) parts.push("The changeset exceeded the bounded fan-out capacity; unassigned scope is reported under input coverage.");
2495
+ parts.push("No configured pipeline can prove absence of defects; this describes settled work only.");
2496
+ return parts.join(" ").slice(0, 4e3);
2209
2497
  };
2210
- const findingLocation = (finding) => `${finding.path}:${finding.startLine}${finding.endLine === finding.startLine ? "" : `-${finding.endLine}`}`;
2211
- const renderRetiredBody = (input) => {
2212
- const shortSha = input.currentState.reviewedHeadSha.slice(0, 7);
2213
- const comments = machineComments(input.priorBody);
2214
- const original = originalVisibleBody(input.priorBody);
2215
- const resolved = input.resolvedFindings.length === 0 ? [] : [
2216
- "### Findings resolved by later review",
2217
- "",
2218
- ...input.resolvedFindings.map((finding) => `- \`${findingLocation(finding)}\` ~~${finding.title}~~ · resolved at \`${shortSha}\``),
2219
- ""
2220
- ];
2221
- const prefix = [
2222
- `> ℹ️ Superseded — ${input.resolvedFindings.length} of ${input.priorState.unresolvedFindings.length} findings resolved at \`${shortSha}\`; see [the latest review](${input.currentReviewUrl}).`,
2223
- "",
2224
- "<details>",
2225
- "<summary>Previous review details</summary>",
2226
- "",
2227
- ...resolved,
2228
- "<!-- effect-agent-pr-review retired-original:start -->"
2229
- ];
2230
- const suffix = [
2231
- "<!-- effect-agent-pr-review retired-original:end -->",
2232
- "",
2233
- "</details>",
2234
- ...comments.length === 0 ? [] : ["", ...comments]
2235
- ];
2236
- const render = (visible) => [
2237
- ...prefix,
2238
- visible,
2239
- ...suffix
2240
- ].join("\n");
2241
- if (render(original).length <= MAX_REVIEW_BODY_CHARS) return render(original);
2242
- const truncationNotice = "\n\n_Original review content truncated during retirement._";
2243
- const budget = Math.max(0, MAX_REVIEW_BODY_CHARS - render(truncationNotice).length);
2244
- return render(`${original.slice(0, budget)}${truncationNotice}`);
2498
+ const remapPlanUnitIds = (plan, offset) => {
2499
+ if (offset === 0) return plan;
2500
+ const units = plan.units.map((unit, index) => ReviewUnit.make({
2501
+ ...unit,
2502
+ unitId: `unit-${String(offset + index + 1).padStart(3, "0")}`
2503
+ }));
2504
+ const mappedIds = /* @__PURE__ */ new Map();
2505
+ for (const [index, unit] of plan.units.entries()) {
2506
+ const remapped = units[index];
2507
+ if (remapped !== void 0) mappedIds.set(unit.unitId, remapped.unitId);
2508
+ }
2509
+ return ReviewUnitPlan.make({
2510
+ ...plan,
2511
+ units,
2512
+ discoveryPasses: plan.discoveryPasses.map((pass) => {
2513
+ const unitId = mappedIds.get(pass.unitId) ?? pass.unitId;
2514
+ return ReviewDiscoveryPass.make({
2515
+ ...pass,
2516
+ unitId,
2517
+ passId: `${unitId}${pass.passId.slice(pass.unitId.length)}`
2518
+ });
2519
+ })
2520
+ });
2245
2521
  };
2246
- /** Compute one prior review's resolved subset and deterministic retired body. */
2247
- const decideReviewRetirement = (input) => {
2248
- const current = new Set(input.currentState.unresolvedFindings.map(findingIdentity));
2249
- const resolvedFindings = input.priorState.unresolvedFindings.filter((finding) => !current.has(findingIdentity(finding)));
2250
- return {
2251
- body: renderRetiredBody({
2252
- ...input,
2253
- resolvedFindings
2254
- }),
2255
- resolvedFindings,
2256
- priorFindingCount: input.priorState.unresolvedFindings.length
2522
+ const scheduleFanOutWork = (input) => {
2523
+ const retryPathSet = new Set(input.retry?.paths ?? []);
2524
+ const retryStages = new Set(input.retry?.stages ?? []);
2525
+ if (retryPathSet.size === 0) {
2526
+ const plan = planReviewUnits(input.files, { totalChangedFiles: input.totalChangedFiles });
2527
+ const passesByUnit = /* @__PURE__ */ new Map();
2528
+ for (const pass of plan.discoveryPasses) {
2529
+ const passes = passesByUnit.get(pass.unitId) ?? [];
2530
+ passes.push(pass);
2531
+ passesByUnit.set(pass.unitId, passes);
2532
+ }
2533
+ return {
2534
+ plan,
2535
+ passesByUnit,
2536
+ overflowRetryPaths: []
2537
+ };
2538
+ }
2539
+ const freshFiles = input.files.filter((file) => !retryPathSet.has(file.path));
2540
+ const retryFiles = input.files.filter((file) => retryPathSet.has(file.path));
2541
+ const freshPlan = planReviewUnits(freshFiles, { totalChangedFiles: input.totalChangedFiles });
2542
+ const retryPlan = remapPlanUnitIds(planReviewUnits(retryFiles, { totalChangedFiles: input.totalChangedFiles }), freshPlan.units.length);
2543
+ const acceptedFresh = freshPlan.units.slice(0, 8);
2544
+ const acceptedRetry = retryPlan.units.slice(0, Math.max(0, 8 - acceptedFresh.length));
2545
+ const overflowRetryPaths = retryPlan.units.slice(acceptedRetry.length).flatMap((unit) => [...unit.paths]);
2546
+ const retryPassFilter = (pass) => {
2547
+ const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
2548
+ return retryStages.has(stage);
2257
2549
  };
2258
- };
2259
- const inlineCommentIdentity = (comment) => {
2260
- if (comment.startLine === null || comment.endLine === null) return void 0;
2261
- const firstLine = comment.body.split("\n", 1)[0] ?? "";
2262
- const title = INLINE_FINDING_TITLE_PATTERN.exec(firstLine)?.[1];
2263
- return title === void 0 ? void 0 : findingIdentity({
2264
- path: comment.path,
2265
- startLine: comment.startLine,
2266
- endLine: comment.endLine,
2267
- title
2550
+ const acceptedRetryIds = new Set(acceptedRetry.map((unit) => unit.unitId));
2551
+ let retryPasses = retryPlan.discoveryPasses.filter((pass) => acceptedRetryIds.has(pass.unitId) && retryPassFilter(pass));
2552
+ if (retryStages.has("verification") && !retryStages.has("discovery") && !retryStages.has("specialist")) retryPasses = retryPlan.discoveryPasses.filter((pass) => acceptedRetryIds.has(pass.unitId));
2553
+ const acceptedFreshIds = new Set(acceptedFresh.map((unit) => unit.unitId));
2554
+ const discoveryPasses = [...freshPlan.discoveryPasses.filter((pass) => acceptedFreshIds.has(pass.unitId)), ...retryPasses];
2555
+ const plan = ReviewUnitPlan.make({
2556
+ totalFiles: input.files.length,
2557
+ truncated: freshPlan.truncated || retryPlan.truncated,
2558
+ units: [...acceptedFresh, ...acceptedRetry],
2559
+ discoveryPasses,
2560
+ undiffablePaths: [.../* @__PURE__ */ new Set([...freshPlan.undiffablePaths, ...retryPlan.undiffablePaths])].sort(),
2561
+ partialEvidencePaths: [.../* @__PURE__ */ new Set([...freshPlan.partialEvidencePaths, ...retryPlan.partialEvidencePaths])].sort(),
2562
+ unassignedEvidenceShardCount: freshPlan.unassignedEvidenceShardCount + retryPlan.unassignedEvidenceShardCount,
2563
+ unassignedEvidenceShardIds: [...freshPlan.unassignedEvidenceShardIds, ...retryPlan.unassignedEvidenceShardIds].slice(0, 96),
2564
+ unassignedPaths: [.../* @__PURE__ */ new Set([
2565
+ ...freshPlan.unassignedPaths,
2566
+ ...retryPlan.unassignedPaths,
2567
+ ...overflowRetryPaths
2568
+ ])].sort()
2268
2569
  });
2269
- };
2270
- const failOpen = (effect, fallback, message) => effect.pipe(Effect.catch((error) => Effect.logWarning(`${message}: ${String(error)}`).pipe(Effect.as(fallback))));
2271
- const isStrictlyOlderReview = (review, input) => {
2272
- if (review.submittedAt === null) return false;
2273
- const submittedAt = DateTime.toEpochMillis(review.submittedAt);
2274
- const currentSubmittedAt = DateTime.toEpochMillis(input.currentSubmittedAt);
2275
- return submittedAt < currentSubmittedAt || submittedAt === currentSubmittedAt && review.reviewId < input.currentReviewId;
2570
+ const passesByUnit = /* @__PURE__ */ new Map();
2571
+ for (const pass of discoveryPasses) {
2572
+ const passes = passesByUnit.get(pass.unitId) ?? [];
2573
+ passes.push(pass);
2574
+ passesByUnit.set(pass.unitId, passes);
2575
+ }
2576
+ return {
2577
+ plan,
2578
+ passesByUnit,
2579
+ overflowRetryPaths
2580
+ };
2276
2581
  };
2277
2582
  /**
2278
- * Retire every marker-bearing prior review against the newest posted state.
2279
- * Every lookup, edit, and minimization is isolated: retirement is cosmetic
2280
- * and can never change the run or check outcome.
2583
+ * Run the complete host-scheduled fan-out pipeline over one selected
2584
+ * changeset snapshot: plan, independent discovery, exact verification, and a
2585
+ * deterministic host-composed CodeReview from verifier-confirmed candidates
2586
+ * only. The verdict is derived from confirmed severities, never model prose.
2281
2587
  */
2282
- const retireStaleReviews = Effect.fn("retireStaleReviews")(function* (input) {
2283
- const host = yield* ReviewRetirementHost;
2284
- const authenticator = yield* ReviewStateAuthenticator;
2285
- if (authenticator.status !== "available") {
2286
- yield* Effect.logWarning("Skipping stale-review retirement because authenticated review state is unavailable.");
2287
- return ReviewRetirementReport.make({
2288
- reviewsRetired: 0,
2289
- findingsResolved: 0,
2290
- commentsMinimized: 0,
2291
- failures: 0
2292
- });
2293
- }
2294
- let failures = 0;
2295
- let reviewsRetired = 0;
2296
- let findingsResolved = 0;
2297
- let commentsMinimized = 0;
2298
- const reviews = yield* failOpen(host.listReviews, void 0, "Could not list prior reviews");
2299
- if (reviews === void 0) return ReviewRetirementReport.make({
2300
- reviewsRetired,
2301
- findingsResolved,
2302
- commentsMinimized,
2303
- failures: 1
2304
- });
2305
- for (const review of reviews) {
2306
- if (review.authorNodeId !== input.currentAuthorNodeId || !isStrictlyOlderReview(review, input) || !hasReviewMetadataMarker(review.body)) continue;
2307
- const priorState = yield* failOpen(authenticator.extract(review.body), Option.none(), `Could not authenticate prior review ${review.reviewId}`);
2308
- if (Option.isNone(priorState)) continue;
2309
- const decision = decideReviewRetirement({
2310
- priorBody: review.body,
2311
- priorState: priorState.value,
2312
- currentState: input.currentState,
2313
- currentReviewUrl: input.currentReviewUrl
2314
- });
2315
- if (yield* failOpen(host.updateBody(review.reviewId, decision.body).pipe(Effect.as(true)), false, `Could not retire prior review ${review.reviewId}`)) {
2316
- reviewsRetired += 1;
2317
- findingsResolved += decision.resolvedFindings.length;
2318
- } else failures += 1;
2319
- if (decision.resolvedFindings.length === 0) continue;
2320
- const comments = yield* failOpen(host.listComments(review.reviewId), void 0, `Could not list inline comments for prior review ${review.reviewId}`);
2321
- if (comments === void 0) {
2322
- failures += 1;
2323
- continue;
2324
- }
2325
- const resolved = new Set(decision.resolvedFindings.map(findingIdentity));
2326
- for (const comment of comments) {
2327
- const identity = inlineCommentIdentity(comment);
2328
- if (identity === void 0 || !resolved.has(identity)) continue;
2329
- if (yield* failOpen(host.minimizeComment(comment.nodeId).pipe(Effect.as(true)), false, `Could not minimize resolved inline comment ${comment.nodeId}`)) commentsMinimized += 1;
2330
- else failures += 1;
2331
- }
2332
- }
2333
- return ReviewRetirementReport.make({
2334
- reviewsRetired,
2335
- findingsResolved,
2336
- commentsMinimized,
2337
- failures
2588
+ const runFanOutReview = (binding, input) => Effect.gen(function* () {
2589
+ const { plan, passesByUnit, overflowRetryPaths } = scheduleFanOutWork(input);
2590
+ const outcomes = yield* Effect.forEach(plan.units, (unit) => reviewUnit(binding, unit, passesByUnit.get(unit.unitId) ?? [], input), { concurrency: 4 });
2591
+ const failedPasses = outcomes.flatMap((outcome) => outcome.failedPasses);
2592
+ const unsettledCandidates = outcomes.reduce((total, outcome) => total + outcome.unsettledCandidates, 0);
2593
+ const reasons = [];
2594
+ if (failedPasses.length > 0) reasons.push(boundedListReason("configured review passes did not settle", failedPasses.map((pass) => `${pass.workId} (${pass.errorTag})`)));
2595
+ if (unsettledCandidates > 0) reasons.push(`${unsettledCandidates} discovered candidate(s) did not receive exact verification`);
2596
+ const requiredSpecialistPasses = plan.discoveryPasses.filter((pass) => pass.perspective === "risk-specialist").length;
2597
+ const confirmed = outcomes.flatMap((outcome) => outcome.confirmed);
2598
+ const assurance = ReviewAssurance.make({
2599
+ status: reasons.length === 0 ? "settled" : "incomplete",
2600
+ requiredGeneralDiscoveryPasses: plan.discoveryPasses.length - requiredSpecialistPasses,
2601
+ completedGeneralDiscoveryPasses: outcomes.reduce((total, outcome) => total + outcome.completedGeneralPasses, 0),
2602
+ requiredSpecialistPasses,
2603
+ completedSpecialistPasses: outcomes.reduce((total, outcome) => total + outcome.completedSpecialistPasses, 0),
2604
+ requiredVerificationPasses: outcomes.reduce((total, outcome) => total + outcome.requiredVerificationPasses, 0),
2605
+ completedVerificationPasses: outcomes.reduce((total, outcome) => total + outcome.completedVerificationPasses, 0),
2606
+ discoveredCandidates: outcomes.reduce((total, outcome) => total + outcome.discoveredCandidates, 0),
2607
+ confirmedCandidates: confirmed.length,
2608
+ rejectedCandidates: outcomes.reduce((total, outcome) => total + outcome.rejectedCandidates, 0),
2609
+ unsettledCandidates,
2610
+ discardedInvalidFindings: outcomes.reduce((total, outcome) => total + outcome.discardedFindings, 0),
2611
+ failedPasses,
2612
+ reasons
2338
2613
  });
2614
+ const findings = rankAndDedupeFindings(confirmed.flatMap(({ assessment, candidate }) => candidate._tag === "FindingCandidate" ? [confirmedFindingForPublication(assessment, candidate)] : []));
2615
+ const concerns = rankAndDedupeConcerns(confirmed.flatMap(({ candidate }) => candidate._tag === "ConcernCandidate" ? [ReviewConcern.make({
2616
+ ...candidate.concern,
2617
+ evidencePaths: [...new Set(candidate.evidencePaths)].sort()
2618
+ })] : []));
2619
+ const walkthrough = outcomes.flatMap((outcome) => outcome.walkthrough);
2620
+ const blocking = findings.some((finding) => finding.severity === "blocking") || concerns.some((concern) => concern.severity === "blocking");
2621
+ return {
2622
+ review: CodeReview.make({
2623
+ summary: composeSummary(plan, assurance),
2624
+ verdict: blocking ? "request-changes" : findings.length > 0 || concerns.length > 0 ? "comment" : "approve",
2625
+ findings,
2626
+ ...concerns.length === 0 ? {} : { concerns },
2627
+ ...walkthrough.length === 0 ? {} : { walkthrough }
2628
+ }),
2629
+ assurance,
2630
+ plan,
2631
+ unreviewedPaths: [.../* @__PURE__ */ new Set([
2632
+ ...outcomes.flatMap((outcome) => outcome.unreviewedPaths),
2633
+ ...plan.unassignedPaths,
2634
+ ...plan.partialEvidencePaths,
2635
+ ...plan.undiffablePaths
2636
+ ])].sort(),
2637
+ unreviewedPasses: [...outcomes.flatMap((outcome) => outcome.unreviewedPasses), ...overflowRetryPaths.length === 0 ? [] : (input.retry?.stages.length ? input.retry.stages : [
2638
+ "discovery",
2639
+ "specialist",
2640
+ "verification"
2641
+ ]).map((stage) => ({
2642
+ stage,
2643
+ paths: overflowRetryPaths
2644
+ }))],
2645
+ turns: outcomes.reduce((total, outcome) => total + outcome.turns, 0)
2646
+ };
2647
+ });
2648
+ //#endregion
2649
+ //#region src/internal/fingerprint.ts
2650
+ const MARKER_PREFIX = "<!-- effect-agent-pr-review fingerprint=sha256:";
2651
+ const MARKER_SUFFIX = " -->";
2652
+ const MARKER_PATTERN = /<!-- effect-agent-pr-review fingerprint=sha256:([0-9a-f]{64}) -->/g;
2653
+ /** Render the invisible review-body marker for one fingerprint. */
2654
+ const renderFingerprintMarker = (fingerprint) => `${MARKER_PREFIX}${fingerprint}${MARKER_SUFFIX}`;
2655
+ /** The rendered marker length is fixed; publication reserves room for it. */
2656
+ const FINGERPRINT_MARKER_LENGTH = renderFingerprintMarker("0".repeat(64)).length;
2657
+ /** Extract the last fingerprint marker in one review body, if any. */
2658
+ const extractFingerprint = (body) => {
2659
+ let last;
2660
+ for (const match of body.matchAll(MARKER_PATTERN)) last = match[1];
2661
+ return last;
2662
+ };
2663
+ /** SHA-256 via `Crypto.Crypto`; unavailable crypto is a defect, not an expected failure. */
2664
+ const sha256Hex = Effect.fn("sha256Hex")(function* (text) {
2665
+ const digest = yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(text)).pipe(Effect.orDie);
2666
+ return Encoding.encodeHex(digest);
2339
2667
  });
2668
+ const FIELD = "\0";
2669
+ const RECORD = "";
2670
+ const SECTION = "";
2671
+ /**
2672
+ * Unified-diff hunk coordinates describe where a patch applies, not what it
2673
+ * changes. A content-equivalent rebase can shift both coordinates while
2674
+ * leaving every context/addition/deletion line unchanged, so exclude only
2675
+ * those coordinates from the canonical patch representation.
2676
+ */
2677
+ const canonicalPatch = (patch) => patch.replace(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/gm, "@@ -_ +_ @@");
2678
+ /**
2679
+ * Canonical changeset encoding: sorted by path so provider ordering never
2680
+ * matters, with every review-relevant field of every file.
2681
+ */
2682
+ const canonicalChangeset = (files) => files.map((file) => `${file.path}${FIELD}${file.status}${FIELD}${String(file.additions)}${FIELD}${String(file.deletions)}${FIELD}${file.patch === void 0 ? "" : canonicalPatch(file.patch)}${FIELD}${file.reviewBaseContent ?? ""}${FIELD}${file.reviewHeadContent ?? ""}`).sort().join(RECORD);
2683
+ /**
2684
+ * Fingerprint one review's complete input surface: the (already
2685
+ * ignore-filtered) changeset plus the caller's prompt signature — the
2686
+ * rendered instructions and any review-shaping options the instructions do
2687
+ * not carry.
2688
+ */
2689
+ const computeChangesetFingerprint = (files, signature) => sha256Hex(`${canonicalChangeset(files)}${SECTION}${signature}`);
2690
+ /** Profile fingerprints are SHA-256 over configuration-only signatures. */
2691
+ const computeProfileFingerprint = (signature) => sha256Hex(signature);
2340
2692
  //#endregion
2341
2693
  //#region src/internal/github.ts
2342
2694
  const defaultGraphqlUrl = (apiUrl) => apiUrl === "https://api.github.com" ? "https://api.github.com/graphql" : apiUrl.replace(/\/api\/v3$/, "/api/graphql");
@@ -2669,6 +3021,137 @@ const gitHubReviewRetirementHostLayer = Layer.effect(ReviewRetirementHost)(Effec
2669
3021
  })
2670
3022
  });
2671
3023
  }));
3024
+ const MAX_ADJUDICATION_PAGES = 5;
3025
+ const GitHubThreadCommentWire = Schema.Struct({
3026
+ id: Schema.Int,
3027
+ in_reply_to_id: Schema.optionalKey(Schema.NullOr(Schema.Int)),
3028
+ path: Schema.String,
3029
+ body: Schema.String,
3030
+ author_association: Schema.optionalKey(Schema.NullOr(Schema.String)),
3031
+ user: Schema.NullOr(Schema.Struct({ login: Schema.String })),
3032
+ created_at: Schema.optionalKey(Schema.NullOr(Schema.String)),
3033
+ line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
3034
+ original_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
3035
+ start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
3036
+ original_start_line: Schema.optionalKey(Schema.NullOr(Schema.Int))
3037
+ });
3038
+ const GitHubThreadCommentsPageWire = Schema.Array(GitHubThreadCommentWire);
3039
+ const GitHubIssueCommentWire = Schema.Struct({
3040
+ body: Schema.optionalKey(Schema.NullOr(Schema.String)),
3041
+ author_association: Schema.optionalKey(Schema.NullOr(Schema.String)),
3042
+ user: Schema.NullOr(Schema.Struct({ login: Schema.String })),
3043
+ created_at: Schema.optionalKey(Schema.NullOr(Schema.String))
3044
+ });
3045
+ const GitHubIssueCommentsPageWire = Schema.Array(GitHubIssueCommentWire);
3046
+ const toAdjudicationComment = (wire, sourceOrder) => {
3047
+ const login = wire.user?.login;
3048
+ if (login === void 0 || login.length === 0) return void 0;
3049
+ return AdjudicationComment.make({
3050
+ body: (wire.body ?? "").slice(0, 65536),
3051
+ authorAssociation: (wire.author_association ?? "NONE").slice(0, 40),
3052
+ authorLogin: login.slice(0, 100),
3053
+ createdAt: parseGitHubSubmittedAt(wire.created_at ?? null),
3054
+ sourceOrder
3055
+ });
3056
+ };
3057
+ /**
3058
+ * GitHub-backed host reads for maintainer adjudication, installed by
3059
+ * `gitHubReviewLayers` in `github-env.ts` at the public composition root: this action's own
3060
+ * inline finding threads (roots authored by the configured review author)
3061
+ * with their replies, and the pull request's top-level conversation comments.
3062
+ * Both listings are creation-ordered.
3063
+ */
3064
+ const gitHubReviewAdjudicationHostLayer = Layer.effect(ReviewAdjudicationHost)(Effect.gen(function* () {
3065
+ const target = yield* GitHubReviewTarget;
3066
+ const client = yield* HttpClient.HttpClient;
3067
+ const reviewAuthorLogin = (target.reviewAuthorLogin ?? "github-actions[bot]").toLowerCase();
3068
+ const asAdjudicationFailure = (operation) => (error) => ReviewAdjudicationFailure.make({
3069
+ operation,
3070
+ reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2048)
3071
+ });
3072
+ const decodeAdjudication = (schema, operation) => {
3073
+ const decode = Schema.decodeUnknownEffect(schema);
3074
+ return (response) => response.json.pipe(Effect.mapError(asAdjudicationFailure(operation)), Effect.flatMap((body) => decode(body).pipe(Effect.mapError(asAdjudicationFailure(operation)))));
3075
+ };
3076
+ const listPaged = (input) => Effect.gen(function* () {
3077
+ const values = [];
3078
+ const perPage = 100;
3079
+ for (let page = 1; page <= MAX_ADJUDICATION_PAGES; page += 1) {
3080
+ const response = yield* client.execute(withCommonHeaders(HttpClientRequest.get(input.url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setUrlParams({
3081
+ per_page: String(perPage),
3082
+ page: String(page),
3083
+ sort: "created",
3084
+ direction: "asc"
3085
+ })), target.token)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.mapError(asAdjudicationFailure(input.operation)));
3086
+ const pageValues = yield* input.decode(response);
3087
+ values.push(...pageValues);
3088
+ if (pageValues.length < perPage) return values;
3089
+ }
3090
+ return yield* ReviewAdjudicationFailure.make({
3091
+ operation: input.operation,
3092
+ reason: `history exceeds the bounded ${MAX_ADJUDICATION_PAGES * 100}-item lookup`
3093
+ });
3094
+ });
3095
+ const listFindingThreads = Effect.gen(function* () {
3096
+ const wires = yield* listPaged({
3097
+ operation: "listReviewCommentsForAdjudication",
3098
+ url: `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}/comments`,
3099
+ decode: decodeAdjudication(GitHubThreadCommentsPageWire, "listReviewCommentsForAdjudication")
3100
+ });
3101
+ const positiveLine = (value) => value !== void 0 && value !== null && value > 0 ? value : null;
3102
+ const threads = /* @__PURE__ */ new Map();
3103
+ for (const wire of wires) {
3104
+ if (wire.in_reply_to_id !== void 0 && wire.in_reply_to_id !== null) continue;
3105
+ if (wire.user?.login.toLowerCase() !== reviewAuthorLogin) continue;
3106
+ threads.set(wire.id, {
3107
+ root: wire,
3108
+ replies: []
3109
+ });
3110
+ }
3111
+ for (const [sourceOrder, wire] of wires.entries()) {
3112
+ if (wire.in_reply_to_id === void 0 || wire.in_reply_to_id === null) continue;
3113
+ const thread = threads.get(wire.in_reply_to_id);
3114
+ if (thread === void 0) continue;
3115
+ const reply = toAdjudicationComment(wire, sourceOrder);
3116
+ if (reply === void 0) continue;
3117
+ if (parseThreadAdjudication(reply.body) === void 0) continue;
3118
+ if (!AUTHORIZED_ADJUDICATION_ASSOCIATIONS.has(reply.authorAssociation)) {
3119
+ yield* Effect.logDebug(`Ignored inline adjudication command from @${reply.authorLogin} (${reply.authorAssociation}).`);
3120
+ continue;
3121
+ }
3122
+ if (thread.replies.length >= 100) return yield* ReviewAdjudicationFailure.make({
3123
+ operation: "listReviewCommentsForAdjudication",
3124
+ reason: `inline thread ${wire.in_reply_to_id} exceeds the bounded 100-command adjudication lookup`
3125
+ });
3126
+ thread.replies.push(reply);
3127
+ }
3128
+ return [...threads.values()].filter((thread) => thread.root.path.length > 0 && thread.root.path.length <= 500).map(({ root, replies }) => {
3129
+ const endLine = positiveLine(root.line ?? root.original_line);
3130
+ const startLine = positiveLine(root.start_line ?? root.original_start_line) ?? endLine;
3131
+ return AdjudicableThread.make({
3132
+ path: root.path,
3133
+ startLine,
3134
+ endLine,
3135
+ rootBody: root.body.slice(0, 65536),
3136
+ replies
3137
+ });
3138
+ });
3139
+ });
3140
+ const listIssueComments = Effect.gen(function* () {
3141
+ return (yield* listPaged({
3142
+ operation: "listIssueCommentsForAdjudication",
3143
+ url: `${target.apiUrl}/repos/${target.repository}/issues/${target.number}/comments`,
3144
+ decode: decodeAdjudication(GitHubIssueCommentsPageWire, "listIssueCommentsForAdjudication")
3145
+ })).flatMap((wire, sourceOrder) => {
3146
+ const comment = toAdjudicationComment(wire, sourceOrder);
3147
+ return comment === void 0 ? [] : [comment];
3148
+ });
3149
+ });
3150
+ return ReviewAdjudicationHost.of({
3151
+ listFindingThreads,
3152
+ listIssueComments
3153
+ });
3154
+ }));
2672
3155
  /** Reading the pull request's previously posted reviews failed. */
2673
3156
  var PriorReviewLookupFailure = class extends Schema.TaggedError()("PriorReviewLookupFailure", { reason: Schema.String }) {
2674
3157
  get message() {
@@ -2776,6 +3259,6 @@ const fingerprintUnchanged = (current) => Effect.gen(function* () {
2776
3259
  return Option.isSome(latest) && latest.value === current;
2777
3260
  });
2778
3261
  //#endregion
2779
- export { extractFingerprint as $, ChangedFileStatus as $n, ReviewAssurance as $t, ReviewState as A, ReviewToolkit as An, MAX_MERGED_FINDINGS as At, fromStoredConcern as B, readFileDiffHandler as Bn, ReviewRiskCategory as Bt, MAX_REVIEW_STATE_MARKER_CHARS as C, PullRequestReviewer as Cn, defaultFileReviewerPolicy as Ct, ReviewHeadComparison as D, ReviewConcern as Dn, reviewCandidateSubjectKey as Dt, ReviewExecutionContext as E, ReadFileDiff as En, makeFileReviewerInstructions as Et, StoredReviewConcern as F, defaultReviewPolicy as Fn, ReviewDiscoveryPass as Ft, toStoredConcern as G, MAX_FILE_CHARS as Gn, UNIT_EVIDENCE_CHAR_BUDGET as Gt, isLineageAncestor as H, resolveGuidance as Hn, ReviewUnitId as Ht, StoredReviewFinding as I, fileDiffView as In, ReviewDiscoveryPerspective as It, validateReviewState as J, PullRequestSourceFailure as Jn, planReviewUnits as Jt, toStoredFinding as K, PullRequestMetadata as Kn, classifyReviewRisks as Kt, StoredUnreviewedPass as L, fileReviewEvidenceChunks as Ln, ReviewEvidenceShard as Lt, ReviewStateAuthenticator as M, ReviewVerdict as Mn, MAX_REVIEW_UNITS as Mt, ReviewStateMarker as N, WalkthroughEntry as Nn, MAX_UNIT_EVIDENCE_SHARDS as Nt, ReviewMode as O, ReviewFinding as On, runFanOutReview as Ot, ReviewStateMarkerTooLarge as P, clampMaxFindings as Pn, MAX_UNIT_FILES as Pt, computeProfileFingerprint as Q, ChangedFile as Qn, FailedReviewUnit as Qt, UnreviewedStage as R, listChangedFilesHandler as Rn, ReviewEvidenceShardId as Rt, GitCommitSha as S, MAX_WALKTHROUGH_SUMMARY_CHARS as Sn, confirmedFindingForPublication as St, MAX_STORED_UNREVIEWED_PATHS as T, ReadFile as Tn, makeFileReviewerDefinition as Tt, selectReviewRange as U, reviewInstructions as Un, ReviewUnitPlan as Ut, fromStoredFinding as V, readFileHandler as Vn, ReviewUnit as Vt, selectedPullRequestSourceLayer as W, MAX_CHANGED_FILES as Wn, UNIT_CHANGED_LINE_BUDGET as Wt, FINGERPRINT_MARKER_LENGTH as X, normalizeRepoRelativePath as Xn, rankAndDedupeFindings as Xt, webCryptoReviewStateAuthenticatorLayer as Y, ReviewInputViolation as Yn, rankAndDedupeConcerns as Yt, computeChangesetFingerprint as Z, anchorViolation as Zn, FailedReviewPass as Zt, ReviewRetirementHost as _, ListChangedFilesQuery as _n, ReviewCandidateId as _t, PriorReviews as a, fanOutInputCoverage as an, isReviewableFile as ar, FileReviewEvidence as at, hasReviewMetadataMarker as b, MAX_PATCH_CHARS as bn, ReviewWorkPhase as bt, fingerprintUnchanged as c, ChangedFilesView as cn, FileReviewer as ct, gitHubReviewPublisherLayer as d, FileDiffView as dn, MAX_CHILD_FINDINGS as dt, ReviewCoverage as en, ChangedPath as er, renderFingerprintMarker as et, gitHubReviewRetirementHostLayer as f, FileSlice as fn, MAX_FILE_REVIEW_TOOL_CALLS as ft, ReviewRetirementFailure as g, ListChangedFiles as gn, ReviewCandidate as gt, RetirableReviewComment as h, FindingSeverity as hn, REVIEW_UNIT_CONCURRENCY as ht, PriorReviewLookupFailure as i, compatibilityCoverage as in, hasReviewableContent as ir, FileReviewBrief as it, ReviewStateAuthenticationFailure as j, ReviewToolkitLayer as jn, MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS as jt, ReviewScopeMode as k, ReviewMission as kn, MAX_FILE_EVIDENCE_CHARS as kt, gitHubPriorReviewsLayer as l, CodeReview as ln, FindingCandidate as lt, RetirableReview as m, FindingCategory as mn, MAX_UNIT_CANDIDATES as mt, GitHubApiFailure as n, assessFlatReview as nn, annotatePatch as nr, ConcernCandidate as nt, PublishedReview as o, flatAssurance as on, parsePatch as or, FileReviewReport as ot, parseGitHubSubmittedAt as p, FileSliceQuery as pn, MAX_REVIEW_CHILDREN as pt, unavailableReviewStateAuthenticatorLayer as q, PullRequestSource as qn, findingAnchorInUnitEvidence as qt, GitHubReviewTarget as r, boundedListReason as rn, commentableLines as rr, DiscoveredConcern as rt, ReviewPublisher as s, ChangedFileSummary as sn, renderReviewContent as sr, FileReviewToolkit as st, DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN as t, ReviewInputCoverage as tn, MAX_REVIEW_CONTENT_CHARS as tr, CandidateAssessment as tt, gitHubPullRequestSourceLayer as u, FileDiffQuery as un, MAX_CHILD_CONCERNS as ut, ReviewRetirementReport as v, MAX_CONCERNS as vn, ReviewPassMisbehaved as vt, MAX_STORED_UNREVIEWED_PASSES as w, REVIEW_TOOL_RESULT_MAX_BYTES as wn, fileReviewerInstructions as wt, retireStaleReviews as x, MAX_WALKTHROUGH_ENTRIES as xn, assessmentSettlesSuggestionExactly as xt, decideReviewRetirement as y, MAX_FINDINGS as yn, ReviewWorkPerspective as yt, buildProfileMission as z, makeReviewInstructions as zn, ReviewPassId as zt };
3262
+ export { ReviewDiscoveryPass as $, ReviewFinding as $n, MAX_STORED_ADJUDICATIONS as $t, MAX_CHILD_FINDINGS as A, validateReviewState as An, parsePatch as Ar, buildPriorReviewContext as At, assessmentSettlesSuggestionExactly as B, FindingSeverity as Bn, threadFindingTarget as Bt, FileReviewBrief as C, fullReviewSelection as Cn, ChangedFileStatus as Cr, anchorViolation as Ct, FileReviewer as D, toStoredConcern as Dn, commentableLines as Dr, MAX_THREAD_ADJUDICATION_COMMANDS as Dt, FileReviewToolkit as E, selectedPullRequestSourceLayer as En, annotatePatch as Er, AdjudicationComment as Et, ReviewCandidate as F, FileDiffQuery as Fn, noReviewAdjudicationHostLayer as Ft, makeFileReviewerInstructions as G, MAX_PATCH_CHARS as Gn, ReviewRetirementHost as Gt, defaultFileReviewerPolicy as H, ListChangedFilesQuery as Hn, RetirableReview as Ht, ReviewCandidateId as I, FileDiffView as In, parseIssueAdjudication as It, MAX_MERGED_FINDINGS as J, PullRequestReviewer as Jn, hasReviewMetadataMarker as Jt, reviewCandidateSubjectKey as K, MAX_WALKTHROUGH_ENTRIES as Kn, ReviewRetirementReport as Kt, ReviewPassMisbehaved as L, FileSlice as Ln, parseThreadAdjudication as Lt, MAX_REVIEW_CHILDREN as M, ChangedFileSummary as Mn, deriveAdjudications as Mt, MAX_UNIT_CANDIDATES as N, ChangedFilesView as Nn, mergeAdjudications as Nt, FindingCandidate as O, toStoredFinding as On, hasReviewableContent as Or, ReviewAdjudicationFailure as Ot, REVIEW_UNIT_CONCURRENCY as P, CodeReview as Pn, noReviewAdjudicationHost as Pt, MAX_UNIT_FILES as Q, ReviewConcern as Qn, MAX_REVIEW_STATE_MARKER_CHARS as Qt, ReviewWorkPerspective as R, FileSliceQuery as Rn, renderAdjudicationContextLine as Rt, DiscoveredConcern as S, fullReviewExecutionContextLayer as Sn, ChangedFile as Sr, splitCarriedScope as St, FileReviewReport as T, selectReviewRange as Tn, MAX_REVIEW_CONTENT_CHARS as Tr, AdjudicableThread as Tt, fileReviewerInstructions as U, MAX_CONCERNS as Un, RetirableReviewComment as Ut, confirmedFindingForPublication as V, ListChangedFiles as Vn, INLINE_FINDING_TITLE_PATTERN as Vt, makeFileReviewerDefinition as W, MAX_FINDINGS as Wn, ReviewRetirementFailure as Wt, MAX_REVIEW_UNITS as X, ReadFile as Xn, AdjudicationDisposition as Xt, MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS as Y, REVIEW_TOOL_RESULT_MAX_BYTES as Yn, retireStaleReviews as Yt, MAX_UNIT_EVIDENCE_SHARDS as Z, ReadFileDiff as Zn, GitCommitSha as Zt, computeProfileFingerprint as _, buildProfileMission as _n, PullRequestMetadata as _r, ReviewInputCoverage as _t, PriorReviews as a, ReviewScopeMode as an, clampMaxFindings as ar, ReviewUnit as at, CandidateAssessment as b, fromStoredConcern as bn, ReviewInputViolation as br, fanOutInputCoverage as bt, fingerprintUnchanged as c, ReviewStateAuthenticator as cn, fileReviewEvidenceChunks as cr, UNIT_EVIDENCE_CHAR_BUDGET as ct, gitHubReviewAdjudicationHostLayer as d, StoredAdjudication as dn, readFileDiffHandler as dr, planReviewUnits as dt, MAX_STORED_UNREVIEWED_PASSES as en, ReviewMission as er, ReviewDiscoveryPerspective as et, gitHubReviewPublisherLayer as f, StoredReviewConcern as fn, readFileHandler as fr, rankAndDedupeConcerns as ft, computeChangesetFingerprint as g, adjudicationIdentity as gn, MAX_FILE_CHARS as gr, ReviewAssurance as gt, FINGERPRINT_MARKER_LENGTH as h, UnreviewedStage as hn, MAX_CHANGED_FILES as hr, FailedReviewPass as ht, PriorReviewLookupFailure as i, ReviewMode as in, WalkthroughEntry as ir, ReviewRiskCategory as it, MAX_FILE_REVIEW_TOOL_CALLS as j, webCryptoReviewStateAuthenticatorLayer as jn, renderReviewContent as jr, collectReviewAdjudications as jt, MAX_CHILD_CONCERNS as k, unavailableReviewStateAuthenticatorLayer as kn, isReviewableFile as kr, ReviewAdjudicationHost as kt, gitHubPriorReviewsLayer as l, ReviewStateMarker as ln, listChangedFilesHandler as lr, classifyReviewRisks as lt, parseGitHubSubmittedAt as m, StoredUnreviewedPass as mn, reviewInstructions as mr, reviewConcernKey as mt, GitHubApiFailure as n, ReviewExecutionContext as nn, ReviewToolkitLayer as nr, ReviewEvidenceShardId as nt, PublishedReview as o, ReviewState as on, defaultReviewPolicy as or, ReviewUnitId as ot, gitHubReviewRetirementHostLayer as p, StoredReviewFinding as pn, resolveGuidance as pr, rankAndDedupeFindings as pt, runFanOutReview as q, MAX_WALKTHROUGH_SUMMARY_CHARS as qn, decideReviewRetirement as qt, GitHubReviewTarget as r, ReviewHeadComparison as rn, ReviewVerdict as rr, ReviewPassId as rt, ReviewPublisher as s, ReviewStateAuthenticationFailure as sn, fileDiffView as sr, ReviewUnitPlan as st, DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN as t, MAX_STORED_UNREVIEWED_PATHS as tn, ReviewToolkit as tr, ReviewEvidenceShard as tt, gitHubPullRequestSourceLayer as u, ReviewStateMarkerTooLarge as un, makeReviewInstructions as ur, findingAnchorInUnitEvidence as ut, extractFingerprint as v, concernIdentity as vn, PullRequestSource as vr, assessFlatReview as vt, FileReviewEvidence as w, isLineageAncestor as wn, ChangedPath as wr, AUTHORIZED_ADJUDICATION_ASSOCIATIONS as wt, ConcernCandidate as x, fromStoredFinding as xn, normalizeRepoRelativePath as xr, flatAssurance as xt, renderFingerprintMarker as y, findingIdentity as yn, PullRequestSourceFailure as yr, boundedListReason as yt, ReviewWorkPhase as z, FindingCategory as zn, renderPriorFindingContextLine as zt };
2780
3263
 
2781
- //# sourceMappingURL=github-C6jrBLA2.mjs.map
3264
+ //# sourceMappingURL=github-NjgxGqwM.mjs.map