pr-shepherd 0.51.0 → 0.52.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 +25 -17
  3. package/bin/checks/log-excerpt.d.mts +1 -0
  4. package/bin/checks/log-excerpt.mjs +192 -0
  5. package/bin/checks/triage.mjs +2 -121
  6. package/bin/cli/iterate-lean.mjs +0 -3
  7. package/bin/cli/poll-summary-emitter.mjs +2 -3
  8. package/bin/cli/poll-summary-formatter.mjs +9 -2
  9. package/bin/commands/check.mjs +1 -0
  10. package/bin/commands/iterate/escalate.mjs +0 -13
  11. package/bin/commands/iterate/fix-code.d.mts +2 -0
  12. package/bin/commands/iterate/fix-code.mjs +14 -42
  13. package/bin/commands/iterate/index.mjs +140 -3
  14. package/bin/commands/iterate/merge-state.mjs +33 -18
  15. package/bin/commands/iterate/parent-first.d.mts +9 -0
  16. package/bin/commands/iterate/parent-first.mjs +57 -0
  17. package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
  18. package/bin/commands/iterate/stale-ancestry.mjs +37 -0
  19. package/bin/commands/poll-summary-instructions.mjs +179 -99
  20. package/bin/commands/poll-summary.mjs +28 -4
  21. package/bin/exit-codes.d.mts +2 -0
  22. package/bin/exit-codes.mjs +2 -0
  23. package/bin/github/batch-parsers.mjs +1 -0
  24. package/bin/github/batch-raw-rules.d.mts +3 -0
  25. package/bin/github/gql/poll-summary-fragment.gql +54 -0
  26. package/bin/github/gql/pr-merge-policy.gql +3 -0
  27. package/bin/github/poll-summary-fingerprint.d.mts +8 -0
  28. package/bin/github/poll-summary-fingerprint.mjs +28 -0
  29. package/bin/github/poll-summary-projector.mjs +67 -14
  30. package/bin/github/poll-summary-queue-removal.d.mts +5 -0
  31. package/bin/github/poll-summary-queue-removal.mjs +16 -0
  32. package/bin/github/poll-summary-raw.d.mts +33 -0
  33. package/bin/github/poll-summary-readiness.d.mts +6 -0
  34. package/bin/github/poll-summary-readiness.mjs +25 -0
  35. package/bin/github/poll-summary.d.mts +3 -0
  36. package/bin/github/poll-summary.mjs +5 -0
  37. package/bin/state/ready-receipts.d.mts +45 -0
  38. package/bin/state/ready-receipts.mjs +86 -0
  39. package/bin/types/escalate.d.mts +2 -3
  40. package/bin/types/github.d.mts +2 -0
  41. package/bin/types/poll-summary.d.mts +12 -2
  42. package/bin/types/report.d.mts +2 -0
  43. package/package.json +4 -4
  44. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  45. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  46. package/plugins/pr-shepherd/.mcp.json +1 -1
  47. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
  48. package/bin/state/bot-cr-seen.d.mts +0 -51
  49. package/bin/state/bot-cr-seen.mjs +0 -100
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
2
3
  import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
3
4
  /** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
@@ -5,135 +6,214 @@ export function withPollSummaryInstructions(result, mergeRequested) {
5
6
  if (result.selection.kind !== "stack") {
6
7
  return { ...result, instructions: explicitInstructions(result) };
7
8
  }
8
- const planned = planStack(result, mergeRequested);
9
- const reason = planned.action === "cancel"
10
- ? "all_terminal"
11
- : planned.action === "wait"
12
- ? result.reason === "timeout"
13
- ? "timeout"
14
- : "waiting"
15
- : "actionable";
9
+ const prs = [...result.prs].sort((left, right) => position(left) - position(right));
10
+ const staleChildren = new Set(result.stackAncestry?.map((gap) => gap.childPr) ?? []);
11
+ const firstUnready = prs.find((item) => item.state === "OPEN" && (!isReady(item) || staleChildren.has(item.pr)));
12
+ const blocked = prs.map((item) => firstUnready && item.state === "OPEN" && position(item) > position(firstUnready)
13
+ ? {
14
+ ...item,
15
+ ...(["cancel", "mark_ready", "merge"].includes(item.action) && {
16
+ action: "wait",
17
+ reasons: [...item.reasons, "lower-layer-not-ready"],
18
+ }),
19
+ blockedByPr: firstUnready.pr,
20
+ ...(item.isDraft &&
21
+ item.pollCommand && {
22
+ pollCommand: item.pollCommand.replace(" --until-terminal", " --timeout 1s --debounce 0s") +
23
+ (item.pollCommand.includes("--no-auto-mark-ready") ? "" : " --no-auto-mark-ready"),
24
+ }),
25
+ }
26
+ : item.state === "OPEN" &&
27
+ (!isReady(item) || staleChildren.has(item.pr)) &&
28
+ ["cancel", "merge"].includes(item.action)
29
+ ? {
30
+ ...item,
31
+ action: "fix_code",
32
+ reasons: [
33
+ ...item.reasons,
34
+ staleChildren.has(item.pr) ? "stale-ancestry" : "ready-receipt-required",
35
+ ],
36
+ }
37
+ : item);
38
+ const projected = {
39
+ ...result,
40
+ prs: blocked.map((item) => {
41
+ if (item.state === "OPEN" && item.isInMergeQueue && item.action === "cancel") {
42
+ return {
43
+ ...item,
44
+ action: "wait",
45
+ reasons: [...item.reasons, "already-in-merge-queue"],
46
+ };
47
+ }
48
+ if (item.state === "CLOSED" && closedDependency(blocked, item.pr)) {
49
+ return {
50
+ ...item,
51
+ action: "escalate",
52
+ reasons: [...item.reasons, "closed-unmerged-dependency"],
53
+ };
54
+ }
55
+ if (item.state !== "OPEN" && item.state !== "MERGED") {
56
+ return {
57
+ ...item,
58
+ action: "escalate",
59
+ reasons: [...item.reasons, "unverified-stack-state"],
60
+ };
61
+ }
62
+ return item;
63
+ }),
64
+ };
65
+ const planned = planStack(projected, mergeRequested);
16
66
  const instructions = [...planned.instructions];
17
- if (result.quotaWarning && planned.action !== "wait" && planned.action !== "cancel") {
67
+ if (result.quotaWarning && planned.action === "shepherd") {
18
68
  instructions.push(buildQuotaAwareContinuation(result.quotaWarning, `${instructions.length + 1}. After completing the stack action,`));
19
69
  }
20
- return { ...result, reason, nextAction: planned.action, instructions };
70
+ return {
71
+ ...projected,
72
+ reason: planned.action === "cancel"
73
+ ? "all_terminal"
74
+ : planned.waiting
75
+ ? result.reason === "timeout"
76
+ ? "timeout"
77
+ : "waiting"
78
+ : "actionable",
79
+ stackMergeable: planned.stackMergeable,
80
+ nextAction: planned.action,
81
+ instructions,
82
+ };
21
83
  }
22
84
  function planStack(result, mergeRequested) {
23
85
  const open = result.prs.filter((item) => item.state === "OPEN");
24
- if (open.length === 0) {
25
- return { action: "cancel", instructions: ["1. Stop — every selected PR is terminal."] };
86
+ const lastOpen = open.at(-1);
87
+ const closedDependencyPr = result.prs.find((item) => item.state === "CLOSED" && lastOpen && position(item) < position(lastOpen));
88
+ const unverifiedLayer = result.prs.find((item) => item.state !== "OPEN" && item.state !== "MERGED");
89
+ const gaps = result.stackAncestry ?? [];
90
+ const stackMergeable = gaps.length === 0 && open.every(isReady);
91
+ const candidates = open.filter((item) => !isReady(item) || gaps.some((gap) => gap.childPr === item.pr));
92
+ const autonomousCandidates = candidates.filter((item) => item.action !== "escalate");
93
+ const runnableCandidates = autonomousCandidates.filter((item) => item.pollCommand);
94
+ const missingCommands = autonomousCandidates.filter((item) => !item.pollCommand);
95
+ const escalated = result.prs.filter((item) => item.action === "escalate");
96
+ if (closedDependencyPr || unverifiedLayer || escalated.length > 0) {
97
+ const instructions = [];
98
+ appendAutonomousInstructions(instructions, runnableCandidates);
99
+ const stop = runnableCandidates.length === 0;
100
+ if (closedDependencyPr) {
101
+ instructions.push(`${instructions.length + 1}. PR #${closedDependencyPr.pr} was closed without merging below an open layer. ${stop ? "Stop and ask" : "After autonomous shepherding, ask"} the stack owner whether to restore that dependency or rebuild the upper branches.`);
102
+ }
103
+ if (unverifiedLayer && unverifiedLayer.pr !== closedDependencyPr?.pr) {
104
+ instructions.push(`${instructions.length + 1}. PR #${unverifiedLayer.pr} has state \`${unverifiedLayer.state}\` rather than open or merged. ${stop ? "Stop and ask" : "After autonomous shepherding, ask"} the stack owner to reconcile this layer before declaring the stack complete.`);
105
+ }
106
+ for (const item of escalated) {
107
+ if (item.pr === closedDependencyPr?.pr || item.pr === unverifiedLayer?.pr)
108
+ continue;
109
+ instructions.push(`${instructions.length + 1}. PR #${item.pr} requires human action (${item.reasons.join(", ")}). ${stop ? "Stop for that decision." : "Keep shepherding other PRs before the handoff."}`);
110
+ }
111
+ for (const item of missingCommands) {
112
+ instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. Ask for direction.`);
113
+ }
114
+ if (runnableCandidates.length > 0) {
115
+ instructions.push(`${instructions.length + 1}. After the listed one-PR sessions, rerun this same \`--stack\` selector. Stop for the human handoff only when no autonomous shepherding remains.`);
116
+ }
117
+ return { action: stop ? "escalate" : "shepherd", stackMergeable: false, instructions };
26
118
  }
27
- const lastOpenPosition = positionOf(result, open.at(-1).pr);
28
- const closedBelowOpen = result.prs.find((item) => item.state === "CLOSED" && positionOf(result, item.pr) < lastOpenPosition);
29
- if (closedBelowOpen) {
119
+ if (open.length === 0) {
30
120
  return {
31
- action: "escalate",
32
- instructions: [
33
- `1. PR #${closedBelowOpen.pr} is closed without merging below an open stack layer. Stop stack merge and rebase operations here; the closed dependency must be restored or the higher branches rebuilt on a valid base.`,
34
- "2. Ask the stack owner which recovery path to take, then rerun the same aggregate `--stack` selector after the stack is repaired.",
35
- ],
121
+ action: "cancel",
122
+ stackMergeable: true,
123
+ instructions: ["1. Stop — every stack layer is merged."],
36
124
  };
37
125
  }
38
- const gap = result.stackAncestry?.[0];
39
- const firstOpen = open[0];
40
- if (firstOpen.mergeStateStatus === "BEHIND")
41
- return rebaseWholeStack(result, firstOpen);
42
- if (mergeRequested) {
43
- const mergeTarget = readyLowerStackTarget(open, result.stackAncestry ?? []);
44
- if (mergeTarget) {
45
- const stackNumber = result.selection.kind === "stack" ? result.selection.stackNumber : 0;
126
+ if (!stackMergeable) {
127
+ if (missingCommands.length > 0) {
128
+ const instructions = [];
129
+ appendAutonomousInstructions(instructions, runnableCandidates);
130
+ for (const item of missingCommands) {
131
+ instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. ${runnableCandidates.length === 0 ? "Stop and ask" : "After autonomous shepherding, ask"} for direction.`);
132
+ }
133
+ if (runnableCandidates.length > 0) {
134
+ instructions.push(`${instructions.length + 1}. After the listed one-PR sessions, rerun this same \`--stack\` selector. Stop for the human handoff only when no autonomous shepherding remains.`);
135
+ }
46
136
  return {
47
- action: "merge",
48
- instructions: [
49
- `1. The contiguous ready lower stack ends at PR #${mergeTarget.pr}. Merge the native stack through that PR with \`gh stack merge --squash ${mergeTarget.pr}\`; verify that the selector names PR #${mergeTarget.pr} in stack #${stackNumber} before running it. This includes still-open lower layers and leaves higher layers open.`,
50
- "2. After GitHub completes the stack merge and updates the remaining branches, rerun the same aggregate `--stack` selector. If an ancestry mismatch remains, follow the rebase instructions returned then.",
51
- ],
137
+ action: runnableCandidates.length > 0 ? "shepherd" : "escalate",
138
+ stackMergeable: false,
139
+ instructions,
52
140
  };
53
141
  }
54
- if (firstOpen.action === "wait") {
55
- return waitingStack(result);
56
- }
57
- }
58
- const firstWork = open.find((item) => ["fix_code", "mark_ready", "escalate"].includes(item.action));
59
- if (firstWork && (!gap || positionOf(result, firstWork.pr) <= positionOf(result, gap.childPr))) {
60
- return pollOneLayer(firstWork);
142
+ const instructions = [];
143
+ appendAutonomousInstructions(instructions, autonomousCandidates);
144
+ instructions.push(`${instructions.length + 1}. After the selected one-PR sessions, rerun this same \`--stack\` selector.`);
145
+ return { action: "shepherd", stackMergeable: false, instructions };
61
146
  }
62
- if (gap) {
147
+ if (open.some((item) => item.isInMergeQueue)) {
63
148
  return {
64
- action: "fix_code",
149
+ action: "wait",
150
+ stackMergeable: true,
151
+ waiting: true,
65
152
  instructions: [
66
- `1. PR #${gap.childPr} still records base \`${gap.childBaseRefName}\` at \`${gap.childBaseRefOid}\`, while parent PR #${gap.parentPr} now ends at \`${gap.parentHeadRefName}\` \`${gap.parentHeadRefOid}\`. From a clean checkout of \`${result.repo}\`, check out the parent stack branch \`${gap.parentHeadRefName}\`.`,
67
- "2. Rebase the upstack branches onto that parent with `gh stack rebase --upstack --no-trunk`, resolve any conflicts, and push the rewritten branches with `gh stack push`.",
68
- "3. Rerun the same aggregate `--stack` selector and follow the next returned action.",
153
+ "1. The stack is in the merge queue. Recheck at the configured polling cadence; finish only after every layer is merged, and route any ejected layer to its one-PR session.",
69
154
  ],
70
155
  };
71
156
  }
72
- const behind = open.find((item) => item.mergeStateStatus === "BEHIND");
73
- if (behind)
74
- return rebaseWholeStack(result, behind);
75
- if (firstWork)
76
- return pollOneLayer(firstWork);
77
- if (!mergeRequested && open.every((item) => item.action === "cancel")) {
157
+ if (!mergeRequested && result.prs.every((item) => item.action === "cancel")) {
78
158
  return {
79
159
  action: "cancel",
80
- instructions: ["1. Stop — every open stack layer is ready and the stack is linear."],
160
+ stackMergeable: true,
161
+ instructions: ["1. Stop — every stack layer is terminal or fully READY."],
81
162
  };
82
163
  }
83
- return waitingStack(result);
84
- }
85
- function rebaseWholeStack(result, behind) {
86
- return {
87
- action: "fix_code",
88
- instructions: [
89
- `1. GitHub reports PR #${behind.pr} is behind its base \`${behind.baseRefName}\`. From a clean checkout of \`${result.repo}\`, check out its stack branch \`${behind.headRefName}\`.`,
90
- "2. Rebase that native stack from its trunk with `gh stack rebase`, resolving any conflicts.",
91
- "3. Push the updated stack with `gh stack push` and rerun the same aggregate `--stack` selector.",
92
- ],
93
- };
94
- }
95
- function readyLowerStackTarget(open, gaps) {
96
- const mismatchedChildren = new Set(gaps.map((gap) => gap.childPr));
97
- let target;
98
- for (const item of open) {
99
- if (mismatchedChildren.has(item.pr) || item.action !== "merge")
100
- break;
101
- target = item;
102
- }
103
- return target;
104
- }
105
- function positionOf(result, pr) {
106
- return result.prs.find((item) => item.pr === pr)?.stack?.position ?? Number.MAX_SAFE_INTEGER;
107
- }
108
- function pollOneLayer(item) {
109
- if (!item.pollCommand) {
164
+ if (!mergeRequested) {
165
+ const instructions = [];
166
+ appendAutonomousInstructions(instructions, open.filter((item) => item.action !== "cancel"));
167
+ instructions.push(`${instructions.length + 1}. After the selected one-PR sessions, rerun this same \`--stack\` selector.`);
110
168
  return {
111
- action: "escalate",
112
- instructions: [
113
- `1. PR #${item.pr} needs attention, but GitHub returned no one-PR poll command.`,
114
- ],
169
+ action: "shepherd",
170
+ stackMergeable,
171
+ instructions,
115
172
  };
116
173
  }
174
+ const stackNumber = result.selection.kind === "stack" ? result.selection.stackNumber : 0;
117
175
  return {
118
- action: item.action,
176
+ action: "merge",
177
+ stackMergeable: true,
119
178
  instructions: [
120
- `1. Work on the lowest actionable layer, PR #${item.pr}: run \`${item.pollCommand}\`.`,
121
- "2. Follow that one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
122
- "3. Rerun the aggregate `--stack` selector before acting on a higher layer.",
179
+ "1. Check `gh stack merge --help`. If the `gh-stack` extension is unavailable, run `gh extension install github/gh-stack`, then rerun this same `--stack --merge` selector before merging.",
180
+ `2. Stack #${stackNumber} in \`${result.repo}\` is mergeable through PR #${open.at(-1).pr}. Run \`GH_REPO=${result.repo} gh stack merge --yes --squash ${stackNumber}\` to merge the whole native stack or enqueue it when the base uses a merge queue.`,
181
+ "3. After the merge attempt, rerun this same `--stack --merge` selector until every layer is merged (`CANCEL`); shepherd any layer that GitHub rejects or ejects.",
123
182
  ],
124
183
  };
125
184
  }
126
- function waitingStack(result) {
127
- if (result.quotaWarning) {
128
- return {
129
- action: "wait",
130
- instructions: [
131
- buildQuotaAwareContinuation(result.quotaWarning, "1. This native stack is non-terminal. Before continuing,"),
132
- ],
133
- };
185
+ function closedDependency(items, pr) {
186
+ const open = items.filter((item) => item.state === "OPEN");
187
+ const lastOpen = open.at(-1);
188
+ return Boolean(lastOpen &&
189
+ items.some((item) => item.pr === pr && item.state === "CLOSED" && position(item) < position(lastOpen)));
190
+ }
191
+ function appendAutonomousInstructions(instructions, candidates) {
192
+ if (candidates.length === 0)
193
+ return;
194
+ instructions.push(`${instructions.length + 1}. Start or delegate the relevant one-PR sessions below; review and CI work on separate layers can proceed concurrently.`);
195
+ for (const item of candidates) {
196
+ instructions.push(item.pollCommand
197
+ ? `${instructions.length + 1}. Run \`${item.pollCommand}\` for PR #${item.pr}${item.blockedByPr ? ` (stack-blocked by PR #${item.blockedByPr})` : ""}${item.queueRemoval ? `; GitHub removed it from the merge queue (${item.queueRemoval.reason ?? "unknown reason"})` : ""}.`
198
+ : `${instructions.length + 1}. PR #${item.pr} needs a one-PR Shepherd session, but no command was available.`);
134
199
  }
135
- return {
136
- action: "wait",
137
- instructions: ["1. Recheck this native stack after the lowest open layer changes state."],
138
- };
200
+ instructions.push(`${instructions.length + 1}. Keep upper draft PRs in draft until every lower layer has completed Shepherd READY.`);
201
+ }
202
+ function isReady(item) {
203
+ return (item.state === "OPEN" &&
204
+ item.stack !== undefined &&
205
+ item.readyReceipt === true &&
206
+ !item.isDraft &&
207
+ !item.queueRemoval &&
208
+ item.mergeable !== "CONFLICTING" &&
209
+ item.mergeStateStatus !== "DIRTY" &&
210
+ (item.isInMergeQueue || item.mergeable === "MERGEABLE") &&
211
+ (item.isInMergeQueue ||
212
+ !["DIRTY", "BEHIND", "UNKNOWN", "BLOCKED", "HAS_HOOKS"].includes(item.mergeStateStatus)) &&
213
+ (item.checks?.failing ?? 0) === 0 &&
214
+ (item.isInMergeQueue || (item.checks?.inProgress ?? 0) === 0) &&
215
+ (item.review?.actionable ?? 0) === 0);
216
+ }
217
+ function position(item) {
218
+ return item.stack?.position ?? Number.MAX_SAFE_INTEGER;
139
219
  }
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  import { loadConfig } from "../config/load.mjs";
2
3
  import { ShepherdError } from "../exit-codes.mjs";
3
4
  import { getRepoInfo } from "../github/client.mjs";
@@ -54,16 +55,31 @@ async function runAggregatePollCore(opts) {
54
55
  stackPrNumber: undefined,
55
56
  prNumbers: last.prs.map((item) => item.pr),
56
57
  });
57
- if (explicit.prs.every((item) => item.action === "cancel")) {
58
+ if (explicit.prs.every((item) => item.state === "MERGED")) {
58
59
  const warning = await aggregateQuotaWarning(explicit, quotaBands, opts.intervalSeconds);
59
60
  if (warning)
60
61
  pendingQuotaWarning = warning;
61
62
  return attachUsage({
62
63
  ...explicit,
64
+ selection: last.selection,
63
65
  reason: "all_terminal",
66
+ nextAction: "cancel",
67
+ stackMergeable: true,
68
+ instructions: ["1. Stop — every stack layer is merged."],
64
69
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
65
70
  }, opts.merge);
66
71
  }
72
+ return attachUsage({
73
+ ...explicit,
74
+ selection: last.selection,
75
+ reason: "actionable",
76
+ nextAction: "escalate",
77
+ stackMergeable: false,
78
+ instructions: [
79
+ `1. Native stack #${last.selection.stackNumber} disappeared before every tracked layer merged. Stop and ask the stack owner to reconcile the remaining PRs; do not claim the stack is complete.`,
80
+ ],
81
+ ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
82
+ }, opts.merge);
67
83
  }
68
84
  const retryMs = opts.untilTerminal ? pollGraphQlRetryAfterMs(error) : null;
69
85
  if (retryMs === null || rateLimitRetries >= 1)
@@ -77,10 +93,11 @@ async function runAggregatePollCore(opts) {
77
93
  ? last.nextAction === "cancel"
78
94
  : last.prs.every((item) => item.action === "cancel");
79
95
  const immediate = last.selection.kind === "stack"
80
- ? ["escalate", "merge", "mark_ready"].includes(last.nextAction ?? "wait")
96
+ ? (last.nextAction === "escalate" || last.nextAction === "merge") &&
97
+ last.reason !== "waiting"
81
98
  : last.prs.some((item) => ["escalate", "merge", "mark_ready"].includes(item.action));
82
99
  const hasFix = last.selection.kind === "stack"
83
- ? last.nextAction === "fix_code"
100
+ ? last.nextAction === "shepherd" && last.reason !== "waiting"
84
101
  : last.prs.some((item) => item.action === "fix_code");
85
102
  const warning = await aggregateQuotaWarning(last, quotaBands, opts.intervalSeconds);
86
103
  if (warning)
@@ -145,5 +162,12 @@ function isMissingStack(error) {
145
162
  }
146
163
  function attachUsage(result, mergeRequested) {
147
164
  const apiUsage = summarizeApiTelemetry();
148
- return withPollSummaryInstructions(apiUsage ? { ...result, apiUsage } : result, mergeRequested === true);
165
+ const enriched = apiUsage ? { ...result, apiUsage } : result;
166
+ // A disappeared native stack is an aggregate-level terminal handoff. Keep
167
+ // that explicit escalation intact even when the one-PR fallback contains
168
+ // open orphan rows that would otherwise be re-planned as fix_code.
169
+ if (enriched.selection.kind === "stack" && enriched.nextAction === "escalate") {
170
+ return enriched;
171
+ }
172
+ return withPollSummaryInstructions(enriched, mergeRequested === true);
149
173
  }
@@ -25,6 +25,8 @@ export declare const EXIT: Readonly<{
25
25
  readonly CLOSED: 14;
26
26
  /** Agent should run the emitted merge or queue command. */
27
27
  readonly MERGE: 15;
28
+ /** Aggregate stack has PRs to shepherd through one-PR sessions. */
29
+ readonly SHEPHERD: 16;
28
30
  /** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
29
31
  readonly USAGE: 64;
30
32
  /** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
@@ -24,6 +24,8 @@ export const EXIT = Object.freeze({
24
24
  CLOSED: 14,
25
25
  /** Agent should run the emitted merge or queue command. */
26
26
  MERGE: 15,
27
+ /** Aggregate stack has PRs to shepherd through one-PR sessions. */
28
+ SHEPHERD: 16,
27
29
  /** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
28
30
  USAGE: 64,
29
31
  /** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
@@ -125,6 +125,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
125
125
  null,
126
126
  },
127
127
  baseRefName: raw.baseRefName,
128
+ ...(raw.baseRef?.target?.oid && { baseRefOid: raw.baseRef.target.oid }),
128
129
  reviewRequests,
129
130
  latestReviews,
130
131
  reviewThreads,
@@ -80,6 +80,9 @@ interface RawRuleParameters {
80
80
  }>;
81
81
  }
82
82
  export interface RawBaseRef {
83
+ target?: {
84
+ oid: string;
85
+ } | null;
83
86
  branchProtectionRule: RawBranchProtectionRule | null;
84
87
  rules: {
85
88
  pageInfo?: {
@@ -3,6 +3,7 @@ fragment PollSummaryPr on PullRequest {
3
3
  title
4
4
  url
5
5
  state
6
+ updatedAt
6
7
  isDraft
7
8
  viewerCanUpdate
8
9
  headRefName
@@ -11,6 +12,26 @@ fragment PollSummaryPr on PullRequest {
11
12
  baseRefOid
12
13
  mergeable
13
14
  mergeStateStatus
15
+ lifecycleEvents: timelineItems(
16
+ last: 1
17
+ itemTypes: [CLOSED_EVENT, REOPENED_EVENT, CONVERT_TO_DRAFT_EVENT, READY_FOR_REVIEW_EVENT]
18
+ ) {
19
+ nodes {
20
+ __typename
21
+ ... on ClosedEvent {
22
+ createdAt
23
+ }
24
+ ... on ReopenedEvent {
25
+ createdAt
26
+ }
27
+ ... on ConvertToDraftEvent {
28
+ createdAt
29
+ }
30
+ ... on ReadyForReviewEvent {
31
+ createdAt
32
+ }
33
+ }
34
+ }
14
35
  reviewDecision
15
36
  reviewRequests(last: 50) {
16
37
  nodes {
@@ -38,6 +59,33 @@ fragment PollSummaryPr on PullRequest {
38
59
  }
39
60
  }
40
61
  isInMergeQueue
62
+ mergeQueueAdditions: timelineItems(last: 1, itemTypes: [ADDED_TO_MERGE_QUEUE_EVENT]) {
63
+ nodes {
64
+ ... on AddedToMergeQueueEvent {
65
+ createdAt
66
+ }
67
+ }
68
+ }
69
+ mergeQueueRemovals: timelineItems(last: 1, itemTypes: [REMOVED_FROM_MERGE_QUEUE_EVENT]) {
70
+ nodes {
71
+ ... on RemovedFromMergeQueueEvent {
72
+ id
73
+ reason
74
+ createdAt
75
+ actor {
76
+ login
77
+ }
78
+ beforeCommit {
79
+ oid
80
+ parents(first: 100) {
81
+ nodes {
82
+ oid
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
41
89
  mergeQueueEntry {
42
90
  headCommit {
43
91
  statusCheckRollup {
@@ -54,6 +102,9 @@ fragment PollSummaryPr on PullRequest {
54
102
  status
55
103
  conclusion
56
104
  detailsUrl
105
+ annotations(first: 1) {
106
+ totalCount
107
+ }
57
108
  checkSuite {
58
109
  workflowRun {
59
110
  databaseId
@@ -175,6 +226,9 @@ fragment PollSummaryPr on PullRequest {
175
226
  status
176
227
  conclusion
177
228
  detailsUrl
229
+ annotations(first: 1) {
230
+ totalCount
231
+ }
178
232
  checkSuite {
179
233
  workflowRun {
180
234
  databaseId
@@ -1,6 +1,9 @@
1
1
  fragment PrMergePolicy on PullRequest {
2
2
  isMergeQueueEnabled
3
3
  baseRef {
4
+ target {
5
+ oid
6
+ }
4
7
  branchProtectionRule {
5
8
  requiresApprovingReviews
6
9
  requiredApprovingReviewCount
@@ -0,0 +1,8 @@
1
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
2
+ /**
3
+ * Bind receipts to the review and head-commit CI evidence shared by one-PR
4
+ * completion and stack reconciliation. Queue entry, queue checks, and GitHub's
5
+ * computed merge state are checked separately: including them here would
6
+ * invalidate a READY receipt merely because a ready PR entered the queue.
7
+ */
8
+ export declare function fingerprintRawSummaryPr(raw: RawSummaryPr): string | null;
@@ -0,0 +1,28 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Bind receipts to the review and head-commit CI evidence shared by one-PR
4
+ * completion and stack reconciliation. Queue entry, queue checks, and GitHub's
5
+ * computed merge state are checked separately: including them here would
6
+ * invalidate a READY receipt merely because a ready PR entered the queue.
7
+ */
8
+ export function fingerprintRawSummaryPr(raw) {
9
+ if (!raw.updatedAt || !raw.headRefOid || !raw.baseRefOid)
10
+ return null;
11
+ const evidence = {
12
+ number: raw.number,
13
+ state: raw.state,
14
+ lifecycleEvents: raw.lifecycleEvents,
15
+ isDraft: raw.isDraft,
16
+ headRefOid: raw.headRefOid,
17
+ baseRefOid: raw.baseRefOid,
18
+ baseRefName: raw.baseRefName,
19
+ reviewDecision: raw.reviewDecision,
20
+ reviewRequests: raw.reviewRequests,
21
+ latestReviews: raw.latestReviews,
22
+ comments: raw.comments,
23
+ reviews: raw.reviews,
24
+ reviewThreads: raw.reviewThreads,
25
+ commits: raw.commits,
26
+ };
27
+ return createHash("sha256").update(JSON.stringify(evidence)).digest("hex");
28
+ }