pr-shepherd 0.49.0 → 0.51.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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -6
  3. package/bin/cli/help-command-pages.d.mts +8 -8
  4. package/bin/cli/help-command-pages.mjs +8 -8
  5. package/bin/cli/help.d.mts +8 -8
  6. package/bin/cli/iterate-instructions.mjs +9 -2
  7. package/bin/cli/iterate-lean.mjs +11 -0
  8. package/bin/cli/poll-summary-emitter.mjs +15 -0
  9. package/bin/cli/poll-summary-formatter.mjs +10 -24
  10. package/bin/cli-parser.mjs +3 -0
  11. package/bin/commands/iterate/check-instructions.d.mts +2 -8
  12. package/bin/commands/iterate/check-instructions.mjs +2 -11
  13. package/bin/commands/iterate/escalate.mjs +30 -2
  14. package/bin/commands/iterate/fix-code.mjs +62 -20
  15. package/bin/commands/iterate/render.mjs +1 -1
  16. package/bin/commands/iterate/stall.mjs +30 -0
  17. package/bin/commands/iterate/thread-mutation-routing.d.mts +0 -2
  18. package/bin/commands/iterate/thread-mutation-routing.mjs +1 -2
  19. package/bin/commands/poll-quota.d.mts +2 -1
  20. package/bin/commands/poll-quota.mjs +12 -0
  21. package/bin/commands/poll-summary-explicit-instructions.d.mts +2 -0
  22. package/bin/commands/poll-summary-explicit-instructions.mjs +22 -0
  23. package/bin/commands/poll-summary-instructions.d.mts +3 -0
  24. package/bin/commands/poll-summary-instructions.mjs +139 -0
  25. package/bin/commands/poll-summary-signature.d.mts +2 -0
  26. package/bin/commands/poll-summary-signature.mjs +16 -0
  27. package/bin/commands/poll-summary.mjs +25 -39
  28. package/bin/commands/resolve-mutate.mjs +22 -68
  29. package/bin/comments/resolve.d.mts +5 -0
  30. package/bin/comments/resolve.mjs +2 -11
  31. package/bin/github/gql/poll-summary-fragment.gql +1 -0
  32. package/bin/github/poll-summary-projector.mjs +2 -1
  33. package/bin/github/poll-summary-raw.d.mts +1 -0
  34. package/bin/github/poll-summary-route.mjs +4 -1
  35. package/bin/github/poll-summary.d.mts +2 -1
  36. package/bin/github/poll-summary.mjs +19 -0
  37. package/bin/state/fix-attempts.d.mts +3 -4
  38. package/bin/state/fix-attempts.mjs +2 -3
  39. package/bin/types/escalate.d.mts +10 -0
  40. package/bin/types/poll-summary.d.mts +14 -0
  41. package/package.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/skills/pr-shepherd/SKILL.md +6 -11
  46. package/plugins/pr-shepherd/skills/reduce-pr-noise/SKILL.md +17 -0
  47. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +24 -0
  48. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/settings.md +27 -0
@@ -4,12 +4,13 @@ import { getRepoInfo } from "../github/client.mjs";
4
4
  import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-telemetry.mjs";
5
5
  import { fetchPollSummary } from "../github/poll-summary.mjs";
6
6
  import { sleep } from "../util/sleep.mjs";
7
- import { graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs } from "./poll-quota.mjs";
8
- import { evaluateWorktreeGraphqlQuotaWarning } from "../state/graphql-quota-warnings.mjs";
7
+ import { aggregateQuotaWarning, graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs, } from "./poll-quota.mjs";
8
+ import { withPollSummaryInstructions } from "./poll-summary-instructions.mjs";
9
+ import { summaryStatusSignature } from "./poll-summary-signature.mjs";
9
10
  const MAX_TIMER_MS = 2 ** 31 - 1;
10
11
  const TIMER_DRIFT_TOLERANCE_MS = 500;
11
12
  export function runPollSummary(opts) {
12
- return withApiTelemetryScope(async () => attachUsage(await runPollSummaryCore(opts)));
13
+ return withApiTelemetryScope(async () => attachUsage(await runPollSummaryCore(opts), opts.merge));
13
14
  }
14
15
  export function runAggregatePoll(opts) {
15
16
  return withApiTelemetryScope(() => runAggregatePollCore(opts));
@@ -19,13 +20,14 @@ async function runPollSummaryCore(opts) {
19
20
  const fetched = await fetchPollSummary(opts, repo);
20
21
  const allTerminal = fetched.prs.every((item) => item.action === "cancel");
21
22
  const actionable = fetched.prs.some((item) => item.action !== "wait" && item.action !== "cancel");
22
- return {
23
+ return withPollSummaryInstructions({
23
24
  mode: "summary",
24
25
  repo: `${repo.owner}/${repo.name}`,
25
26
  selection: fetched.selection,
26
27
  reason: allTerminal ? "all_terminal" : actionable ? "actionable" : "waiting",
27
28
  prs: fetched.prs,
28
- };
29
+ ...(fetched.stackAncestry?.length && { stackAncestry: fetched.stackAncestry }),
30
+ }, opts.merge === true);
29
31
  }
30
32
  async function runAggregatePollCore(opts) {
31
33
  const intervalMs = Math.min(opts.intervalSeconds * 1000, MAX_TIMER_MS);
@@ -60,7 +62,7 @@ async function runAggregatePollCore(opts) {
60
62
  ...explicit,
61
63
  reason: "all_terminal",
62
64
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
63
- });
65
+ }, opts.merge);
64
66
  }
65
67
  }
66
68
  const retryMs = opts.untilTerminal ? pollGraphQlRetryAfterMs(error) : null;
@@ -71,9 +73,15 @@ async function runAggregatePollCore(opts) {
71
73
  await sleep(retryMs);
72
74
  continue;
73
75
  }
74
- const allTerminal = last.prs.every((item) => item.action === "cancel");
75
- const immediate = last.prs.some((item) => ["escalate", "merge", "mark_ready"].includes(item.action));
76
- const hasFix = last.prs.some((item) => item.action === "fix_code");
76
+ const allTerminal = last.selection.kind === "stack"
77
+ ? last.nextAction === "cancel"
78
+ : last.prs.every((item) => item.action === "cancel");
79
+ const immediate = last.selection.kind === "stack"
80
+ ? ["escalate", "merge", "mark_ready"].includes(last.nextAction ?? "wait")
81
+ : last.prs.some((item) => ["escalate", "merge", "mark_ready"].includes(item.action));
82
+ const hasFix = last.selection.kind === "stack"
83
+ ? last.nextAction === "fix_code"
84
+ : last.prs.some((item) => item.action === "fix_code");
77
85
  const warning = await aggregateQuotaWarning(last, quotaBands, opts.intervalSeconds);
78
86
  if (warning)
79
87
  pendingQuotaWarning = warning;
@@ -82,14 +90,14 @@ async function runAggregatePollCore(opts) {
82
90
  ...last,
83
91
  reason: "all_terminal",
84
92
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
85
- });
93
+ }, opts.merge);
86
94
  }
87
95
  if (immediate) {
88
96
  return attachUsage({
89
97
  ...last,
90
98
  reason: "actionable",
91
99
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
92
- });
100
+ }, opts.merge);
93
101
  }
94
102
  if (hasFix) {
95
103
  if (debounceMs === 0)
@@ -97,19 +105,19 @@ async function runAggregatePollCore(opts) {
97
105
  ...last,
98
106
  reason: "actionable",
99
107
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
100
- });
108
+ }, opts.merge);
101
109
  debounceUntil ??= Date.now() + debounceMs;
102
110
  if (Date.now() >= debounceUntil)
103
111
  return attachUsage({
104
112
  ...last,
105
113
  reason: "actionable",
106
114
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
107
- });
115
+ }, opts.merge);
108
116
  }
109
117
  else {
110
118
  debounceUntil = null;
111
119
  if (opts.untilTerminal && pendingQuotaWarning) {
112
- return attachUsage({ ...last, quotaWarning: pendingQuotaWarning });
120
+ return attachUsage({ ...last, quotaWarning: pendingQuotaWarning }, opts.merge);
113
121
  }
114
122
  }
115
123
  const elapsedMs = Date.now() - start;
@@ -119,7 +127,7 @@ async function runAggregatePollCore(opts) {
119
127
  if (!opts.untilTerminal && debounceUntil === null) {
120
128
  const remainingMs = timeoutMs - elapsedMs;
121
129
  if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
122
- return attachUsage({ ...last, reason: "timeout" });
130
+ return attachUsage({ ...last, reason: "timeout" }, opts.merge);
123
131
  }
124
132
  }
125
133
  const statusSignature = summaryStatusSignature(last);
@@ -132,32 +140,10 @@ async function runAggregatePollCore(opts) {
132
140
  await sleep(sleepMs);
133
141
  }
134
142
  }
135
- async function aggregateQuotaWarning(result, bands, intervalSeconds) {
136
- const usage = summarizeApiTelemetry()?.graphql;
137
- const [owner, repo] = result.repo.split("/");
138
- if (!usage || !owner || !repo)
139
- return undefined;
140
- return evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands.map((band) => ({
141
- ...band,
142
- pollIntervalMinutes: Math.max(band.pollIntervalMinutes, intervalSeconds / 60),
143
- })), usage, true);
144
- }
145
- function summaryStatusSignature(result) {
146
- return JSON.stringify(result.prs.map((item) => ({
147
- pr: item.pr,
148
- action: item.action,
149
- state: item.state,
150
- mergeable: item.mergeable,
151
- mergeStateStatus: item.mergeStateStatus,
152
- reviewDecision: item.reviewDecision,
153
- checks: item.checks,
154
- review: item.review,
155
- })));
156
- }
157
143
  function isMissingStack(error) {
158
144
  return (error instanceof ShepherdError && error.message.includes("not part of a native GitHub stack"));
159
145
  }
160
- function attachUsage(result) {
146
+ function attachUsage(result, mergeRequested) {
161
147
  const apiUsage = summarizeApiTelemetry();
162
- return apiUsage ? { ...result, apiUsage } : result;
148
+ return withPollSummaryInstructions(apiUsage ? { ...result, apiUsage } : result, mergeRequested === true);
163
149
  }
@@ -1,12 +1,9 @@
1
1
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { applyResolveOptions } from "../comments/resolve.mjs";
3
3
  import { fetchPrBatch } from "../github/batch.mjs";
4
- import { loadConfig } from "../config/load.mjs";
5
- import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, normalizeBotUsernames, } from "../comments/authors.mjs";
6
- import { shouldResolveOtherHumanThread } from "./iterate/thread-mutation-routing.mjs";
7
4
  import { markReplySeen } from "../state/seen-comments.mjs";
8
5
  import { threadTranscriptBody } from "../threads/transcript.mjs";
9
- import { addPrShepherdMarker, threadEndedByShepherd } from "../comments/marker.mjs";
6
+ import { addPrShepherdMarker } from "../comments/marker.mjs";
10
7
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
11
8
  /** @deprecated Hidden implementation for `resolve`; use `apply review`. */
12
9
  export async function runResolveMutate(opts) {
@@ -15,73 +12,30 @@ export async function runResolveMutate(opts) {
15
12
  if (prNumber === null) {
16
13
  throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
17
14
  }
18
- const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
19
- const config = loadConfig();
20
- const botUsernames = normalizeBotUsernames(config.botUsernames);
21
- const threadById = new Map(data.reviewThreads.map((t) => [t.id, t]));
22
- const humanThreadIds = new Set(data.reviewThreads
23
- .filter((t) => isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames))
24
- .map((t) => t.id));
25
- const humanCommentIds = new Set(data.comments
26
- .filter((c) => isHumanAuthor(c) && !isConfiguredBotAuthor(c, botUsernames))
27
- .map((c) => c.id));
28
- const humanReviewIds = new Set([...data.reviewSummaries, ...data.approvedReviews, ...data.changesRequestedReviews]
29
- .filter((r) => isHumanAuthor(r) && !isConfiguredBotAuthor(r, botUsernames))
30
- .map((r) => r.id));
31
- // Iterate uses viewer capability fields while deciding which commands to
32
- // print. Once a caller explicitly runs apply, GitHub's mutation response is
33
- // authoritative and this path must not second-guess that intent.
34
- const requestedReplyIds = new Set(opts.replyThreadIds ?? []);
35
- const policy = config.iterate?.resolveOtherHumanThreads ?? "none";
36
- const allowedHumanResolveIds = new Set(data.reviewThreads
37
- .filter((thread) => {
38
- if (!humanThreadIds.has(thread.id))
39
- return false;
40
- const paired = requestedReplyIds.has(thread.id) || threadEndedByShepherd(thread);
41
- if (!paired)
42
- return false;
43
- if (isViewerAuthoredHuman(thread, botUsernames))
44
- return true;
45
- return shouldResolveOtherHumanThread(thread, policy);
46
- })
47
- .map((thread) => thread.id));
48
- const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id) || allowedHumanResolveIds.has(id));
49
- const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id) && !allowedHumanResolveIds.has(id));
50
- const knownThreadIds = new Set(data.reviewThreads.map((thread) => thread.id));
51
- const replyThreadIds = opts.replyThreadIds?.filter((id) => knownThreadIds.has(id));
52
- const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !knownThreadIds.has(id));
53
- const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id));
54
- const skippedHumanMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => humanCommentIds.has(id) || humanReviewIds.has(id));
55
- const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && data.changesRequestedReviews.some((review) => review.id === id));
56
- const skippedHumanDismissals = (opts.dismissReviewIds ?? []).filter((id) => humanReviewIds.has(id));
57
- const skippedIneligibleDismissals = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && !dismissReviewIds.includes(id));
58
- const hasMutation = resolveThreadIds.length > 0 ||
59
- (replyThreadIds?.length ?? 0) > 0 ||
60
- minimizeCommentIds.length > 0 ||
61
- dismissReviewIds.length > 0;
15
+ // Fetch only to retain the pre-reply transcript for successful-reply seen
16
+ // markers. It never determines which user-supplied IDs are sent to GitHub.
17
+ let threadById;
18
+ if (opts.replyThreadIds?.length) {
19
+ try {
20
+ threadById = new Map((await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true })).data.reviewThreads.map((thread) => [thread.id, thread]));
21
+ }
22
+ catch {
23
+ // Seen-marker bookkeeping is best-effort. A failed read must not block
24
+ // the explicit mutation request; GitHub's mutation response is authoritative.
25
+ }
26
+ }
27
+ // Iterate capability-checks and routes its generated commands. Direct apply
28
+ // requests are user-directed: forward every supplied ID unchanged and let
29
+ // GitHub report whether each requested mutation is permitted or applicable.
62
30
  const result = await applyResolveOptions(prNumber, repo, {
63
- resolveThreadIds,
64
- replyThreadIds,
65
- minimizeCommentIds,
66
- dismissReviewIds,
31
+ resolveThreadIds: opts.resolveThreadIds,
32
+ replyThreadIds: opts.replyThreadIds,
33
+ minimizeCommentIds: opts.minimizeCommentIds,
34
+ dismissReviewIds: opts.dismissReviewIds,
67
35
  dismissMessage: opts.dismissMessage,
68
- requireSha: hasMutation ? opts.requireSha : undefined,
36
+ requireSha: opts.requireSha,
69
37
  });
70
- if (skippedHumanResolves.length > 0)
71
- result.skippedHumanResolves = skippedHumanResolves;
72
- if (skippedHumanMinimizes.length > 0)
73
- result.skippedHumanMinimizes = skippedHumanMinimizes;
74
- if (skippedHumanDismissals.length > 0)
75
- result.skippedHumanDismissals = skippedHumanDismissals;
76
- if (skippedNonHumanReplies.length > 0)
77
- result.skippedNonHumanReplies = skippedNonHumanReplies;
78
- if (skippedIneligibleDismissals.length > 0) {
79
- result.skippedDismissals = [
80
- ...(result.skippedDismissals ?? []),
81
- ...skippedIneligibleDismissals,
82
- ];
83
- }
84
- if (opts.dismissMessage) {
38
+ if (opts.dismissMessage && threadById) {
85
39
  const markedMessage = addPrShepherdMarker(opts.dismissMessage);
86
40
  await Promise.all(result.repliedThreads.map((id) => {
87
41
  const thread = threadById.get(id);
@@ -7,10 +7,15 @@ export interface ResolveResult {
7
7
  minimizedComments: string[];
8
8
  dismissedReviews: string[];
9
9
  errors: string[];
10
+ /** @deprecated Direct apply forwards every supplied dismissal ID to GitHub. */
10
11
  skippedDismissals?: string[];
12
+ /** @deprecated Direct apply no longer applies author policy. */
11
13
  skippedHumanResolves?: string[];
14
+ /** @deprecated Direct apply no longer applies author policy. */
12
15
  skippedHumanMinimizes?: string[];
16
+ /** @deprecated Direct apply no longer applies author policy. */
13
17
  skippedHumanDismissals?: string[];
18
+ /** @deprecated Direct apply forwards every supplied reply ID to GitHub. */
14
19
  skippedNonHumanReplies?: string[];
15
20
  /** @deprecated Direct apply requests now rely on GitHub's mutation response. */
16
21
  skippedUnauthorizedReplies?: string[];
@@ -35,9 +35,6 @@ export async function applyResolveOptions(pr, repo, opts) {
35
35
  const replyThreadIds = dedupeIds(opts.replyThreadIds ?? []);
36
36
  const minimizeCommentIds = opts.minimizeCommentIds ?? [];
37
37
  const dismissReviewIds = dedupeIds(opts.dismissReviewIds ?? []);
38
- const minimizeCommentIdSet = new Set(minimizeCommentIds);
39
- const filteredDismissReviewIds = dismissReviewIds.filter((id) => !minimizeCommentIdSet.has(id));
40
- const overlappingDismissIds = dismissReviewIds.filter((id) => minimizeCommentIdSet.has(id));
41
38
  const result = {
42
39
  repliedThreads: [],
43
40
  resolvedThreads: [],
@@ -45,13 +42,7 @@ export async function applyResolveOptions(pr, repo, opts) {
45
42
  dismissedReviews: [],
46
43
  errors: [],
47
44
  };
48
- if (overlappingDismissIds.length > 0) {
49
- result.skippedDismissals = [];
50
- for (const id of overlappingDismissIds) {
51
- result.skippedDismissals.push(id);
52
- }
53
- }
54
- if ((filteredDismissReviewIds.length > 0 || replyThreadIds.length > 0) && !opts.dismissMessage) {
45
+ if ((dismissReviewIds.length > 0 || replyThreadIds.length > 0) && !opts.dismissMessage) {
55
46
  throw new Error("--message is required when replying to threads or dismissing reviews");
56
47
  }
57
48
  if (opts.requireSha) {
@@ -59,7 +50,7 @@ export async function applyResolveOptions(pr, repo, opts) {
59
50
  // before reviewers see the fix.
60
51
  await waitForSha(pr, repo, opts.requireSha);
61
52
  }
62
- await bulkApply(replyThreadIds, resolveThreadIds, minimizeCommentIds, filteredDismissReviewIds, opts.dismissMessage ?? "", result);
53
+ await bulkApply(replyThreadIds, resolveThreadIds, minimizeCommentIds, dismissReviewIds, opts.dismissMessage ?? "", result);
63
54
  return result;
64
55
  }
65
56
  /** @deprecated Compatibility alias; use `autoResolveThreads`. */
@@ -8,6 +8,7 @@ fragment PollSummaryPr on PullRequest {
8
8
  headRefName
9
9
  headRefOid
10
10
  baseRefName
11
+ baseRefOid
11
12
  mergeable
12
13
  mergeStateStatus
13
14
  reviewDecision
@@ -55,7 +55,8 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
55
55
  ...(Object.keys(checks).length > 0 && { checks }),
56
56
  ...(Object.keys(review).length > 0 && { review }),
57
57
  ...(stack && { stack }),
58
- ...(!["wait", "cancel"].includes(action) && {
58
+ ...(!["wait", "cancel"].includes(action) &&
59
+ !(opts.stackPrNumber !== undefined && raw.stack && action === "merge") && {
59
60
  pollCommand: buildPollCommand(repoName, raw.number, opts),
60
61
  }),
61
62
  };
@@ -52,6 +52,7 @@ export interface RawSummaryPr {
52
52
  viewerCanUpdate: boolean;
53
53
  headRefName: string;
54
54
  headRefOid: string;
55
+ baseRefOid: string;
55
56
  baseRefName: string;
56
57
  mergeable: string;
57
58
  mergeStateStatus: string;
@@ -35,9 +35,12 @@ export function routePollSummary(raw, checks, review, opts) {
35
35
  if (opts.merge && raw.isInMergeQueue) {
36
36
  return { action: "wait", reasons: ["already-in-merge-queue"] };
37
37
  }
38
- if (opts.merge && raw.stack) {
38
+ if (opts.merge && raw.stack && opts.stackPrNumber === undefined) {
39
39
  return { action: "fix_code", reasons: ["authoritative-poll-required"] };
40
40
  }
41
+ if (opts.merge && raw.stack) {
42
+ return { action: "merge", reasons: ["appears-ready"] };
43
+ }
41
44
  if (opts.merge && !raw.stack)
42
45
  return { action: "merge", reasons: ["appears-ready"] };
43
46
  return { action: "cancel", reasons: ["appears-ready"] };
@@ -1,7 +1,8 @@
1
- import type { PollSummaryCommandOptions, PollSummaryItem, PollSummarySelection } from "../types.mts";
1
+ import type { PollSummaryCommandOptions, PollSummaryItem, PollSummarySelection, PollSummaryStackAncestry } from "../types.mts";
2
2
  import { type RepoInfo } from "./client.mts";
3
3
  export interface FetchedPollSummary {
4
4
  selection: PollSummarySelection;
5
5
  prs: PollSummaryItem[];
6
+ stackAncestry?: PollSummaryStackAncestry[];
6
7
  }
7
8
  export declare function fetchPollSummary(opts: PollSummaryCommandOptions, repo: RepoInfo): Promise<FetchedPollSummary>;
@@ -97,9 +97,28 @@ async function fetchStackSummary(opts, repo) {
97
97
  throw new ShepherdError(`GitHub returned incomplete stack membership (${unique.size} of ${stackSize} entries)`, EXIT.TEMPFAIL);
98
98
  }
99
99
  const ordered = [...unique.values()].sort((left, right) => left.position - right.position);
100
+ const stackAncestry = [];
101
+ for (let index = 1; index < ordered.length; index++) {
102
+ const parent = ordered[index - 1].pullRequest;
103
+ const child = ordered[index].pullRequest;
104
+ if (parent.state !== "OPEN" || child.state !== "OPEN")
105
+ continue;
106
+ if (child.baseRefName === parent.headRefName && child.baseRefOid === parent.headRefOid) {
107
+ continue;
108
+ }
109
+ stackAncestry.push({
110
+ parentPr: parent.number,
111
+ parentHeadRefName: parent.headRefName,
112
+ parentHeadRefOid: parent.headRefOid,
113
+ childPr: child.number,
114
+ childBaseRefName: child.baseRefName,
115
+ childBaseRefOid: child.baseRefOid,
116
+ });
117
+ }
100
118
  return {
101
119
  selection: { kind: "stack", anchor, stackNumber, stackSize },
102
120
  prs: await Promise.all(ordered.map((entry) => summarizePollSummaryPr(entry.pullRequest, repo, opts, viewerCanAdminister))),
121
+ ...(stackAncestry.length > 0 && { stackAncestry }),
103
122
  };
104
123
  }
105
124
  function deduplicate(values) {
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * Persistent attempt counter for the iterate escalation guard.
3
3
  *
4
- * Tracks how many times each review thread has been dispatched to the fix_code
5
- * handler without being resolved. Counts are reset automatically when the HEAD
6
- * commit SHA changes (i.e. a new push landed).
4
+ * Tracks how many caller-visible times each review thread has been dispatched to
5
+ * the fix_code handler without being resolved. Body edits reset the count.
7
6
  *
8
7
  * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/fix-attempts.json`.
9
8
  */
10
9
  export interface FixAttemptsState {
11
- /** HEAD SHA at the time the counts were last written. Reset key. */
10
+ /** HEAD SHA at the time the counts were last written, retained for observability/compatibility. */
12
11
  headSha: string;
13
12
  /** Map from thread ID → number of fix_code dispatches that included this thread. */
14
13
  threadAttempts: Record<string, number>;
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Persistent attempt counter for the iterate escalation guard.
3
3
  *
4
- * Tracks how many times each review thread has been dispatched to the fix_code
5
- * handler without being resolved. Counts are reset automatically when the HEAD
6
- * commit SHA changes (i.e. a new push landed).
4
+ * Tracks how many caller-visible times each review thread has been dispatched to
5
+ * the fix_code handler without being resolved. Body edits reset the count.
7
6
  *
8
7
  * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/fix-attempts.json`.
9
8
  */
@@ -1,4 +1,5 @@
1
1
  import type { AgentCheck, AgentComment, AgentThread } from "./report.mts";
2
+ import type { ResolveCommand } from "./iterate.mts";
2
3
  import type { CheckStatus, Review } from "./github.mts";
3
4
  import type { MergeQueueRemovalStatus, StackStatus } from "./merge-requirements.mts";
4
5
  export type EscalateTrigger = "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "check-follow-up-unavailable" | "authorization-required" | "bot-cr-not-dismissed" | "merge-queue-removed" | "stacked-pr";
@@ -19,6 +20,10 @@ export interface EscalateDetails {
19
20
  unresolvedThreads: AgentThread[];
20
21
  ambiguousComments: AgentComment[];
21
22
  changesRequestedReviews: Review[];
23
+ /** First-look review summaries that must be shown before any pending minimization. */
24
+ firstLookSummaries?: Review[];
25
+ /** Previously seen review summaries whose edited bodies must be shown again. */
26
+ editedSummaries?: Review[];
22
27
  /** Failing checks whose next step requires human attention. */
23
28
  checks?: AgentCheck[];
24
29
  stalledChecks?: AgentStalledCheck[];
@@ -26,6 +31,11 @@ export interface EscalateDetails {
26
31
  threadId: string;
27
32
  attempts: number;
28
33
  }>;
34
+ /** Review mutations generated for this tick, retained so an escalation cannot strand them. */
35
+ pendingReviewCommands?: {
36
+ resolveOnlyCommand?: ResolveCommand;
37
+ resolveCommand?: ResolveCommand;
38
+ };
29
39
  suggestion: string;
30
40
  humanMessage: string;
31
41
  mergeQueueRemoval?: MergeQueueRemovalStatus;
@@ -18,6 +18,15 @@ export interface PollSummaryReview {
18
18
  actionable?: number;
19
19
  incomplete?: true;
20
20
  }
21
+ /** The two GitHub refs at an adjacent, still-open stack boundary. */
22
+ export interface PollSummaryStackAncestry {
23
+ parentPr: number;
24
+ parentHeadRefName: string;
25
+ parentHeadRefOid: string;
26
+ childPr: number;
27
+ childBaseRefName: string;
28
+ childBaseRefOid: string;
29
+ }
21
30
  interface PollSummaryStack {
22
31
  number: number;
23
32
  size: number;
@@ -63,6 +72,11 @@ export interface PollSummaryResult {
63
72
  selection: PollSummarySelection;
64
73
  reason: "actionable" | "all_terminal" | "waiting" | "timeout";
65
74
  prs: PollSummaryItem[];
75
+ /** Present only for native-stack boundaries whose recorded refs differ. */
76
+ stackAncestry?: PollSummaryStackAncestry[];
77
+ /** The next stack-level transition, which may differ from an individual row's hint. */
78
+ nextAction?: ShepherdAction;
79
+ instructions?: string[];
66
80
  apiUsage?: ApiUsage;
67
81
  quotaWarning?: GraphqlQuotaWarning;
68
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.49.0",
3
+ "version": "0.51.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@0.49.0", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.51.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@0.49.0", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.51.0", "pr-shepherd-mcp"]
5
5
  }
6
6
  }
@@ -2,7 +2,7 @@
2
2
  name: pr-shepherd
3
3
  description: 'Create or iterate a GitHub pull request with pr-shepherd (MCP or CLI). Use for requests like "make a PR and use pr-shepherd", "iterate PR #123", or "run pr-shepherd until this PR is ready".'
4
4
  user-invocable: true
5
- argument-hint: "[PR number or URL] [--merge]"
5
+ argument-hint: "[PR number or URL | --stack PR] [--merge]"
6
6
  allowed-tools: ["MCP", "Bash", "Read", "Grep", "Glob", "Edit", "Write"]
7
7
  ---
8
8
 
@@ -18,9 +18,9 @@ If the requested PR does not exist yet, review and commit the in-scope changes,
18
18
 
19
19
  ## Arguments: $ARGUMENTS
20
20
 
21
- 1. Parse optional PR numbers, repository-qualified `owner/repo#N` references, or GitHub PR URLs and an optional `--merge` flag from `$ARGUMENTS`; alternatively parse one `--stack PR` selector. Otherwise let pr-shepherd infer the current branch PR. Reject any remaining argument. Follow the target repository's local `AGENTS.md` and `CLAUDE.md` standards while making changes.
21
+ 1. Parse optional PR numbers, repository-qualified `owner/repo#N` references, or GitHub PR URLs and an optional `--merge` flag from `$ARGUMENTS`; alternatively parse one `--stack PR` selector. A clear request to merge, land, or enqueue the selected PR or stack also opts into `--merge` without a literal flag; a request only to create or open a PR does not. When the user asks to shepherd or merge a native stack and supplies an anchor PR without a literal `--stack`, use that PR as the `--stack` selector. Otherwise let pr-shepherd infer the current branch PR. Reject any remaining argument. Follow the target repository's local `AGENTS.md` and `CLAUDE.md` standards while making changes.
22
22
 
23
- 2. For the CLI, convert supplied `owner/repo#N` references to `https://github.com/owner/repo/pull/N`; otherwise pass supplied URLs or bare numbers unchanged, then run `pr-shepherd [PR ...] --until-terminal`, or `pr-shepherd --stack PR --until-terminal` for a stack, omitting `[PR ...]` when none was supplied and appending `--merge` when requested. This command keeps ordinary `[WAIT]` and `[MARK_READY]` ticks inside the same invocation; aggregate selectors return when any row needs work or all rows are terminal. It also returns for a quota warning or an emitted `[MERGE]` command, which is non-terminal and must run before the next invocation. A qualified reference may name a fork or upstream repository: it is the GitHub target, while the current checkout continues to supply local git/config/rules context. Do not run `pr-shepherd iterate`. If the CLI is unavailable and the `iterate` MCP tool is available, first repository-qualify every supplied reference with its GitHub URL or `owner/repo#N`; resolve bare numbers through `gh pr view <number> --json url --jq .url`, and resolve an omitted target with `gh pr view --json url --jq .url`. If that does not produce the required qualified selector, stop and report that MCP cannot safely determine it. Otherwise call `iterate` with `pr`, `prs`, or `stack` as selected, plus `merge: true` when `--merge` was supplied, and print its full result.
23
+ 2. For the CLI, convert supplied `owner/repo#N` references to `https://github.com/owner/repo/pull/N`; otherwise pass supplied URLs or bare numbers unchanged, then run `pr-shepherd [PR ...] --until-terminal`, or `pr-shepherd --stack PR --until-terminal` for a stack, omitting `[PR ...]` when none was supplied and appending `--merge` when requested. This command keeps ordinary `[WAIT]` and `[MARK_READY]` ticks inside the same invocation; aggregate selectors return when any row needs work or all rows are terminal. It also returns for a quota warning or an emitted `[MERGE]` command, which is non-terminal and must run before the next invocation. A qualified reference may name a fork or upstream repository: it is the GitHub target, while the current checkout continues to supply local git/config/rules context. Do not run `pr-shepherd iterate`. If the CLI is unavailable and the `iterate` MCP tool is available, first repository-qualify every supplied reference with its GitHub URL or `owner/repo#N`; resolve bare numbers through `gh pr view <number> --json url --jq .url`, and resolve an omitted target with `gh pr view --json url --jq .url`. If that does not produce the required qualified selector, stop and report that MCP cannot safely determine it. Otherwise call `iterate` with `pr`, `prs`, or `stack` as selected, plus `merge: true` when merge intent was requested, and print its full result.
24
24
 
25
25
  3. Print the full result and follow every returned `## Instructions` step exactly. For CLI output, run each printed mutation command when instructed. For MCP output, use MCP `apply` and `build_suggestion_patches` with the same qualified PR reference; do not run a shell `pr-shepherd apply` command.
26
26
 
@@ -51,7 +51,7 @@ annotations, or CI log excerpts.
51
51
  - The command builds from the fetched PR head and accepts a clean local descendant only when the complete ordered patch stream passes `git apply --check`.
52
52
  - If the command refuses because a suggestion is unsafe or no longer applies, inspect the current source, the displayed replacement block, and reviewer intent before editing manually. Do not apply a stale numeric range blindly or retry unchanged input.
53
53
  - A returned patch was checked against the then-current worktree. If it later fails, re-inspect the worktree because it changed after validation.
54
- - Keep the generated thread IDs and flag placement unchanged. Viewer-authored human feedback may intentionally appear in both reply and resolve flags; unmarked other-human feedback remains reply-only. Marker-ended other-human feedback is already acknowledged and has no generated mutation.
54
+ - Use the generated thread IDs and flag placement returned with the patch command.
55
55
 
56
56
  ### CI failure triage
57
57
 
@@ -77,16 +77,11 @@ When several bullets share one runId (matrix jobs from the same run), the `rerun
77
77
 
78
78
  Applies to every `apply review:` / `resolve-only:` command the CLI prints. Covers only what stays safe if you run the printed command **unmodified** — `$HEAD_SHA`/`$DISMISS_MESSAGE` substitution remains a separate CLI-printed step because the command is unsafe by default without those placeholders.
79
79
 
80
- The CLI only includes IDs whose per-object GitHub viewer capability and semantic routing authorize the corresponding generated action. Direct `apply review` honors those emitted IDs without a second authorization preflight and surfaces GitHub's per-operation result. Do not reconstruct omitted review reply, thread resolution, or bot-review dismissal IDs and do not hand them off: denied or unverifiable generated mutations are one-look skips that Shepherd suppresses until the item is edited. Location is not required for generated reply/resolve mutations; unauthorized threads without a path or line remain one-look skips.
80
+ The CLI only includes IDs whose per-object GitHub viewer capability and semantic routing authorize the corresponding generated action. Generated commands are pre-populated; omission is not a prohibition. A separate, user-directed `apply review` request may supply any reply, resolve, minimize, or dismiss IDs; it forwards them without Shepherd author, capability, or current-state filtering, and GitHub's per-operation response is authoritative.
81
81
 
82
- - Run every generated `apply review:` / `resolve-only:` command even when no code change is warranted. The command records the agent's disposition of the included review items; skipping it leaves authorized threads active and can eventually trigger `fix-thrash`.
83
- - Never add first-look-only or check-annotation IDs to `--reply-thread-ids`, `--resolve-thread-ids`, `--dismiss-review-ids`, or `--minimize-comment-ids` — those flags are pre-populated by the CLI.
82
+ - When `## Instructions` says to run a generated `apply review:` / `resolve-only:` command, run it even when no code change is warranted. An `[ESCALATE]` instruction may require user direction first. The command records the agent's disposition of the included review items; skipping it leaves authorized threads active and can eventually trigger `fix-thrash`.
84
83
  - Keep every existing `--dismiss-review-ids` ID the CLI already included. Each is a bot or non-human review that must be dismissed; omitting one leaves the PR in `CHANGES_REQUESTED`.
85
84
 
86
- ### Review-mutation routing
87
-
88
- For threads under both `## Review threads` and `## Review threads to resolve`, evaluate every thread before running mutations. Keep unmarked bot/non-human and viewer-authored IDs in both `--reply-thread-ids` and `--resolve-thread-ids`, including when the feedback is advisory, already satisfied, or otherwise warrants no code change: the reply runs before the resolve. Unmarked other-human IDs use `--reply-thread-ids` only unless the CLI also put them in `--resolve-thread-ids`. When the latest comment begins `<!-- pr-shepherd -->`, it is an established Shepherd reply—not merely a same-account comment. A marked thread that is still being resolved is resolve-only for retry. Do not add IDs the CLI omitted, and do not move IDs between flags.
89
-
90
85
  ### Shepherd Journal
91
86
 
92
87
  Link threads and comments in a journal entry from their headings in the CLI output. Cite reviews by ID.
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: reduce-pr-noise
3
+ description: Reduce repetitive pr-shepherd output by configuring bot-comment classification rules or existing noise settings. Use for requests to silence a specific bot notice, quiet polling, trim CI logs, or tune comment visibility; use pr-shepherd for PR iteration itself.
4
+ user-invocable: true
5
+ argument-hint: "[noisy bot comment or output]"
6
+ allowed-tools: ["Bash", "Read", "Grep", "Glob", "Edit", "Write"]
7
+ ---
8
+
9
+ # Reduce pr-shepherd noise
10
+
11
+ Identify the unwanted output from the request, a representative Shepherd result, and the current configuration. If a content-specific rule needs a message pattern and none is available, ask for an example before writing that rule.
12
+
13
+ - For a recurring bot message identified by its author and text, read [bot-comment classifiers](references/classifiers.md).
14
+ - For polling status, CI checks or logs, and broad comment visibility, read [noise settings](references/settings.md).
15
+ - Read both references only when the request needs both kinds of change.
16
+
17
+ Make the smallest change that addresses the observed noise. Use project-local files for project-specific policy; use the user's home configuration only when they request a personal default. Validate the match or setting and explain what will become less visible. If the user asks only how to configure it, give the relevant instructions without editing files.
@@ -0,0 +1,24 @@
1
+ # Bot-comment classifiers
2
+
3
+ Use a classification rule when the unwanted item has a recognizable author and message pattern. Inspect a representative item first so the rule does not hide other feedback from the same bot. Prefer an existing settings control for polling- or output-wide behavior.
4
+
5
+ Place an `.mts` file in `.pr-shepherd/classification/` in the project. Shepherd uses the first classification directory on the working directory's ancestor chain; it stops at the home directory if reached, otherwise at the filesystem root. A home-level rule directory is not discovered when the project is outside the home tree. Rule directories do not merge. Files ending in `.ts`, `.mts`, `.mjs`, or `.js` load, except names beginning with `_` or `.`. An `.mts` rule can use erasable TypeScript syntax and `import type`, without transpilation-only features such as enums.
6
+
7
+ Each file default-exports a `ClassifyRule` from `pr-shepherd/classify`. A rule receives one of `review-thread`, `pr-comment`, `review-summary`, or `changes-requested`, with `author`, `authorType`, `body`, `id`, and optional `url` or thread `path`. Match the observed `kind`, login, and distinctive body text. For example:
8
+
9
+ ```ts
10
+ import type { ClassifyRule } from "pr-shepherd/classify";
11
+
12
+ const rule: ClassifyRule = (item) => {
13
+ if (item.kind !== "pr-comment" && item.kind !== "review-summary") return null;
14
+ if (item.author.toLowerCase() !== "gemini-code-assist") return null;
15
+ if (!/^You have reached your daily quota limit\b/i.test(item.body)) return null;
16
+ return { suppress: true };
17
+ };
18
+
19
+ export default rule;
20
+ ```
21
+
22
+ `suppress: true` removes a matched item from agent output. Add `autoResolve: true` only when the requested policy also calls for resolving its thread or minimizing its comment or review summary. It is unsupported for `changes-requested` reviews, whose dismissal needs a message. With both flags, `actions.autoMinimizeSuppressed: true` lets Shepherd perform the authorized mutation silently; when GitHub does not confirm capability, the item returns to normal first-look visibility. Matching rules combine their flags, so inspect existing rules before adding one.
23
+
24
+ Exercise the exported rule against the observed item and negative examples: a different author, kind, and substantive message from the same bot. Confirm only the intended item matches before enabling automatic resolution. After editing a rule, restart a persistent MCP server or long-running poll process; it caches loaded rule modules. A new CLI process loads the change. A classifier does not retroactively remove already displayed output.