pr-shepherd 0.52.0 → 0.53.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 (117) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +17 -12
  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 +22 -0
  10. package/bin/cli/iterate-lean.mjs +1 -0
  11. package/bin/cli/poll-summary-formatter.mjs +4 -3
  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/api-usage.mjs +2 -2
  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 -0
  20. package/bin/commands/iterate/fix-code.mjs +6 -1
  21. package/bin/commands/iterate/helpers.d.mts +0 -1
  22. package/bin/commands/iterate/helpers.mjs +0 -15
  23. package/bin/commands/iterate/index.mjs +48 -27
  24. package/bin/commands/iterate/merge-state.mjs +6 -4
  25. package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
  26. package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
  27. package/bin/commands/iterate/parent-first.d.mts +6 -7
  28. package/bin/commands/iterate/parent-first.mjs +10 -54
  29. package/bin/commands/iterate/render.d.mts +1 -1
  30. package/bin/commands/iterate/render.mjs +7 -12
  31. package/bin/commands/iterate/stale-ancestry.d.mts +1 -1
  32. package/bin/commands/iterate/stale-ancestry.mjs +10 -7
  33. package/bin/commands/iterate/stall.mjs +40 -3
  34. package/bin/commands/poll-progress.d.mts +5 -1
  35. package/bin/commands/poll-progress.mjs +7 -1
  36. package/bin/commands/poll-quota.mjs +2 -2
  37. package/bin/commands/poll-summary-instructions.d.mts +6 -1
  38. package/bin/commands/poll-summary-instructions.mjs +71 -122
  39. package/bin/commands/poll-summary.mjs +4 -2
  40. package/bin/commands/ready-delay.d.mts +9 -4
  41. package/bin/commands/ready-delay.mjs +34 -20
  42. package/bin/commands/shepherd-journal.mjs +4 -1
  43. package/bin/commands/stack-drain.d.mts +35 -0
  44. package/bin/commands/stack-drain.mjs +138 -0
  45. package/bin/commands/stack-layer-readiness.d.mts +6 -0
  46. package/bin/commands/stack-layer-readiness.mjs +34 -0
  47. package/bin/commands/stack-stall.d.mts +14 -0
  48. package/bin/commands/stack-stall.mjs +68 -0
  49. package/bin/commands/stack-work.d.mts +32 -0
  50. package/bin/commands/stack-work.mjs +38 -0
  51. package/bin/config/load.d.mts +2 -0
  52. package/bin/config/load.mjs +10 -0
  53. package/bin/config.json +1 -0
  54. package/bin/github/api-telemetry-aggregate.d.mts +1 -0
  55. package/bin/github/api-telemetry-aggregate.mjs +8 -2
  56. package/bin/github/api-telemetry.d.mts +4 -0
  57. package/bin/github/api-telemetry.mjs +12 -0
  58. package/bin/github/batch-parsers.mjs +1 -1
  59. package/bin/github/batch-raw-rules.d.mts +0 -3
  60. package/bin/github/batch-raw-types.d.mts +2 -0
  61. package/bin/github/errors.d.mts +5 -0
  62. package/bin/github/errors.mjs +4 -0
  63. package/bin/github/gql/batch-pr.gql +1 -0
  64. package/bin/github/gql/poll-stack-summary.gql +8 -2
  65. package/bin/github/gql/poll-stack-topology.gql +38 -0
  66. package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
  67. package/bin/github/gql/poll-summary-check-page.gql +23 -0
  68. package/bin/github/gql/poll-summary-fragment.gql +5 -62
  69. package/bin/github/gql/pr-merge-policy.gql +0 -3
  70. package/bin/github/graphql-http.mjs +6 -0
  71. package/bin/github/http-auth.d.mts +3 -0
  72. package/bin/github/http-auth.mjs +6 -0
  73. package/bin/github/http-intermediate.d.mts +1 -0
  74. package/bin/github/http-intermediate.mjs +3 -0
  75. package/bin/github/merge-queue-checks.mjs +10 -1
  76. package/bin/github/poll-summary-check-hydration.d.mts +12 -0
  77. package/bin/github/poll-summary-check-hydration.mjs +55 -0
  78. package/bin/github/poll-summary-fingerprint.mjs +24 -4
  79. package/bin/github/poll-summary-projector.mjs +8 -7
  80. package/bin/github/poll-summary-queue-removal.mjs +10 -1
  81. package/bin/github/poll-summary-raw.d.mts +19 -31
  82. package/bin/github/poll-summary-route.mjs +8 -5
  83. package/bin/github/poll-summary.mjs +16 -73
  84. package/bin/github/queries.d.mts +7 -0
  85. package/bin/github/queries.mjs +9 -1
  86. package/bin/github/queue-removal-freshness.d.mts +16 -0
  87. package/bin/github/queue-removal-freshness.mjs +26 -0
  88. package/bin/github/stack-read.d.mts +34 -0
  89. package/bin/github/stack-read.mjs +92 -0
  90. package/bin/log/log-file.d.mts +1 -1
  91. package/bin/log/log-file.mjs +4 -17
  92. package/bin/state/base.d.mts +18 -1
  93. package/bin/state/base.mjs +65 -13
  94. package/bin/state/fix-attempts.d.mts +1 -1
  95. package/bin/state/fix-attempts.mjs +1 -1
  96. package/bin/state/graphql-quota-policy.d.mts +7 -1
  97. package/bin/state/graphql-quota-policy.mjs +41 -14
  98. package/bin/state/graphql-quota-warnings.mjs +4 -6
  99. package/bin/state/iterate-stall.d.mts +7 -15
  100. package/bin/state/iterate-stall.mjs +6 -64
  101. package/bin/state/rest-cache.d.mts +1 -1
  102. package/bin/state/rest-cache.mjs +1 -1
  103. package/bin/state/stack-stall.d.mts +16 -0
  104. package/bin/state/stack-stall.mjs +12 -0
  105. package/bin/state/stall-state-store.d.mts +37 -0
  106. package/bin/state/stall-state-store.mjs +74 -0
  107. package/bin/types/api-usage.d.mts +2 -0
  108. package/bin/types/escalate.d.mts +1 -1
  109. package/bin/types/github.d.mts +1 -1
  110. package/bin/types/iterate.d.mts +2 -1
  111. package/bin/types/merge-requirements.d.mts +9 -0
  112. package/bin/types/poll-summary.d.mts +5 -2
  113. package/bin/types/report.d.mts +1 -1
  114. package/package.json +1 -1
  115. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  116. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  117. 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 { 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,25 @@ 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 autoMarkReady = !opts.noAutoMarkReady && config.actions.autoMarkReady;
179
+ const markReadyResult = await markReadyIfAuthorized(canMarkReady && autoMarkReady, base, report);
175
180
  if (markReadyResult)
176
181
  return markReadyResult;
177
182
  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) {
183
+ const receiptWritten = receiptCurrent || (await recordReadyReceipt(receiptKey, report));
184
+ // Aggregate --stack routing trusts only a layer's receipt, so an unwritten
185
+ // one keeps the elapsed marker and retries next tick. A one-PR receipt
186
+ // only lets a rerun skip the wait, so its failure never changes the action.
187
+ if (!receiptWritten && report.mergeStatus.mergeRequirements?.stack !== undefined) {
183
188
  const receiptWait = {
184
189
  ...base,
185
190
  action: "wait",
186
191
  shouldCancel: false,
187
- remainingSeconds: readyDelaySeconds,
188
192
  log: `WAIT: PR #${base.pr} reached ready-delay but its stack readiness receipt could not be persisted`,
189
193
  };
190
194
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, receiptWait, report, reviewSummaryIds);
191
195
  }
196
+ await clearReadyDelay(report.pr, repoOwner, repoName);
192
197
  await clearStallState(stallKey);
193
198
  const mergeResult = buildReadyMergeOutcome(opts.merge, true, base, report);
194
199
  if (mergeResult)
@@ -201,7 +206,14 @@ async function runIterateCore(opts) {
201
206
  log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
202
207
  };
203
208
  }
204
- return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, { ...base, action: "wait", log: buildWaitLog(base) }, report, reviewSummaryIds);
209
+ const hold = stackDraftHold(report, autoMarkReady);
210
+ const wait = {
211
+ ...base,
212
+ action: "wait",
213
+ log: buildWaitLog(base),
214
+ ...(hold && { stackDraftHold: hold }),
215
+ };
216
+ return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, report, reviewSummaryIds);
205
217
  }
206
218
  async function recordReadyReceipt(key, report) {
207
219
  if (report.status !== "READY" ||
@@ -245,10 +257,11 @@ async function recordReadyReceipt(key, report) {
245
257
  return false;
246
258
  }
247
259
  }
248
- async function invalidateStaleReadyReceipt(key, report, hasActionableWork) {
260
+ /** Clear a stale READY receipt; return whether a current receipt remains. */
261
+ async function revalidateReadyReceipt(key, report, hasReadinessWork) {
249
262
  const receipt = await readReadyReceipt(key);
250
263
  if (!receipt)
251
- return;
264
+ return false;
252
265
  const retainQueuedReceipt = report.mergeQueue?.inQueue === true &&
253
266
  report.mergeStatus.state === "OPEN" &&
254
267
  !report.mergeStatus.isDraft &&
@@ -257,9 +270,9 @@ async function invalidateStaleReadyReceipt(key, report, hasActionableWork) {
257
270
  report.mergeStatus.isDraft ||
258
271
  !report.headSha ||
259
272
  !report.baseRefOid ||
260
- (!retainQueuedReceipt && (report.status !== "READY" || hasActionableWork))) {
273
+ (!retainQueuedReceipt && (report.status !== "READY" || hasReadinessWork))) {
261
274
  await clearReadyReceipt(key);
262
- return;
275
+ return false;
263
276
  }
264
277
  try {
265
278
  const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
@@ -268,7 +281,12 @@ async function invalidateStaleReadyReceipt(key, report, hasActionableWork) {
268
281
  // views still place the PR in the queue.
269
282
  const queuedBaseAdvanced = retainQueuedReceipt && raw.isInMergeQueue;
270
283
  const fingerprint = fingerprintRawSummaryPr(queuedBaseAdvanced ? { ...raw, baseRefOid: receipt.baseRefOid } : raw);
284
+ // The fresh snapshot must name the commits this tick evaluated, or the
285
+ // receipt would vouch for a head or base the report never saw.
286
+ const sameCommits = raw.headRefOid === report.headSha &&
287
+ (queuedBaseAdvanced || raw.baseRefOid === report.baseRefOid);
271
288
  if (fingerprint === null ||
289
+ !sameCommits ||
272
290
  !isReadyReceiptCurrent(receipt, {
273
291
  headRefOid: raw.headRefOid,
274
292
  baseRefOid: queuedBaseAdvanced ? receipt.baseRefOid : raw.baseRefOid,
@@ -277,10 +295,13 @@ async function invalidateStaleReadyReceipt(key, report, hasActionableWork) {
277
295
  isDraft: raw.isDraft,
278
296
  })) {
279
297
  await clearReadyReceipt(key);
298
+ return false;
280
299
  }
300
+ return true;
281
301
  }
282
302
  catch {
283
303
  // Fail closed: an unreadable current snapshot cannot validate old evidence.
284
304
  await clearReadyReceipt(key);
305
+ return false;
285
306
  }
286
307
  }
@@ -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,8 @@
1
- import type { RepoInfo } from "../../github/client.mts";
2
- import type { ShepherdReport } from "../../types.mts";
1
+ import type { ShepherdReport, StackDraftHold } from "../../types.mts";
3
2
  /**
4
- * Draft children may only be converted after their immediate parent has
5
- * independently completed a one-PR ready-delay and the stack boundary is
6
- * still linear. A failed or incomplete stack read blocks this mutation but
7
- * does not block ordinary review/CI work in the caller.
3
+ * A native stack draft this one-PR session cannot promote because it is ready
4
+ * and automatic mark-ready is off. A draft that is not ready yet keeps the
5
+ * ordinary wait. A clean draft is marked ready on its own, without waiting
6
+ * for lower layers.
8
7
  */
9
- export declare function parentBlocksMarkReady(report: ShepherdReport, repo: RepoInfo): Promise<boolean>;
8
+ export declare function stackDraftHold(report: ShepherdReport, autoMarkReady: boolean): StackDraftHold | undefined;
@@ -1,57 +1,13 @@
1
- import { fetchPollSummary } from "../../github/poll-summary.mjs";
2
1
  /**
3
- * Draft children may only be converted after their immediate parent has
4
- * independently completed a one-PR ready-delay and the stack boundary is
5
- * still linear. A failed or incomplete stack read blocks this mutation but
6
- * does not block ordinary review/CI work in the caller.
2
+ * A native stack draft this one-PR session cannot promote because it is ready
3
+ * and automatic mark-ready is off. A draft that is not ready yet keeps the
4
+ * ordinary wait. A clean draft is marked ready on its own, without waiting
5
+ * for lower layers.
7
6
  */
8
- export async function parentBlocksMarkReady(report, repo) {
9
- const stack = report.mergeStatus.mergeRequirements?.stack;
10
- if (!stack || stack.position === 1)
11
- return false;
12
- if (stack.position < 1)
13
- return true;
14
- try {
15
- const summary = await fetchPollSummary({ stackPrNumber: report.pr }, repo);
16
- const child = summary.prs.find((item) => item.pr === report.pr);
17
- const lowerLayers = summary.prs
18
- .filter((item) => (item.stack?.position ?? Number.MAX_SAFE_INTEGER) < stack.position)
19
- .sort((left, right) => (left.stack?.position ?? Number.MAX_SAFE_INTEGER) -
20
- (right.stack?.position ?? Number.MAX_SAFE_INTEGER));
21
- 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")
33
- 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;
50
- }
51
- return false;
52
- }
53
- catch {
54
- // Never convert a child draft based on an unverifiable parent.
55
- return true;
56
- }
7
+ export function stackDraftHold(report, autoMarkReady) {
8
+ if (!report.mergeStatus.mergeRequirements?.stack || !report.mergeStatus.isDraft)
9
+ return undefined;
10
+ if (report.status !== "READY" || report.mergeStatus.blockingBotReviewInProgress)
11
+ return undefined;
12
+ return autoMarkReady ? undefined : { kind: "auto-mark-ready-disabled" };
57
13
  }
@@ -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 {};
@@ -1,6 +1,9 @@
1
1
  function writeTickProgress(tick, elapsedSeconds, detail) {
2
2
  process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — ${detail}\n`);
3
3
  }
4
+ function waitReason(result) {
5
+ return result.log.replace(/^WAIT: /, "");
6
+ }
4
7
  function waitSignature(result) {
5
8
  const activity = result.activity ?? {
6
9
  commitCount: 0,
@@ -36,7 +39,10 @@ export function writeWaitProgress(opts) {
36
39
  const elapsedSeconds = Math.round(opts.elapsedMs / 1000);
37
40
  const sleepSeconds = Math.round(opts.sleepMs / 1000);
38
41
  if (!opts.quietStatus) {
39
- writeTickProgress(opts.tick, elapsedSeconds, opts.verbose ? `sleeping ${sleepSeconds}s` : `still running; next tick in ${sleepSeconds}s`);
42
+ const reason = waitReason(opts.result);
43
+ writeTickProgress(opts.tick, elapsedSeconds, opts.verbose
44
+ ? `${reason} — sleeping ${sleepSeconds}s`
45
+ : `${reason}; next tick in ${sleepSeconds}s`);
40
46
  return opts.lastWaitSignature;
41
47
  }
42
48
  const signature = waitSignature(opts.result);
@@ -1,5 +1,5 @@
1
1
  import { evaluateWorktreeGraphqlQuotaWarning } from "../state/graphql-quota-warnings.mjs";
2
- import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
2
+ import { summarizeApiTelemetry, withGraphqlCredentialFingerprint, } from "../github/api-telemetry.mjs";
3
3
  import { GitHubRequestError } from "../github/errors.mjs";
4
4
  import { isRateLimitMessage } from "../comments/rate-limit.mjs";
5
5
  const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
@@ -44,5 +44,5 @@ export async function aggregateQuotaWarning(result, bands, intervalSeconds) {
44
44
  return evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands.map((band) => ({
45
45
  ...band,
46
46
  pollIntervalMinutes: Math.max(band.pollIntervalMinutes, intervalSeconds / 60),
47
- })), usage, true);
47
+ })), withGraphqlCredentialFingerprint(usage), true);
48
48
  }
@@ -1,3 +1,8 @@
1
- import type { PollSummaryResult } from "../types.mts";
1
+ import type { PollSummaryItem, PollSummaryResult } from "../types.mts";
2
2
  /** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
3
3
  export declare function withPollSummaryInstructions(result: PollSummaryResult, mergeRequested: boolean): PollSummaryResult;
4
+ /** The projected summary, plus the idle layers when the stack plan can only wait on them. */
5
+ export declare function planPollSummary(result: PollSummaryResult, mergeRequested: boolean): {
6
+ result: PollSummaryResult;
7
+ idle?: PollSummaryItem[];
8
+ };