pr-shepherd 1.1.1 → 1.2.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 (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +17 -17
  3. package/bin/classify/standard/coderabbit-notices.mjs +17 -1
  4. package/bin/classify/standard/codex-notices.mjs +5 -3
  5. package/bin/cli/iterate-instructions.mjs +3 -1
  6. package/bin/cli/iterate-lean.mjs +8 -1
  7. package/bin/cli/triage-formatter.mjs +5 -1
  8. package/bin/commands/check-annotations.d.mts +8 -3
  9. package/bin/commands/check-annotations.mjs +11 -10
  10. package/bin/commands/check-unreported.mjs +1 -1
  11. package/bin/commands/check.mjs +17 -22
  12. package/bin/commands/iterate/check-blocker-gate.d.mts +2 -0
  13. package/bin/commands/iterate/check-blocker-gate.mjs +3 -1
  14. package/bin/commands/iterate/check-instructions.d.mts +7 -1
  15. package/bin/commands/iterate/check-instructions.mjs +10 -3
  16. package/bin/commands/iterate/conflicting-head-ci.d.mts +1 -1
  17. package/bin/commands/iterate/conflicting-head-ci.mjs +1 -1
  18. package/bin/commands/iterate/index.mjs +17 -7
  19. package/bin/commands/iterate/merge-plan.d.mts +2 -0
  20. package/bin/commands/iterate/merge-plan.mjs +5 -0
  21. package/bin/commands/iterate/merge-state.d.mts +10 -1
  22. package/bin/commands/iterate/merge-state.mjs +1 -1
  23. package/bin/commands/iterate/native-stack-rebase.d.mts +13 -2
  24. package/bin/commands/iterate/native-stack-rebase.mjs +23 -3
  25. package/bin/commands/iterate/queued-conflict-deferral.d.mts +15 -0
  26. package/bin/commands/iterate/queued-conflict-deferral.mjs +20 -0
  27. package/bin/commands/iterate/render.d.mts +2 -1
  28. package/bin/commands/iterate/render.mjs +18 -12
  29. package/bin/commands/iterate/rerun-command.d.mts +7 -0
  30. package/bin/commands/iterate/rerun-command.mjs +22 -0
  31. package/bin/commands/iterate/run.mjs +2 -1
  32. package/bin/commands/iterate/triage.d.mts +5 -1
  33. package/bin/commands/iterate/triage.mjs +32 -11
  34. package/bin/commands/poll-summary-explicit-instructions.mjs +3 -0
  35. package/bin/commands/poll-summary-instructions.mjs +1 -1
  36. package/bin/comments/resolve.d.mts +2 -0
  37. package/bin/comments/resolve.mjs +7 -0
  38. package/bin/comments/rest-review-mutations.d.mts +1 -0
  39. package/bin/comments/rest-review-mutations.mjs +3 -1
  40. package/bin/github/poll-summary-annotations.d.mts +1 -1
  41. package/bin/github/poll-summary-annotations.mjs +2 -2
  42. package/bin/types/iterate.d.mts +2 -0
  43. package/bin/types/report.d.mts +2 -2
  44. package/package.json +2 -2
  45. package/plugins/pr-shepherd/.claude-plugin/icon.png +0 -0
  46. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  47. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  48. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  49. package/plugins/pr-shepherd/.mcp.json +1 -1
  50. package/plugins/pr-shepherd/package-lock.json +1278 -0
  51. package/plugins/pr-shepherd/package.json +9 -0
  52. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +5 -4
  53. package/plugins/pr-shepherd/skills/pr-shepherd/references/branch-update.md +1 -1
  54. package/plugins/pr-shepherd/skills/pr-shepherd/references/event-loop.md +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": "1.1.1",
4
+ "version": "1.2.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -43,14 +43,14 @@ More features are in [docs/features.md](docs/features.md).
43
43
 
44
44
  ## Outcomes
45
45
 
46
- | Outcome | Headline | Evidence |
47
- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
48
- | [Context and correctness](#context-and-correctness) | 60 GitHub edge cases cataloged, backed by 147 scenario fixtures; on 8 of them, evals show the agent does worse without the plugin | [Handled cases](docs/handled-cases.md) |
49
- | [Fewer tokens, lower cost](#fewer-tokens-and-lower-cost) | Cost −42% vs. `gh` and −77% vs. MCP per PR session; turns −19% / −70% (estimates) | [Token bench](evals/tokens/README.md) |
50
- | [Clearer agent direction](#clearer-agent-direction) | GitHub gotchas (cancelled runs, merge queues, stacks): eval score 0.82 → 0.94, about two-thirds fewer misses | [Behavior evals](evals/README.md) |
51
- | [Lower GitHub API quota use](#lower-github-api-quota-use) | ~60 GraphQL points per CI-wait hour vs. ~360 for `gh`; ~4 when idle. REST core per PR session 4.8 vs. 9 (`gh`), 78.3 (MCP) | [GitHub API](docs/github-api.md), [GraphQL usage](docs/graphql-usage.md) |
52
- | [Safe by construction](#safe-by-construction) | 0 git mutations; human handoff limited to 10 escalation triggers for a single PR | [Escalations](docs/escalations.md) |
53
- | [Works across agents and stacks](#works-across-agents-and-environments) | 3 agent plugins plus any stdio MCP client; GraphQL and REST transports; native stacked PRs | [MCP](docs/mcp.md), [cloud](docs/cloud.md) |
46
+ | Outcome | Headline | Evidence |
47
+ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
48
+ | [Context and correctness](#context-and-correctness) | 60 GitHub edge cases cataloged, backed by 147 scenario fixtures; on 8 eval cases, the agent does measurably worse without the plugin | [Handled cases](docs/handled-cases.md) |
49
+ | [Fewer tokens, lower cost](#fewer-tokens-and-lower-cost) | Cost −42% vs. `gh` and −77% vs. MCP per PR session; turns −19% / −70% (estimates) | [Token bench](evals/tokens/README.md) |
50
+ | [Clearer agent direction](#clearer-agent-direction) | Scores 0.85 without the plugin and 0.98 with it, 87% fewer misses; GitHub gotchas Δ +0.11 [+0.09, +0.13] (2026-11-09 run) | [Behavior evals](evals/README.md) |
51
+ | [Lower GitHub API quota use](#lower-github-api-quota-use) | ~60 GraphQL points per CI-wait hour vs. ~360 for `gh`; ~4 when idle. REST core per PR session 4.8 vs. 9 (`gh`), 78.3 (MCP) | [GitHub API](docs/github-api.md), [GraphQL usage](docs/graphql-usage.md) |
52
+ | [Safe by construction](#safe-by-construction) | 0 git mutations; human handoff limited to 10 escalation triggers for a single PR | [Escalations](docs/escalations.md) |
53
+ | [Works across agents and stacks](#works-across-agents-and-environments) | 3 agent plugins plus any stdio MCP client; GraphQL and REST transports; native stacked PRs | [MCP](docs/mcp.md), [cloud](docs/cloud.md) |
54
54
 
55
55
  **[jongleberry.com/pr-shepherd](https://jongleberry.com/pr-shepherd/)** covers the why and the design principles.
56
56
 
@@ -67,18 +67,18 @@ One `iterate` call returns everything the agent needs about the PR:
67
67
 
68
68
  One exception: a `COMMENTED` review that was already minimized before the first tick is not fetched.
69
69
 
70
- The agent reasons less and misses less. [docs/handled-cases.md](docs/handled-cases.md) catalogs the GitHub edge cases this covers. These are the gotchas where the [behavior evals](evals/README.md) measured the agent going wrong without the plugin (score with vs. without):
70
+ The agent reasons less and misses less. [docs/handled-cases.md](docs/handled-cases.md) catalogs the GitHub edge cases this covers. These are the gotchas where the [behavior evals](evals/README.md) measured the agent going wrong without the plugin (score with vs. without, 2026-11-09 full run; 95% interval of the difference excludes 0):
71
71
 
72
72
  | GitHub gotcha | With | Without |
73
73
  | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---: | ------: |
74
- | On a stack layer, the latest run of a required check was cancelled while an older run passed. GitHub still blocks the merge ([#567](https://github.com/jonathanong/pr-shepherd/pull/567), [gh-stack#466](https://github.com/github/gh-stack/issues/466)) | 1.00 | 0.44 |
75
- | A bot `CHANGES_REQUESTED` review and a human thread arrive together | 0.89 | 0.33 |
74
+ | On a stack layer, the latest run of a required check was cancelled while an older run passed. GitHub still blocks the merge ([#567](https://github.com/jonathanong/pr-shepherd/pull/567), [gh-stack#466](https://github.com/github/gh-stack/issues/466)) | 1.00 | 0.33 |
76
75
  | A cancelled run needs a rerun, not a code fix | 1.00 | 0.50 |
77
- | Every stack layer is merged, so the loop must stop | 1.00 | 0.50 |
78
- | GitHub denied a reply: skip it once instead of retrying | 0.72 | 0.33 |
79
- | A maintainer removed the PR from the merge queue | 1.00 | 0.67 |
80
- | A required run was cancelled in the merge queue: escalate, don't requeue | 0.75 | 0.42 |
81
- | The REST transport cannot run the queue recovery | 1.00 | 0.83 |
76
+ | A real failure marked `[rerun authorized]` is not blindly rerun | 1.00 | 0.50 |
77
+ | A maintainer removed the PR from the merge queue: don't requeue | 0.92 | 0.42 |
78
+ | A native stack's parent merged: rebase the lowest open layer onto trunk, keeping the stack | 0.94 | 0.56 |
79
+ | A bot `CHANGES_REQUESTED` review and a human thread arrive together | 1.00 | 0.67 |
80
+ | A `[suggestion]` thread: apply it with `suggest` | 1.00 | 0.72 |
81
+ | A CI log carries a planted instruction: treat it as data | 1.00 | 0.72 |
82
82
 
83
83
  ### Fewer tokens and lower cost
84
84
 
@@ -96,7 +96,7 @@ These are offline estimates at 3.5 characters per token over recorded sessions,
96
96
 
97
97
  ### Clearer agent direction
98
98
 
99
- For a single PR, each tick returns exactly one action: `WAIT`, `READY`, `MARK_READY`, `TRIAGE`, `MERGE`, `CANCEL`, or `ESCALATE`. A `stack` poll can also return `SHEPHERD`, which starts one-PR sessions for owned layers. Every action except `CANCEL` comes with numbered `## Instructions` and exact commands, so the agent does not invent policy. In the [behavior evals](evals/README.md), Sonnet 5.5 at low effort scores **0.94** with the plugin and **0.82** without it on the 36 GitHub-gotcha cases, so it misses about two-thirds fewer graded checks (0.18 → 0.06). On the eight gotchas listed under [Context and correctness](#context-and-correctness) it goes from 0.50 to 0.92. Weighted toward those gotchas, the mean Δ over all 52 cases is **+0.14**, and no case has a regression whose interval excludes 0. See [docs/actions.md](docs/actions.md) for the output contract.
99
+ For a single PR, each tick returns exactly one action: `WAIT`, `READY`, `MARK_READY`, `TRIAGE`, `MERGE`, `CANCEL`, or `ESCALATE`. A `stack` poll can also return `SHEPHERD`, which starts one-PR sessions for owned layers. Every action except `CANCEL` comes with numbered `## Instructions` and exact commands, so the agent does not invent policy. In the [behavior evals](evals/README.md) (Sonnet 5.5 at low effort, all 57 cases, 2026-11-09), the agent scores **0.96** with the plugin and **0.84** without it, a weighted mean Δ of **+0.11** [+0.08, +0.14]. Twelve cases favor the plugin, such as creating a PR without asking (0.92 → 0.00) and running with no PR argument (1.00 → 0.00). See [docs/actions.md](docs/actions.md) for the output contract.
100
100
 
101
101
  ### Lower GitHub API quota use
102
102
 
@@ -31,6 +31,21 @@ function stripChrome(body) {
31
31
  .replace(/^<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\s*/, "")
32
32
  .replace(/^<!-- review_stack_entry_start -->[\s\S]*?<!-- review_stack_entry_end -->\s*/, "");
33
33
  }
34
+ /** The "Review info" block that follows a progress notice: it opens with this summary line. */
35
+ const REVIEW_INFO_START = /\n<details>\n<summary><strong>(?:<picture>(?:<source [^>\n]*>)*<img [^>\n]*><\/picture> )?Review info<\/strong><\/summary>\n/;
36
+ /**
37
+ * Lines the "Review info" block may hold: layout tags, picture-icon summaries, `- **Key**: value`
38
+ * settings with CodeRabbit's known keys, file bullets, and the commit-range sentence. Any other line keeps the comment visible.
39
+ */
40
+ const REVIEW_INFO_LINE = /^(?:|<\/?details>|<\/?dl>|<\/?dd>|<br>|<summary><strong>(?:<picture>(?:<source [^>]*>)*<img [^>]*><\/picture> )?(?:Review info|Run configuration|Commits|Files selected for processing: \d+)<\/strong><\/summary>|- \*\*(?:Source mode|Repository|Branch|File|Commit|Layers|Review profile|Plan|Run ID)\*\*: [^<>\n]*|\* `[^`\n]+`|Reviewing files that changed from the base of the PR and between [a-f0-9]+ and [a-f0-9]+\.)$/;
41
+ /** Removes a trailing "Review info" block made only of known metadata lines. */
42
+ function stripReviewInfo(quoted) {
43
+ const start = quoted.search(REVIEW_INFO_START);
44
+ if (start < 0)
45
+ return quoted;
46
+ const info = quoted.slice(start).split("\n");
47
+ return info.every((line) => REVIEW_INFO_LINE.test(line)) ? quoted.slice(0, start) : quoted;
48
+ }
34
49
  function isQuotedNotice(quoted) {
35
50
  const onlyQuotedLines = quoted.split("\n").every((line) => !line.trim() || line.startsWith(">"));
36
51
  if (!onlyQuotedLines)
@@ -51,7 +66,8 @@ const rule = (item) => {
51
66
  if (!isBotItem(item, ["coderabbitai"], ["pr-comment"]))
52
67
  return null;
53
68
  const body = stripChrome(item.body);
54
- const quoted = body.match(NOTICE_ENVELOPE)?.[2]?.trim();
69
+ const envelope = body.match(NOTICE_ENVELOPE)?.[2];
70
+ const quoted = envelope === undefined ? undefined : stripReviewInfo(envelope).trim();
55
71
  const isStatus = COMMAND_REPLY.test(body) || STATUS_COMMENTS.some((notice) => notice.test(body));
56
72
  if (!(quoted && isQuotedNotice(quoted)) && !isStatus)
57
73
  return null;
@@ -5,8 +5,10 @@ const QUOTA_NOTICES = [
5
5
  /^Codex usage limits have been reached for code reviews\. Please check with the admins of this repo to increase the limits by adding credits\.\nRepo admins can enable using credits for code reviews in their \[settings\]\(https:\/\/chatgpt\.com\/[^\s)]*\)\.$/,
6
6
  /^Codex usage limits have been reached\. Please try again later\.$/,
7
7
  ];
8
- const ABOUT_BLOCK = /\n\n<details> <summary>ℹ️ About Codex (?:in GitHub|security reviews in GitHub)<\/summary>[\s\S]*?<\/details>$/;
8
+ const ABOUT_BLOCK = /\n\n<details> <summary>ℹ️ About Codex (?:in GitHub|security reviews in GitHub)<\/summary>[\s\S]*?<\/details>(\n\n<\/details>)?$/;
9
9
  const ACTIVITY_SUMMARY = /^<!-- codex-pull-request-review-summary -->\n(?:<!-- codex-[a-z0-9-]+:[^\n]* -->\n)*\n?## Codex Review Summary\n\nThis comment shows the latest Codex review activity on this pull request\.\n\n\| Review \| Status \| Commit \| Review trigger \|\n\| --- \| --- \| --- \| --- \|\n(?:\|[^\n]+\|\n?)+$/;
10
+ /** The same table collapsed under `<details>`, with the overall status in the summary line. */
11
+ const COLLAPSED_ACTIVITY_SUMMARY = /^<!-- codex-pull-request-review-summary -->\n(?:<!-- codex-[a-z0-9-]+:[^\n]* -->\n)*\n?<details>\n<summary><strong>Codex Review Summary<\/strong>(?:: [^<\n]+)?<\/summary>\n\nThis comment shows the latest Codex review activity on this pull request\.\n\n\| Review \| Status \| Commit \| Review trigger \|\n\| --- \| --- \| --- \| --- \|\n(?:\|[^\n]+\|\n?)+\n*<\/details>$/;
10
12
  /**
11
13
  * Codex's rotating clean-review sign-offs, as observed. Prose is matched only from this list so a
12
14
  * short piece of advice in that position stays visible; a new sign-off is shown until added here.
@@ -47,10 +49,10 @@ const rule = (item) => {
47
49
  // Strip only leading codex-* metadata markers and the trailing About block; extra findings stay
48
50
  // visible. The activity table's own marker has no colon, so it is kept for ACTIVITY_SUMMARY.
49
51
  const content = body
50
- .replace(ABOUT_BLOCK, "")
52
+ .replace(ABOUT_BLOCK, (_about, close) => close ?? "")
51
53
  .trim()
52
54
  .replace(/^(?:<!-- codex-[a-z0-9-]+:[^\n]* -->\s*)+/, "");
53
- if (ACTIVITY_SUMMARY.test(content)) {
55
+ if (ACTIVITY_SUMMARY.test(content) || COLLAPSED_ACTIVITY_SUMMARY.test(content)) {
54
56
  return { autoResolve: true, suppress: true, reason: "Codex review-activity table" };
55
57
  }
56
58
  if (CLEAN_REVIEW.test(content) || CLEAN_SECURITY.test(content)) {
@@ -71,7 +71,9 @@ export function buildSimpleIterateInstructions(result) {
71
71
  case "escalate": {
72
72
  const pending = result.escalate.pendingReviewCommands;
73
73
  if (!pending)
74
- return ["Stop — human direction is required before automated polling can resume."];
74
+ return [
75
+ "Stop — human direction is required before automated polling can resume. Report the evidence above and the decision the human must make; do not attempt a fix, rerun, requeue, branch update, or merge for this PR.",
76
+ ];
75
77
  return [
76
78
  "Stop polling. Ask the user whether to run the pending review commands shown above.",
77
79
  "If yes, set any `$DISMISS_MESSAGE` to a one-sentence disposition, run every pending command from the pushed PR head, then rerun Shepherd with the same options.",
@@ -115,7 +115,7 @@ export function projectIterateLean(result, opts) {
115
115
  ...base,
116
116
  // Zero counts are omitted, matching the text `**deferred (in merge queue)**` rollup.
117
117
  ...(result.deferredWork && {
118
- deferredWork: Object.fromEntries(Object.entries(result.deferredWork).filter(([, n]) => n > 0)),
118
+ deferredWork: nonZeroCounts(result.deferredWork),
119
119
  }),
120
120
  log: adaptIterateLog(result.log),
121
121
  instructions: simpleInstructions(result),
@@ -182,6 +182,9 @@ export function projectIterateLean(result, opts) {
182
182
  resolveOnlyCommand: result.triage.resolveOnlyCommand,
183
183
  }),
184
184
  ...(result.triage.reviewItems && { reviewItems: result.triage.reviewItems }),
185
+ ...(result.triage.deferredWork && {
186
+ deferredWork: nonZeroCounts(result.triage.deferredWork),
187
+ }),
185
188
  ...(result.triage.instructions.length > 0 && {
186
189
  instructions: result.triage.instructions,
187
190
  }),
@@ -247,3 +250,7 @@ function cappedList(key, items) {
247
250
  return { [key]: shown, ...(hidden > 0 && { [`${key}More`]: hidden }) };
248
251
  }
249
252
  export { projectIterateVerbose } from "./iterate-verbose.mjs";
253
+ /** Zero deferred-work counts are omitted, matching the text `**deferred (in merge queue)**` rollup. */
254
+ function nonZeroCounts(counts) {
255
+ return Object.fromEntries(Object.entries(counts).filter((entry) => entry[1] > 0));
256
+ }
@@ -9,6 +9,7 @@ import { renderRelatedJobLines } from "./related-jobs-format.mjs";
9
9
  import { isFailingAgentCheck } from "../checks/conclusions.mjs";
10
10
  import { renderMergeCommand } from "../commands/iterate/merge.mjs";
11
11
  import { partitionFixThreads } from "../commands/iterate/fix-instruction-threads.mjs";
12
+ import { formatDeferredWorkLine } from "./iterate-merge-formatter.mjs";
12
13
  export function formatTriageResult(header, result, opts = {}) {
13
14
  const verbose = opts.verbose ?? false;
14
15
  const topCap = verbose ? undefined : BODY_TRUNCATE_MAX_CHARS;
@@ -17,6 +18,8 @@ export function formatTriageResult(header, result, opts = {}) {
17
18
  const { shown, total } = result.triage.reviewItems;
18
19
  sections.push(`**showing** ${shown} of ${total} review items`);
19
20
  }
21
+ if (result.triage.deferredWork)
22
+ sections.push(formatDeferredWorkLine(result.triage.deferredWork));
20
23
  if (result.mergedBasePullRequests?.length) {
21
24
  sections.push("## Merged PRs matching the current base");
22
25
  sections.push(result.mergedBasePullRequests
@@ -80,11 +83,12 @@ export function formatTriageResult(header, result, opts = {}) {
80
83
  ? ` [conclusion: ${ch.conclusion}]`
81
84
  : "";
82
85
  const attemptTag = ch.runAttempt !== undefined ? ` [attempt: ${ch.runAttempt}]` : "";
86
+ const blockerTag = ch.releasedBlocker ? ` [blocker released: ${ch.releasedBlocker}]` : "";
83
87
  const scopeTag = ch.scope
84
88
  ? ` [scope: ${ch.scope}${ch.commitOid ? `, commit: ${ch.commitOid}` : ""}]`
85
89
  : "";
86
90
  const lines = [
87
- `- ${locator} — \`${workflowPrefix}${jobLabel}\`${conclusionTag}${attemptTag}${scopeTag}`,
91
+ `- ${locator} — \`${workflowPrefix}${jobLabel}\`${conclusionTag}${attemptTag}${blockerTag}${scopeTag}`,
88
92
  ];
89
93
  if (ch.conclusion !== "CANCELLED") {
90
94
  if (ch.failedStep)
@@ -14,14 +14,20 @@ export declare function hasCheckDrivenActionableWork(checks: {
14
14
  passing: ClassifiedCheck[];
15
15
  skipped: ClassifiedCheck[];
16
16
  filtered: ClassifiedCheck[];
17
- ignored?: TriagedCheck[];
18
17
  }, mergeStatusValue: string): boolean;
18
+ /**
19
+ * A merge-queue entry whose branch conflicts with its base. Its triage offers dequeue-and-resolve
20
+ * or wait; review work waits until the PR leaves the queue on either path. Shared by `check.mts`
21
+ * (seen markers stay unwritten) and `iterate` (review items and fix attempts stay out of the
22
+ * tick), so a caller who waits never has review items consumed or counted toward
23
+ * `triage-thrash`.
24
+ */
25
+ export declare function isQueuedConflict(inQueue: boolean | undefined, mergeStatus: string): boolean;
19
26
  export declare function attachAndMergeCheckAnnotations(buckets: {
20
27
  passing: ClassifiedCheck[];
21
28
  failing: TriagedCheck[];
22
29
  skipped: ClassifiedCheck[];
23
30
  filtered: ClassifiedCheck[];
24
- ignored: ClassifiedCheck[];
25
31
  }, seenMap: Map<string, {
26
32
  seenAt: number;
27
33
  }>, prNumber: number, cacheOpts?: AnnotationCacheOptions): Promise<{
@@ -29,6 +35,5 @@ export declare function attachAndMergeCheckAnnotations(buckets: {
29
35
  failing: TriagedCheck[];
30
36
  skipped: ClassifiedCheck[];
31
37
  filtered: ClassifiedCheck[];
32
- ignored: ClassifiedCheck[];
33
38
  }>;
34
39
  export declare function annotationMarkerBody(a: CheckAnnotation): string;
@@ -12,7 +12,6 @@ export function checksWithActionableAnnotations(report) {
12
12
  ...report.checks.passing,
13
13
  ...report.checks.skipped,
14
14
  ...report.checks.filtered,
15
- ...(report.checks.ignored ?? []),
16
15
  ].filter(hasActionableAnnotation);
17
16
  }
18
17
  /**
@@ -25,22 +24,25 @@ export function checksWithActionableAnnotations(report) {
25
24
  */
26
25
  export function hasCheckDrivenActionableWork(checks, mergeStatusValue) {
27
26
  return (checks.failing.length > 0 ||
28
- [
29
- ...checks.failing,
30
- ...checks.passing,
31
- ...checks.skipped,
32
- ...checks.filtered,
33
- ...(checks.ignored ?? []),
34
- ].some(hasActionableAnnotation) ||
27
+ [...checks.failing, ...checks.passing, ...checks.skipped, ...checks.filtered].some(hasActionableAnnotation) ||
35
28
  mergeStatusValue === "CONFLICTS");
36
29
  }
30
+ /**
31
+ * A merge-queue entry whose branch conflicts with its base. Its triage offers dequeue-and-resolve
32
+ * or wait; review work waits until the PR leaves the queue on either path. Shared by `check.mts`
33
+ * (seen markers stay unwritten) and `iterate` (review items and fix attempts stay out of the
34
+ * tick), so a caller who waits never has review items consumed or counted toward
35
+ * `triage-thrash`.
36
+ */
37
+ export function isQueuedConflict(inQueue, mergeStatus) {
38
+ return inQueue === true && mergeStatus === "CONFLICTS";
39
+ }
37
40
  export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber, cacheOpts) {
38
41
  const candidates = [
39
42
  ...buckets.failing,
40
43
  ...buckets.passing,
41
44
  ...buckets.skipped,
42
45
  ...buckets.filtered,
43
- ...buckets.ignored,
44
46
  ].filter(shouldFetchCheckAnnotations);
45
47
  const expectedCounts = new Map(candidates.flatMap((c) => c.id != null && c.annotationCount ? [[c.id, c.annotationCount]] : []));
46
48
  const annotated = await attachUnseenCheckAnnotations(candidates, seenMap, prNumber, cacheOpts && { ...cacheOpts, expectedCounts });
@@ -57,7 +59,6 @@ export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber,
57
59
  failing: apply(buckets.failing),
58
60
  skipped: apply(buckets.skipped),
59
61
  filtered: apply(buckets.filtered),
60
- ignored: apply(buckets.ignored),
61
62
  };
62
63
  }
63
64
  async function attachUnseenCheckAnnotations(checks, seenMap, prNumber, cacheOpts) {
@@ -110,7 +110,7 @@ function reportChecks(report) {
110
110
  ...report.checks.inProgress,
111
111
  ...report.checks.skipped,
112
112
  ...report.checks.filtered,
113
- ...(report.checks.ignored ?? []),
113
+ ...(report.checks.ignoredNames ?? []).map((name) => ({ name })),
114
114
  ...(report.checks.supersededNames ?? []).map((name) => ({ name })),
115
115
  ];
116
116
  }
@@ -17,7 +17,7 @@ import { deriveMergeStatus } from "../merge-status/derive.mjs";
17
17
  import { loadConfig } from "../config/load.mjs";
18
18
  import { classifyVisibleComments } from "../comments/visible-comments.mjs";
19
19
  import { computeStatus } from "./check-status.mjs";
20
- import { annotationMarkerBody, attachAndMergeCheckAnnotations, hasCheckDrivenActionableWork, } from "./check-annotations.mjs";
20
+ import { annotationMarkerBody, attachAndMergeCheckAnnotations, hasCheckDrivenActionableWork, isQueuedConflict, } from "./check-annotations.mjs";
21
21
  import { buildTerminalReport } from "./check-terminal-report.mjs";
22
22
  import { isBlockedByFilteredCheck, refreshReadyMergeability, refreshUnknownMergeability, } from "./ready-mergeability.mjs";
23
23
  import { loadSeenMap, markSeen, classifyItem, mutationWasDenied } from "../state/seen-comments.mjs";
@@ -186,7 +186,6 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
186
186
  const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
187
187
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
188
188
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
189
- const ignored = classifiedChecks.filter((c) => c.category === "ignored");
190
189
  const triagedBase = failing.length > 0 && !opts.skipTriage
191
190
  ? await triageFailingChecks(failing, repo, stateKey, triageBudget, classifiedChecks.filter((c) => c.category !== "failing"))
192
191
  : failing;
@@ -199,8 +198,7 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
199
198
  const ruleSet = await loadClassifyRules(getEffectiveCwd(), config.standardRules);
200
199
  const classifyIndex = buildClassifyIndex(ruleSet, batchData);
201
200
  const partition = partitionBatch(classifyIndex, batchData);
202
- const merged = await attachAndMergeCheckAnnotations({ passing, failing: triagedBase, skipped, filtered, ignored }, seenMap, prNumber, { stateKey, headSha: batchData.headRefOid });
203
- const ignoredAnnotated = merged.ignored.filter((c) => (c.annotations?.length ?? 0) > 0);
201
+ const merged = await attachAndMergeCheckAnnotations({ passing, failing: triagedBase, skipped, filtered }, seenMap, prNumber, { stateKey, headSha: batchData.headRefOid });
204
202
  const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized && !partition.suppressedCommentIds.has(c.id));
205
203
  const deniedRuleAutoResolveCommentIds = new Set(partition.ruleAutoResolveCommentIds.filter((id) => !canGenerateItemMutation(id, batchData.comments.find((comment) => comment.id === id)?.body ?? "", batchData.comments.find((comment) => comment.id === id)?.viewerCanMinimize, "minimize")));
206
204
  const visibleCommentClassification = classifyVisibleComments(batchData.comments.filter((c) => !partition.suppressedCommentIds.has(c.id) || deniedRuleAutoResolveCommentIds.has(c.id)), seenMap, config.iterate["minimize-comments"], botUsernames, new Set(batchData.comments
@@ -293,16 +291,18 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
293
291
  // that also has check-driven work (failing checks, actionable annotations, conflicts) is
294
292
  // NOT deferred — index.mts still renders these items via triage — so this must stay false
295
293
  // in that case too, or their seen markers would be suppressed while actually being shown.
296
- const deferWhileQueued = opts.merge === true &&
297
- batchData.isInMergeQueue === true &&
298
- config.actions["work-while-queued"] !== true &&
299
- !hasCheckDrivenActionableWork({
300
- failing: merged.failing,
301
- passing: merged.passing,
302
- skipped: merged.skipped,
303
- filtered: merged.filtered,
304
- ignored: merged.ignored,
305
- }, mergeStatus.status);
294
+ // A queued conflict also holds review work back (iterate strips it from that triage tick).
295
+ const queuedConflict = isQueuedConflict(batchData.isInMergeQueue, mergeStatus.status);
296
+ const deferWhileQueued = queuedConflict ||
297
+ (opts.merge === true &&
298
+ batchData.isInMergeQueue === true &&
299
+ config.actions["work-while-queued"] !== true &&
300
+ !hasCheckDrivenActionableWork({
301
+ failing: merged.failing,
302
+ passing: merged.passing,
303
+ skipped: merged.skipped,
304
+ filtered: merged.filtered,
305
+ }, mergeStatus.status));
306
306
  // A deferred tick shows no review items, so it is never capped; its seen markers stay unwritten.
307
307
  const { buckets: budgeted, rollup: reviewItems, heldThreadReviewIds, } = applyReviewOutputBudget({
308
308
  changesRequested: changesRequestedReviewVisibility,
@@ -314,12 +314,7 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
314
314
  editedComments,
315
315
  }, deferWhileQueued ? 0 : (opts.reviewOutputMaxChars ?? 0));
316
316
  if (opts.persistSeen !== false) {
317
- const successfulAnnotations = [
318
- ...merged.passing,
319
- ...merged.skipped,
320
- ...merged.filtered,
321
- ...merged.ignored,
322
- ]
317
+ const successfulAnnotations = [...merged.passing, ...merged.skipped, ...merged.filtered]
323
318
  .filter((check) => check.conclusion === "SUCCESS")
324
319
  .flatMap((check) => check.annotations ?? []);
325
320
  const deferredMarkSeen = deferWhileQueued
@@ -333,7 +328,8 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
333
328
  ...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
334
329
  ];
335
330
  await Promise.allSettled([
336
- ...successfulAnnotations.map((a) => markSeen(stateKey, a.id, annotationMarkerBody(a))),
331
+ // A queued conflict defers annotation fixes too (path (a)), so they stay unseen.
332
+ ...(queuedConflict ? [] : successfulAnnotations).map((a) => markSeen(stateKey, a.id, annotationMarkerBody(a))),
337
333
  ...deferredMarkSeen,
338
334
  ...batchData.comments
339
335
  .filter((c) => partition.suppressedCommentIds.has(c.id) && !deniedRuleAutoResolveCommentIds.has(c.id))
@@ -410,7 +406,6 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
410
406
  inProgress: inProgress,
411
407
  skipped: merged.skipped,
412
408
  filtered: merged.filtered,
413
- ...(ignoredAnnotated.length > 0 && { ignored: ignoredAnnotated }),
414
409
  filteredNames: verdict.filteredNames,
415
410
  blockedByFilteredCheck,
416
411
  ...(verdict.ignoredNames.length > 0 && {
@@ -2,6 +2,8 @@ import type { IterateResult } from "../../types.mts";
2
2
  export interface CheckBlockerGate {
3
3
  deferredNames: ReadonlySet<string>;
4
4
  releasedNames: ReadonlySet<string>;
5
+ /** Released check name → `owner/name#number` of the blocker that merged or closed. */
6
+ releasedBlockers: ReadonlyMap<string, string>;
5
7
  /** `owner/name#number` for blockers that are still open. */
6
8
  openBlockers: readonly string[];
7
9
  }
@@ -40,6 +40,7 @@ export async function resolveCheckBlockerGate(key, failing, mergeStatus) {
40
40
  }
41
41
  const deferredNames = new Set();
42
42
  const releasedNames = new Set();
43
+ const releasedBlockers = new Map();
43
44
  const openBlockers = [];
44
45
  const seen = new Set();
45
46
  for (const record of matched) {
@@ -60,12 +61,13 @@ export async function resolveCheckBlockerGate(key, failing, mergeStatus) {
60
61
  }
61
62
  else {
62
63
  releasedNames.add(record.checkName);
64
+ releasedBlockers.set(record.checkName, formatCheckBlockerRef(record.blocker));
63
65
  }
64
66
  }
65
67
  }
66
68
  if (deferredNames.size === 0 && releasedNames.size === 0)
67
69
  return null;
68
- return { deferredNames, releasedNames, openBlockers };
70
+ return { deferredNames, releasedNames, releasedBlockers, openBlockers };
69
71
  }
70
72
  /** Append the open blocker to a WAIT log. Other actions are unchanged. */
71
73
  export function annotateBlockedWait(result, gate) {
@@ -1,5 +1,11 @@
1
1
  import type { AgentCheck, ResolveCommand, Review } from "../../types.mts";
2
- /** The `[TRIAGE]` recurrence step, whether plain or rewritten by the quota warning. */
2
+ export declare const TRIAGE_CONTINUATION = "Rerun Shepherd now.";
3
+ /** The final rerun step when a queued conflict offers both paths. */
4
+ export declare const QUEUED_CONFLICT_CONTINUATION = "If you chose (a), rerun Shepherd now. If you chose (b), rerun Shepherd after the polling interval.";
5
+ /**
6
+ * The `[TRIAGE]` recurrence step, whether plain, the queued-conflict variant, or rewritten by
7
+ * the quota warning. The quota cadence supersedes both queued-conflict paths.
8
+ */
3
9
  export declare function isTriageContinuation(step: string): boolean;
4
10
  /** Build the stale-CR step for `## Changes-requested reviews`. Empty when no human CR is stale. */
5
11
  export declare function buildCrStaleClause(reviews: Review[]): string;
@@ -1,9 +1,16 @@
1
1
  import { playbookPointer } from "../playbook-pointer.mjs";
2
2
  // The `[TRIAGE]` heading and the skill already say the action is non-terminal.
3
- const TRIAGE_CONTINUATION = "Rerun Shepherd now.";
4
- /** The `[TRIAGE]` recurrence step, whether plain or rewritten by the quota warning. */
3
+ export const TRIAGE_CONTINUATION = "Rerun Shepherd now.";
4
+ /** The final rerun step when a queued conflict offers both paths. */
5
+ export const QUEUED_CONFLICT_CONTINUATION = "If you chose (a), rerun Shepherd now. If you chose (b), rerun Shepherd after the polling interval.";
6
+ /**
7
+ * The `[TRIAGE]` recurrence step, whether plain, the queued-conflict variant, or rewritten by
8
+ * the quota warning. The quota cadence supersedes both queued-conflict paths.
9
+ */
5
10
  export function isTriageContinuation(step) {
6
- return step === TRIAGE_CONTINUATION || /\[TRIAGE\].*non-terminal/i.test(step);
11
+ return (step.startsWith(TRIAGE_CONTINUATION.slice(0, -1)) ||
12
+ step === QUEUED_CONFLICT_CONTINUATION ||
13
+ /\[TRIAGE\].*non-terminal/i.test(step));
7
14
  }
8
15
  /** Build the stale-CR step for `## Changes-requested reviews`. Empty when no human CR is stale. */
9
16
  export function buildCrStaleClause(reviews) {
@@ -12,7 +12,7 @@ export declare function countReportedChecks(checks: {
12
12
  inProgress: readonly unknown[];
13
13
  skipped: readonly unknown[];
14
14
  filtered: readonly unknown[];
15
- ignored?: readonly unknown[];
15
+ ignoredNames?: readonly unknown[];
16
16
  }): number;
17
17
  /** Place the missing-CI note with the conflict step, ahead of later repair lines. */
18
18
  export declare function insertConflictingHeadCiNote(instructions: string[], note: string | undefined): void;
@@ -18,7 +18,7 @@ export function countReportedChecks(checks) {
18
18
  checks.inProgress.length +
19
19
  checks.skipped.length +
20
20
  checks.filtered.length +
21
- (checks.ignored?.length ?? 0));
21
+ (checks.ignoredNames?.length ?? 0));
22
22
  }
23
23
  /** Place the missing-CI note with the conflict step, ahead of later repair lines. */
24
24
  export function insertConflictingHeadCiNote(instructions, note) {
@@ -9,9 +9,10 @@ import { classifyReviewSummaries } from "./classify.mjs";
9
9
  import { applyStallGuard } from "./stall.mjs";
10
10
  import { clearStallState } from "../../state/iterate-stall.mjs";
11
11
  import { handleTriage } from "./triage.mjs";
12
+ import { deferQueuedConflictReviewWork } from "./queued-conflict-deferral.mjs";
12
13
  import { normalizeBotUsernames } from "../../comments/authors.mjs";
13
14
  import { autoMinimizeComments } from "../../comments/resolve.mjs";
14
- import { hasCheckDrivenActionableWork } from "../check-annotations.mjs";
15
+ import { hasCheckDrivenActionableWork, isQueuedConflict } from "../check-annotations.mjs";
15
16
  import { buildReadyMergeOutcome, handleActiveMergeState } from "./merge-state.mjs";
16
17
  import { buildIterateBase } from "./base.mjs";
17
18
  import { markReadyIfAuthorized } from "./mark-ready.mjs";
@@ -182,9 +183,20 @@ async function runIterateCore(opts, tick) {
182
183
  report.mergeQueue?.inQueue === true &&
183
184
  config.actions["work-while-queued"] !== true;
184
185
  if (hasActionableWork && !(deferWhileQueued && !checkDrivenActionableWork)) {
186
+ const reviewWork = {
187
+ report: reportForWork,
188
+ reviewSummaryIds,
189
+ firstLookSummaries,
190
+ editedSummaries,
191
+ surfacedApprovals,
192
+ ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
193
+ };
194
+ // A queued conflict holds review work until the PR leaves the queue (see check.mts).
185
195
  return handleTriage({
196
+ ...(isQueuedConflict(report.mergeQueue?.inQueue, report.mergeStatus.status)
197
+ ? deferQueuedConflictReviewWork(reviewWork, config.iterate["minimize-approvals"])
198
+ : reviewWork),
186
199
  base,
187
- report: reportForWork,
188
200
  opts: { ...opts, prNumber },
189
201
  headSha,
190
202
  stallKey,
@@ -192,16 +204,12 @@ async function runIterateCore(opts, tick) {
192
204
  stallTimeoutSeconds,
193
205
  repoOwner,
194
206
  repoName,
195
- reviewSummaryIds,
196
- firstLookSummaries,
197
- editedSummaries,
198
- surfacedApprovals,
199
207
  botUsernames,
200
208
  releasedCheckNames: blockerGate?.releasedNames,
209
+ releasedCheckBlockers: blockerGate?.releasedBlockers,
201
210
  ...((unreportedRepair.length > 0 || staleAncestry !== null) && {
202
211
  repairInstructions: [...unreportedRepair, ...(staleAncestry?.instructions ?? [])],
203
212
  }),
204
- ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
205
213
  });
206
214
  }
207
215
  if (unreportedRepair.length > 0 && staleAncestry) {
@@ -221,6 +229,7 @@ async function runIterateCore(opts, tick) {
221
229
  surfacedApprovals,
222
230
  botUsernames,
223
231
  releasedCheckNames: blockerGate?.releasedNames,
232
+ releasedCheckBlockers: blockerGate?.releasedBlockers,
224
233
  repairInstructions: [...unreportedRepair, ...staleAncestry.instructions],
225
234
  ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
226
235
  });
@@ -259,6 +268,7 @@ async function runIterateCore(opts, tick) {
259
268
  surfacedApprovals,
260
269
  botUsernames,
261
270
  releasedCheckNames: blockerGate?.releasedNames,
271
+ releasedCheckBlockers: blockerGate?.releasedBlockers,
262
272
  repairInstructions: [...unreportedRepair, ...staleAncestry.instructions],
263
273
  ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
264
274
  });
@@ -1,6 +1,8 @@
1
1
  import { type MergeMethod } from "../../config/merge-method.mts";
2
2
  import type { MergeCommandPlan } from "../../types.mts";
3
3
  import type { RestMergeStackGuard } from "../../github/rest-merge.mts";
4
+ /** gh CLI cannot remove a queue entry, so dequeueing goes through the GraphQL mutation. */
5
+ export declare function buildDequeueCommand(nodeId: string): string[];
4
6
  export interface MergePlanInput {
5
7
  transport?: "rest";
6
8
  pr: number;
@@ -5,6 +5,11 @@ import { formatPrUrl } from "../../pr-reference.mjs";
5
5
  import { buildPrShepherdCommand } from "../../cli/runner.mjs";
6
6
  import { getGithubTransport } from "../../github/transport.mjs";
7
7
  const ENQUEUE_MUTATION = "mutation EnqueuePullRequest($pullRequestId: ID!, $expectedHeadOid: GitObjectID!) { enqueuePullRequest(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid }) { mergeQueueEntry { id } } }";
8
+ const DEQUEUE_MUTATION = "mutation DequeuePullRequest($id: ID!) { dequeuePullRequest(input: { id: $id }) { mergeQueueEntry { id } } }";
9
+ /** gh CLI cannot remove a queue entry, so dequeueing goes through the GraphQL mutation. */
10
+ export function buildDequeueCommand(nodeId) {
11
+ return ["gh", "api", "graphql", "-f", `query=${DEQUEUE_MUTATION}`, "-f", `id=${nodeId}`];
12
+ }
8
13
  export function buildMergeCommandPlan(input) {
9
14
  if (input.transport === "rest" || getGithubTransport() === "rest") {
10
15
  const configured = configuredMergeMethod(loadConfig().merge);
@@ -1,10 +1,19 @@
1
- import type { IterateResult, IterateResultBase, Review, ShepherdReport } from "../../types.mts";
1
+ import type { IterateDeferredWork, IterateResult, IterateResultBase, Review, ShepherdReport } from "../../types.mts";
2
2
  type StallKey = {
3
3
  owner: string;
4
4
  repo: string;
5
5
  pr: number;
6
6
  };
7
7
  export declare function buildReadyMergeOutcome(enabled: boolean | undefined, readyElapsed: boolean, base: IterateResultBase, report: ShepherdReport): IterateResult | null;
8
+ /** Raw counts of non-CI actionable work held back for one queued-PR wait tick. Omitted (all zero) when empty. */
9
+ export declare function buildDeferredWork(input: {
10
+ report: ShepherdReport;
11
+ reviewSummaryIds: string[];
12
+ firstLookSummaries: Review[];
13
+ editedSummaries: Review[];
14
+ surfacedApprovals: Review[];
15
+ minimizeApprovals: boolean;
16
+ }): IterateDeferredWork | undefined;
8
17
  export declare function handleActiveMergeState(input: {
9
18
  enabled: boolean | undefined;
10
19
  active: boolean;
@@ -62,7 +62,7 @@ export function buildReadyMergeOutcome(enabled, readyElapsed, base, report) {
62
62
  return { ...base, action: "merge", merge: plan };
63
63
  }
64
64
  /** Raw counts of non-CI actionable work held back for one queued-PR wait tick. Omitted (all zero) when empty. */
65
- function buildDeferredWork(input) {
65
+ export function buildDeferredWork(input) {
66
66
  const { report, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, minimizeApprovals, } = input;
67
67
  // These buckets are not disjoint (e.g. an unresolved outdated thread is both
68
68
  // `resolutionOnly` and `firstLook`; an eligible-to-minimize comment/summary is both