pr-shepherd 0.52.0 → 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 (104) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +11 -8
  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 -0
  11. package/bin/cli/poll-summary-formatter.mjs +3 -1
  12. package/bin/cli/runner.d.mts +1 -0
  13. package/bin/cli/runner.mjs +3 -1
  14. package/bin/commands/check.mjs +13 -4
  15. package/bin/commands/clean.mjs +7 -13
  16. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  17. package/bin/commands/iterate/check-instructions.mjs +11 -7
  18. package/bin/commands/iterate/escalate.mjs +4 -0
  19. package/bin/commands/iterate/fix-code.mjs +6 -1
  20. package/bin/commands/iterate/helpers.d.mts +0 -1
  21. package/bin/commands/iterate/helpers.mjs +0 -15
  22. package/bin/commands/iterate/index.mjs +57 -27
  23. package/bin/commands/iterate/merge-state.mjs +6 -4
  24. package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
  25. package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
  26. package/bin/commands/iterate/parent-first.d.mts +18 -2
  27. package/bin/commands/iterate/parent-first.mjs +45 -32
  28. package/bin/commands/iterate/render.d.mts +1 -1
  29. package/bin/commands/iterate/render.mjs +7 -12
  30. package/bin/commands/iterate/stale-ancestry.d.mts +1 -1
  31. package/bin/commands/iterate/stale-ancestry.mjs +10 -7
  32. package/bin/commands/iterate/stall.mjs +40 -3
  33. package/bin/commands/poll-progress.d.mts +5 -1
  34. package/bin/commands/poll-progress.mjs +7 -1
  35. package/bin/commands/poll-summary-instructions.d.mts +6 -1
  36. package/bin/commands/poll-summary-instructions.mjs +51 -87
  37. package/bin/commands/poll-summary.mjs +4 -2
  38. package/bin/commands/poll.mjs +3 -1
  39. package/bin/commands/ready-delay.d.mts +9 -4
  40. package/bin/commands/ready-delay.mjs +34 -20
  41. package/bin/commands/shepherd-journal.mjs +4 -1
  42. package/bin/commands/stack-drain.d.mts +35 -0
  43. package/bin/commands/stack-drain.mjs +129 -0
  44. package/bin/commands/stack-layer-readiness.d.mts +7 -0
  45. package/bin/commands/stack-layer-readiness.mjs +35 -0
  46. package/bin/commands/stack-stall.d.mts +14 -0
  47. package/bin/commands/stack-stall.mjs +69 -0
  48. package/bin/commands/stack-work.d.mts +32 -0
  49. package/bin/commands/stack-work.mjs +39 -0
  50. package/bin/config/load.d.mts +2 -0
  51. package/bin/config/load.mjs +10 -0
  52. package/bin/config.json +1 -0
  53. package/bin/github/batch-parsers.mjs +1 -1
  54. package/bin/github/batch-raw-rules.d.mts +0 -3
  55. package/bin/github/batch-raw-types.d.mts +2 -0
  56. package/bin/github/errors.d.mts +5 -0
  57. package/bin/github/errors.mjs +4 -0
  58. package/bin/github/gql/batch-pr.gql +1 -0
  59. package/bin/github/gql/poll-stack-summary.gql +8 -2
  60. package/bin/github/gql/poll-stack-topology.gql +38 -0
  61. package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
  62. package/bin/github/gql/poll-summary-check-page.gql +23 -0
  63. package/bin/github/gql/poll-summary-fragment.gql +5 -62
  64. package/bin/github/gql/pr-merge-policy.gql +0 -3
  65. package/bin/github/merge-queue-checks.mjs +10 -1
  66. package/bin/github/poll-summary-check-hydration.d.mts +12 -0
  67. package/bin/github/poll-summary-check-hydration.mjs +55 -0
  68. package/bin/github/poll-summary-fingerprint.mjs +24 -4
  69. package/bin/github/poll-summary-projector.mjs +8 -7
  70. package/bin/github/poll-summary-queue-removal.mjs +10 -1
  71. package/bin/github/poll-summary-raw.d.mts +19 -31
  72. package/bin/github/poll-summary-route.mjs +8 -5
  73. package/bin/github/poll-summary.mjs +16 -73
  74. package/bin/github/queries.d.mts +7 -0
  75. package/bin/github/queries.mjs +9 -1
  76. package/bin/github/queue-removal-freshness.d.mts +16 -0
  77. package/bin/github/queue-removal-freshness.mjs +26 -0
  78. package/bin/github/stack-read.d.mts +34 -0
  79. package/bin/github/stack-read.mjs +92 -0
  80. package/bin/log/log-file.d.mts +1 -1
  81. package/bin/log/log-file.mjs +4 -17
  82. package/bin/state/base.d.mts +18 -1
  83. package/bin/state/base.mjs +65 -13
  84. package/bin/state/fix-attempts.d.mts +1 -1
  85. package/bin/state/fix-attempts.mjs +1 -1
  86. package/bin/state/graphql-quota-warnings.mjs +2 -6
  87. package/bin/state/iterate-stall.d.mts +7 -15
  88. package/bin/state/iterate-stall.mjs +6 -64
  89. package/bin/state/rest-cache.d.mts +1 -1
  90. package/bin/state/rest-cache.mjs +1 -1
  91. package/bin/state/stack-stall.d.mts +16 -0
  92. package/bin/state/stack-stall.mjs +12 -0
  93. package/bin/state/stall-state-store.d.mts +37 -0
  94. package/bin/state/stall-state-store.mjs +74 -0
  95. package/bin/types/escalate.d.mts +1 -1
  96. package/bin/types/github.d.mts +1 -1
  97. package/bin/types/iterate.d.mts +2 -1
  98. package/bin/types/merge-requirements.d.mts +19 -0
  99. package/bin/types/poll-summary.d.mts +5 -0
  100. package/bin/types/report.d.mts +1 -1
  101. package/package.json +1 -1
  102. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  103. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  104. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -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";
@@ -22,7 +22,7 @@ import { fingerprintRawSummaryPr } from "../../github/poll-summary-fingerprint.m
22
22
  import { currentQueueRemovalEvent } from "../../github/poll-summary-queue-removal.mjs";
23
23
  import { isCurrentSummaryReady } from "../../github/poll-summary-readiness.mjs";
24
24
  import { clearReadyReceipt, isReadyReceiptCurrent, readReadyReceipt, writeReadyReceipt, } from "../../state/ready-receipts.mjs";
25
- import { parentBlocksMarkReady } from "./parent-first.mjs";
25
+ import { findParentMarkReadyBlock, heldByLowerLayer, stackDraftHold } from "./parent-first.mjs";
26
26
  import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
27
27
  export function runIterate(opts) {
28
28
  return withIterateApiUsage(opts, () => runIterateCore(opts));
@@ -51,8 +51,10 @@ async function runIterateCore(opts) {
51
51
  throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
52
52
  }
53
53
  const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
54
+ const receiptKey = { owner: repoOwner, repo: repoName, pr: report.pr };
54
55
  if (report.mergeStatus.state !== "OPEN") {
55
- await updateReadyDelay(report.pr, false, readyDelaySeconds, repoOwner, repoName);
56
+ await clearReadyDelay(report.pr, repoOwner, repoName);
57
+ await clearReadyReceipt(receiptKey);
56
58
  await clearStallState(stallKey);
57
59
  return buildTerminalCancelResult(report);
58
60
  }
@@ -74,19 +76,22 @@ async function runIterateCore(opts) {
74
76
  if (unminimized.length > 0)
75
77
  reviewSummaryIds = [...reviewSummaryIds, ...unminimized];
76
78
  }
77
- 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 ||
78
83
  report.threads.resolutionOnly.length > 0 ||
79
84
  report.threads.firstLook.length > 0 ||
80
85
  (report.threads.ruleAutoResolveIds?.length ?? 0) > 0 ||
81
86
  report.comments.actionable.length > 0 ||
82
87
  (report.comments.minimizeIds?.length ?? 0) > 0 ||
83
- report.comments.firstLook.length > 0 ||
84
88
  report.changesRequestedReviews.length > 0 ||
85
89
  hasCheckDrivenActionableWork(report.checks, report.mergeStatus.status) ||
86
90
  reviewSummaryIds.length > 0 ||
87
91
  firstLookSummaries.length > 0 ||
88
92
  editedSummaries.length > 0 ||
89
93
  (config.iterate.minimizeApprovals && surfacedApprovals.length > 0);
94
+ const hasActionableWork = hasReadinessWork || report.comments.firstLook.length > 0;
90
95
  const activeMerge = Boolean(opts.merge && (report.mergeQueue?.inQueue || report.mergeQueue?.autoMergeRequest));
91
96
  const staleAncestry = await findStaleNativeStackAncestry(report, {
92
97
  owner: repoOwner,
@@ -94,15 +99,17 @@ async function runIterateCore(opts) {
94
99
  });
95
100
  const isCleanReadyState = report.status === "READY" &&
96
101
  !report.mergeStatus.isDraft &&
97
- !hasActionableWork &&
102
+ !hasReadinessWork &&
98
103
  !activeMerge &&
99
104
  staleAncestry === null;
100
- const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName);
101
- if (report.mergeStatus.mergeRequirements?.stack) {
102
- await invalidateStaleReadyReceipt({ owner: repoOwner, repo: repoName, pr: report.pr }, report, hasActionableWork);
103
- }
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 });
104
112
  const base = buildIterateBase(report, readyState);
105
- const headSha = (await getCurrentHeadSha()) ?? "unknown";
106
113
  // Checks (including merge-queue synthetic-commit checks) and hard conflicts are signals
107
114
  // GitHub itself is already acting on — the queue will eject the PR for these regardless of
108
115
  // what Shepherd does, so they always surface immediately. Only review threads/comments/
@@ -168,27 +175,28 @@ async function runIterateCore(opts) {
168
175
  const canMarkReady = report.status === "READY" &&
169
176
  report.mergeStatus.isDraft &&
170
177
  !report.mergeStatus.blockingBotReviewInProgress;
171
- const blockedByParent = canMarkReady
172
- ? await parentBlocksMarkReady(report, { owner: repoOwner, name: repoName })
173
- : false;
174
- const markReadyResult = await markReadyIfAuthorized(canMarkReady && !blockedByParent && !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);
175
183
  if (markReadyResult)
176
184
  return markReadyResult;
177
185
  if (readyState.shouldCancel && !report.mergeStatus.isDraft) {
178
- const needsStackReceipt = report.mergeStatus.mergeRequirements?.stack !== undefined;
179
- const receiptWritten = needsStackReceipt
180
- ? await recordReadyReceipt({ owner: repoOwner, repo: repoName, pr: report.pr }, report)
181
- : true;
182
- if (!receiptWritten) {
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) {
183
191
  const receiptWait = {
184
192
  ...base,
185
193
  action: "wait",
186
194
  shouldCancel: false,
187
- remainingSeconds: readyDelaySeconds,
188
195
  log: `WAIT: PR #${base.pr} reached ready-delay but its stack readiness receipt could not be persisted`,
189
196
  };
190
197
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, receiptWait, report, reviewSummaryIds);
191
198
  }
199
+ await clearReadyDelay(report.pr, repoOwner, repoName);
192
200
  await clearStallState(stallKey);
193
201
  const mergeResult = buildReadyMergeOutcome(opts.merge, true, base, report);
194
202
  if (mergeResult)
@@ -201,7 +209,20 @@ async function runIterateCore(opts) {
201
209
  log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
202
210
  };
203
211
  }
204
- 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);
205
226
  }
206
227
  async function recordReadyReceipt(key, report) {
207
228
  if (report.status !== "READY" ||
@@ -245,10 +266,11 @@ async function recordReadyReceipt(key, report) {
245
266
  return false;
246
267
  }
247
268
  }
248
- async function invalidateStaleReadyReceipt(key, report, hasActionableWork) {
269
+ /** Clear a stale READY receipt; return whether a current receipt remains. */
270
+ async function revalidateReadyReceipt(key, report, hasReadinessWork) {
249
271
  const receipt = await readReadyReceipt(key);
250
272
  if (!receipt)
251
- return;
273
+ return false;
252
274
  const retainQueuedReceipt = report.mergeQueue?.inQueue === true &&
253
275
  report.mergeStatus.state === "OPEN" &&
254
276
  !report.mergeStatus.isDraft &&
@@ -257,9 +279,9 @@ async function invalidateStaleReadyReceipt(key, report, hasActionableWork) {
257
279
  report.mergeStatus.isDraft ||
258
280
  !report.headSha ||
259
281
  !report.baseRefOid ||
260
- (!retainQueuedReceipt && (report.status !== "READY" || hasActionableWork))) {
282
+ (!retainQueuedReceipt && (report.status !== "READY" || hasReadinessWork))) {
261
283
  await clearReadyReceipt(key);
262
- return;
284
+ return false;
263
285
  }
264
286
  try {
265
287
  const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
@@ -268,7 +290,12 @@ async function invalidateStaleReadyReceipt(key, report, hasActionableWork) {
268
290
  // views still place the PR in the queue.
269
291
  const queuedBaseAdvanced = retainQueuedReceipt && raw.isInMergeQueue;
270
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);
271
297
  if (fingerprint === null ||
298
+ !sameCommits ||
272
299
  !isReadyReceiptCurrent(receipt, {
273
300
  headRefOid: raw.headRefOid,
274
301
  baseRefOid: queuedBaseAdvanced ? receipt.baseRefOid : raw.baseRefOid,
@@ -277,10 +304,13 @@ async function invalidateStaleReadyReceipt(key, report, hasActionableWork) {
277
304
  isDraft: raw.isDraft,
278
305
  })) {
279
306
  await clearReadyReceipt(key);
307
+ return false;
280
308
  }
309
+ return true;
281
310
  }
282
311
  catch {
283
312
  // Fail closed: an unreadable current snapshot cannot validate old evidence.
284
313
  await clearReadyReceipt(key);
314
+ return false;
285
315
  }
286
316
  }
@@ -2,9 +2,11 @@ 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
+ import { buildPrShepherdCommand } from "../../cli/runner.mjs";
6
+ import { inlineCode } from "../../util/markdown.mjs";
5
7
  /**
6
- * A one-PR poll cannot prove every native-stack layer is ready or linear. Route it through the
7
- * aggregate stack selector, which does that reconciliation before emitting the whole-stack merge.
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.
8
10
  */
9
11
  function buildStackedRouteResult(base, report, stack) {
10
12
  const prUrl = formatPrUrl(report.repo, report.pr);
@@ -22,14 +24,14 @@ function buildStackedRouteResult(base, report, stack) {
22
24
  checks: [],
23
25
  changesRequestedReviews: [],
24
26
  resolveCommand: {
25
- argv: ["pr-shepherd", "apply", "review", prUrl],
27
+ argv: buildPrShepherdCommand(["apply", "review", prUrl]).argv,
26
28
  requiresHeadSha: false,
27
29
  requiresDismissMessage: false,
28
30
  hasMutations: false,
29
31
  },
30
32
  instructions: [
31
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.`,
32
- `Run \`pr-shepherd --stack ${prUrl} --until-terminal --merge\` to reconcile the complete stack and run its emitted whole-stack merge command.`,
34
+ `Run ${inlineCode(buildPrShepherdCommand(["--stack", prUrl, "--until-terminal", "--merge"]).text)} to reconcile the stack and run each bottom-layer merge command it emits.`,
33
35
  ],
34
36
  inProgressRunIds: [],
35
37
  protectedRuns: [],
@@ -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
+ }
@@ -1,9 +1,25 @@
1
1
  import type { RepoInfo } from "../../github/client.mts";
2
- import type { ShepherdReport } from "../../types.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";
3
8
  /**
4
9
  * Draft children may only be converted after their immediate parent has
5
10
  * independently completed a one-PR ready-delay and the stack boundary is
6
11
  * still linear. A failed or incomplete stack read blocks this mutation but
7
12
  * does not block ordinary review/CI work in the caller.
8
13
  */
9
- export declare function parentBlocksMarkReady(report: ShepherdReport, repo: RepoInfo): Promise<boolean>;
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;
@@ -1,16 +1,17 @@
1
1
  import { fetchPollSummary } from "../../github/poll-summary.mjs";
2
+ import { stackLayerBlockReason } from "../stack-layer-readiness.mjs";
2
3
  /**
3
4
  * Draft children may only be converted after their immediate parent has
4
5
  * independently completed a one-PR ready-delay and the stack boundary is
5
6
  * still linear. A failed or incomplete stack read blocks this mutation but
6
7
  * does not block ordinary review/CI work in the caller.
7
8
  */
8
- export async function parentBlocksMarkReady(report, repo) {
9
+ export async function findParentMarkReadyBlock(report, repo) {
9
10
  const stack = report.mergeStatus.mergeRequirements?.stack;
10
11
  if (!stack || stack.position === 1)
11
- return false;
12
+ return undefined;
12
13
  if (stack.position < 1)
13
- return true;
14
+ return "unverifiable";
14
15
  try {
15
16
  const summary = await fetchPollSummary({ stackPrNumber: report.pr }, repo);
16
17
  const child = summary.prs.find((item) => item.pr === report.pr);
@@ -19,39 +20,51 @@ export async function parentBlocksMarkReady(report, repo) {
19
20
  .sort((left, right) => (left.stack?.position ?? Number.MAX_SAFE_INTEGER) -
20
21
  (right.stack?.position ?? Number.MAX_SAFE_INTEGER));
21
22
  if (!child || child.state !== "OPEN" || lowerLayers.length !== stack.position - 1)
22
- return true;
23
- // Any stale boundary up through the child means at least one lower layer
24
- // is no longer the base it was reviewed against. Ignore gaps above this
25
- // child because they do not affect its immediate promotion boundary.
26
- const checkedLayers = new Set([report.pr, ...lowerLayers.map((item) => item.pr)]);
27
- if (summary.stackAncestry?.some((gap) => checkedLayers.has(gap.childPr)))
28
- return true;
29
- for (const parent of lowerLayers) {
30
- // A merged parent is already satisfied; GitHub may have retargeted the
31
- // child to the trunk as part of the merge.
32
- if (parent.state === "MERGED")
23
+ return "unverifiable";
24
+ // A stale boundary means that layer is no longer based on the parent it was
25
+ // reviewed against. Gaps above this child do not affect its promotion boundary.
26
+ const staleChildren = new Set(summary.stackAncestry?.map((gap) => gap.childPr));
27
+ for (const layer of lowerLayers) {
28
+ // A merged layer is already satisfied; GitHub may have retargeted the
29
+ // layer above it to the trunk as part of the merge.
30
+ if (layer.state === "MERGED")
33
31
  continue;
34
- if (parent.state !== "OPEN")
35
- return true;
36
- if (parent.isDraft || parent.mergeable === "CONFLICTING")
37
- return true;
38
- if (["DIRTY", "BEHIND", "UNKNOWN"].includes(parent.mergeStateStatus))
39
- return true;
40
- // A receipt only establishes readiness after a merge-queue removal once
41
- // the one-PR session has observed and acknowledged that exact removal.
42
- // The aggregate projection preserves an unacknowledged removal here, so
43
- // do not let its otherwise-current receipt promote a child draft.
44
- if (parent.queueRemoval)
45
- return true;
46
- // A parent that looks ready but has not completed its own one-PR receipt
47
- // is not sufficient evidence for a child draft transition.
48
- if (parent.readyReceipt !== true)
49
- return true;
32
+ const reason = staleChildren.has(layer.pr) ? "stale-ancestry" : stackLayerBlockReason(layer);
33
+ if (reason)
34
+ return { pr: layer.pr, reason };
50
35
  }
51
- return false;
36
+ // This layer's own stale boundary belongs to its own session's repair; the
37
+ // earlier stale-ancestry read missed it, so this snapshot is unsettled.
38
+ return staleChildren.has(report.pr) ? "unverifiable" : undefined;
52
39
  }
53
40
  catch {
54
41
  // Never convert a child draft based on an unverifiable parent.
55
- return true;
42
+ return "unverifiable";
56
43
  }
57
44
  }
45
+ /**
46
+ * A native stack draft this one-PR session cannot promote: a lower layer blocks it, or
47
+ * automatic mark-ready is off. Undefined when the session can still advance the PR by
48
+ * iterating.
49
+ */
50
+ export function stackDraftHold(report, autoMarkReady, parentBlock) {
51
+ if (!report.mergeStatus.mergeRequirements?.stack || !report.mergeStatus.isDraft)
52
+ return undefined;
53
+ // Any lower-layer block outranks the session flag: even with automatic
54
+ // mark-ready enabled, this draft cannot advance until that layer does, and a
55
+ // lower-layer read that failed must not hide behind the flag.
56
+ if (parentBlock === "unverifiable")
57
+ return { kind: "lower-layer-not-ready" };
58
+ if (parentBlock)
59
+ return { kind: "lower-layer-not-ready", lowerLayer: parentBlock };
60
+ return autoMarkReady ? undefined : { kind: "auto-mark-ready-disabled" };
61
+ }
62
+ /**
63
+ * The lower layer a held draft waits on. That layer's own session owns this draft's
64
+ * progress, so the draft neither stalls nor keeps polling while it waits.
65
+ */
66
+ export function heldByLowerLayer(result) {
67
+ return result.action === "wait" && result.stackDraftHold?.kind === "lower-layer-not-ready"
68
+ ? result.stackDraftHold.lowerLayer
69
+ : undefined;
70
+ }
@@ -2,4 +2,4 @@ import type { AgentThread, AgentComment, AgentCheck, Review, ResolveCommand, Fir
2
2
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
3
3
  export declare function renderResolveCommand(rc: ResolveCommand): string;
4
4
  export declare function buildFixInstructions(threads: AgentThread[], actionableComments: AgentComment[], checks: AgentCheck[], changesRequestedReviews: Review[], baseBranch: string, resolveCommand: ResolveCommand, hasConflicts: boolean, prReference: string | number, _cancelledCount: number, firstLookThreads?: FirstLookThread[], firstLookComments?: FirstLookComment[], firstLookSummaries?: Review[], editedSummaries?: Review[], _inProgressRunIds?: string[], resolutionOnlyThreads?: ReviewThread[], resolveOnlyCommand?: ResolveCommand, behindBaseHint?: string, // iterate.behindBaseHint — see buildBehindBaseHintInstruction
5
- isBehind?: boolean, viewerCanUpdate?: boolean, hasExhaustedWorkflowRerun?: boolean): string[];
5
+ isBehind?: boolean, viewerCanUpdate?: boolean, hasExhaustedWorkflowRerun?: boolean, stackRebase?: string): string[];
@@ -4,6 +4,7 @@ import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, buildShepherdJournalInstruction,
4
4
  import { isFailingAgentCheck } from "../../checks/conclusions.mjs";
5
5
  import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
6
6
  import { partitionFixThreads, reviewSectionRefs } from "./fix-instruction-threads.mjs";
7
+ import { buildBranchPushInstruction, buildConflictInstruction } from "./native-stack-rebase.mjs";
7
8
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
8
9
  export function renderResolveCommand(rc) {
9
10
  const parts = [...rc.argv];
@@ -12,14 +13,11 @@ export function renderResolveCommand(rc) {
12
13
  return renderShellCommand(parts);
13
14
  }
14
15
  export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prReference, _cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], _inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
15
- isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
16
+ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, stackRebase) {
16
17
  const instructions = [];
17
18
  const { locatedThreads, unlocatedMutatedThreads, unlocatedThreads } = partitionFixThreads(threads, resolveCommand, resolveOnlyCommand);
18
19
  const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
19
- const repeatedWorkflowBranchRecoveryInstructions = buildRepeatedWorkflowBranchRecoveryInstructions(baseBranch, hasExhaustedWorkflowRerun, {
20
- isBehind,
21
- hasConflicts,
22
- });
20
+ const repeatedWorkflowBranchRecoveryInstructions = buildRepeatedWorkflowBranchRecoveryInstructions(baseBranch, hasExhaustedWorkflowRerun, { isBehind, hasConflicts }, stackRebase);
23
21
  const hasRepeatedWorkflowBranchRecovery = repeatedWorkflowBranchRecoveryInstructions.length > 0;
24
22
  const hasAnnotations = checks.some((c) => (c.annotations?.length ?? 0) > 0);
25
23
  const hasNonConflictHints = threads.length > 0 ||
@@ -41,7 +39,7 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
41
39
  instructions.push(`Review each item ${sectionRef} and decide whether it needs a code change.`);
42
40
  }
43
41
  if (hasConflicts && !hasRepeatedWorkflowBranchRecovery) {
44
- instructions.push("The branch has merge conflicts (see `**branch**` above). Resolve them before committing.");
42
+ instructions.push(buildConflictInstruction(stackRebase));
45
43
  }
46
44
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
47
45
  if (firstLookTotal > 0) {
@@ -82,11 +80,8 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
82
80
  instructions.push(...buildBehindBaseHintInstruction(baseBranch, behindBaseHint, isBehind));
83
81
  const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
84
82
  const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
85
- if (hasConflicts) {
86
- instructions.push(`Commit any remaining conflict-resolution changes and push to the PR head branch${mutationSuffix}.`);
87
- }
88
- else if (hasRepeatedWorkflowBranchRecovery) {
89
- instructions.push("Push the updated PR head branch before iterating immediately.");
83
+ if (hasConflicts || hasRepeatedWorkflowBranchRecovery) {
84
+ instructions.push(buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix));
90
85
  }
91
86
  else if (hasNonConflictHints) {
92
87
  instructions.push("If you changed code, commit any remaining changes and push to the PR head branch, then run the remaining review mutations using the pushed commit SHA and iterate immediately with the same options. If you did not change code, do not commit and continue with the remaining steps.");
@@ -101,6 +96,6 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
101
96
  }
102
97
  if (resolveOnlyCommand?.hasMutations)
103
98
  instructions.push("Run the `resolve-only:` command shown above.");
104
- instructions.push(...buildResolveCommandInstruction(resolveCommand), buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha));
99
+ instructions.push(...buildResolveCommandInstruction(resolveCommand), buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha, stackRebase !== undefined));
105
100
  return instructions;
106
101
  }
@@ -4,7 +4,7 @@ import type { ShepherdReport } from "../../types/report.mts";
4
4
  /**
5
5
  * A verified stale boundary for the PR being shepherded.
6
6
  *
7
- * The aggregate stack read is authoritative for this check: it compares the
7
+ * The stack topology read is authoritative for this check: it compares the
8
8
  * child's recorded base OID with the current head OID of its immediate open
9
9
  * parent. GitHub can report both PRs CLEAN while this boundary is stale.
10
10
  */
@@ -1,4 +1,5 @@
1
- import { fetchPollSummary } from "../../github/poll-summary.mjs";
1
+ import { readStackTopology, stackAncestryGaps } from "../../github/stack-read.mjs";
2
+ import { buildNativeStackRebaseInstruction } from "./native-stack-rebase.mjs";
2
3
  /**
3
4
  * Find a stale immediate parent boundary for one PR.
4
5
  *
@@ -12,13 +13,13 @@ export async function findStaleNativeStackAncestry(report, repo) {
12
13
  if (!stack || stack.position <= 1)
13
14
  return null;
14
15
  try {
15
- const summary = await fetchPollSummary({ stackPrNumber: report.pr }, repo);
16
- const ancestry = summary.stackAncestry?.find((gap) => gap.childPr === report.pr);
16
+ const topology = await readStackTopology(report.pr, repo);
17
+ const ancestry = stackAncestryGaps(topology.ordered).find((gap) => gap.childPr === report.pr);
17
18
  if (!ancestry)
18
19
  return null;
19
20
  return {
20
21
  ...ancestry,
21
- instructions: buildStaleNativeStackAncestryInstructions(repo, ancestry),
22
+ instructions: buildStaleNativeStackAncestryInstructions(repo, stack.number, ancestry),
22
23
  };
23
24
  }
24
25
  catch {
@@ -28,10 +29,12 @@ export async function findStaleNativeStackAncestry(report, repo) {
28
29
  }
29
30
  }
30
31
  /** Build the one-PR repair guidance after a stale boundary was verified. */
31
- function buildStaleNativeStackAncestryInstructions(repo, ancestry) {
32
+ function buildStaleNativeStackAncestryInstructions(repo, stackNumber, ancestry) {
32
33
  return [
33
34
  `PR #${ancestry.childPr} records base \`${ancestry.childBaseRefName}\` at \`${ancestry.childBaseRefOid}\`, but its open parent PR #${ancestry.parentPr} currently ends at \`${ancestry.parentHeadRefName}\` \`${ancestry.parentHeadRefOid}\`.`,
34
- `From a clean checkout of \`${repo.owner}/${repo.name}\`, check out the parent stack branch \`${ancestry.parentHeadRefName}\`.`,
35
- "Run `gh stack rebase --upstack --no-trunk`, resolve any conflicts, and push the rewritten stack with `gh stack push`.",
35
+ buildNativeStackRebaseInstruction(`${repo.owner}/${repo.name}`, stackNumber, {
36
+ parentBranch: ancestry.parentHeadRefName,
37
+ }),
38
+ "Push the rewritten stack with `gh stack push`.",
36
39
  ];
37
40
  }
@@ -90,12 +90,27 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
90
90
  };
91
91
  }
92
92
  const fingerprint = computeStallFingerprint(prospectiveResult.action, headSha, base, report, reviewSummaryIds);
93
- const stored = await readStallState(stallKey);
93
+ const read = await readStallState(stallKey);
94
+ if (!read.ok) {
95
+ if (stallTimeoutSeconds <= 0)
96
+ return prospectiveResult;
97
+ return stallStateUnavailable(base, prReference, prospectiveResult, read.reason);
98
+ }
99
+ const stored = read.state;
100
+ const persistTimer = async () => {
101
+ const wrote = await writeStallState(stallKey, { fingerprint, firstSeenAt: nowSeconds });
102
+ if (!wrote.ok && stallTimeoutSeconds > 0) {
103
+ return stallStateUnavailable(base, prReference, prospectiveResult, wrote.reason);
104
+ }
105
+ return undefined;
106
+ };
94
107
  if (stored && stored.fingerprint === fingerprint) {
95
108
  const ageSeconds = nowSeconds - stored.firstSeenAt;
96
109
  if (ageSeconds < 0) {
97
110
  // Clock skew: stored timestamp is in the future. Reset to avoid perpetually negative age.
98
- await writeStallState(stallKey, { fingerprint, firstSeenAt: nowSeconds });
111
+ const failed = await persistTimer();
112
+ if (failed)
113
+ return failed;
99
114
  }
100
115
  else if (stallTimeoutSeconds <= 0) {
101
116
  // Stall detection disabled: refresh so re-enabling starts a fresh timer.
@@ -126,9 +141,31 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
126
141
  return prospectiveResult;
127
142
  }
128
143
  // Fingerprint changed or no prior state — reset the stall timer.
129
- await writeStallState(stallKey, { fingerprint, firstSeenAt: nowSeconds });
144
+ const failed = await persistTimer();
145
+ if (failed)
146
+ return failed;
130
147
  return prospectiveResult;
131
148
  }
149
+ function stallStateUnavailable(base, prReference, prospectiveResult, reason) {
150
+ const pending = pendingReviewCommandsFromResult(prospectiveResult);
151
+ const escalateBase = {
152
+ triggers: ["stall-state-unavailable"],
153
+ unresolvedThreads: [],
154
+ ambiguousComments: [],
155
+ changesRequestedReviews: [],
156
+ ...surfacedSummariesFromResult(prospectiveResult),
157
+ ...(pending && { pendingReviewCommands: pending }),
158
+ suggestion: buildEscalateSuggestion(["stall-state-unavailable"], reason),
159
+ };
160
+ return {
161
+ ...base,
162
+ action: "escalate",
163
+ escalate: {
164
+ ...escalateBase,
165
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
166
+ },
167
+ };
168
+ }
132
169
  function findCiStartStalledChecks(checks, nowSeconds, opts) {
133
170
  if (opts.stallTimeoutSeconds <= 0 || opts.action !== "wait")
134
171
  return [];
@@ -1,11 +1,15 @@
1
1
  import type { IterateResult } from "../types.mts";
2
+ type WaitResult = Extract<IterateResult, {
3
+ action: "wait";
4
+ }>;
2
5
  export declare function writeWaitProgress(opts: {
3
6
  tick: number;
4
7
  elapsedMs: number;
5
8
  sleepMs: number;
6
- result: IterateResult;
9
+ result: WaitResult;
7
10
  quietStatus: boolean;
8
11
  verbose: boolean;
9
12
  lastWaitSignature: string | null;
10
13
  }): string | null;
11
14
  export declare function writeDebounceProgress(tick: number, elapsedMs: number, remainingMs: number): void;
15
+ export {};