pr-shepherd 0.59.2 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/bin/api.d.mts +1 -1
  4. package/bin/classify/types.d.mts +1 -1
  5. package/bin/cli/edited-comments-format.d.mts +14 -0
  6. package/bin/cli/edited-comments-format.mjs +39 -0
  7. package/bin/cli/fix-formatter.mjs +5 -2
  8. package/bin/cli/iterate-activity-formatter.d.mts +1 -1
  9. package/bin/cli/iterate-activity-formatter.mjs +4 -5
  10. package/bin/cli/iterate-check-names.d.mts +5 -0
  11. package/bin/cli/iterate-check-names.mjs +17 -0
  12. package/bin/cli/iterate-formatter.mjs +15 -19
  13. package/bin/cli/iterate-lean.mjs +19 -11
  14. package/bin/cli/iterate-verbose.mjs +7 -2
  15. package/bin/cli/list-formatters.d.mts +0 -3
  16. package/bin/cli/list-formatters.mjs +1 -5
  17. package/bin/commands/check-review-budget.d.mts +39 -0
  18. package/bin/commands/check-review-budget.mjs +78 -0
  19. package/bin/commands/check-wait-detectors.d.mts +6 -1
  20. package/bin/commands/check-wait-detectors.mjs +2 -1
  21. package/bin/commands/check.d.mts +2 -0
  22. package/bin/commands/check.mjs +46 -27
  23. package/bin/commands/iterate/check-instructions.d.mts +5 -0
  24. package/bin/commands/iterate/check-instructions.mjs +4 -0
  25. package/bin/commands/iterate/classify.d.mts +1 -1
  26. package/bin/commands/iterate/edited-comment-markers.d.mts +3 -0
  27. package/bin/commands/iterate/edited-comment-markers.mjs +10 -0
  28. package/bin/commands/iterate/fix-code.mjs +17 -1
  29. package/bin/commands/iterate/fix-loop-instruction.d.mts +2 -8
  30. package/bin/commands/iterate/fix-loop-instruction.mjs +8 -29
  31. package/bin/commands/iterate/helpers.mjs +9 -5
  32. package/bin/commands/iterate/index.mjs +44 -17
  33. package/bin/commands/iterate/render.d.mts +1 -1
  34. package/bin/commands/iterate/render.mjs +18 -17
  35. package/bin/commands/poll-rate-limit-cancel.mjs +1 -1
  36. package/bin/commands/poll-seen-options.d.mts +8 -0
  37. package/bin/commands/poll-seen-options.mjs +7 -0
  38. package/bin/commands/poll.mjs +7 -5
  39. package/bin/commands/rule-auto-resolve-format.d.mts +0 -12
  40. package/bin/commands/rule-auto-resolve-format.mjs +2 -11
  41. package/bin/commands/rule-auto-resolve.d.mts +0 -5
  42. package/bin/commands/rule-auto-resolve.mjs +1 -42
  43. package/bin/commands/shepherd-journal.d.mts +0 -9
  44. package/bin/commands/shepherd-journal.mjs +0 -15
  45. package/bin/comments/review-output-budget.d.mts +32 -0
  46. package/bin/comments/review-output-budget.mjs +55 -0
  47. package/bin/comments/visible-comments.d.mts +3 -1
  48. package/bin/comments/visible-comments.mjs +10 -5
  49. package/bin/config/load.d.mts +8 -2
  50. package/bin/config/load.mjs +7 -0
  51. package/bin/config.json +2 -1
  52. package/bin/github/poll-summary-fingerprint.mjs +5 -1
  53. package/bin/github/poll-summary-review.mjs +2 -1
  54. package/bin/mcp/server.mjs +1 -1
  55. package/bin/reporters/agent.d.mts +1 -3
  56. package/bin/reporters/agent.mjs +0 -1
  57. package/bin/types/escalate.d.mts +9 -0
  58. package/bin/types/iterate.d.mts +19 -1
  59. package/bin/types/report.d.mts +23 -4
  60. package/bin/util/cap-list.d.mts +7 -0
  61. package/bin/util/cap-list.mjs +13 -0
  62. package/package.json +2 -2
  63. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  64. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  65. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  66. package/plugins/pr-shepherd/.mcp.json +1 -1
  67. package/plugins/pr-shepherd/skills/pr-shepherd/references/fix-code-loop.md +1 -2
  68. package/plugins/pr-shepherd/skills/pr-shepherd/references/merge-queue-ejection.md +1 -1
  69. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +1 -1
  70. package/src/classify/types.mts +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.59.2",
4
+ "version": "0.60.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -33,8 +33,8 @@ $ pr-shepherd 123
33
33
 
34
34
  1. Fix each warranted item.
35
35
  2. Triage `## Failing checks`. Playbook: "CI failure triage".
36
- 3. Commit and push any changes.
37
- 4. Journal key decisions or rejections with URLs or IDs: `pr-shepherd apply journal 123 '- <decision>'`
36
+ 3. Optional: If you reject a code review suggestion, journal why: `pr-shepherd apply journal 123 '- <reason>'`.
37
+ 4. Commit and push any changes.
38
38
  5. `$DISMISS_MESSAGE`: one sentence on what changed. Run even if no code changed: `pr-shepherd apply review 123 --reply-thread-ids PRRT_kwDOSGizTs58XB1L --message "$DISMISS_MESSAGE" --require-sha HEAD`
39
39
  6. Rerun Shepherd now.
40
40
  ```
@@ -151,7 +151,7 @@ See [docs/graphql-usage.md](docs/graphql-usage.md) and [docs/rest-usage.md](docs
151
151
 
152
152
  1. **Install the MCP server and the CLI.** See [Install](#install) below.
153
153
  2. **Write your policy into a skill or agent instruction.** Examples: "skip non-blocking review comments", "reply instead of fixing nits", "never merge". Shepherd reports state and the next action. Your instructions decide how the agent treats each comment. The shipped `pr-shepherd` skill is a thin dispatcher you can wrap ([docs/skills.md](docs/skills.md)).
154
- 3. **Configure Shepherd.** In `.pr-shepherdrc.yml`, turn on the bundled classification rules. They drop known bot noise before it reaches the agent, which saves more tokens and tool calls. With the default `actions.autoMinimizeSuppressed: true`, several rules also minimize matching comments on GitHub and log a Shepherd Journal entry; set it to `false` to keep comments untouched. See [docs/configuration.md](docs/configuration.md#classifystandard--default-).
154
+ 3. **Configure Shepherd.** In `.pr-shepherdrc.yml`, turn on the bundled classification rules. They drop known bot noise before it reaches the agent, which saves more tokens and tool calls. With the default `actions.autoMinimizeSuppressed: true`, several rules also minimize matching comments on GitHub and surface the mutation result without editing the PR body. See [docs/configuration.md](docs/configuration.md#classifystandard--default-).
155
155
 
156
156
  ```yaml
157
157
  classify:
package/bin/api.d.mts CHANGED
@@ -18,7 +18,7 @@ export interface CreatePrShepherdOptions {
18
18
  }
19
19
  /** A positive PR number, GitHub pull-request URL, or owner/repo#number reference. */
20
20
  export type PrReference = number | string;
21
- type IterateOptions = Omit<IterateCommandOptions, "format" | "prNumber" | "targetRepository" | "persistSeen" | "fingerprintCache" | "stallDeadlineSink" | "deferQuotaWarning" | "quotaWarningMinimumPollIntervalMinutes">;
21
+ type IterateOptions = Omit<IterateCommandOptions, "format" | "prNumber" | "targetRepository" | "persistSeen" | "deferEditedCommentMarkers" | "fingerprintCache" | "stallDeadlineSink" | "deferQuotaWarning" | "quotaWarningMinimumPollIntervalMinutes">;
22
22
  export type SingleIterateInput = IterateOptions & {
23
23
  pr?: PrReference;
24
24
  prs?: never;
@@ -29,7 +29,7 @@ export interface ClassifyAction {
29
29
  readonly autoResolve?: boolean;
30
30
  /** When true, hides the item from agent output (seen marker is still written). */
31
31
  readonly suppress?: boolean;
32
- /** Note included in the auto-resolve report, tick line, and Shepherd Journal when this rule fires. */
32
+ /** Note included in the auto-resolve report and tick line when this rule fires. */
33
33
  readonly reason?: string;
34
34
  }
35
35
  export type ClassifyRule = (item: ClassifyItem) => ClassifyAction | null | undefined;
@@ -0,0 +1,14 @@
1
+ import type { EditedComment } from "../types.mts";
2
+ /** Insert the edited-comments section before `## Instructions` (or append it). */
3
+ export declare function insertEditedCommentsSection(text: string, comments: readonly EditedComment[] | undefined, verbose: boolean): string;
4
+ /** JSON projection matching the text section: the agent comment plus its minimized status. */
5
+ export declare function projectEditedComments(comments: readonly EditedComment[]): {
6
+ id: string;
7
+ viewerCanMinimize?: boolean;
8
+ author: string;
9
+ authorType?: import("../types.mts").AuthorType;
10
+ authorAssociation?: import("../types.mts").CommentAuthorAssociation;
11
+ body: string;
12
+ url: string;
13
+ isMinimized?: boolean | undefined;
14
+ }[];
@@ -0,0 +1,39 @@
1
+ import { blockquote, renderAuthor } from "./list-formatters.mjs";
2
+ import { BODY_TRUNCATE_MAX_CHARS } from "./body-truncate.mjs";
3
+ import { toAgentComment } from "../reporters/agent.mjs";
4
+ const HEADING = "Edited comments";
5
+ function editedTag(c) {
6
+ return c.isMinimized ? "[status: minimized, edited]" : "[edited since first look]";
7
+ }
8
+ /** Markdown for `editedComments`; null when there are none. Edits are informational only. */
9
+ function formatEditedCommentsSection(comments, verbose) {
10
+ if (!comments?.length)
11
+ return null;
12
+ const cap = verbose ? undefined : BODY_TRUNCATE_MAX_CHARS;
13
+ const parts = [
14
+ `## ${HEADING} (${comments.length}) — informational; edits do not block readiness`,
15
+ ];
16
+ for (const c of comments) {
17
+ const heading = c.url ? `[commentId=${c.id}](${c.url})` : `\`commentId=${c.id}\``;
18
+ parts.push(`### ${heading} (${renderAuthor(c.author, c.authorType, c.authorAssociation)}) ${editedTag(c)}`);
19
+ parts.push(blockquote(c.body, cap, c.url));
20
+ }
21
+ return parts.join("\n\n");
22
+ }
23
+ /** Insert the edited-comments section before `## Instructions` (or append it). */
24
+ export function insertEditedCommentsSection(text, comments, verbose) {
25
+ const section = formatEditedCommentsSection(comments, verbose);
26
+ if (!section)
27
+ return text;
28
+ const index = text.lastIndexOf("## Instructions");
29
+ if (index === -1)
30
+ return `${text}\n\n${section}`;
31
+ return `${text.slice(0, index)}${section}\n\n${text.slice(index)}`;
32
+ }
33
+ /** JSON projection matching the text section: the agent comment plus its minimized status. */
34
+ export function projectEditedComments(comments) {
35
+ return comments.map((c) => ({
36
+ ...toAgentComment(c),
37
+ ...(c.isMinimized && { isMinimized: true }),
38
+ }));
39
+ }
@@ -13,6 +13,10 @@ export function formatFixCodeResult(header, result, opts = {}) {
13
13
  const verbose = opts.verbose ?? false;
14
14
  const topCap = verbose ? undefined : BODY_TRUNCATE_MAX_CHARS;
15
15
  const sections = [header];
16
+ if (result.fix.reviewItems) {
17
+ const { shown, total } = result.fix.reviewItems;
18
+ sections.push(`**showing** ${shown} of ${total} review items`);
19
+ }
16
20
  if (result.mergedBasePullRequests?.length) {
17
21
  sections.push("## Merged PRs matching the current base");
18
22
  sections.push(result.mergedBasePullRequests
@@ -50,9 +54,8 @@ export function formatFixCodeResult(header, result, opts = {}) {
50
54
  sections.push("## Actionable comments");
51
55
  for (const c of result.fix.actionableComments) {
52
56
  const heading = c.url ? `[commentId=${c.id}](${c.url})` : `\`commentId=${c.id}\``;
53
- const editedMarker = c.edited ? " [edited since first look]" : "";
54
57
  const authorizationMarker = c.viewerCanMinimize === false ? " [viewer cannot minimize]" : "";
55
- sections.push(`### ${heading} (${renderAuthor(c.author, c.authorType, c.authorAssociation)})${authorizationMarker}${editedMarker}`);
58
+ sections.push(`### ${heading} (${renderAuthor(c.author, c.authorType, c.authorAssociation)})${authorizationMarker}`);
56
59
  sections.push(blockquote(c.body, topCap, c.url));
57
60
  }
58
61
  }
@@ -1,2 +1,2 @@
1
1
  import type { IterateResult } from "../types.mts";
2
- export declare function formatActivityLine(result: IterateResult): string | null;
2
+ export declare function formatActivityLine(result: IterateResult, verbose?: boolean): string | null;
@@ -1,4 +1,5 @@
1
- export function formatActivityLine(result) {
1
+ import { formatNameList } from "./iterate-check-names.mjs";
2
+ export function formatActivityLine(result, verbose = false) {
2
3
  const activity = result.activity ?? {
3
4
  commitCount: 0,
4
5
  reviewRoundCount: 0,
@@ -17,10 +18,8 @@ export function formatActivityLine(result) {
17
18
  parts.push(`${activity.reviewItemsSinceLatestCommit.length} review items since latest commit`);
18
19
  }
19
20
  if (hasActiveChecks) {
20
- parts.push(`active: ${result
21
- .inProgressChecks.slice(0, 5)
22
- .map((check) => `\`${check.name}\``)
23
- .join(", ")}`);
21
+ const names = result.inProgressChecks.map((check) => check.name);
22
+ parts.push(`active: ${formatNameList(names, verbose)}`);
24
23
  }
25
24
  return `**activity** ${parts.join(" · ")}`;
26
25
  }
@@ -0,0 +1,5 @@
1
+ import type { IterateResult } from "../types.mts";
2
+ /** Backticked inline name list; lean output caps it and appends `+N more`. */
3
+ export declare function formatNameList(names: readonly string[], verbose: boolean): string;
4
+ /** `**ignored**` and `**superseded**` header lines, omitted when their lists are empty. */
5
+ export declare function formatSuppressedNameLines(result: IterateResult, verbose: boolean): string[];
@@ -0,0 +1,17 @@
1
+ import { capList, moreSuffix } from "../util/cap-list.mjs";
2
+ /** Backticked inline name list; lean output caps it and appends `+N more`. */
3
+ export function formatNameList(names, verbose) {
4
+ const { shown, hidden } = verbose ? { shown: names, hidden: 0 } : capList(names);
5
+ return shown.map((n) => "`" + n + "`").join(", ") + moreSuffix(hidden);
6
+ }
7
+ /** `**ignored**` and `**superseded**` header lines, omitted when their lists are empty. */
8
+ export function formatSuppressedNameLines(result, verbose) {
9
+ const lines = [];
10
+ if (result.ignoredNames && result.ignoredNames.length > 0) {
11
+ lines.push(`**ignored** ${formatNameList(result.ignoredNames, verbose)}`);
12
+ }
13
+ if (result.supersededNames && result.supersededNames.length > 0) {
14
+ lines.push(`**superseded** ${formatNameList(result.supersededNames, verbose)}`);
15
+ }
16
+ return lines;
17
+ }
@@ -1,3 +1,5 @@
1
+ /* eslint-disable max-lines */
2
+ import { insertEditedCommentsSection } from "./edited-comments-format.mjs";
1
3
  import { formatFixCodeResult } from "./fix-formatter.mjs";
2
4
  import { formatRelevantChecks } from "./iterate-checks-formatter.mjs";
3
5
  import { joinSections } from "../util/markdown.mjs";
@@ -6,6 +8,7 @@ import { formatMergeRequirementLines } from "../merge-status/requirements-format
6
8
  import { appendMergeQueueHeader, formatDeferredWorkLine, formatMergeAction, } from "./iterate-merge-formatter.mjs";
7
9
  import { formatApiUsage, formatQuotaWarning } from "./api-usage-formatter.mjs";
8
10
  import { formatActivityLine } from "./iterate-activity-formatter.mjs";
11
+ import { formatSuppressedNameLines } from "./iterate-check-names.mjs";
9
12
  import { branchStateSegment } from "./iterate-branch-segment.mjs";
10
13
  import { insertRuleAutoResolveSection } from "../commands/rule-auto-resolve-format.mjs";
11
14
  import { formatTransportEvidence } from "./transport-formatter.mjs";
@@ -41,7 +44,7 @@ export function formatIterateResult(result, opts) {
41
44
  instructions.includes(`--repo ${result.repo} `));
42
45
  return generated ? full.replace(repoSeg, "") : full;
43
46
  };
44
- const finish = (text) => dropRepeatedRepo(insertRuleAutoResolveSection(text, result.ruleAutoResolve));
47
+ const finish = (text) => dropRepeatedRepo(insertEditedCommentsSection(insertRuleAutoResolveSection(text, result.ruleAutoResolve), result.editedComments, verbose));
45
48
  const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
46
49
  const reviewDecisionSeg = result.mergeStatus === "BLOCKED" && result.reviewDecision
47
50
  ? ` · **reviewDecision** \`${result.reviewDecision}\``
@@ -57,7 +60,7 @@ export function formatIterateResult(result, opts) {
57
60
  let summaryLine;
58
61
  if (verbose) {
59
62
  const verboseBranch = branchSeg ? ` · ${branchSeg}` : "";
60
- summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress, ${result.summary.superseded} superseded · **remainingSeconds** ${result.remainingSeconds} · **blockingBotReviewInProgress** ${result.blockingBotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}${verboseBranch}${readyDelaySuffix ? ` · **ready-delay** \`${readyDelaySuffix}\` (override)` : ""}`;
63
+ summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress, ${result.summary.queued} queued, ${result.summary.superseded} superseded · **remainingSeconds** ${result.remainingSeconds} · **blockingBotReviewInProgress** ${result.blockingBotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}${verboseBranch}${readyDelaySuffix ? ` · **ready-delay** \`${readyDelaySuffix}\` (override)` : ""}`;
61
64
  }
62
65
  else {
63
66
  const counts = [];
@@ -69,6 +72,8 @@ export function formatIterateResult(result, opts) {
69
72
  counts.push(`${result.summary.filtered} filtered`);
70
73
  if (result.summary.inProgress > 0)
71
74
  counts.push(`${result.summary.inProgress} inProgress`);
75
+ if (result.summary.queued > 0)
76
+ counts.push(`${result.summary.queued} queued`);
72
77
  if (result.summary.superseded > 0)
73
78
  counts.push(`${result.summary.superseded} superseded`);
74
79
  const segs = counts.length > 0 ? [`**summary** ${counts.join(", ")}`] : [];
@@ -114,17 +119,10 @@ export function formatIterateResult(result, opts) {
114
119
  else if (requiredLine) {
115
120
  headerLines.push(requiredLine);
116
121
  }
117
- if (result.ignoredNames && result.ignoredNames.length > 0) {
118
- const names = result.ignoredNames.map((n) => "`" + n + "`").join(", ");
119
- headerLines.push(`**ignored** ${names}`);
120
- }
121
- if (result.supersededNames && result.supersededNames.length > 0) {
122
- const names = result.supersededNames.map((n) => "`" + n + "`").join(", ");
123
- headerLines.push(`**superseded** ${names}`);
124
- }
122
+ headerLines.push(...formatSuppressedNameLines(result, verbose));
125
123
  appendUnreportedLines(headerLines, result);
126
124
  appendMergeQueueHeader(headerLines, result);
127
- const activityLine = formatActivityLine(result);
125
+ const activityLine = formatActivityLine(result, verbose);
128
126
  if (activityLine)
129
127
  headerLines.push(activityLine);
130
128
  headerLines.push(...formatNextCheckLines(result.nextCheck));
@@ -176,14 +174,7 @@ export function formatIterateResult(result, opts) {
176
174
  else if (requiredLine) {
177
175
  cancelHeaderLines.push(requiredLine);
178
176
  }
179
- if (result.ignoredNames && result.ignoredNames.length > 0) {
180
- const ignoredStr = result.ignoredNames.map((n) => "`" + n + "`").join(", ");
181
- cancelHeaderLines.push(`**ignored** ${ignoredStr}`);
182
- }
183
- if (result.supersededNames && result.supersededNames.length > 0) {
184
- const supersededStr = result.supersededNames.map((n) => "`" + n + "`").join(", ");
185
- cancelHeaderLines.push(`**superseded** ${supersededStr}`);
186
- }
177
+ cancelHeaderLines.push(...formatSuppressedNameLines(result, verbose));
187
178
  appendUnreportedLines(cancelHeaderLines, result);
188
179
  appendMergeQueueHeader(cancelHeaderLines, result);
189
180
  if (activityLine)
@@ -198,6 +189,11 @@ export function formatIterateResult(result, opts) {
198
189
  case "escalate":
199
190
  return finish(joinSections([
200
191
  header,
192
+ ...(result.escalate.reviewItems
193
+ ? [
194
+ `**showing** ${result.escalate.reviewItems.shown} of ${result.escalate.reviewItems.total} review items`,
195
+ ]
196
+ : []),
201
197
  ...(apiUsage ? [apiUsage] : []),
202
198
  ...(verboseChecks ? [verboseChecks] : []),
203
199
  result.escalate.humanMessage,
@@ -1,8 +1,10 @@
1
1
  import { adaptIterateLog, buildSimpleIterateInstructions } from "./iterate-instructions.mjs";
2
2
  import { projectRuleAutoResolve } from "../commands/rule-auto-resolve-format.mjs";
3
+ import { projectEditedComments } from "./edited-comments-format.mjs";
3
4
  import { projectMergeRequirements } from "../merge-status/requirements-format.mjs";
4
5
  import { branchSegmentShowsBase } from "./iterate-branch-segment.mjs";
5
6
  import { isRedundantMergeQueue } from "./iterate-merge-formatter.mjs";
7
+ import { capList } from "../util/cap-list.mjs";
6
8
  /**
7
9
  * Project an IterateResult to a lean JSON shape for the default (non-verbose) output.
8
10
  * Omits fields that are the trivial default (false, 0, empty) or state-gated fields
@@ -31,7 +33,7 @@ export function projectIterateLean(result, opts) {
31
33
  }),
32
34
  };
33
35
  }
34
- const counts = Object.fromEntries(["passing", "skipped", "filtered", "inProgress", "superseded"]
36
+ const counts = Object.fromEntries(["passing", "skipped", "filtered", "inProgress", "queued", "superseded"]
35
37
  .filter((k) => result.summary[k] > 0)
36
38
  .map((k) => [k, result.summary[k]]));
37
39
  const summary = Object.keys(counts).length > 0 ? counts : null;
@@ -82,15 +84,9 @@ export function projectIterateLean(result, opts) {
82
84
  }),
83
85
  },
84
86
  }),
85
- ...((result.inProgressChecks?.length ?? 0) > 0 && {
86
- inProgressChecks: result.inProgressChecks,
87
- }),
88
- ...((result.ignoredNames?.length ?? 0) > 0 && {
89
- ignoredNames: result.ignoredNames,
90
- }),
91
- ...((result.supersededNames?.length ?? 0) > 0 && {
92
- supersededNames: result.supersededNames,
93
- }),
87
+ ...cappedList("inProgressChecks", result.inProgressChecks),
88
+ ...cappedList("ignoredNames", result.ignoredNames),
89
+ ...cappedList("supersededNames", result.supersededNames),
94
90
  ...((result.unreportedRequiredChecks?.length ?? 0) > 0 && {
95
91
  unreportedRequiredChecks: result.unreportedRequiredChecks,
96
92
  }),
@@ -104,6 +100,9 @@ export function projectIterateLean(result, opts) {
104
100
  ...(result.ruleAutoResolve && {
105
101
  ruleAutoResolve: projectRuleAutoResolve(result.ruleAutoResolve),
106
102
  }),
103
+ ...(result.editedComments?.length && {
104
+ editedComments: projectEditedComments(result.editedComments),
105
+ }),
107
106
  };
108
107
  switch (result.action) {
109
108
  case "ready":
@@ -144,7 +143,7 @@ export function projectIterateLean(result, opts) {
144
143
  case "fix_code":
145
144
  return {
146
145
  ...base,
147
- ...(result.checks.length > 0 && { checks: result.checks }),
146
+ // Passing checks stay summary counts and failures stay in `fix.checks`, as in lean text.
148
147
  ...(result.cancelled.length > 0 && { cancelled: result.cancelled }),
149
148
  fix: {
150
149
  ...(result.fix.threads.length > 0 && { threads: result.fix.threads }),
@@ -190,6 +189,7 @@ export function projectIterateLean(result, opts) {
190
189
  ...(result.fix.resolveOnlyCommand && {
191
190
  resolveOnlyCommand: result.fix.resolveOnlyCommand,
192
191
  }),
192
+ ...(result.fix.reviewItems && { reviewItems: result.fix.reviewItems }),
193
193
  ...(result.fix.instructions.length > 0 && {
194
194
  instructions: result.fix.instructions,
195
195
  }),
@@ -229,6 +229,7 @@ export function projectIterateLean(result, opts) {
229
229
  result.escalate.thrashHistory.length > 0 && {
230
230
  thrashHistory: result.escalate.thrashHistory,
231
231
  }),
232
+ ...(result.escalate.reviewItems && { reviewItems: result.escalate.reviewItems }),
232
233
  ...(result.escalate.mergeQueueRemoval && {
233
234
  mergeQueueRemoval: result.escalate.mergeQueueRemoval,
234
235
  }),
@@ -246,4 +247,11 @@ export function projectIterateLean(result, opts) {
246
247
  };
247
248
  }
248
249
  }
250
+ /** `{ [key]: first 5, [key + "More"]: hidden count }`, matching the capped Markdown lists. */
251
+ function cappedList(key, items) {
252
+ if (!items || items.length === 0)
253
+ return {};
254
+ const { shown, hidden } = capList(items);
255
+ return { [key]: shown, ...(hidden > 0 && { [`${key}More`]: hidden }) };
256
+ }
249
257
  export { projectIterateVerbose } from "./iterate-verbose.mjs";
@@ -1,11 +1,16 @@
1
1
  import { adaptIterateLog, buildSimpleIterateInstructions } from "./iterate-instructions.mjs";
2
+ import { projectEditedComments } from "./edited-comments-format.mjs";
2
3
  export function projectIterateVerbose(result, opts) {
3
4
  const readyDelaySuffix = opts?.readyDelaySuffix;
4
5
  const readyDelayOverride = readyDelaySuffix ? { readyDelayOverride: readyDelaySuffix } : {};
6
+ // Edited comments use the lean projection: their bookkeeping fields have no Markdown form.
7
+ const edited = result.editedComments && {
8
+ editedComments: projectEditedComments(result.editedComments),
9
+ };
5
10
  if (result.action === "fix_code")
6
- return { ...result, ...readyDelayOverride };
11
+ return { ...result, ...readyDelayOverride, ...edited };
7
12
  const log = "log" in result && typeof result.log === "string" ? { log: adaptIterateLog(result.log) } : {};
8
13
  // `cancel` has no instructions, so verbose JSON omits the field as verbose Markdown omits the section.
9
14
  const instructions = result.action === "cancel" ? {} : { instructions: buildSimpleIterateInstructions(result) };
10
- return { ...result, ...log, ...readyDelayOverride, ...instructions };
15
+ return { ...result, ...log, ...readyDelayOverride, ...instructions, ...edited };
11
16
  }
@@ -49,9 +49,6 @@ export declare function renderCommentBullet(c: {
49
49
  }, opts?: {
50
50
  statusTag?: string;
51
51
  }): string;
52
- export declare function renderEditedCommentTag(c: {
53
- edited?: boolean;
54
- }): string | undefined;
55
52
  export declare function renderReviewBullet(r: {
56
53
  id: string;
57
54
  author: string;
@@ -101,9 +101,6 @@ export function renderCommentBullet(c, opts = {}) {
101
101
  const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
102
102
  return `- \`commentId=${c.id}\`${link} (${renderAuthor(c.author, c.authorType, c.authorAssociation)})${statusSuffix}: ${renderBodyPreview(c.body)}`;
103
103
  }
104
- export function renderEditedCommentTag(c) {
105
- return c.edited ? "[edited since first look]" : undefined;
106
- }
107
104
  export function renderReviewBullet(r, opts = {}) {
108
105
  const base = `- \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType, r.authorAssociation)})`;
109
106
  const staleTag = r.staleReview
@@ -138,8 +135,7 @@ export function buildFirstLookBullets(firstLookThreads, resolutionOnlyIds, first
138
135
  }));
139
136
  }
140
137
  for (const c of firstLookComments) {
141
- const editedSuffix = c.edited ? ", edited" : "";
142
- bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
138
+ bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
143
139
  }
144
140
  return bullets;
145
141
  }
@@ -0,0 +1,39 @@
1
+ import { type ReviewItemRollup } from "../comments/review-output-budget.mts";
2
+ import type { ActionableComment, EditedComment, FirstLookComment, FirstLookThread, Review, ReviewThread } from "../types.mts";
3
+ /** Every check.mts list that renders a review item, generates a mutation for it, or marks it seen. */
4
+ export interface ReviewOutputBuckets {
5
+ changesRequested: {
6
+ visible: Review[];
7
+ toMarkSeen: Review[];
8
+ };
9
+ threads: {
10
+ activeThreads: ReviewThread[];
11
+ resolutionOnlyThreads: ReviewThread[];
12
+ firstLookThreads: FirstLookThread[];
13
+ toMarkSeen: ReviewThread[];
14
+ };
15
+ comments: {
16
+ actionable: ActionableComment[];
17
+ minimizeIds: string[];
18
+ toMarkSeen: ActionableComment[];
19
+ };
20
+ firstLookComments: FirstLookComment[];
21
+ firstLookSummaries: Review[];
22
+ editedSummaries: Review[];
23
+ editedComments: EditedComment[];
24
+ }
25
+ /**
26
+ * Applies `iterate.reviewOutputMaxChars` to one tick's review buckets. Priority: changes-requested
27
+ * reviews, active threads and comments, first-look items, review summaries, edited comments.
28
+ * Approved reviews are opt-in minimize targets, not review work, and are never held.
29
+ * Held items leave every list — the report, the generated mutation IDs, and the seen-marker
30
+ * writes — so the next tick shows them. `rollup` is set only when something was held.
31
+ * Edited comments are held only alongside other held work: edits never block readiness, so a
32
+ * ready or terminal tick must not leave some of them unshown. `heldThreadReviewIds` names the
33
+ * parent reviews of held unresolved threads, which must not be minimized while those wait.
34
+ */
35
+ export declare function applyReviewOutputBudget(buckets: ReviewOutputBuckets, maxChars: number): {
36
+ buckets: ReviewOutputBuckets;
37
+ rollup?: ReviewItemRollup;
38
+ heldThreadReviewIds?: string[];
39
+ };
@@ -0,0 +1,78 @@
1
+ import { selectReviewOutput, } from "../comments/review-output-budget.mjs";
2
+ import { threadTranscriptBody } from "../threads/transcript.mjs";
3
+ const bodyItem = (item) => ({
4
+ id: item.id,
5
+ cost: item.body.length,
6
+ ...(item.createdAtUnix !== undefined && { createdAtUnix: item.createdAtUnix }),
7
+ });
8
+ const threadItem = (thread) => ({
9
+ ...bodyItem(thread),
10
+ cost: threadTranscriptBody(thread).length,
11
+ });
12
+ /**
13
+ * Applies `iterate.reviewOutputMaxChars` to one tick's review buckets. Priority: changes-requested
14
+ * reviews, active threads and comments, first-look items, review summaries, edited comments.
15
+ * Approved reviews are opt-in minimize targets, not review work, and are never held.
16
+ * Held items leave every list — the report, the generated mutation IDs, and the seen-marker
17
+ * writes — so the next tick shows them. `rollup` is set only when something was held.
18
+ * Edited comments are held only alongside other held work: edits never block readiness, so a
19
+ * ready or terminal tick must not leave some of them unshown. `heldThreadReviewIds` names the
20
+ * parent reviews of held unresolved threads, which must not be minimized while those wait.
21
+ */
22
+ export function applyReviewOutputBudget(buckets, maxChars) {
23
+ const { changesRequested, threads, comments } = buckets;
24
+ const selection = selectReviewOutput([
25
+ changesRequested.visible.map(bodyItem),
26
+ [
27
+ ...threads.activeThreads.map(threadItem),
28
+ ...threads.resolutionOnlyThreads.map(threadItem),
29
+ ...comments.actionable.map(bodyItem),
30
+ ],
31
+ [...threads.firstLookThreads.map(threadItem), ...buckets.firstLookComments.map(bodyItem)],
32
+ [...buckets.firstLookSummaries.map(bodyItem), ...buckets.editedSummaries.map(bodyItem)],
33
+ buckets.editedComments.map(bodyItem),
34
+ ], maxChars);
35
+ if (selection.rollup === undefined)
36
+ return { buckets };
37
+ const nonEditedIds = [
38
+ ...changesRequested.visible,
39
+ ...threads.activeThreads,
40
+ ...threads.resolutionOnlyThreads,
41
+ ...comments.actionable,
42
+ ...threads.firstLookThreads,
43
+ ...buckets.firstLookComments,
44
+ ...buckets.firstLookSummaries,
45
+ ...buckets.editedSummaries,
46
+ ].map((item) => item.id);
47
+ if (nonEditedIds.every(selection.keep))
48
+ return { buckets };
49
+ const heldThreadReviewIds = [...threads.activeThreads, ...threads.resolutionOnlyThreads]
50
+ .filter((thread) => !selection.keep(thread.id))
51
+ .flatMap((thread) => (thread.reviewId !== undefined ? [thread.reviewId] : []));
52
+ const keep = (items) => items.filter((item) => selection.keep(item.id));
53
+ return {
54
+ buckets: {
55
+ changesRequested: {
56
+ visible: keep(changesRequested.visible),
57
+ toMarkSeen: keep(changesRequested.toMarkSeen),
58
+ },
59
+ threads: {
60
+ activeThreads: keep(threads.activeThreads),
61
+ resolutionOnlyThreads: keep(threads.resolutionOnlyThreads),
62
+ firstLookThreads: keep(threads.firstLookThreads),
63
+ toMarkSeen: keep(threads.toMarkSeen),
64
+ },
65
+ comments: {
66
+ actionable: keep(comments.actionable),
67
+ minimizeIds: comments.minimizeIds.filter(selection.keep),
68
+ toMarkSeen: keep(comments.toMarkSeen),
69
+ },
70
+ firstLookComments: keep(buckets.firstLookComments),
71
+ firstLookSummaries: keep(buckets.firstLookSummaries),
72
+ editedSummaries: keep(buckets.editedSummaries),
73
+ editedComments: keep(buckets.editedComments),
74
+ },
75
+ rollup: selection.rollup,
76
+ ...(heldThreadReviewIds.length > 0 && { heldThreadReviewIds }),
77
+ };
78
+ }
@@ -10,7 +10,11 @@ type StateKey = {
10
10
  /**
11
11
  * Outcome of the GraphQL transport's REST change detectors. `reused` replays the stored report
12
12
  * without any GraphQL request. Otherwise `digest` is what the detectors read before this tick's
13
- * GraphQL snapshot, recorded beside the new report by `recordWaitDetectors`.
13
+ * GraphQL snapshot, recorded beside the new report by `recordWaitDetectors`. `changed` is set
14
+ * when this read differs from the digest stored beside the report, or no digest is stored yet
15
+ * (the seeding tick): the stored report may predate what the detectors saw, so the tick must
16
+ * not reuse it through the GraphQL fingerprint either. A same-digest read past
17
+ * `poll.reconcileSeconds` leaves `changed` unset.
14
18
  */
15
19
  export type WaitDetectorResult = {
16
20
  reused: ShepherdReport;
@@ -18,6 +22,7 @@ export type WaitDetectorResult = {
18
22
  } | {
19
23
  digest: string;
20
24
  readAt: number;
25
+ changed?: true;
21
26
  };
22
27
  /**
23
28
  * On the GraphQL transport, run the REST change detectors for an idle stored report. Returns
@@ -83,7 +83,8 @@ export async function readWaitDetectors(prNumber, repo, stateKey, config, now =
83
83
  baseTipOid: observed.baseTipOid,
84
84
  };
85
85
  }
86
- return { digest: state.digest, readAt: now };
86
+ const changed = previous?.digest !== state.digest;
87
+ return { digest: state.digest, readAt: now, ...(changed && { changed: true }) };
87
88
  }
88
89
  /**
89
90
  * After a GraphQL tick, store the digest its detectors read beside the report that tick built.
@@ -7,6 +7,8 @@ type CheckOptions = GlobalOptions & {
7
7
  persistSeen?: boolean;
8
8
  fingerprintCache?: boolean;
9
9
  merge?: boolean;
10
+ /** Per-tick review-item body budget (`iterate.reviewOutputMaxChars`). Omitted or 0: no cap. */
11
+ reviewOutputMaxChars?: number;
10
12
  };
11
13
  export declare function runCheck(opts: CheckOptions, context?: CheckExecutionContext): Promise<ShepherdReport>;
12
14
  export {};