pr-shepherd 1.1.2 → 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 (49) 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 -0
  9. package/bin/commands/check-annotations.mjs +10 -0
  10. package/bin/commands/check.mjs +15 -11
  11. package/bin/commands/iterate/check-blocker-gate.d.mts +2 -0
  12. package/bin/commands/iterate/check-blocker-gate.mjs +3 -1
  13. package/bin/commands/iterate/check-instructions.d.mts +7 -1
  14. package/bin/commands/iterate/check-instructions.mjs +10 -3
  15. package/bin/commands/iterate/index.mjs +17 -7
  16. package/bin/commands/iterate/merge-plan.d.mts +2 -0
  17. package/bin/commands/iterate/merge-plan.mjs +5 -0
  18. package/bin/commands/iterate/merge-state.d.mts +10 -1
  19. package/bin/commands/iterate/merge-state.mjs +1 -1
  20. package/bin/commands/iterate/native-stack-rebase.d.mts +13 -2
  21. package/bin/commands/iterate/native-stack-rebase.mjs +23 -3
  22. package/bin/commands/iterate/queued-conflict-deferral.d.mts +15 -0
  23. package/bin/commands/iterate/queued-conflict-deferral.mjs +20 -0
  24. package/bin/commands/iterate/render.d.mts +2 -1
  25. package/bin/commands/iterate/render.mjs +18 -12
  26. package/bin/commands/iterate/rerun-command.d.mts +7 -0
  27. package/bin/commands/iterate/rerun-command.mjs +22 -0
  28. package/bin/commands/iterate/run.mjs +2 -1
  29. package/bin/commands/iterate/triage.d.mts +5 -1
  30. package/bin/commands/iterate/triage.mjs +32 -11
  31. package/bin/commands/poll-summary-explicit-instructions.mjs +3 -0
  32. package/bin/commands/poll-summary-instructions.mjs +1 -1
  33. package/bin/comments/resolve.d.mts +2 -0
  34. package/bin/comments/resolve.mjs +7 -0
  35. package/bin/comments/rest-review-mutations.d.mts +1 -0
  36. package/bin/comments/rest-review-mutations.mjs +3 -1
  37. package/bin/types/iterate.d.mts +2 -0
  38. package/bin/types/report.d.mts +2 -0
  39. package/package.json +2 -2
  40. package/plugins/pr-shepherd/.claude-plugin/icon.png +0 -0
  41. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  42. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  43. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  44. package/plugins/pr-shepherd/.mcp.json +1 -1
  45. package/plugins/pr-shepherd/package-lock.json +1278 -0
  46. package/plugins/pr-shepherd/package.json +9 -0
  47. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +5 -4
  48. package/plugins/pr-shepherd/skills/pr-shepherd/references/branch-update.md +1 -1
  49. 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.2",
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)
@@ -15,6 +15,14 @@ export declare function hasCheckDrivenActionableWork(checks: {
15
15
  skipped: ClassifiedCheck[];
16
16
  filtered: ClassifiedCheck[];
17
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;
18
26
  export declare function attachAndMergeCheckAnnotations(buckets: {
19
27
  passing: ClassifiedCheck[];
20
28
  failing: TriagedCheck[];
@@ -27,6 +27,16 @@ export function hasCheckDrivenActionableWork(checks, mergeStatusValue) {
27
27
  [...checks.failing, ...checks.passing, ...checks.skipped, ...checks.filtered].some(hasActionableAnnotation) ||
28
28
  mergeStatusValue === "CONFLICTS");
29
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
+ }
30
40
  export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber, cacheOpts) {
31
41
  const candidates = [
32
42
  ...buckets.failing,
@@ -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";
@@ -291,15 +291,18 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
291
291
  // that also has check-driven work (failing checks, actionable annotations, conflicts) is
292
292
  // NOT deferred — index.mts still renders these items via triage — so this must stay false
293
293
  // in that case too, or their seen markers would be suppressed while actually being shown.
294
- const deferWhileQueued = opts.merge === true &&
295
- batchData.isInMergeQueue === true &&
296
- config.actions["work-while-queued"] !== true &&
297
- !hasCheckDrivenActionableWork({
298
- failing: merged.failing,
299
- passing: merged.passing,
300
- skipped: merged.skipped,
301
- filtered: merged.filtered,
302
- }, 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));
303
306
  // A deferred tick shows no review items, so it is never capped; its seen markers stay unwritten.
304
307
  const { buckets: budgeted, rollup: reviewItems, heldThreadReviewIds, } = applyReviewOutputBudget({
305
308
  changesRequested: changesRequestedReviewVisibility,
@@ -325,7 +328,8 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
325
328
  ...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
326
329
  ];
327
330
  await Promise.allSettled([
328
- ...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))),
329
333
  ...deferredMarkSeen,
330
334
  ...batchData.comments
331
335
  .filter((c) => partition.suppressedCommentIds.has(c.id) && !deniedRuleAutoResolveCommentIds.has(c.id))
@@ -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) {
@@ -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
@@ -35,8 +35,19 @@ export declare function buildNativeStackLayerRebase(repo: string, pr: {
35
35
  trunk: string;
36
36
  bottomPr?: number;
37
37
  }, stackBottomPr?: number): string | undefined;
38
- /** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
39
- export declare function buildConflictInstruction(stackRebase: string | undefined): string;
38
+ /**
39
+ * Point at the conflicts, and at the stack rebase when the PR is a native stack layer. A queued
40
+ * conflicting entry cannot form a merge group, so GitHub removes it once the queue reaches it.
41
+ * Pulling it out early (dequeue, then resolve) or waiting for that removal is the caller's
42
+ * call, so a queued PR gets both paths without a recommendation. `dequeueCommand` is the
43
+ * rendered dequeue mutation; it is present only while the PR is a queue entry.
44
+ */
45
+ export declare function buildConflictInstruction(stackRebase: string | undefined, dequeueCommand?: string): string;
46
+ /**
47
+ * The two paths for a conflicting PR that is still a queue entry. `resolve` is the branch
48
+ * update route; on a native stack, dequeueing a layer can also take queued layers above it out.
49
+ */
50
+ export declare function buildQueuedConflictChoice(dequeueCommand: string, resolve: string, stack: boolean): string;
40
51
  /**
41
52
  * Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack.
42
53
  * After a merge-queue ejection update, push only when the update or a fix changed the head.
@@ -54,10 +54,30 @@ export function buildNativeStackLayerRebase(repo, pr, stack, trunkConflict, stac
54
54
  : { parentBranch: pr.baseBranch };
55
55
  return buildNativeStackRebaseInstruction(repo, stack.number, start);
56
56
  }
57
- /** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
58
- export function buildConflictInstruction(stackRebase) {
57
+ /**
58
+ * Point at the conflicts, and at the stack rebase when the PR is a native stack layer. A queued
59
+ * conflicting entry cannot form a merge group, so GitHub removes it once the queue reaches it.
60
+ * Pulling it out early (dequeue, then resolve) or waiting for that removal is the caller's
61
+ * call, so a queued PR gets both paths without a recommendation. `dequeueCommand` is the
62
+ * rendered dequeue mutation; it is present only while the PR is a queue entry.
63
+ */
64
+ export function buildConflictInstruction(stackRebase, dequeueCommand) {
59
65
  const pointer = "The branch has merge conflicts (see `**branch**` above).";
60
- return stackRebase ? `${pointer} ${stackRebase}` : `${pointer} Resolve them before committing.`;
66
+ const resolve = stackRebase ?? "Resolve them before committing.";
67
+ if (!dequeueCommand)
68
+ return `${pointer} ${resolve}`;
69
+ return `${pointer} ${buildQueuedConflictChoice(dequeueCommand, resolve, stackRebase !== undefined)}`;
70
+ }
71
+ /**
72
+ * The two paths for a conflicting PR that is still a queue entry. `resolve` is the branch
73
+ * update route; on a native stack, dequeueing a layer can also take queued layers above it out.
74
+ */
75
+ export function buildQueuedConflictChoice(dequeueCommand, resolve, stack) {
76
+ const leaves = stack
77
+ ? "this PR leaves the queue, and so may any queued layers above it in this stack; other entries stay queued"
78
+ : "only this PR leaves the queue and the other entries stay queued";
79
+ const requeue = stack ? "enqueue it and any removed layers again" : "enqueue it again";
80
+ return `The PR is still in the merge queue, but GitHub cannot merge it until the conflicts are resolved. Choose one path: (a) pull it from the queue and resolve now — first run \`${dequeueCommand}\` (\`gh pr\` cannot remove a queue entry); ${leaves}; then: ${resolve} Once the PR is ready again, ${requeue} (with \`--merge\`, Shepherd prints the enqueue command). Or (b) wait — skip the dequeue and every step marked \`If you chose (a):\`, and rerun Shepherd after the polling interval instead of immediately, until GitHub removes the entry itself; this step repeats until then, and an unchanged conflict escalates through \`stall-timeout\`. On either path, review items stay deferred until the PR leaves the queue.`;
61
81
  }
62
82
  /**
63
83
  * Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack.
@@ -0,0 +1,15 @@
1
+ import type { IterateDeferredWork, Review, ShepherdReport } from "../../types.mts";
2
+ /** Review work for one triage tick; see `isQueuedConflict` in check-annotations.mts. */
3
+ interface ReviewWork {
4
+ report: ShepherdReport;
5
+ reviewSummaryIds: string[];
6
+ firstLookSummaries: Review[];
7
+ editedSummaries: Review[];
8
+ surfacedApprovals: Review[];
9
+ ruleAutoResolveThreadIds?: string[];
10
+ }
11
+ /** Strip review work from a queued-conflict tick and count what was held back. */
12
+ export declare function deferQueuedConflictReviewWork(work: ReviewWork, minimizeApprovals: boolean): Omit<ReviewWork, "ruleAutoResolveThreadIds"> & {
13
+ deferredWork?: IterateDeferredWork;
14
+ };
15
+ export {};
@@ -0,0 +1,20 @@
1
+ import { buildDeferredWork } from "./merge-state.mjs";
2
+ /** Strip review work from a queued-conflict tick and count what was held back. */
3
+ export function deferQueuedConflictReviewWork(work, minimizeApprovals) {
4
+ const deferredWork = buildDeferredWork({ ...work, minimizeApprovals });
5
+ const { reviewItems: _held, ...report } = work.report;
6
+ const { ruleAutoResolveIds: _rule, ...threads } = report.threads;
7
+ return {
8
+ report: {
9
+ ...report,
10
+ threads: { ...threads, actionable: [], resolutionOnly: [], firstLook: [] },
11
+ comments: { ...report.comments, actionable: [], minimizeIds: [], firstLook: [] },
12
+ changesRequestedReviews: [],
13
+ },
14
+ reviewSummaryIds: [],
15
+ firstLookSummaries: [],
16
+ editedSummaries: [],
17
+ surfacedApprovals: [],
18
+ ...(deferredWork && { deferredWork }),
19
+ };
20
+ }
@@ -10,4 +10,5 @@ export declare function renderResolveCommand(rc: ResolveCommand): string;
10
10
  export declare function buildFixInstructions(threads: AgentThread[], actionableComments: AgentComment[], checks: AgentCheck[], changesRequestedReviews: Review[], baseBranch: string, resolveCommand: ResolveCommand, hasConflicts: boolean, prReference: string | number, firstLookThreads?: FirstLookThread[], firstLookComments?: FirstLookComment[], firstLookSummaries?: Review[], editedSummaries?: Review[], resolutionOnlyThreads?: ReviewThread[], resolveOnlyCommand?: ResolveCommand, behindBaseHint?: string, // iterate.behind-base-hint — see buildBehindBaseHintInstruction
11
11
  isBehind?: boolean, _viewerCanUpdate?: boolean, hasExhaustedWorkflowRerun?: boolean, stackRebase?: string, // native stack layers rebase with gh-stack, not branch by branch
12
12
  queueEjection?: QueueEjectionRecovery, // current merge-queue removal with failing queue checks
13
- instructionStyle?: InstructionStyle): string[];
13
+ instructionStyle?: InstructionStyle, // see iterate.instructions
14
+ dequeueCommand?: string): string[];