pr-shepherd 0.51.1 → 0.52.1

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 (116) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +29 -18
  3. package/bin/cli/help-command-pages.d.mts +1 -1
  4. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  5. package/bin/cli/help-iterate-poll-pages.mjs +4 -3
  6. package/bin/cli/help-top-page.d.mts +1 -1
  7. package/bin/cli/help-top-page.mjs +3 -2
  8. package/bin/cli/help.d.mts +2 -2
  9. package/bin/cli/iterate-instructions.mjs +41 -0
  10. package/bin/cli/iterate-lean.mjs +1 -3
  11. package/bin/cli/poll-summary-emitter.mjs +2 -3
  12. package/bin/cli/poll-summary-formatter.mjs +12 -3
  13. package/bin/cli/runner.d.mts +1 -0
  14. package/bin/cli/runner.mjs +3 -1
  15. package/bin/commands/check.mjs +14 -4
  16. package/bin/commands/clean.mjs +7 -13
  17. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  18. package/bin/commands/iterate/check-instructions.mjs +11 -7
  19. package/bin/commands/iterate/escalate.mjs +4 -13
  20. package/bin/commands/iterate/fix-code.d.mts +2 -0
  21. package/bin/commands/iterate/fix-code.mjs +20 -43
  22. package/bin/commands/iterate/helpers.d.mts +0 -1
  23. package/bin/commands/iterate/helpers.mjs +0 -15
  24. package/bin/commands/iterate/index.mjs +178 -11
  25. package/bin/commands/iterate/merge-state.mjs +35 -18
  26. package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
  27. package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
  28. package/bin/commands/iterate/parent-first.d.mts +25 -0
  29. package/bin/commands/iterate/parent-first.mjs +70 -0
  30. package/bin/commands/iterate/render.d.mts +1 -1
  31. package/bin/commands/iterate/render.mjs +7 -12
  32. package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
  33. package/bin/commands/iterate/stale-ancestry.mjs +40 -0
  34. package/bin/commands/iterate/stall.mjs +40 -3
  35. package/bin/commands/poll-progress.d.mts +5 -1
  36. package/bin/commands/poll-progress.mjs +7 -1
  37. package/bin/commands/poll-summary-instructions.d.mts +6 -1
  38. package/bin/commands/poll-summary-instructions.mjs +151 -107
  39. package/bin/commands/poll-summary.mjs +32 -6
  40. package/bin/commands/poll.mjs +3 -1
  41. package/bin/commands/ready-delay.d.mts +9 -4
  42. package/bin/commands/ready-delay.mjs +34 -20
  43. package/bin/commands/shepherd-journal.mjs +4 -1
  44. package/bin/commands/stack-drain.d.mts +35 -0
  45. package/bin/commands/stack-drain.mjs +129 -0
  46. package/bin/commands/stack-layer-readiness.d.mts +7 -0
  47. package/bin/commands/stack-layer-readiness.mjs +35 -0
  48. package/bin/commands/stack-stall.d.mts +14 -0
  49. package/bin/commands/stack-stall.mjs +69 -0
  50. package/bin/commands/stack-work.d.mts +32 -0
  51. package/bin/commands/stack-work.mjs +39 -0
  52. package/bin/config/load.d.mts +2 -0
  53. package/bin/config/load.mjs +10 -0
  54. package/bin/config.json +1 -0
  55. package/bin/exit-codes.d.mts +2 -0
  56. package/bin/exit-codes.mjs +2 -0
  57. package/bin/github/batch-parsers.mjs +1 -0
  58. package/bin/github/batch-raw-types.d.mts +2 -0
  59. package/bin/github/errors.d.mts +5 -0
  60. package/bin/github/errors.mjs +4 -0
  61. package/bin/github/gql/batch-pr.gql +1 -0
  62. package/bin/github/gql/poll-stack-summary.gql +8 -2
  63. package/bin/github/gql/poll-stack-topology.gql +38 -0
  64. package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
  65. package/bin/github/gql/poll-summary-check-page.gql +23 -0
  66. package/bin/github/gql/poll-summary-fragment.gql +53 -56
  67. package/bin/github/merge-queue-checks.mjs +10 -1
  68. package/bin/github/poll-summary-check-hydration.d.mts +12 -0
  69. package/bin/github/poll-summary-check-hydration.mjs +55 -0
  70. package/bin/github/poll-summary-fingerprint.d.mts +8 -0
  71. package/bin/github/poll-summary-fingerprint.mjs +48 -0
  72. package/bin/github/poll-summary-projector.mjs +70 -16
  73. package/bin/github/poll-summary-queue-removal.d.mts +5 -0
  74. package/bin/github/poll-summary-queue-removal.mjs +25 -0
  75. package/bin/github/poll-summary-raw.d.mts +52 -31
  76. package/bin/github/poll-summary-readiness.d.mts +6 -0
  77. package/bin/github/poll-summary-readiness.mjs +25 -0
  78. package/bin/github/poll-summary-route.mjs +8 -5
  79. package/bin/github/poll-summary.d.mts +3 -0
  80. package/bin/github/poll-summary.mjs +21 -73
  81. package/bin/github/queries.d.mts +7 -0
  82. package/bin/github/queries.mjs +9 -1
  83. package/bin/github/queue-removal-freshness.d.mts +16 -0
  84. package/bin/github/queue-removal-freshness.mjs +26 -0
  85. package/bin/github/stack-read.d.mts +34 -0
  86. package/bin/github/stack-read.mjs +92 -0
  87. package/bin/log/log-file.d.mts +1 -1
  88. package/bin/log/log-file.mjs +4 -17
  89. package/bin/state/base.d.mts +18 -1
  90. package/bin/state/base.mjs +65 -13
  91. package/bin/state/fix-attempts.d.mts +1 -1
  92. package/bin/state/fix-attempts.mjs +1 -1
  93. package/bin/state/graphql-quota-warnings.mjs +2 -6
  94. package/bin/state/iterate-stall.d.mts +7 -15
  95. package/bin/state/iterate-stall.mjs +6 -64
  96. package/bin/state/ready-receipts.d.mts +45 -0
  97. package/bin/state/ready-receipts.mjs +86 -0
  98. package/bin/state/rest-cache.d.mts +1 -1
  99. package/bin/state/rest-cache.mjs +1 -1
  100. package/bin/state/stack-stall.d.mts +16 -0
  101. package/bin/state/stack-stall.mjs +12 -0
  102. package/bin/state/stall-state-store.d.mts +37 -0
  103. package/bin/state/stall-state-store.mjs +74 -0
  104. package/bin/types/escalate.d.mts +2 -3
  105. package/bin/types/github.d.mts +2 -0
  106. package/bin/types/iterate.d.mts +2 -1
  107. package/bin/types/merge-requirements.d.mts +19 -0
  108. package/bin/types/poll-summary.d.mts +17 -2
  109. package/bin/types/report.d.mts +2 -0
  110. package/package.json +2 -2
  111. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  112. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  113. package/plugins/pr-shepherd/.mcp.json +1 -1
  114. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
  115. package/bin/state/bot-cr-seen.d.mts +0 -51
  116. package/bin/state/bot-cr-seen.mjs +0 -100
@@ -1,5 +1,4 @@
1
1
  import { loadConfig } from "../../config/load.mjs";
2
- import { inlineCode } from "../../util/markdown.mjs";
3
2
  import { renderResolveCommand } from "./render.mjs";
4
3
  function renderEscalateAuthor(item) {
5
4
  return [`@${item.author}`, item.authorType, item.authorAssociation].filter(Boolean).join(" · ");
@@ -194,10 +193,6 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
194
193
  if (removal.beforeCommitOid)
195
194
  lines.push(`- queue commit: \`${removal.beforeCommitOid}\``);
196
195
  }
197
- if (escalate.stack) {
198
- const s = escalate.stack;
199
- lines.push("", "## GitHub stack", "", `- layer: \`${s.position}\` of \`${s.size}\` in stack \`${s.number}\``, `- stack base: ${inlineCode(s.baseRefName)}`);
200
- }
201
196
  if (escalate.authorization && escalate.authorization.length > 0) {
202
197
  lines.push("");
203
198
  lines.push("## Authorization");
@@ -231,10 +226,6 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
231
226
  return lines.join("\n");
232
227
  }
233
228
  export function buildEscalateSuggestion(triggers, detail) {
234
- if (triggers.includes("stacked-pr")) {
235
- const selector = detail ?? "<pr>";
236
- return `This PR belongs to a GitHub stack, so Shepherd will not emit a merge command. \`gh pr merge\` targets the PR's own base branch — for a mid-stack layer that is the unmerged parent branch, not the stack's base — and auto-merge is unsupported on stacked PRs. Merge from the GitHub stack UI, or run \`gh stack merge --squash ${selector}\` (requires the \`github/gh-stack\` extension — run \`gh extension install github/gh-stack\` first if it's not installed), which lands this PR and every unmerged layer below it.`;
237
- }
238
229
  if (triggers.includes("check-follow-up-unavailable")) {
239
230
  return "One or more failing checks have no autonomous follow-up available. Use the displayed conclusion, run or URL, and included evidence to handle them manually.";
240
231
  }
@@ -249,6 +240,10 @@ export function buildEscalateSuggestion(triggers, detail) {
249
240
  const duration = detail ?? "60 minutes";
250
241
  return `No progress detected for ${duration} — state has not changed. This is a manual checkpoint: inspect the PR and apply a manual fix before resuming.`;
251
242
  }
243
+ if (triggers.includes("stall-state-unavailable")) {
244
+ const reason = detail ?? "unknown error";
245
+ return `The stall timer could not be saved (${reason}). Fix PR_SHEPHERD_STATE_DIR or the directory permissions, then resume. Automated polling is paused because a stuck loop would not be detected.`;
246
+ }
252
247
  if (triggers.includes("base-branch-unknown")) {
253
248
  const reason = detail ? ` (${detail})` : "";
254
249
  return `Could not determine the PR's base branch${reason} — automated rebases are paused because branch safety is unclear. Run the rebase manually against the PR's real target branch.`;
@@ -257,9 +252,5 @@ export function buildEscalateSuggestion(triggers, detail) {
257
252
  const attempts = loadConfig().iterate.fixAttemptsPerThread;
258
253
  return `The same thread(s) remain unresolved after their pending review commands were returned for ${attempts} FIX_CODE ticks. Automated iteration is paused for a manual decision.`;
259
254
  }
260
- if (triggers.includes("bot-cr-not-dismissed")) {
261
- const ids = detail ? ` (review IDs: ${detail})` : "";
262
- return `Bot CHANGES_REQUESTED review(s) remained undismissed past the stall window${ids}. The agent likely dropped \`--dismiss-review-ids\` from a prior apply command. Dismiss the review(s) manually (or re-run \`pr-shepherd apply review\` with the IDs) to unblock the PR.`;
263
- }
264
255
  return "Ambiguous state — automated handling cannot proceed safely. Inspect the PR and act manually.";
265
256
  }
@@ -20,6 +20,8 @@ interface HandleFixCodeContext {
20
20
  surfacedApprovals: Review[];
21
21
  botUsernames: NormalizedBotUsernames;
22
22
  ruleAutoResolveThreadIds?: string[];
23
+ /** Verified stack-repair guidance, when ancestry is stale. */
24
+ repairInstructions?: string[];
23
25
  }
24
26
  export declare function handleFixCode(ctx: HandleFixCodeContext): Promise<IterateResult>;
25
27
  export {};
@@ -1,12 +1,12 @@
1
1
  /* eslint-disable max-lines */
2
2
  import { readFixAttempts, writeFixAttempts, } from "../../state/fix-attempts.mjs";
3
- import { readBotCrSeenState, writeBotCrSeenState, updateBotCrSeenState, } from "../../state/bot-cr-seen.mjs";
4
3
  import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
5
4
  import { hashBody, markSeen } from "../../state/seen-comments.mjs";
6
5
  import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
7
6
  import { buildResolveCommand } from "./classify.mjs";
8
7
  import { buildThreadMutationRouting, threadHasAuthorizedMutation, } from "./thread-mutation-routing.mjs";
9
8
  import { buildFixInstructions } from "./render.mjs";
9
+ import { buildNativeStackLayerRebase } from "./native-stack-rebase.mjs";
10
10
  import { applyStallGuard } from "./stall.mjs";
11
11
  import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
12
12
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
@@ -17,15 +17,15 @@ import { formatPrUrl } from "../../pr-reference.mjs";
17
17
  function checkRequiresHumanFollowUp(check) {
18
18
  if (check.rerunCommand)
19
19
  return false;
20
- // Once GitHub advances beyond the original attempt, Shepherd's one autonomous rerun has
21
- // already been consumed. Hand the repeated failure off even when logs are available; the
22
- // human still receives that evidence in the escalation payload.
23
- if (check.runAttempt !== undefined && check.runAttempt > 1)
24
- return true;
25
20
  if (check.conclusion === "ACTION_REQUIRED" ||
26
21
  check.conclusion === "CANCELLED" ||
27
22
  check.conclusion === "STARTUP_FAILURE")
28
23
  return true;
24
+ // A later attempt cannot be rerun automatically, but its included log can still
25
+ // identify a code or configuration fix for the agent. Only a later failure with
26
+ // no actionable evidence needs a human handoff.
27
+ if (check.runAttempt !== undefined && check.runAttempt > 1)
28
+ return !check.logExcerpt?.trim();
29
29
  // An external check's direct URL is actionable evidence: the agent can inspect the
30
30
  // provider and/or reproduce the reported failure locally. Only a truly bare check
31
31
  // has no autonomous investigation path.
@@ -68,7 +68,7 @@ function pendingReviewCommands(resolveCommand, resolveOnlyCommand) {
68
68
  return Object.keys(pending).length > 0 ? pending : undefined;
69
69
  }
70
70
  export async function handleFixCode(ctx) {
71
- const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, } = ctx;
71
+ const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
72
72
  const prReference = formatPrUrl(report.repo, prNumber);
73
73
  const failingChecks = report.checks.failing;
74
74
  const annotatedExtra = checksWithActionableAnnotations(report).filter((c) => c.category !== "failing");
@@ -105,37 +105,6 @@ export async function handleFixCode(ctx) {
105
105
  ...(report.comments.minimizeIds ?? report.comments.actionable.map((comment) => comment.id)),
106
106
  ...reviewSummaryIds,
107
107
  ], changesRequestedReviewsForWork, checks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads, resolveOtherHumanThreads);
108
- const botCrReviews = report.changesRequestedReviews.filter((r) => (!isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames)) &&
109
- report.viewerAuthorization?.viewerCanAdminister === true);
110
- const botCrStateKey = { owner: repoOwner, repo: repoName, pr: prNumber };
111
- const previousBotCrState = await readBotCrSeenState(botCrStateKey);
112
- const nowSeconds = Math.floor(Date.now() / 1000);
113
- const { next: nextBotCrState, staleIds: staleBotCrIds } = updateBotCrSeenState(previousBotCrState, botCrReviews, nowSeconds, stallTimeoutSeconds);
114
- await writeBotCrSeenState(botCrStateKey, nextBotCrState);
115
- if (staleBotCrIds.length > 0) {
116
- const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(toAgentChecks(failingChecks));
117
- const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
118
- const escalateBase = {
119
- triggers: ["bot-cr-not-dismissed"],
120
- unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
121
- ambiguousComments: report.comments.actionable.map(toAgentComment),
122
- changesRequestedReviews: report.changesRequestedReviews,
123
- ...(firstLookSummaries.length > 0 && { firstLookSummaries }),
124
- ...(editedSummaries.length > 0 && { editedSummaries }),
125
- ...(pending && { pendingReviewCommands: pending }),
126
- suggestion: buildEscalateSuggestion(["bot-cr-not-dismissed"], staleBotCrIds.join(", ")),
127
- };
128
- return {
129
- ...base,
130
- action: "escalate",
131
- escalate: {
132
- ...escalateBase,
133
- humanMessage: buildEscalateHumanMessage(escalateBase, prReference, {
134
- merge: opts.merge,
135
- }),
136
- },
137
- };
138
- }
139
108
  const escalateTriggers = countFixCodeAttempt
140
109
  ? checkEscalateTriggers(retryableActionableThreads, priorThreadAttempts)
141
110
  : { triggers: [], thrashHistory: undefined };
@@ -216,8 +185,9 @@ export async function handleFixCode(ctx) {
216
185
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
217
186
  const belongsToActiveWorkflowRun = (check) => check.runId !== null && inProgressWorkflowRunIds.has(check.runId);
218
187
  const manualFollowUpChecks = failingAgentChecks.filter((check) => !belongsToActiveWorkflowRun(check) && checkRequiresHumanFollowUp(check));
219
- const exhaustedAttempts = manualFollowUpChecks.filter((check) => check.runAttempt !== undefined && check.runAttempt > 1);
220
- const hasBehindBaseRecovery = isBehind && exhaustedAttempts.length > 0;
188
+ const exhaustedAttempts = failingAgentChecks.filter((check) => check.runAttempt !== undefined && check.runAttempt > 1);
189
+ const manualExhaustedAttempts = exhaustedAttempts.filter((check) => manualFollowUpChecks.includes(check));
190
+ const hasBehindBaseRecovery = isBehind && manualExhaustedAttempts.length > 0;
221
191
  const hasAutonomousWork = hasConflicts ||
222
192
  hasBehindBaseRecovery ||
223
193
  threads.length > 0 ||
@@ -235,8 +205,8 @@ export async function handleFixCode(ctx) {
235
205
  if (manualFollowUpChecks.length > 0 && !hasAutonomousWork) {
236
206
  const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(failingAgentChecks);
237
207
  const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
238
- const checkSuggestion = exhaustedAttempts.length > 0
239
- ? `GitHub reports a later workflow attempt (${exhaustedAttempts
208
+ const checkSuggestion = manualExhaustedAttempts.length > 0
209
+ ? `GitHub reports a later workflow attempt (${manualExhaustedAttempts
240
210
  .map((check) => `${check.runId ?? check.name}: attempt ${check.runAttempt}`)
241
211
  .join(", ")}), so Shepherd's single rerun allowance is exhausted. Use the included evidence to handle the repeated failure manually before resuming.`
242
212
  : buildEscalateSuggestion(["check-follow-up-unavailable"]);
@@ -302,7 +272,14 @@ export async function handleFixCode(ctx) {
302
272
  }
303
273
  const firstLookThreads = report.threads.firstLook;
304
274
  const firstLookComments = report.comments.firstLook;
305
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, exhaustedAttempts.length > 0);
275
+ // Conflicts, and a behind branch whose rerun already failed, both ask for a branch update.
276
+ const stackRebase = hasConflicts || (isBehind && exhaustedAttempts.length > 0)
277
+ ? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, report.mergeStatus.mergeRequirements?.stack)
278
+ : undefined;
279
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, exhaustedAttempts.length > 0, stackRebase);
280
+ if (repairInstructions && repairInstructions.length > 0) {
281
+ instructions.unshift(...repairInstructions);
282
+ }
306
283
  const prospectiveResult = {
307
284
  ...base,
308
285
  baseBranch: baseLookup.branch,
@@ -7,6 +7,5 @@ export declare function buildTerminalCancelResult(report: ShepherdReport): Itera
7
7
  /** Build completed, non-skipped checks relevant to PR readiness. */
8
8
  export declare function buildRelevantChecks(report: ShepherdReport): RelevantCheck[];
9
9
  export declare function buildActiveChecks(report: ShepherdReport): ActiveCheck[];
10
- export declare function getCurrentHeadSha(): Promise<string | null>;
11
10
  export declare function buildWaitLog(base: IterateResultBase): string;
12
11
  export declare function blockedCancelNote(base: IterateResultBase): string;
@@ -1,8 +1,4 @@
1
- import { execFile as execFileCb } from "node:child_process";
2
- import { promisify } from "node:util";
3
- import { getExecutionCwd } from "../../execution-context.mjs";
4
1
  import { blockedReasonFromRequirements } from "../../merge-status/requirements-format.mjs";
5
- const execFile = promisify(execFileCb);
6
2
  export function buildSummary(report) {
7
3
  return {
8
4
  passing: report.checks.passing.length,
@@ -104,17 +100,6 @@ export function buildActiveChecks(report) {
104
100
  ...(c.commitOid !== undefined && { commitOid: c.commitOid }),
105
101
  }));
106
102
  }
107
- export async function getCurrentHeadSha() {
108
- try {
109
- const { stdout } = await execFile("git", ["rev-parse", "HEAD"], {
110
- cwd: getExecutionCwd(),
111
- });
112
- return stdout.trim();
113
- }
114
- catch {
115
- return null;
116
- }
117
- }
118
103
  export function buildWaitLog(base) {
119
104
  const { summary, remainingSeconds } = base;
120
105
  const parts = [`WAIT: ${summary.passing} passing, ${summary.inProgress} in-progress`];
@@ -1,10 +1,10 @@
1
1
  /* eslint-disable max-lines */
2
2
  import { runCheck } from "../check.mjs";
3
- import { updateReadyDelay } from "../ready-delay.mjs";
3
+ import { clearReadyDelay, updateReadyDelay } from "../ready-delay.mjs";
4
4
  import { getCurrentPrNumber } from "../../github/client.mjs";
5
5
  import { loadConfig } from "../../config/load.mjs";
6
6
  import { EXIT, ShepherdError } from "../../exit-codes.mjs";
7
- import { getCurrentHeadSha, buildWaitLog, buildTerminalCancelResult, blockedCancelNote, } from "./helpers.mjs";
7
+ import { buildWaitLog, buildTerminalCancelResult, blockedCancelNote } from "./helpers.mjs";
8
8
  import { classifyReviewSummaries } from "./classify.mjs";
9
9
  import { applyStallGuard } from "./stall.mjs";
10
10
  import { clearStallState } from "../../state/iterate-stall.mjs";
@@ -16,6 +16,14 @@ import { buildReadyMergeOutcome, handleActiveMergeState } from "./merge-state.mj
16
16
  import { buildIterateBase } from "./base.mjs";
17
17
  import { markReadyIfAuthorized } from "./mark-ready.mjs";
18
18
  import { withIterateApiUsage } from "./run.mjs";
19
+ import { fetchRawSummaryPr } from "../../github/poll-summary.mjs";
20
+ import { summarizePollSummaryPr } from "../../github/poll-summary-projector.mjs";
21
+ import { fingerprintRawSummaryPr } from "../../github/poll-summary-fingerprint.mjs";
22
+ import { currentQueueRemovalEvent } from "../../github/poll-summary-queue-removal.mjs";
23
+ import { isCurrentSummaryReady } from "../../github/poll-summary-readiness.mjs";
24
+ import { clearReadyReceipt, isReadyReceiptCurrent, readReadyReceipt, writeReadyReceipt, } from "../../state/ready-receipts.mjs";
25
+ import { findParentMarkReadyBlock, heldByLowerLayer, stackDraftHold } from "./parent-first.mjs";
26
+ import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
19
27
  export function runIterate(opts) {
20
28
  return withIterateApiUsage(opts, () => runIterateCore(opts));
21
29
  }
@@ -43,8 +51,10 @@ async function runIterateCore(opts) {
43
51
  throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
44
52
  }
45
53
  const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
54
+ const receiptKey = { owner: repoOwner, repo: repoName, pr: report.pr };
46
55
  if (report.mergeStatus.state !== "OPEN") {
47
- await updateReadyDelay(report.pr, false, readyDelaySeconds, repoOwner, repoName);
56
+ await clearReadyDelay(report.pr, repoOwner, repoName);
57
+ await clearReadyReceipt(receiptKey);
48
58
  await clearStallState(stallKey);
49
59
  return buildTerminalCancelResult(report);
50
60
  }
@@ -66,24 +76,40 @@ async function runIterateCore(opts) {
66
76
  if (unminimized.length > 0)
67
77
  reviewSummaryIds = [...reviewSummaryIds, ...unminimized];
68
78
  }
69
- const hasActionableWork = report.threads.actionable.length > 0 ||
79
+ // Hidden PR comments surface once so the agent can acknowledge them, but
80
+ // they are not readiness evidence: bots keep editing hidden notices after a
81
+ // PR settles. They never restart the ready-delay or void a READY receipt.
82
+ const hasReadinessWork = report.threads.actionable.length > 0 ||
70
83
  report.threads.resolutionOnly.length > 0 ||
71
84
  report.threads.firstLook.length > 0 ||
72
85
  (report.threads.ruleAutoResolveIds?.length ?? 0) > 0 ||
73
86
  report.comments.actionable.length > 0 ||
74
87
  (report.comments.minimizeIds?.length ?? 0) > 0 ||
75
- report.comments.firstLook.length > 0 ||
76
88
  report.changesRequestedReviews.length > 0 ||
77
89
  hasCheckDrivenActionableWork(report.checks, report.mergeStatus.status) ||
78
90
  reviewSummaryIds.length > 0 ||
79
91
  firstLookSummaries.length > 0 ||
80
92
  editedSummaries.length > 0 ||
81
93
  (config.iterate.minimizeApprovals && surfacedApprovals.length > 0);
94
+ const hasActionableWork = hasReadinessWork || report.comments.firstLook.length > 0;
82
95
  const activeMerge = Boolean(opts.merge && (report.mergeQueue?.inQueue || report.mergeQueue?.autoMergeRequest));
83
- const isCleanReadyState = report.status === "READY" && !hasActionableWork && !activeMerge;
84
- const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName);
96
+ const staleAncestry = await findStaleNativeStackAncestry(report, {
97
+ owner: repoOwner,
98
+ name: repoName,
99
+ });
100
+ const isCleanReadyState = report.status === "READY" &&
101
+ !report.mergeStatus.isDraft &&
102
+ !hasReadinessWork &&
103
+ !activeMerge &&
104
+ staleAncestry === null;
105
+ const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork);
106
+ const headSha = report.headSha ?? "unknown";
107
+ // The elapsed marker survives a hidden-comment acknowledgement tick and is
108
+ // consumed only when this tick cancels or merges. A receipt that is still
109
+ // current already proves the delay elapsed for this exact head, base, and
110
+ // readiness evidence, so a rerun (e.g. with --merge) does not wait again.
111
+ const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName, { headSha, alreadyElapsed: receiptCurrent });
85
112
  const base = buildIterateBase(report, readyState);
86
- const headSha = (await getCurrentHeadSha()) ?? "unknown";
87
113
  // Checks (including merge-queue synthetic-commit checks) and hard conflicts are signals
88
114
  // GitHub itself is already acting on — the queue will eject the PR for these regardless of
89
115
  // what Shepherd does, so they always surface immediately. Only review threads/comments/
@@ -126,13 +152,51 @@ async function runIterateCore(opts) {
126
152
  });
127
153
  if (mergeStateResult)
128
154
  return mergeStateResult;
155
+ if (staleAncestry) {
156
+ return handleFixCode({
157
+ base,
158
+ report,
159
+ opts: { ...opts, prNumber, neverCancelRuns },
160
+ headSha,
161
+ stallKey,
162
+ prNumber,
163
+ stallTimeoutSeconds,
164
+ repoOwner,
165
+ repoName,
166
+ reviewSummaryIds,
167
+ firstLookSummaries,
168
+ editedSummaries,
169
+ surfacedApprovals,
170
+ botUsernames,
171
+ repairInstructions: staleAncestry.instructions,
172
+ ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
173
+ });
174
+ }
129
175
  const canMarkReady = report.status === "READY" &&
130
176
  report.mergeStatus.isDraft &&
131
177
  !report.mergeStatus.blockingBotReviewInProgress;
132
- const markReadyResult = await markReadyIfAuthorized(canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady, base, report);
178
+ const parentBlock = canMarkReady
179
+ ? await findParentMarkReadyBlock(report, { owner: repoOwner, name: repoName })
180
+ : undefined;
181
+ const autoMarkReady = !opts.noAutoMarkReady && config.actions.autoMarkReady;
182
+ const markReadyResult = await markReadyIfAuthorized(canMarkReady && !parentBlock && autoMarkReady, base, report);
133
183
  if (markReadyResult)
134
184
  return markReadyResult;
135
- if (readyState.shouldCancel) {
185
+ if (readyState.shouldCancel && !report.mergeStatus.isDraft) {
186
+ const receiptWritten = receiptCurrent || (await recordReadyReceipt(receiptKey, report));
187
+ // Aggregate --stack routing trusts only a layer's receipt, so an unwritten
188
+ // one keeps the elapsed marker and retries next tick. A one-PR receipt
189
+ // only lets a rerun skip the wait, so its failure never changes the action.
190
+ if (!receiptWritten && report.mergeStatus.mergeRequirements?.stack !== undefined) {
191
+ const receiptWait = {
192
+ ...base,
193
+ action: "wait",
194
+ shouldCancel: false,
195
+ log: `WAIT: PR #${base.pr} reached ready-delay but its stack readiness receipt could not be persisted`,
196
+ };
197
+ return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, receiptWait, report, reviewSummaryIds);
198
+ }
199
+ await clearReadyDelay(report.pr, repoOwner, repoName);
136
200
  await clearStallState(stallKey);
137
201
  const mergeResult = buildReadyMergeOutcome(opts.merge, true, base, report);
138
202
  if (mergeResult)
@@ -145,5 +209,108 @@ async function runIterateCore(opts) {
145
209
  log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
146
210
  };
147
211
  }
148
- return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, { ...base, action: "wait", log: buildWaitLog(base) }, report, reviewSummaryIds);
212
+ const hold = stackDraftHold(report, autoMarkReady, parentBlock);
213
+ const wait = {
214
+ ...base,
215
+ action: "wait",
216
+ log: buildWaitLog(base),
217
+ ...(hold && { stackDraftHold: hold }),
218
+ };
219
+ // The lower layer's own session owns progress here, so this draft cannot
220
+ // stall; its stall clock restarts once that layer releases it.
221
+ if (heldByLowerLayer(wait)) {
222
+ await clearStallState(stallKey);
223
+ return wait;
224
+ }
225
+ return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, report, reviewSummaryIds);
226
+ }
227
+ async function recordReadyReceipt(key, report) {
228
+ if (report.status !== "READY" ||
229
+ report.mergeStatus.state !== "OPEN" ||
230
+ report.mergeStatus.isDraft ||
231
+ !report.headSha ||
232
+ !report.baseRefOid)
233
+ return false;
234
+ try {
235
+ const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
236
+ const fingerprint = fingerprintRawSummaryPr(raw);
237
+ if (fingerprint === null ||
238
+ raw.state !== "OPEN" ||
239
+ raw.isDraft ||
240
+ raw.headRefOid !== report.headSha ||
241
+ raw.baseRefOid !== report.baseRefOid)
242
+ return false;
243
+ const summary = await summarizePollSummaryPr(raw, { owner: key.owner, name: key.repo }, { stackPrNumber: report.pr });
244
+ if (!isCurrentSummaryReady(raw, summary.checks ?? {}, summary.review ?? {}))
245
+ return false;
246
+ const removalEvent = currentQueueRemovalEvent(raw);
247
+ await writeReadyReceipt({
248
+ version: 1,
249
+ ...key,
250
+ headRefOid: raw.headRefOid,
251
+ baseRefOid: raw.baseRefOid,
252
+ status: "READY",
253
+ isDraft: false,
254
+ readinessFingerprint: fingerprint,
255
+ ...(removalEvent?.id && {
256
+ acknowledgedQueueRemovalId: removalEvent.id,
257
+ }),
258
+ recordedAtUnix: Math.floor(Date.now() / 1000),
259
+ });
260
+ return true;
261
+ }
262
+ catch {
263
+ // The receipt is supplementary evidence. A transient summary fetch or
264
+ // state-directory failure must not turn a completed one-PR poll into a
265
+ // different Shepherd action.
266
+ return false;
267
+ }
268
+ }
269
+ /** Clear a stale READY receipt; return whether a current receipt remains. */
270
+ async function revalidateReadyReceipt(key, report, hasReadinessWork) {
271
+ const receipt = await readReadyReceipt(key);
272
+ if (!receipt)
273
+ return false;
274
+ const retainQueuedReceipt = report.mergeQueue?.inQueue === true &&
275
+ report.mergeStatus.state === "OPEN" &&
276
+ !report.mergeStatus.isDraft &&
277
+ Boolean(report.headSha && report.baseRefOid);
278
+ if (report.mergeStatus.state !== "OPEN" ||
279
+ report.mergeStatus.isDraft ||
280
+ !report.headSha ||
281
+ !report.baseRefOid ||
282
+ (!retainQueuedReceipt && (report.status !== "READY" || hasReadinessWork))) {
283
+ await clearReadyReceipt(key);
284
+ return false;
285
+ }
286
+ try {
287
+ const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
288
+ // Queue predecessors can advance the target branch without changing this
289
+ // PR's source. Compare against the receipt's base only while both fresh
290
+ // views still place the PR in the queue.
291
+ const queuedBaseAdvanced = retainQueuedReceipt && raw.isInMergeQueue;
292
+ const fingerprint = fingerprintRawSummaryPr(queuedBaseAdvanced ? { ...raw, baseRefOid: receipt.baseRefOid } : raw);
293
+ // The fresh snapshot must name the commits this tick evaluated, or the
294
+ // receipt would vouch for a head or base the report never saw.
295
+ const sameCommits = raw.headRefOid === report.headSha &&
296
+ (queuedBaseAdvanced || raw.baseRefOid === report.baseRefOid);
297
+ if (fingerprint === null ||
298
+ !sameCommits ||
299
+ !isReadyReceiptCurrent(receipt, {
300
+ headRefOid: raw.headRefOid,
301
+ baseRefOid: queuedBaseAdvanced ? receipt.baseRefOid : raw.baseRefOid,
302
+ readinessFingerprint: fingerprint,
303
+ status: "READY",
304
+ isDraft: raw.isDraft,
305
+ })) {
306
+ await clearReadyReceipt(key);
307
+ return false;
308
+ }
309
+ return true;
310
+ }
311
+ catch {
312
+ // Fail closed: an unreadable current snapshot cannot validate old evidence.
313
+ await clearReadyReceipt(key);
314
+ return false;
315
+ }
149
316
  }
@@ -2,26 +2,43 @@ import { clearStallState } from "../../state/iterate-stall.mjs";
2
2
  import { buildEscalateHumanMessage, buildEscalateSuggestion } from "./escalate.mjs";
3
3
  import { buildMergeCommandPlan } from "./merge.mjs";
4
4
  import { formatPrUrl } from "../../pr-reference.mjs";
5
- /** A stacked PR's merge is human-only: `gh pr merge` targets the PR's own base, which for a
6
- * mid-stack layer is an unmerged parent branch, and auto-merge is unsupported on stacks. */
7
- function buildStackedEscalateResult(base, report, stack) {
8
- const escalateBase = {
9
- triggers: ["stacked-pr"],
10
- unresolvedThreads: [],
11
- ambiguousComments: [],
12
- changesRequestedReviews: [],
13
- stack,
14
- suggestion: buildEscalateSuggestion(["stacked-pr"], String(report.pr)),
15
- };
5
+ import { buildPrShepherdCommand } from "../../cli/runner.mjs";
6
+ import { inlineCode } from "../../util/markdown.mjs";
7
+ /**
8
+ * A one-PR poll cannot prove the layers below it merged or that its PR number is a safe merge
9
+ * selector. Route it through the aggregate stack selector, which merges one bottom layer at a time.
10
+ */
11
+ function buildStackedRouteResult(base, report, stack) {
12
+ const prUrl = formatPrUrl(report.repo, report.pr);
16
13
  return {
17
14
  ...base,
18
- action: "escalate",
19
- escalate: {
20
- ...escalateBase,
21
- humanMessage: buildEscalateHumanMessage(escalateBase, formatPrUrl(report.repo, report.pr), {
22
- merge: true,
23
- }),
15
+ action: "fix_code",
16
+ fix: {
17
+ threads: [],
18
+ resolutionOnlyThreads: [],
19
+ actionableComments: [],
20
+ reviewSummaryIds: [],
21
+ firstLookSummaries: [],
22
+ editedSummaries: [],
23
+ surfacedApprovals: [],
24
+ checks: [],
25
+ changesRequestedReviews: [],
26
+ resolveCommand: {
27
+ argv: buildPrShepherdCommand(["apply", "review", prUrl]).argv,
28
+ requiresHeadSha: false,
29
+ requiresDismissMessage: false,
30
+ hasMutations: false,
31
+ },
32
+ instructions: [
33
+ `PR #${report.pr} is layer ${stack.position} of ${stack.size} in native stack #${stack.number}; do not run \`gh pr merge\` for this layer.`,
34
+ `Run ${inlineCode(buildPrShepherdCommand(["--stack", prUrl, "--until-terminal", "--merge"]).text)} to reconcile the stack and run each bottom-layer merge command it emits.`,
35
+ ],
36
+ inProgressRunIds: [],
37
+ protectedRuns: [],
38
+ firstLookThreads: [],
39
+ firstLookComments: [],
24
40
  },
41
+ cancelled: [],
25
42
  };
26
43
  }
27
44
  export function buildReadyMergeOutcome(enabled, readyElapsed, base, report) {
@@ -29,7 +46,7 @@ export function buildReadyMergeOutcome(enabled, readyElapsed, base, report) {
29
46
  return null;
30
47
  const stack = report.mergeStatus.mergeRequirements?.stack;
31
48
  if (stack)
32
- return buildStackedEscalateResult(base, report, stack);
49
+ return buildStackedRouteResult(base, report, stack);
33
50
  const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
34
51
  report.mergeStatus.mergeRequirements?.mergeQueue?.enabled);
35
52
  return {
@@ -0,0 +1,34 @@
1
+ import type { StackStatus } from "../../types.mts";
2
+ /**
3
+ * Where a native-stack rebase starts: an upper layer rebases from its parent stack branch
4
+ * without touching trunk; the bottom layer rebases the whole stack onto trunk.
5
+ */
6
+ export type NativeStackRebaseStart = {
7
+ parentBranch: string;
8
+ } | {
9
+ bottomPr: number;
10
+ };
11
+ /**
12
+ * One gh-stack rebase step. A native stack layer must not be rebased or merged from its base
13
+ * branch alone: that rewrites one branch and strands every layer above it.
14
+ *
15
+ * `gh stack` rebases the stack it tracks locally and `gh stack push` publishes those local
16
+ * layers, so the step first imports the stack by its number (a bare number resolves as a
17
+ * stack number before a PR number) and checks each local layer against its PR head.
18
+ */
19
+ export declare function buildNativeStackRebaseInstruction(repo: string, stackNumber: number, start: NativeStackRebaseStart): string;
20
+ /**
21
+ * The stack-aware branch update for a native stack layer (a conflict, or a behind branch whose
22
+ * workflow keeps failing); undefined outside a stack.
23
+ * A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
24
+ * position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
25
+ * layer is an upper layer whose parent is its own PR base branch.
26
+ */
27
+ export declare function buildNativeStackLayerRebase(repo: string, pr: {
28
+ number: number;
29
+ baseBranch: string;
30
+ }, stack: StackStatus | undefined): string | undefined;
31
+ /** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
32
+ export declare function buildConflictInstruction(stackRebase: string | undefined): string;
33
+ /** Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack. */
34
+ export declare function buildBranchPushInstruction(stackRebase: string | undefined, hasConflicts: boolean, mutationSuffix: string): string;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * One gh-stack rebase step. A native stack layer must not be rebased or merged from its base
3
+ * branch alone: that rewrites one branch and strands every layer above it.
4
+ *
5
+ * `gh stack` rebases the stack it tracks locally and `gh stack push` publishes those local
6
+ * layers, so the step first imports the stack by its number (a bare number resolves as a
7
+ * stack number before a PR number) and checks each local layer against its PR head.
8
+ */
9
+ export function buildNativeStackRebaseInstruction(repo, stackNumber, start) {
10
+ const [checkout, command] = "parentBranch" in start
11
+ ? [
12
+ `check out the parent stack branch \`${start.parentBranch}\``,
13
+ "gh stack rebase --upstack --no-trunk",
14
+ ]
15
+ : [`check out the head branch of PR #${start.bottomPr}`, "gh stack rebase"];
16
+ const prepare = `if \`gh stack\` does not track stack #${stackNumber} locally, import it with \`gh stack checkout ${stackNumber}\`, then confirm every layer's local branch is at its PR's head commit — a stale local layer would overwrite that PR's newer commits on push`;
17
+ return `From a clean checkout of \`${repo}\`, ${prepare}. Then ${checkout} and run \`${command}\`; if it stops on a conflict, resolve it and run \`gh stack rebase --continue\`.`;
18
+ }
19
+ /**
20
+ * The stack-aware branch update for a native stack layer (a conflict, or a behind branch whose
21
+ * workflow keeps failing); undefined outside a stack.
22
+ * A layer whose PR targets the stack's trunk (`stack.baseRefName`) is the bottom open layer —
23
+ * position 1, or a higher layer GitHub retargeted after every layer below it merged. Any other
24
+ * layer is an upper layer whose parent is its own PR base branch.
25
+ */
26
+ export function buildNativeStackLayerRebase(repo, pr, stack) {
27
+ if (!stack)
28
+ return undefined;
29
+ return buildNativeStackRebaseInstruction(repo, stack.number, pr.baseBranch === stack.baseRefName ? { bottomPr: pr.number } : { parentBranch: pr.baseBranch });
30
+ }
31
+ /** Point at the conflicts, and at the stack rebase when the PR is a native stack layer. */
32
+ export function buildConflictInstruction(stackRebase) {
33
+ const pointer = "The branch has merge conflicts (see `**branch**` above).";
34
+ return stackRebase ? `${pointer} ${stackRebase}` : `${pointer} Resolve them before committing.`;
35
+ }
36
+ /** Push a conflict resolution or branch refresh: one branch, or the whole rewritten native stack. */
37
+ export function buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix) {
38
+ if (stackRebase)
39
+ return `Commit any remaining changes on the PR head branch and push the rewritten stack with \`gh stack push\`${mutationSuffix}.`;
40
+ return hasConflicts
41
+ ? `Commit any remaining conflict-resolution changes and push to the PR head branch${mutationSuffix}.`
42
+ : "Push the updated PR head branch before iterating immediately.";
43
+ }
@@ -0,0 +1,25 @@
1
+ import type { RepoInfo } from "../../github/client.mts";
2
+ import type { IterateResult, ShepherdReport, StackDraftHold, StackLowerLayerBlock } from "../../types.mts";
3
+ /**
4
+ * What keeps a draft child from being marked ready: the lowest blocking lower layer,
5
+ * or a stack read that could not attribute the block to one.
6
+ */
7
+ export type ParentMarkReadyBlock = StackLowerLayerBlock | "unverifiable";
8
+ /**
9
+ * Draft children may only be converted after their immediate parent has
10
+ * independently completed a one-PR ready-delay and the stack boundary is
11
+ * still linear. A failed or incomplete stack read blocks this mutation but
12
+ * does not block ordinary review/CI work in the caller.
13
+ */
14
+ export declare function findParentMarkReadyBlock(report: ShepherdReport, repo: RepoInfo): Promise<ParentMarkReadyBlock | undefined>;
15
+ /**
16
+ * A native stack draft this one-PR session cannot promote: a lower layer blocks it, or
17
+ * automatic mark-ready is off. Undefined when the session can still advance the PR by
18
+ * iterating.
19
+ */
20
+ export declare function stackDraftHold(report: ShepherdReport, autoMarkReady: boolean, parentBlock: ParentMarkReadyBlock | undefined): StackDraftHold | undefined;
21
+ /**
22
+ * The lower layer a held draft waits on. That layer's own session owns this draft's
23
+ * progress, so the draft neither stalls nor keeps polling while it waits.
24
+ */
25
+ export declare function heldByLowerLayer(result: IterateResult): StackLowerLayerBlock | undefined;