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
@@ -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[];
@@ -1,9 +1,9 @@
1
1
  import { buildPrShepherdCommand, renderShellCommand } from "../../cli/runner.mjs";
2
- import { buildFailingCheckInstructions, buildCrStaleClause, buildBehindBaseHintInstruction, buildRepeatedWorkflowBranchRecoveryInstructions, buildResolveCommandInstruction, buildFixCompletionInstruction, } from "./check-instructions.mjs";
2
+ import { buildFailingCheckInstructions, buildCrStaleClause, buildBehindBaseHintInstruction, buildRepeatedWorkflowBranchRecoveryInstructions, buildResolveCommandInstruction, buildFixCompletionInstruction, QUEUED_CONFLICT_CONTINUATION, } from "./check-instructions.mjs";
3
3
  import { isFailingAgentCheck } from "../../checks/conclusions.mjs";
4
4
  import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
5
5
  import { partitionFixThreads } from "./fix-instruction-threads.mjs";
6
- import { buildBranchPushInstruction, buildConflictInstruction } from "./native-stack-rebase.mjs";
6
+ import { buildBranchPushInstruction, buildConflictInstruction, buildQueuedConflictChoice, } from "./native-stack-rebase.mjs";
7
7
  import { queueEjectionSteps } from "./queue-recovery-instructions.mjs";
8
8
  import { buildPushSteps } from "./triage-loop-instruction.mjs";
9
9
  /**
@@ -19,7 +19,8 @@ const REQUIRE_HEAD_SHA = "--require-sha HEAD";
19
19
  export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prReference, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behind-base-hint — see buildBehindBaseHintInstruction
20
20
  isBehind = false, _viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, stackRebase, // native stack layers rebase with gh-stack, not branch by branch
21
21
  queueEjection, // current merge-queue removal with failing queue checks
22
- instructionStyle = "inline") {
22
+ instructionStyle = "inline", // see iterate.instructions
23
+ dequeueCommand) {
23
24
  const instructions = [];
24
25
  const { locatedThreads, unlocatedThreads } = partitionFixThreads(threads, resolveCommand, resolveOnlyCommand);
25
26
  const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
@@ -32,18 +33,23 @@ instructionStyle = "inline") {
32
33
  changesRequestedReviews.length > 0 ||
33
34
  actionableComments.length > 0;
34
35
  // Start with interpretation. The agent decides what raw feedback warrants a code change.
36
+ // A queued conflict offers a wait path that publishes nothing, so fixes, branch updates,
37
+ // pushes, and review mutations (which must follow a push) become path (a).
38
+ const pathA = (step) => (dequeueCommand ? `If you chose (a): ${step}` : step);
35
39
  if (hasNonConflictHints)
36
- instructions.push("Fix each warranted item.");
40
+ instructions.push(pathA("Fix each warranted item."));
37
41
  // A conflicting native stack layer follows the printed gh-stack route, so it omits the hint.
38
42
  const branchUpdateHint = buildBehindBaseHintInstruction(baseBranch, behindBaseHint, {
39
43
  isBehind,
40
44
  hasConflicts: hasConflicts && !stackRebase,
41
- });
45
+ }).map(pathA);
42
46
  // The conflict hint belongs with the conflict step; otherwise it precedes the push step.
43
47
  const hintWithConflictStep = hasConflicts && !hasRepeatedWorkflowBranchRecovery;
44
48
  const branchRecovery = hasConflicts || hasRepeatedWorkflowBranchRecovery;
45
49
  if (hintWithConflictStep)
46
- instructions.push(buildConflictInstruction(stackRebase), ...branchUpdateHint);
50
+ instructions.push(buildConflictInstruction(stackRebase, dequeueCommand), ...branchUpdateHint);
51
+ else if (dequeueCommand && hasRepeatedWorkflowBranchRecovery)
52
+ instructions.push(`The branch has merge conflicts (see \`**branch**\` above). ${buildQueuedConflictChoice(dequeueCommand, "Follow the steps marked `If you chose (a):` below.", stackRebase !== undefined)}`);
47
53
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
48
54
  if (firstLookTotal > 0)
49
55
  instructions.push("Review every item under `## First-look items` before acting.");
@@ -56,12 +62,12 @@ instructionStyle = "inline") {
56
62
  instructions.push("Acknowledge each item under `## Unlocated review threads (logged once — no mutation)`. Shepherd cannot route a code fix or review mutation without a path and line; the unchanged item will be skipped on later ticks.");
57
63
  const hasSuggestions = locatedThreads.some((t) => t.suggestion);
58
64
  if (hasSuggestions)
59
- instructions.push(buildCommitSuggestionInstruction(prReference, "## Review threads"));
65
+ instructions.push(pathA(buildCommitSuggestionInstruction(prReference, "## Review threads")));
60
66
  if (resolutionOnlyThreads.length > 0)
61
67
  instructions.push("Review the threads under `## Review threads to resolve` before running the generated mutations.");
62
- instructions.push(...buildFailingCheckInstructions(failingChecks), ...repeatedWorkflowBranchRecoveryInstructions, ...queueEjectionSteps(queueEjection, stackRebase, branchRecovery));
68
+ instructions.push(...buildFailingCheckInstructions(failingChecks), ...repeatedWorkflowBranchRecoveryInstructions.map(pathA), ...queueEjectionSteps(queueEjection, stackRebase, branchRecovery));
63
69
  if (hasAnnotations)
64
- instructions.push("Inspect every referenced range under `## Check annotations` and apply any warranted change.");
70
+ instructions.push(pathA("Inspect every referenced range under `## Check annotations` and apply any warranted change."));
65
71
  // The first step already covers applying changes-requested bodies; only stale CRs add a step.
66
72
  const staleClause = buildCrStaleClause(changesRequestedReviews);
67
73
  if (staleClause)
@@ -83,11 +89,11 @@ instructionStyle = "inline") {
83
89
  const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
84
90
  const genericPush = !(branchRecovery || queueEjection) && hasNonConflictHints;
85
91
  if (branchRecovery || queueEjection) {
86
- instructions.push(buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix, !branchRecovery));
92
+ instructions.push(pathA(buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix, !branchRecovery)));
87
93
  }
88
94
  instructions.push(...buildPushSteps(instructionStyle, genericPush));
89
95
  if (resolveOnlyCommand?.hasMutations)
90
- instructions.push(`Run: \`${renderResolveCommand(resolveOnlyCommand)}\``);
91
- instructions.push(...buildResolveCommandInstruction(resolveCommand, renderResolveCommand(resolveCommand)), buildFixCompletionInstruction());
96
+ instructions.push(pathA(`Run: \`${renderResolveCommand(resolveOnlyCommand)}\``));
97
+ instructions.push(...buildResolveCommandInstruction(resolveCommand, renderResolveCommand(resolveCommand)).map(pathA), dequeueCommand ? QUEUED_CONFLICT_CONTINUATION : buildFixCompletionInstruction());
92
98
  return instructions;
93
99
  }
@@ -0,0 +1,7 @@
1
+ import type { IterateResult } from "../../types.mts";
2
+ /**
3
+ * When the caller passed a qualified PR, name it in the plain `[TRIAGE]` recurrence step, so the
4
+ * caller reruns the same target instead of shortening it to a bare number that resolves in the
5
+ * local checkout. A bare target already reruns correctly. The caller owns the mode and flags.
6
+ */
7
+ export declare function withTriageRerunTarget(result: IterateResult, qualified: boolean): IterateResult;
@@ -0,0 +1,22 @@
1
+ import { formatPrUrl } from "../../pr-reference.mjs";
2
+ import { TRIAGE_CONTINUATION } from "./check-instructions.mjs";
3
+ /**
4
+ * When the caller passed a qualified PR, name it in the plain `[TRIAGE]` recurrence step, so the
5
+ * caller reruns the same target instead of shortening it to a bare number that resolves in the
6
+ * local checkout. A bare target already reruns correctly. The caller owns the mode and flags.
7
+ */
8
+ export function withTriageRerunTarget(result, qualified) {
9
+ if (!qualified || result.action !== "triage")
10
+ return result;
11
+ const instructions = result.triage.instructions;
12
+ if (instructions.at(-1) !== TRIAGE_CONTINUATION)
13
+ return result;
14
+ const target = formatPrUrl(result.repo, result.pr);
15
+ return {
16
+ ...result,
17
+ triage: {
18
+ ...result.triage,
19
+ instructions: [...instructions.slice(0, -1), `Rerun Shepherd now on \`${target}\`.`],
20
+ },
21
+ };
22
+ }
@@ -1,8 +1,9 @@
1
1
  import { withApiTelemetryScope } from "../../github/api-telemetry.mjs";
2
2
  import { attachApiUsage } from "./api-usage.mjs";
3
+ import { withTriageRerunTarget } from "./rerun-command.mjs";
3
4
  export function withIterateApiUsage(opts, runCore) {
4
5
  return withApiTelemetryScope(async () => {
5
6
  const result = await runCore();
6
- return attachApiUsage(result, opts.deferQuotaWarning !== true, false, opts.quotaWarningMinimumPollIntervalMinutes);
7
+ return attachApiUsage(withTriageRerunTarget(result, opts.targetRepository !== undefined), opts.deferQuotaWarning !== true, false, opts.quotaWarningMinimumPollIntervalMinutes);
7
8
  });
8
9
  }
@@ -1,4 +1,4 @@
1
- import type { IterateCommandOptions, IterateResult, IterateResultBase, Review, ShepherdReport } from "../../types.mts";
1
+ import type { IterateCommandOptions, IterateDeferredWork, IterateResult, IterateResultBase, Review, ShepherdReport } from "../../types.mts";
2
2
  import type { NormalizedBotUsernames } from "../../comments/authors.mts";
3
3
  interface HandleTriageContext {
4
4
  base: IterateResultBase;
@@ -21,9 +21,13 @@ interface HandleTriageContext {
21
21
  botUsernames: NormalizedBotUsernames;
22
22
  /** Failing checks whose external blocker has merged or closed. */
23
23
  releasedCheckNames?: ReadonlySet<string>;
24
+ /** Released check name → the blocker that merged or closed, shown on the check. */
25
+ releasedCheckBlockers?: ReadonlyMap<string, string>;
24
26
  ruleAutoResolveThreadIds?: string[];
25
27
  /** Verified stack-repair guidance, when ancestry is stale. */
26
28
  repairInstructions?: string[];
29
+ /** Review work a queued conflict holds back until the PR leaves the queue. */
30
+ deferredWork?: IterateDeferredWork;
27
31
  }
28
32
  export declare function handleTriage(ctx: HandleTriageContext): Promise<IterateResult>;
29
33
  export {};
@@ -11,12 +11,14 @@ import { hasLogEvidence } from "./check-evidence.mjs";
11
11
  import { currentEjectionCommit, } from "./queue-recovery-instructions.mjs";
12
12
  import { buildReleasedBlockerInstruction, buildReviewSubsetInstruction, } from "./check-instructions.mjs";
13
13
  import { buildNativeStackLayerRebase } from "./native-stack-rebase.mjs";
14
+ import { buildDequeueCommand } from "./merge-plan.mjs";
15
+ import { renderShellCommand } from "../../cli/runner.mjs";
14
16
  import { lookupUpperLayerTrunkConflict } from "./stack-trunk-conflict.mjs";
15
17
  import { lookupMergedBasePullRequests } from "./merged-base-pull-requests.mjs";
16
18
  import { conflictingHeadCiNote, countReportedChecks, insertConflictingHeadCiNote, } from "./conflicting-head-ci.mjs";
17
19
  import { conflictingHeadFirstSeenUnix } from "../../state/conflicting-head-seen.mjs";
18
20
  import { applyStallGuard } from "./stall.mjs";
19
- import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
21
+ import { annotationMarkerBody, checksWithActionableAnnotations, isQueuedConflict, } from "../check-annotations.mjs";
20
22
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
21
23
  import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
22
24
  import { canRerunWorkflows } from "../../checks/conclusions.mjs";
@@ -86,7 +88,7 @@ export async function handleTriage(ctx) {
86
88
  return { ...result, escalate: { ...result.escalate, reviewItems } };
87
89
  }
88
90
  async function handleTriageTick(ctx) {
89
- const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, releasedCheckNames = EMPTY_RELEASED, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
91
+ const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, releasedCheckNames = EMPTY_RELEASED, releasedCheckBlockers, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
90
92
  const prReference = formatPrUrl(report.repo, prNumber);
91
93
  const failingChecks = report.checks.failing;
92
94
  const annotatedExtra = checksWithActionableAnnotations(report).filter((c) => c.category !== "failing");
@@ -178,8 +180,10 @@ async function handleTriageTick(ctx) {
178
180
  : []));
179
181
  const initialAttemptRunIds = new Set(failingChecks.flatMap((c) => (c.runId !== null && c.runAttempt === 1 ? [c.runId] : [])));
180
182
  const failingAgentChecks = toAgentChecks(failingChecks).map((c) => {
181
- if (releasedCheckNames.has(c.name))
182
- return c;
183
+ if (releasedCheckNames.has(c.name)) {
184
+ const releasedBlocker = releasedCheckBlockers?.get(c.name);
185
+ return releasedBlocker ? { ...c, releasedBlocker } : c;
186
+ }
183
187
  return rerunAuthorized &&
184
188
  // Rerunning queue CI cannot restore a removed entry and overwrites its failure evidence.
185
189
  c.scope !== "merge_group" &&
@@ -201,6 +205,7 @@ async function handleTriageTick(ctx) {
201
205
  ];
202
206
  const { changesRequestedReviews } = report;
203
207
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
208
+ const queuedConflict = isQueuedConflict(report.mergeQueue?.inQueue, report.mergeStatus.status);
204
209
  const isBehind = report.mergeStatus.status === "BEHIND";
205
210
  const { "behind-base-hint": behindBaseHint, instructions: configuredInstructions } = loadConfig().iterate;
206
211
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
@@ -340,14 +345,26 @@ async function handleTriageTick(ctx) {
340
345
  const stackRebase = hasConflicts || (isBehind && exhaustedAttempts.length > 0) || queueEjection
341
346
  ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, stack, trunkConflict, report.stackBottomPr)
342
347
  : undefined;
343
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, canGenerateGithubMutation(report.viewerAuthorization?.viewerCanUpdate, "ready"), exhaustedAttempts.length > 0, stackRebase, queueEjection, opts.instructions ?? configuredInstructions);
348
+ const dequeueCommand = queuedConflict
349
+ ? renderShellCommand(buildDequeueCommand(report.nodeId))
350
+ : undefined;
351
+ const instructions = buildFixInstructions(threads, actionableComments, checks.filter((check) => !releasedCheckNames.has(check.name)), changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, canGenerateGithubMutation(report.viewerAuthorization?.viewerCanUpdate, "ready"), exhaustedAttempts.length > 0, stackRebase, queueEjection, opts.instructions ?? configuredInstructions, dequeueCommand);
344
352
  if (recovery && "unavailable" in recovery)
345
353
  instructions.unshift(recovery.unavailable);
346
354
  if (acknowledgmentRecovery && "unavailable" in acknowledgmentRecovery)
347
355
  instructions.unshift(acknowledgmentRecovery.unavailable);
356
+ // A queued conflict offers a wait path, so every later branch-mutating step is path (a), and
357
+ // leading repair steps follow the dequeue-or-wait choice instead of preceding it.
358
+ const pathA = (step) => (dequeueCommand ? `If you chose (a): ${step}` : step);
359
+ const prependBranchSteps = (steps) => {
360
+ if (!dequeueCommand)
361
+ return void instructions.unshift(...steps);
362
+ const choice = instructions.findIndex((step) => step.includes(dequeueCommand));
363
+ instructions.splice(choice + 1, 0, ...steps.map(pathA));
364
+ };
348
365
  if (failingAgentChecks.some((check) => releasedCheckNames.has(check.name))) {
349
366
  const completion = instructions.pop();
350
- instructions.push(buildReleasedBlockerInstruction(prNumber));
367
+ instructions.push(pathA(buildReleasedBlockerInstruction(prNumber)));
351
368
  if (completion !== undefined)
352
369
  instructions.push(completion);
353
370
  }
@@ -357,15 +374,16 @@ async function handleTriageTick(ctx) {
357
374
  if (completion !== undefined)
358
375
  instructions.push(completion);
359
376
  }
360
- if (repairInstructions && repairInstructions.length > 0) {
361
- instructions.unshift(...repairInstructions);
362
- }
377
+ if (repairInstructions && repairInstructions.length > 0)
378
+ prependBranchSteps(repairInstructions);
363
379
  if (mergedBasePullRequests.length > 0) {
364
380
  // `gh pr edit` goes through GraphQL; REST callers retarget with the pulls PATCH endpoint.
365
381
  const retarget = report.transport === "rest"
366
382
  ? `gh api --method PATCH repos/${report.repo}/pulls/${prNumber} -f base=<verified-parent-base>`
367
383
  : `gh pr edit ${prReference} --base <verified-parent-base>`;
368
- instructions.unshift(`Inspect every PR under \`## Merged PRs matching the current base\`. If this PR is the remaining layer intended for a merged parent's base branch, run \`${retarget}\` after replacing \`<verified-parent-base>\` with that parent's shell-quoted base branch, then rerun Shepherd immediately and follow its fresh instructions instead of the remaining steps here. Otherwise keep the current base and follow the remaining conflict-resolution steps.`);
384
+ prependBranchSteps([
385
+ `Inspect every PR under \`## Merged PRs matching the current base\`. If this PR is the remaining layer intended for a merged parent's base branch, run \`${retarget}\` after replacing \`<verified-parent-base>\` with that parent's shell-quoted base branch, then rerun Shepherd immediately and follow its fresh instructions instead of the remaining steps here. Otherwise keep the current base and follow the remaining conflict-resolution steps.`,
386
+ ]);
369
387
  }
370
388
  const checkRunCount = countReportedChecks(report.checks);
371
389
  const suitesEmpty = report.headCheckSuitesEmpty === true;
@@ -404,12 +422,15 @@ async function handleTriageTick(ctx) {
404
422
  firstLookThreads,
405
423
  firstLookComments,
406
424
  ...(report.reviewItems && { reviewItems: report.reviewItems }),
425
+ ...(ctx.deferredWork && { deferredWork: ctx.deferredWork }),
407
426
  },
408
427
  };
409
428
  const result = await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds);
410
429
  if (result.action === "triage" && opts.persistSeen !== false) {
411
430
  await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts, threadBodyHashes });
412
- await Promise.allSettled(result.triage.checks.flatMap((ch) => (ch.annotations ?? []).map((a) => markSeen(stallKey, a.id, annotationMarkerBody(a)))));
431
+ // A queued conflict's annotation fixes are path (a); waiting keeps them unseen.
432
+ if (!queuedConflict)
433
+ await Promise.allSettled(result.triage.checks.flatMap((ch) => (ch.annotations ?? []).map((a) => markSeen(stallKey, a.id, annotationMarkerBody(a)))));
413
434
  }
414
435
  return result;
415
436
  }
@@ -35,6 +35,9 @@ export function explicitInstructions(result, rerunCommand) {
35
35
  "2. Follow each selected one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
36
36
  `3. Run ${again} again after selected work completes; one row's \`ESCALATE\` does not stop work on other rows. ${KEEP_WATCHING}`,
37
37
  ];
38
+ if (result.prs.some((item) => item.action === "escalate") && !isHandedOffSelection(result)) {
39
+ instructions[0] = `${instructions[0]} Report each \`ESCALATE\` row's reasons to the user now and leave that PR alone; keep working on the other rows.`;
40
+ }
38
41
  if (onlyDraftVerificationSelection(result)) {
39
42
  instructions[2] = `3. If every draft verification probe returns \`[CANCEL]\` with reason \`auto-mark-ready-disabled\`, stop this selection and report any \`ESCALATE\` rows; leave the drafts unchanged. If any probe returns \`[ESCALATE]\`, report that handoff and stop after the other probes finish. Otherwise follow the probes' instructions and rerun ${again} when that work completes.`;
40
43
  }
@@ -140,7 +140,7 @@ function planStack(result, mergeRequested, rerunCommand) {
140
140
  action: "cancel",
141
141
  stackMergeable: false,
142
142
  instructions: [
143
- `1. Stop shepherding this stack. Automatic mark-ready is disabled and ${work.held.map((item) => `PR #${item.pr}`).join(", ")} ${work.held.length === 1 ? "is" : "are"} clean but still in draft.`,
143
+ `1. Stop shepherding this stack. Automatic mark-ready is disabled and ${work.held.map((item) => `PR #${item.pr}`).join(", ")} ${work.held.length === 1 ? "is" : "are"} clean but still in draft. Leave ${work.held.length === 1 ? "it" : "them"} in draft and end the turn; do not mark ${work.held.length === 1 ? "it" : "them"} ready or suggest doing so, because that is the human's decision.`,
144
144
  ],
145
145
  };
146
146
  }
@@ -11,6 +11,8 @@ export interface ResolveResult {
11
11
  rateLimit?: ResolveRateLimitStop;
12
12
  sessionRefusal?: string;
13
13
  instructions?: string[];
14
+ /** IDs GitHub denied (403/404); Shepherd omits them from later commands until edited. */
15
+ deniedIds?: string[];
14
16
  unrepliedThreads?: string[];
15
17
  unresolvedThreads?: string[];
16
18
  unminimizedComments?: string[];
@@ -86,6 +86,11 @@ export async function applyResolveOptions(pr, repo, opts, preflight) {
86
86
  "Restore GitHub access for this session using the proxy instructions above.",
87
87
  "Retry only the pending IDs listed above.",
88
88
  ];
89
+ else if (result.deniedIds?.length)
90
+ result.instructions = [
91
+ `GitHub denied ${result.deniedIds.map((id) => `\`${id}\``).join(", ")}. Shepherd omits ${result.deniedIds.length === 1 ? "it" : "them"} from later commands until edited, so do not retry, escalate, or route around ${result.deniedIds.length === 1 ? "it" : "them"}.`,
92
+ "Rerun Shepherd with the same options and continue from its output.",
93
+ ];
89
94
  return result;
90
95
  }
91
96
  export async function autoResolveThreads(threadIds) {
@@ -192,6 +197,8 @@ async function bulkApplyChunk(resolveIds, replyIds, minimizeIds, dismissIds, dis
192
197
  }), { mutation: true });
193
198
  if ("restStopped" in resp)
194
199
  restStopped = resp.restStopped === true;
200
+ if ("deniedIds" in resp && Array.isArray(resp.deniedIds) && resp.deniedIds.length)
201
+ (result.deniedIds ??= []).push(...resp.deniedIds);
195
202
  if ("sessionRefusal" in resp && typeof resp.sessionRefusal === "string")
196
203
  result.sessionRefusal = resp.sessionRefusal;
197
204
  data = resp.data;
@@ -18,5 +18,6 @@ export declare function applyRestReviewChunk(input: {
18
18
  retryAfterSeconds?: number;
19
19
  restStopped?: true;
20
20
  sessionRefusal?: string;
21
+ deniedIds?: string[];
21
22
  }>;
22
23
  export { isAmbiguousMutationError } from "./rest-reply.mts";
@@ -108,8 +108,10 @@ export async function applyRestReviewChunk(input) {
108
108
  const message = error instanceof Error ? error.message : String(error);
109
109
  result.errors.push({ message, path: [op.alias] });
110
110
  const stop = rateLimitFromError(error, message);
111
- if (!stop && error instanceof GitHubRequestError && [403, 404].includes(error.status))
111
+ if (!stop && error instanceof GitHubRequestError && [403, 404].includes(error.status)) {
112
112
  await rememberDenied(input, op.id, op.kind);
113
+ (result.deniedIds ??= []).push(op.id);
114
+ }
113
115
  if (error instanceof GitHubRequestError) {
114
116
  result.rateLimit = error.rateLimit;
115
117
  result.retryAfterSeconds = error.retryAfterSeconds;
@@ -4,7 +4,7 @@ import type { RawSummaryPr } from "./poll-summary-raw.mts";
4
4
  type Routed = Pick<PollSummaryItem, "action" | "reasons">;
5
5
  /**
6
6
  * The one-PR check treats unseen annotations on any non-SUCCESS check (failing, skipped,
7
- * filtered, ignored or neutral) as actionable work. The compact summary only has annotation
7
+ * filtered or neutral) as actionable work; `checks.ignore` checks never gate, annotations included. The compact summary only has annotation
8
8
  * totals, and only for a row that otherwise claims readiness, so it loads them then and reads
9
9
  * the annotations of just the non-SUCCESS checks that have any. A row with unseen annotations,
10
10
  * or whose annotations cannot be read, goes to its one-PR `pollCommand` instead of
@@ -11,7 +11,7 @@ const READY_CLAIMS = new Set([
11
11
  ]);
12
12
  /**
13
13
  * The one-PR check treats unseen annotations on any non-SUCCESS check (failing, skipped,
14
- * filtered, ignored or neutral) as actionable work. The compact summary only has annotation
14
+ * filtered or neutral) as actionable work; `checks.ignore` checks never gate, annotations included. The compact summary only has annotation
15
15
  * totals, and only for a row that otherwise claims readiness, so it loads them then and reads
16
16
  * the annotations of just the non-SUCCESS checks that have any. A row with unseen annotations,
17
17
  * or whose annotations cannot be read, goes to its one-PR `pollCommand` instead of
@@ -44,7 +44,7 @@ function nonSuccessCheckTotals(raw, required, totalsKnown) {
44
44
  for (const check of classifiedSummaryChecks(raw, required)) {
45
45
  if (check.scope || check.id == null || !head.has(check.id))
46
46
  continue;
47
- if (check.category === "in_progress" || check.category === "superseded")
47
+ if (["in_progress", "superseded", "ignored"].includes(check.category))
48
48
  continue;
49
49
  if (check.status !== "COMPLETED" || check.conclusion === "SUCCESS")
50
50
  continue;
@@ -149,6 +149,8 @@ interface TriageDetails {
149
149
  shown: number;
150
150
  total: number;
151
151
  };
152
+ /** Review work a queued conflicting PR holds back until it leaves the queue. Omitted when empty. */
153
+ deferredWork?: import("./merge-queue.mts").IterateDeferredWork;
152
154
  }
153
155
  export interface IterateResultTriage extends IterateResultBase {
154
156
  action: "triage";
@@ -69,8 +69,6 @@ export interface ShepherdReport {
69
69
  skipped: ClassifiedCheck[];
70
70
  /** Checks filtered out because they were triggered by a non-PR event (push, schedule, etc.). */
71
71
  filtered: ClassifiedCheck[];
72
- /** Ignored checks with unseen annotations; omitted when empty. */
73
- ignored?: ClassifiedCheck[];
74
72
  filteredNames: string[];
75
73
  blockedByFilteredCheck: boolean;
76
74
  ignoredNames?: string[];
@@ -215,6 +213,8 @@ export interface AgentCheck {
215
213
  rerunCommand?: string;
216
214
  /** Workflow-run attempt number, surfaced only after the initial attempt. */
217
215
  runAttempt?: number;
216
+ /** `owner/name#number` of a recorded blocker that has merged or closed. */
217
+ releasedBlocker?: string;
218
218
  annotations?: CheckAnnotation[];
219
219
  annotationOnly?: true;
220
220
  scope?: "merge_group";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -91,7 +91,7 @@
91
91
  "husky": "^9.1.7",
92
92
  "knip": "^6.14.1",
93
93
  "marked": "^18.0.11",
94
- "oxfmt": "^0.73.0",
94
+ "oxfmt": "^0.75.0",
95
95
  "oxlint": "^1.60.0",
96
96
  "typescript": "^7.0.2",
97
97
  "vitest": "^5.0.0"
@@ -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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@1.1.1", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@1.2.0", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@1.1.1", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@1.2.0", "pr-shepherd-mcp"]
5
5
  }
6
6
  }