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

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.
@@ -38,18 +38,18 @@ export const pullRequestReviewerProfile = PullRequestReviewerProfile.make({
38
38
  export class FanOutReviewerProfile extends Schema.Class<FanOutReviewerProfile>(
39
39
  "@effect-agent/pr-review/FanOutReviewerProfile",
40
40
  )({
41
- /** Ephemeral runtime: one bounded AgentRuntime.run per invocation. */
41
+ /** Ephemeral runtime: bounded host-scheduled child runs per invocation. */
42
42
  deploymentClass: Schema.Literal("E"),
43
- /** Every model-callable tool parent and child is a read; none mutate. */
43
+ /** Every model-callable surface is evidence-only; children expose no tools. */
44
44
  readOnlyToolSurface: Schema.Literal(true),
45
45
  /** The review is posted by the host AFTER the run settles, never by a tool. */
46
46
  publicationOutsideAgentLoop: Schema.Literal(true),
47
47
  /** Child findings are untrusted; anchors are validated against the parsed diff. */
48
48
  anchorsValidatedBeforePublication: Schema.Literal(true),
49
- /** S1 attached ephemeral delegation at depth 1; nested delegation is rejected. */
50
- attachedEphemeralDelegation: Schema.Literal(true),
51
- /** A failed unit surfaces to the coordinator as a typed failed result, never retried. */
52
- failedUnitsReportedNotRetried: Schema.Literal(true),
49
+ /** Host code schedules every pass from the deterministic plan; no coordinator model. */
50
+ hostScheduledPasses: Schema.Literal(true),
51
+ /** A failed pass is retried once, then reported and carried as unreviewed scope. */
52
+ failedPassesRetriedOnceThenCarried: Schema.Literal(true),
53
53
  /** Risk categories and required specialist passes are pure host policy. */
54
54
  hostOwnedRiskClassification: Schema.Literal(true),
55
55
  /** Every host-classified high-risk unit receives a fresh specialist pass. */
@@ -69,8 +69,8 @@ export const fanOutReviewerProfile = FanOutReviewerProfile.make({
69
69
  readOnlyToolSurface: true,
70
70
  publicationOutsideAgentLoop: true,
71
71
  anchorsValidatedBeforePublication: true,
72
- attachedEphemeralDelegation: true,
73
- failedUnitsReportedNotRetried: true,
72
+ hostScheduledPasses: true,
73
+ failedPassesRetriedOnceThenCarried: true,
74
74
  hostOwnedRiskClassification: true,
75
75
  redundantHighRiskDiscovery: true,
76
76
  independentCandidateVerification: true,
@@ -2,7 +2,7 @@ import { Schema } from "effect";
2
2
 
3
3
  import { anchorViolation } from "./anchors.ts";
4
4
  export { anchorViolation } from "./anchors.ts";
5
- import type { ReviewAssurance, ReviewCoverage, ReviewInputCoverage } from "./coverage.ts";
5
+ import type { ReviewAssurance, ReviewInputCoverage } from "./coverage.ts";
6
6
  import type { ChangedFile } from "./diff.ts";
7
7
  import { renderFingerprintMarker } from "./fingerprint.ts";
8
8
  import {
@@ -215,27 +215,27 @@ const renderVerdictCallout = (
215
215
  options: {
216
216
  readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
217
217
  readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
218
- readonly coverage?: ReviewCoverage | undefined;
219
218
  readonly inputCoverage?: ReviewInputCoverage | undefined;
220
219
  readonly assurance?: ReviewAssurance | undefined;
220
+ readonly unreviewedPaths?: ReadonlyArray<string> | undefined;
221
221
  },
222
222
  ): string => {
223
223
  const counts = severityCounts(review, options.carriedFindings, options.carriedConcerns);
224
+ // Code findings outrank machinery gaps: a blocking finding is the
225
+ // actionable signal, and unsettled reviewer-side work is carried forward.
226
+ if (counts.blocking > 0) {
227
+ return `> [!CAUTION]\n> ${countNoun(counts.blocking, "blocking finding")} — do not merge before addressing ${counts.blocking === 1 ? "it" : "them"}.`;
228
+ }
229
+ const carried = options.unreviewedPaths?.length ?? 0;
224
230
  if (
225
231
  options.inputCoverage?.status === "incomplete" ||
226
- (options.inputCoverage === undefined && options.coverage?.status === "incomplete")
232
+ options.assurance?.status === "incomplete"
227
233
  ) {
228
- const suffix =
229
- counts.blocking > 0 ? ` It also has ${countNoun(counts.blocking, "blocking finding")}.` : "";
230
- return `> [!CAUTION]\n> Input coverage is incomplete the check must not pass.${suffix}`;
231
- }
232
- if (options.assurance !== undefined && options.assurance.status !== "settled") {
233
- const suffix =
234
- counts.blocking > 0 ? ` It also has ${countNoun(counts.blocking, "blocking finding")}.` : "";
235
- return `> [!CAUTION]\n> Configured review assurance did not settle — the check must not pass.${suffix}`;
236
- }
237
- if (counts.blocking > 0) {
238
- return `> [!CAUTION]\n> ${countNoun(counts.blocking, "blocking finding")} — do not merge before addressing ${counts.blocking === 1 ? "it" : "them"}.`;
234
+ const carriedNote =
235
+ carried > 0
236
+ ? ` ${countNoun(carried, "affected path")} ${carried === 1 ? "is" : "are"} carried forward and retried automatically on the next run.`
237
+ : "";
238
+ return `> [!WARNING]\n> Review infrastructure did not settle — a reviewer-side gap, NOT a request to change code.${carriedNote} The check reports "incomplete" until a run settles.`;
239
239
  }
240
240
  if (counts.important > 0) {
241
241
  return `> [!IMPORTANT]\n> ${countNoun(counts.important, "important finding")} to address before merging.`;
@@ -393,22 +393,20 @@ export const planPublication = (
393
393
  readonly modelLabel?: string | undefined;
394
394
  /** Workflow-run URL rendered into the footer. */
395
395
  readonly runUrl?: string | undefined;
396
- /** Observed run usage rendered into the footer. */
396
+ /** Observed whole-run usage rendered into the footer. */
397
397
  readonly usage?: { readonly inputTokens: number; readonly outputTokens: number } | undefined;
398
- /** What the usage observed: the whole run, or the coordinator only. */
399
- readonly usageScope?: "run" | "coordinator" | undefined;
400
398
  /**
401
399
  * Changeset fingerprint embedded invisibly in the review body so a later
402
400
  * run can skip re-reviewing an unchanged changeset.
403
401
  */
404
402
  readonly fingerprint?: string | undefined;
405
- /** Host-owned coverage; incomplete coverage is rendered and fails the check. */
406
- readonly coverage?: ReviewCoverage | undefined;
407
403
  /** Host-owned path/evidence assignment, separate from review assurance. */
408
404
  readonly inputCoverage?: ReviewInputCoverage | undefined;
409
405
  /** Host-owned discovery/specialist/verification settlement. */
410
406
  readonly assurance?: ReviewAssurance | undefined;
411
- /** Unchanged unresolved items carried from the prior settled assurance baseline. */
407
+ /** Retryable scope this run could not settle; carried to the next run. */
408
+ readonly unreviewedPaths?: ReadonlyArray<string> | undefined;
409
+ /** Unchanged unresolved items carried from the prior reviewed baseline. */
412
410
  readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
413
411
  readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
414
412
  /** Selected review scope, made visible whenever orchestration chose it. */
@@ -452,14 +450,8 @@ export const planPublication = (
452
450
 
453
451
  const footerParts = ["Automated review by @effect-agent/pr-review"];
454
452
  if (options.modelLabel !== undefined) footerParts.push(options.modelLabel);
455
- // Usage renders only under an EXPLICIT scope: this planner cannot know
456
- // whether a budget snapshot observed the whole run or only a fan-out
457
- // coordinator, and omitting the number is honest where mislabeling is not.
458
- if (options.usage !== undefined && options.usageScope !== undefined) {
459
- const scope = options.usageScope === "coordinator" ? " (coordinator)" : "";
460
- footerParts.push(
461
- `${options.usage.inputTokens} in / ${options.usage.outputTokens} out tokens${scope}`,
462
- );
453
+ if (options.usage !== undefined) {
454
+ footerParts.push(`${options.usage.inputTokens} in / ${options.usage.outputTokens} out tokens`);
463
455
  }
464
456
  if (options.runUrl !== undefined) footerParts.push(`[run](${options.runUrl})`);
465
457
  footerParts.push(`reviewed at ${options.headSha.slice(0, 7)}`);
@@ -490,9 +482,9 @@ export const planPublication = (
490
482
  renderVerdictCallout(review, {
491
483
  carriedFindings,
492
484
  carriedConcerns,
493
- coverage: options.coverage,
494
485
  inputCoverage: options.inputCoverage,
495
486
  assurance: options.assurance,
487
+ unreviewedPaths: options.unreviewedPaths,
496
488
  }),
497
489
  ];
498
490
  if (options.reviewMode !== undefined && options.reviewReason !== undefined) {
@@ -513,7 +505,7 @@ export const planPublication = (
513
505
  if (options.inputCoverage !== undefined && options.assurance !== undefined) {
514
506
  parts.push(
515
507
  "",
516
- `**Input coverage:** ${options.inputCoverage.status} (${options.inputCoverage.assignedPaths.length}/${options.inputCoverage.requiredPaths.length} paths assigned, ${options.inputCoverage.partialPaths.length} partial) · **Review assurance:** ${options.assurance.status} (${options.assurance.completedGeneralDiscoveryPasses}/${options.assurance.requiredGeneralDiscoveryPasses} general discovery, ${options.assurance.completedSpecialistPasses}/${options.assurance.requiredSpecialistPasses} specialist, ${options.assurance.completedVerificationPasses}/${options.assurance.requiredVerificationPasses} verification; ${options.assurance.confirmedCandidates} confirmed / ${options.assurance.rejectedCandidates} rejected / ${options.assurance.unsettledCandidates} unsettled candidates)`,
508
+ `**Input coverage:** ${options.inputCoverage.status} (${options.inputCoverage.assignedPaths.length}/${options.inputCoverage.requiredPaths.length} paths assigned, ${options.inputCoverage.partialPaths.length} partial) · **Review assurance:** ${options.assurance.status} (${options.assurance.completedGeneralDiscoveryPasses}/${options.assurance.requiredGeneralDiscoveryPasses} general discovery, ${options.assurance.completedSpecialistPasses}/${options.assurance.requiredSpecialistPasses} specialist, ${options.assurance.completedVerificationPasses}/${options.assurance.requiredVerificationPasses} verification; ${options.assurance.confirmedCandidates} confirmed / ${options.assurance.rejectedCandidates} rejected / ${options.assurance.unsettledCandidates} unsettled${options.assurance.discardedInvalidFindings > 0 ? ` / ${options.assurance.discardedInvalidFindings} discarded` : ""} candidates)`,
517
509
  );
518
510
  }
519
511
  parts.push("", review.summary);
@@ -525,22 +517,17 @@ export const planPublication = (
525
517
  if (options.inputCoverage?.status === "incomplete") {
526
518
  parts.push(
527
519
  "",
528
- "### 🛑 Incomplete input coverage",
520
+ "### ⚠️ Incomplete input coverage",
529
521
  "",
530
522
  ...options.inputCoverage.reasons.map((reason) => `- ${reason}`),
531
523
  );
532
- } else if (options.inputCoverage === undefined && options.coverage?.status === "incomplete") {
533
- parts.push(
534
- "",
535
- "### 🛑 Incomplete coverage",
536
- "",
537
- ...options.coverage.reasons.map((reason) => `- ${reason}`),
538
- );
539
524
  }
540
- if (options.assurance !== undefined && options.assurance.status !== "settled") {
525
+ if (options.assurance?.status === "incomplete") {
541
526
  parts.push(
542
527
  "",
543
- "### 🛑 Incomplete review assurance",
528
+ "### ⚠️ Unsettled review passes",
529
+ "",
530
+ "The passes below failed on the reviewer's side after a bounded retry. Their paths are carried forward and re-reviewed automatically on the next run — do not change code to satisfy this section.",
544
531
  "",
545
532
  ...options.assurance.reasons.map((reason) => `- ${reason}`),
546
533
  );
@@ -613,14 +600,16 @@ export const planPublication = (
613
600
  options.carriedFindings ?? [],
614
601
  options.carriedConcerns ?? [],
615
602
  );
603
+ // Machinery gaps (incomplete input or unsettled passes) block APPROVE but
604
+ // never REQUEST_CHANGES: requesting changes for a reviewer-side fault would
605
+ // tell the author to edit code nobody reviewed.
606
+ const unclean =
607
+ options.inputCoverage?.status === "incomplete" || options.assurance?.status === "incomplete";
616
608
  const event: ReviewEvent = !options.applyVerdict
617
609
  ? "COMMENT"
618
- : options.inputCoverage?.status === "incomplete" ||
619
- (options.inputCoverage === undefined && options.coverage?.status === "incomplete") ||
620
- (options.assurance !== undefined && options.assurance.status !== "settled") ||
621
- counts.blocking > 0
610
+ : counts.blocking > 0
622
611
  ? "REQUEST_CHANGES"
623
- : review.verdict === "approve" && counts.important === 0
612
+ : review.verdict === "approve" && counts.important === 0 && !unclean
624
613
  ? "APPROVE"
625
614
  : "COMMENT";
626
615
 
@@ -102,8 +102,10 @@ const findingIdentity = (finding: {
102
102
 
103
103
  const REVIEW_METADATA_PATTERN = /<!-- effect-agent-pr-review metadata\n[\s\S]*?\n-->/g;
104
104
  const FINGERPRINT_PATTERN = /<!-- effect-agent-pr-review fingerprint=sha256:[0-9a-f]{64} -->/g;
105
+ // Version-agnostic on purpose: retirement only RECOGNIZES machine markers to
106
+ // keep them byte-identical through an edit; it never authenticates them.
105
107
  const STATE_PATTERN =
106
- /<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->/g;
108
+ /<!-- effect-agent-pr-review state-v\d+:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->/g;
107
109
  const RETIRED_ORIGINAL_PATTERN =
108
110
  /<!-- effect-agent-pr-review retired-original:start -->\n([\s\S]*?)\n<!-- effect-agent-pr-review retired-original:end -->/;
109
111
  const MACHINE_COMMENT_PATTERN = new RegExp(
@@ -346,7 +346,12 @@ export class ReviewFinding extends Schema.Class<ReviewFinding>(
346
346
  title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
347
347
  body: Schema.NonEmptyString.check(Schema.isMaxLength(2_000)),
348
348
  /** Replacement for exactly lines startLine..endLine; omit when unsure. */
349
- suggestion: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(2_000))),
349
+ suggestion: Schema.optionalKey(
350
+ Schema.String.annotate({
351
+ description:
352
+ "Committable replacement source code for exactly lines startLine..endLine: the full replacement for every line in the range and nothing else — never prose describing the change, which belongs in body.",
353
+ }).check(Schema.isMaxLength(2_000)),
354
+ ),
350
355
  }) {}
351
356
 
352
357
  export const ReviewVerdict = Schema.Literals(["approve", "comment", "request-changes"]);
@@ -46,16 +46,22 @@ export class StoredReviewConcern extends Schema.Class<StoredReviewConcern>(
46
46
  body: StoredText,
47
47
  }) {}
48
48
 
49
+ /** The carried-scope bound; a run that cannot fit its leftovers publishes no state. */
50
+ export const MAX_STORED_UNREVIEWED_PATHS = 100;
51
+
49
52
  /**
50
- * Versioned state embedded only after complete input assignment and settled
51
- * configured review assurance. The head plus full-scope fingerprint forms an
52
- * incremental baseline; an absent unresolved item never means the path is
53
- * defect-free. The `acceptedScopeFingerprint` name is retained for wire
54
- * compatibility. Storing hundreds of path strings separately would not fit
55
- * GitHub's bounded review body in the worst case.
53
+ * Versioned state embedded after EVERY completed run that can be signed. The
54
+ * head plus full-scope fingerprint forms an incremental baseline; an absent
55
+ * unresolved item never means the path is defect-free. `unreviewedPaths`
56
+ * carries retryable review gaps (failed passes) forward so the next
57
+ * incremental run re-reviews exactly them plus the new delta — the baseline
58
+ * advances monotonically instead of freezing on one flaky pass and reopening
59
+ * the whole post-baseline scope. The `acceptedScopeFingerprint` name is
60
+ * retained for wire compatibility. Storing hundreds of path strings
61
+ * separately would not fit GitHub's bounded review body in the worst case.
56
62
  */
57
63
  export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-review/ReviewState")({
58
- version: Schema.Literal(1),
64
+ version: Schema.Literal(2),
59
65
  repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
60
66
  pullRequestNumber: Schema.Int.check(Schema.isGreaterThan(0)),
61
67
  baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
@@ -67,6 +73,14 @@ export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-rev
67
73
  reviewedPathCount: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 300 })),
68
74
  unresolvedFindings: Schema.Array(StoredReviewFinding).check(Schema.isMaxLength(20)),
69
75
  unresolvedConcerns: Schema.Array(StoredReviewConcern).check(Schema.isMaxLength(10)),
76
+ /** Retryable review gaps carried into the next incremental run's scope. */
77
+ unreviewedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(MAX_STORED_UNREVIEWED_PATHS)),
78
+ /**
79
+ * True only when the producing run had complete input coverage, no
80
+ * unsettled pass, and nothing carried. Skip-unchanged authority: an
81
+ * unchanged patch may skip re-review only over a settled state.
82
+ */
83
+ settled: Schema.Boolean,
70
84
  lastReviewMode: ReviewScopeMode,
71
85
  }) {}
72
86
 
@@ -100,15 +114,15 @@ export const toStoredConcern = (concern: ReviewConcern): StoredReviewConcern =>
100
114
  export const fromStoredConcern = (concern: StoredReviewConcern): ReviewConcern =>
101
115
  ReviewConcern.make({ severity: concern.severity, title: concern.title, body: concern.body });
102
116
 
103
- const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v1:";
117
+ const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v2:";
104
118
  const STATE_MARKER_SUFFIX = " -->";
105
119
  const STATE_MARKER_PATTERN =
106
- /(?:^|\n)<!-- effect-agent-pr-review state-v1:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
107
- const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-v1\u0000";
120
+ /(?:^|\n)<!-- effect-agent-pr-review state-v2:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
121
+ const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-v2\u0000";
108
122
  export const MAX_REVIEW_STATE_MARKER_CHARS = 24_000;
109
123
  export const ReviewStateMarker = Schema.NonEmptyString.check(
110
124
  Schema.isMaxLength(MAX_REVIEW_STATE_MARKER_CHARS),
111
- Schema.isPattern(/^<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/),
125
+ Schema.isPattern(/^<!-- effect-agent-pr-review state-v2:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/),
112
126
  ).pipe(Schema.brand("@effect-agent/pr-review/ReviewStateMarker"));
113
127
  export type ReviewStateMarker = typeof ReviewStateMarker.Type;
114
128
 
@@ -402,22 +416,32 @@ export const selectReviewRange = (input: {
402
416
  selectedByPath.set(file.path, file);
403
417
  }
404
418
  }
405
- if (input.priorState.baseSha !== input.current.baseSha) {
419
+ // Retryable gaps carried by the prior state re-enter this run's scope; a
420
+ // carried path reverted out of the pull request has nothing left to review.
421
+ const carriedPaths = new Set(
422
+ input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path)),
423
+ );
424
+ for (const path of carriedPaths) affectedPaths.add(path);
425
+ const rescuePaths = input.priorState.baseSha !== input.current.baseSha;
426
+ if (rescuePaths || carriedPaths.size > 0) {
406
427
  for (const file of input.fullFiles) {
407
- if (
408
- affectedPaths.has(file.path) ||
409
- (file.previousPath !== undefined && affectedPaths.has(file.previousPath))
410
- ) {
411
- selectedByPath.set(file.path, file);
412
- }
428
+ const affected =
429
+ (rescuePaths &&
430
+ (affectedPaths.has(file.path) ||
431
+ (file.previousPath !== undefined && affectedPaths.has(file.previousPath)))) ||
432
+ carriedPaths.has(file.path) ||
433
+ (file.previousPath !== undefined && carriedPaths.has(file.previousPath));
434
+ if (affected) selectedByPath.set(file.path, file);
413
435
  }
414
436
  }
415
437
  const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
416
438
  left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
417
439
  );
440
+ const carriedReason =
441
+ carriedPaths.size === 0 ? "" : `; retrying ${carriedPaths.size} carried unreviewed path(s)`;
418
442
  return {
419
443
  mode: "incremental",
420
- reason: `changes since settled review head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
444
+ reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}${carriedReason}`,
421
445
  files: selectedFiles,
422
446
  affectedPaths: [...affectedPaths].sort(),
423
447
  totalFiles: selectedFiles.length,
@@ -6,6 +6,7 @@ import {
6
6
  fileReviewEvidenceChunks,
7
7
  type FindingSeverity,
8
8
  MAX_PATCH_CHARS,
9
+ type ReviewConcern,
9
10
  type ReviewFinding,
10
11
  } from "./review-agent.ts";
11
12
 
@@ -465,3 +466,27 @@ export const rankAndDedupeFindings = (
465
466
  })
466
467
  .slice(0, MAX_MERGED_FINDINGS);
467
468
  };
469
+
470
+ /**
471
+ * The concern analogue of `rankAndDedupeFindings`: dedupe by exact content
472
+ * keeping the most severe duplicate, rank by severity, and cap at the
473
+ * `CodeReview` concerns bound.
474
+ */
475
+ export const rankAndDedupeConcerns = (
476
+ concerns: ReadonlyArray<ReviewConcern>,
477
+ ): ReadonlyArray<ReviewConcern> => {
478
+ const byContent = new Map<string, ReviewConcern>();
479
+ for (const concern of concerns) {
480
+ const key = `${concern.title}\u0000${concern.body}`;
481
+ const previous = byContent.get(key);
482
+ if (
483
+ previous === undefined ||
484
+ severityRank[concern.severity] < severityRank[previous.severity]
485
+ ) {
486
+ byContent.set(key, concern);
487
+ }
488
+ }
489
+ return [...byContent.values()]
490
+ .sort((left, right) => severityRank[left.severity] - severityRank[right.severity])
491
+ .slice(0, 10);
492
+ };