pr-shepherd 0.55.1 → 0.56.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 (102) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -9
  3. package/bin/api.d.mts +5 -0
  4. package/bin/api.mjs +12 -1
  5. package/bin/checks/job-log.d.mts +9 -0
  6. package/bin/checks/job-log.mjs +30 -0
  7. package/bin/checks/jobs-types.d.mts +14 -0
  8. package/bin/checks/jobs-types.mjs +7 -0
  9. package/bin/checks/related-jobs.d.mts +11 -0
  10. package/bin/checks/related-jobs.mjs +32 -0
  11. package/bin/checks/triage-budget.d.mts +17 -0
  12. package/bin/checks/triage-budget.mjs +49 -0
  13. package/bin/checks/triage.d.mts +6 -3
  14. package/bin/checks/triage.mjs +89 -61
  15. package/bin/cli/api-usage-formatter.mjs +2 -2
  16. package/bin/cli/fix-formatter.mjs +2 -0
  17. package/bin/cli/help-command-pages.d.mts +1 -1
  18. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  19. package/bin/cli/help-iterate-poll-pages.mjs +1 -1
  20. package/bin/cli/help.d.mts +1 -1
  21. package/bin/cli/iterate-checks-formatter.mjs +2 -0
  22. package/bin/cli/iterate-instructions.mjs +1 -1
  23. package/bin/cli/related-jobs-format.d.mts +3 -0
  24. package/bin/cli/related-jobs-format.mjs +16 -0
  25. package/bin/commands/check-execution-context.d.mts +12 -0
  26. package/bin/commands/check-execution-context.mjs +38 -0
  27. package/bin/commands/check-fingerprint.mjs +14 -8
  28. package/bin/commands/check-unreported.d.mts +3 -2
  29. package/bin/commands/check-unreported.mjs +4 -4
  30. package/bin/commands/check.d.mts +2 -1
  31. package/bin/commands/check.mjs +21 -6
  32. package/bin/commands/commit-suggestion-instruction.mjs +2 -1
  33. package/bin/commands/iterate/check-instructions.d.mts +9 -5
  34. package/bin/commands/iterate/check-instructions.mjs +15 -35
  35. package/bin/commands/iterate/escalate.mjs +3 -0
  36. package/bin/commands/iterate/fix-code.mjs +6 -2
  37. package/bin/commands/iterate/helpers.mjs +1 -0
  38. package/bin/commands/iterate/index.mjs +20 -8
  39. package/bin/commands/iterate/native-stack-rebase.mjs +3 -2
  40. package/bin/commands/iterate/render.mjs +13 -5
  41. package/bin/commands/iterate/stale-ancestry.d.mts +2 -1
  42. package/bin/commands/iterate/stale-ancestry.mjs +3 -2
  43. package/bin/commands/iterate/unreported-required.mjs +1 -1
  44. package/bin/commands/playbook-pointer.d.mts +2 -0
  45. package/bin/commands/playbook-pointer.mjs +4 -0
  46. package/bin/commands/poll-quota.d.mts +2 -0
  47. package/bin/commands/poll-quota.mjs +18 -25
  48. package/bin/commands/poll-rate-limit-wait.mjs +13 -8
  49. package/bin/commands/poll-summary-instructions.mjs +1 -1
  50. package/bin/commands/ready-delay.d.mts +2 -0
  51. package/bin/commands/ready-delay.mjs +18 -0
  52. package/bin/commands/resolve-mutate.mjs +8 -9
  53. package/bin/commands/shepherd-journal.d.mts +1 -1
  54. package/bin/commands/shepherd-journal.mjs +3 -2
  55. package/bin/commands/stack-drain.mjs +2 -1
  56. package/bin/github/batch-raw-types.d.mts +2 -0
  57. package/bin/github/batch-receipt-evidence.d.mts +5 -0
  58. package/bin/github/batch-receipt-evidence.mjs +61 -0
  59. package/bin/github/batch.d.mts +4 -0
  60. package/bin/github/batch.mjs +42 -8
  61. package/bin/github/errors.d.mts +3 -0
  62. package/bin/github/errors.mjs +15 -6
  63. package/bin/github/gql/batch-pr-page.gql +1 -0
  64. package/bin/github/gql/batch-pr.gql +1 -0
  65. package/bin/github/gql/poll-summary-annotation-probe.gql +8 -0
  66. package/bin/github/gql/reply-thread-comments.gql +25 -0
  67. package/bin/github/gql/reply-thread-transcripts.gql +31 -0
  68. package/bin/github/merge-queue-checks.d.mts +2 -1
  69. package/bin/github/merge-queue-checks.mjs +18 -8
  70. package/bin/github/merge-target-rules.d.mts +2 -1
  71. package/bin/github/merge-target-rules.mjs +4 -4
  72. package/bin/github/poll-summary-annotation-probe.d.mts +2 -0
  73. package/bin/github/poll-summary-annotation-probe.mjs +7 -0
  74. package/bin/github/queries.d.mts +5 -0
  75. package/bin/github/queries.mjs +5 -0
  76. package/bin/github/rate-limit-kind.d.mts +13 -0
  77. package/bin/github/rate-limit-kind.mjs +25 -0
  78. package/bin/github/reply-thread-transcripts.d.mts +3 -0
  79. package/bin/github/reply-thread-transcripts.mjs +89 -0
  80. package/bin/github/rest-http.mjs +1 -0
  81. package/bin/github/rest-text.d.mts +2 -1
  82. package/bin/github/rest-text.mjs +5 -2
  83. package/bin/github/thread-comments.d.mts +5 -1
  84. package/bin/github/thread-comments.mjs +30 -6
  85. package/bin/mcp/server.mjs +32 -5
  86. package/bin/quota-warning.mjs +2 -2
  87. package/bin/reporters/agent.mjs +1 -0
  88. package/bin/threads/transcript.d.mts +1 -0
  89. package/bin/threads/transcript.mjs +4 -1
  90. package/bin/types/check-classification.d.mts +10 -0
  91. package/bin/types/report.d.mts +4 -1
  92. package/package.json +2 -2
  93. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  94. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  95. package/plugins/pr-shepherd/.mcp.json +1 -1
  96. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +57 -70
  97. package/plugins/pr-shepherd/skills/pr-shepherd/references/branch-update.md +9 -0
  98. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +21 -0
  99. package/plugins/pr-shepherd/skills/pr-shepherd/references/journal.md +7 -0
  100. package/plugins/pr-shepherd/skills/pr-shepherd/references/review-mutations.md +9 -0
  101. package/plugins/pr-shepherd/skills/pr-shepherd/references/stack-merge.md +7 -0
  102. package/plugins/pr-shepherd/skills/pr-shepherd/references/suggestion-patches.md +10 -0
@@ -212,7 +212,7 @@ Flags:
212
212
 
213
213
  PR may be a number or GitHub pull request URL. When omitted, the current branch PR is inferred.
214
214
  Exit code: 0 on success; nonzero on failure (sysexits.h — see docs/exit-codes.md).`;
215
- readonly iterate: "pr-shepherd iterate\n\nRun one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.\nThe output contains one action and an action-specific ## Instructions section.\n\nUsage:\n pr-shepherd iterate [PR] [iterate-flags]\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Legacy no-op; workflow runs are never cancelled.\n --merge Shepherd through readiness, then emit a merge or merge-queue command.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).\n\nActions:\n WAIT No immediate action; continue with the next poll.\n MARK_READY Draft PR was marked ready; continue with the next poll.\n READY Clean PR is inside the ready-delay. Wait out remainingSeconds, then poll again.\n FIX_CODE Agent action is required; follow the instructions, then continue polling.\n CANCEL Stop polling: merged/closed or ready-delay elapsed.\n ESCALATE Stop polling until a human provides direction.\n MERGE Run the emitted merge/queue command, then continue monitoring.\n\nExit codes:\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT or READY\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\n 15 MERGE\n A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).";
215
+ readonly iterate: "pr-shepherd iterate\n\nRun one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.\nThe output contains one action and an action-specific ## Instructions section.\n\nUsage:\n pr-shepherd iterate [PR] [iterate-flags]\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Legacy no-op; workflow runs are never cancelled.\n --merge Shepherd through readiness, then emit a merge or merge-queue command.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).\n\nActions:\n WAIT No immediate action; continue with the next poll.\n MARK_READY Draft PR was marked ready; continue with the next poll.\n READY Clean PR is inside the ready-delay. Schedule the rerun for when remainingSeconds elapses. Do not invent unrelated work. Already-owned later work may continue.\n FIX_CODE Agent action is required; follow the instructions, then continue polling.\n CANCEL Stop polling: merged/closed or ready-delay elapsed.\n ESCALATE Stop polling until a human provides direction.\n MERGE Run the emitted merge/queue command, then continue monitoring.\n\nExit codes:\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT or READY\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\n 15 MERGE\n A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).";
216
216
  readonly poll: "pr-shepherd poll\n\nRun iterate repeatedly for one PR, or read compact summaries for an explicit PR set or native\nGitHub stack. Aggregate mode returns when any row needs work, every row is terminal, or timeout.\nPoll exits as soon as iterate returns READY, MARK_READY, CANCEL, or ESCALATE, or when timeout\nreturns the last WAIT result. FIX_CODE starts a --debounce settle window (default:\npoll.debounceSeconds; built-in 1m): poll keeps\niterating at --interval, then runs one more tick after the window and returns that result.\nWith --until-terminal or --merge, poll also continues through MARK_READY.\n\nUsage:\n pr-shepherd poll [PR ...] [poll-flags] [iterate-flags]\n pr-shepherd poll --stack PR [poll-flags] [iterate-flags]\n\nPoll flags:\n --stack PR Select all entries in PR's native GitHub stack, bottom to top.\n --interval <duration> Sleep between WAIT ticks. Bare number = seconds. Default: poll.intervalSeconds (built-in 60s). Stack and multi-PR polls multiply that by poll.stackIntervalFactor (built-in 2) unless this flag is set.\n --timeout <duration> Maximum wall-clock wait for WAIT ticks. Bare number = seconds. Default: poll.timeoutSeconds (built-in 4.5m).\n --debounce <duration> Settle window after first FIX_CODE or stack SHEPHERD before returning. Bare number = seconds. Default: poll.debounceSeconds (built-in 60s). 0 disables.\n --quiet-status Print only changed WAIT snapshots. Overrides poll.quietStatus.\n --no-quiet-status Print every WAIT snapshot. Overrides poll.quietStatus.\n --until-terminal Continue through WAIT/MARK_READY until READY/FIX_CODE/MERGE/CANCEL/ESCALATE or stack SHEPHERD.\n\nForwarded iterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Legacy no-op; workflow runs are never cancelled.\n --merge Shepherd through readiness, then emit a merge or merge-queue command.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed per-tick lines.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number uses each flag's default unit (seconds\nfor --interval/--timeout/--debounce, minutes for --ready-delay/--stall-timeout); decimals are allowed only with\nan explicit unit (4.5m).\nEach WAIT tick writes a stderr line naming what it is waiting on by default; poll.quietStatus can change that default, --quiet-status/--no-quiet-status override it, and --verbose emits detailed per-tick lines.\nFIX_CODE debounce writes a remaining-seconds line to stderr. --timeout does not cut an in-flight debounce short.\nWith --until-terminal, --timeout is ignored for WAIT ticks and polling continues until READY, FIX_CODE, MERGE, CANCEL, or ESCALATE. With --merge, --timeout still bounds WAIT ticks; it only continues through MARK_READY while polling remains within that timeout.\n\nExit codes: same as iterate (the final tick's action/reason decides the code).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT or READY (including a WAIT returned by --timeout)\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\n 15 MERGE\n 16 SHEPHERD (--stack only: run the listed one-PR sessions, then rerun the selector)\n A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).";
217
217
  readonly clean: `pr-shepherd clean
218
218
 
@@ -1,3 +1,4 @@
1
+ import { renderRelatedJobLines } from "./related-jobs-format.mjs";
1
2
  import { renderCheckAnnotation } from "./fix-formatter-extra.mjs";
2
3
  export function formatRelevantChecks(checks) {
3
4
  if (checks.length === 0)
@@ -14,6 +15,7 @@ function formatRelevantCheck(check) {
14
15
  const lines = [`- \`${workflow}${job}\` [conclusion: ${check.conclusion}]`];
15
16
  appendCheckFields(lines, check);
16
17
  appendLogExcerpt(lines, check.logExcerpt);
18
+ lines.push(...renderRelatedJobLines(check.relatedJobs));
17
19
  appendAnnotations(lines, check.annotations, check.logExcerpt);
18
20
  return lines;
19
21
  }
@@ -7,7 +7,7 @@ import { buildPrShepherdCommand } from "./runner.mjs";
7
7
  export function buildSimpleIterateInstructions(result) {
8
8
  switch (result.action) {
9
9
  case "ready": {
10
- const sentence = `PR #${result.pr} is ready. Ready-delay has ${result.remainingSeconds}s left. Run this same command again when the timer elapses. Do not start other work.`;
10
+ const sentence = `PR #${result.pr} is ready. Ready-delay has ${result.remainingSeconds}s left. Rerun this command when the timer elapses. Do not invent unrelated work.`;
11
11
  return [
12
12
  result.quotaWarning ? buildQuotaAwareContinuation(result.quotaWarning, sentence) : sentence,
13
13
  ];
@@ -0,0 +1,3 @@
1
+ import type { RelatedFailedJob } from "../types/check-classification.mts";
2
+ /** Shared text rendering of sibling failed jobs under a failing check bullet. */
3
+ export declare function renderRelatedJobLines(jobs: RelatedFailedJob[] | undefined, indent?: string): string[];
@@ -0,0 +1,16 @@
1
+ /** Shared text rendering of sibling failed jobs under a failing check bullet. */
2
+ export function renderRelatedJobLines(jobs, indent = " ") {
3
+ if (!jobs || jobs.length === 0)
4
+ return [];
5
+ const lines = [`${indent}Other failed jobs in this run:`];
6
+ for (const job of jobs) {
7
+ lines.push(`${indent}- \`${job.name}\` [conclusion: ${job.conclusion}]`);
8
+ if (job.failedStep)
9
+ lines.push(`${indent} > failed step: ${job.failedStep}`);
10
+ if (job.logExcerpt) {
11
+ for (const line of job.logExcerpt.split("\n"))
12
+ lines.push(`${indent} > ${line}`);
13
+ }
14
+ }
15
+ return lines;
16
+ }
@@ -0,0 +1,12 @@
1
+ import type { RepoInfo } from "../github/client.mts";
2
+ import { readStackTopology } from "../github/stack-read.mts";
3
+ import type { RawSummaryPr } from "../github/poll-summary-raw.mts";
4
+ /** Fresh for each iterate tick; never persisted or exposed in CLI output. */
5
+ export declare function createCheckExecutionContext(readyDelaySeconds?: number): {
6
+ readStackTopology(pr: number, repo: RepoInfo): ReturnType<typeof readStackTopology>;
7
+ wantsReceiptSummary(pr: number, repo: RepoInfo): Promise<boolean>;
8
+ setReceiptSummary(raw: RawSummaryPr | null): void;
9
+ getReceiptSummary(): RawSummaryPr | null;
10
+ invalidateReceiptSummary(): void;
11
+ };
12
+ export type CheckExecutionContext = ReturnType<typeof createCheckExecutionContext>;
@@ -0,0 +1,38 @@
1
+ import { readStackTopology } from "../github/stack-read.mjs";
2
+ import { readReadyReceipt } from "../state/ready-receipts.mjs";
3
+ import { readyDelayElapsed } from "./ready-delay.mjs";
4
+ /** Fresh for each iterate tick; never persisted or exposed in CLI output. */
5
+ export function createCheckExecutionContext(readyDelaySeconds) {
6
+ const topologies = new Map();
7
+ let receiptSummary = null;
8
+ let receiptSummaryInvalidated = false;
9
+ return {
10
+ readStackTopology(pr, repo) {
11
+ const key = `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}#${pr}`;
12
+ let pending = topologies.get(key);
13
+ if (!pending) {
14
+ pending = readStackTopology(pr, repo);
15
+ topologies.set(key, pending);
16
+ }
17
+ return pending;
18
+ },
19
+ async wantsReceiptSummary(pr, repo) {
20
+ if (readyDelaySeconds === undefined)
21
+ return false;
22
+ const key = { owner: repo.owner, repo: repo.name, pr };
23
+ if (await readReadyReceipt(key))
24
+ return true;
25
+ return readyDelayElapsed(pr, repo.owner, repo.name, readyDelaySeconds);
26
+ },
27
+ setReceiptSummary(raw) {
28
+ receiptSummary = raw;
29
+ },
30
+ getReceiptSummary() {
31
+ return receiptSummaryInvalidated ? null : receiptSummary;
32
+ },
33
+ invalidateReceiptSummary() {
34
+ receiptSummaryInvalidated = true;
35
+ receiptSummary = null;
36
+ },
37
+ };
38
+ }
@@ -31,20 +31,26 @@ export async function tryReuseFingerprintReport(prNumber, repo, stateKey, config
31
31
  }
32
32
  if (!reportAllowsFingerprintSkip(cached.report))
33
33
  return null;
34
- const live = await fetchPrFingerprint(prNumber, repo);
35
- if (live.isInMergeQueue || cached.fingerprint.isInMergeQueue)
34
+ if (cached.fingerprint.isInMergeQueue)
35
+ return null;
36
+ if (!cached.fingerprint.checkSuitesComplete)
37
+ return null;
38
+ if (cached.fingerprint.commentCount > 100)
36
39
  return null;
37
- if (!live.checkSuitesComplete || !cached.fingerprint.checkSuitesComplete)
40
+ if (cached.fingerprint.reviewCount > 100)
38
41
  return null;
39
- if (live.commentCount > 100 || cached.fingerprint.commentCount > 100)
42
+ if (cached.fingerprint.threadCount > 20)
40
43
  return null;
41
- if (live.reviewCount > 100 || cached.fingerprint.reviewCount > 100)
44
+ if (cached.fingerprint.hasMultiCommentThreads)
42
45
  return null;
43
- if (live.threadCount > 20 || cached.fingerprint.threadCount > 20)
46
+ if (!cached.fingerprint.rulesComplete)
47
+ return null;
48
+ const live = await fetchPrFingerprint(prNumber, repo);
49
+ if (live.isInMergeQueue || !live.checkSuitesComplete)
44
50
  return null;
45
- if (live.hasMultiCommentThreads || cached.fingerprint.hasMultiCommentThreads)
51
+ if (live.commentCount > 100 || live.reviewCount > 100 || live.threadCount > 20)
46
52
  return null;
47
- if (!live.rulesComplete || !cached.fingerprint.rulesComplete)
53
+ if (live.hasMultiCommentThreads || !live.rulesComplete)
48
54
  return null;
49
55
  if (!fingerprintsEqual(cached.fingerprint, live))
50
56
  return null;
@@ -1,6 +1,7 @@
1
1
  import { type WorkflowSuiteSnapshot } from "../checks/unreported-required.mts";
2
2
  import type { BatchPrData, CheckRun, ShepherdReport } from "../types.mts";
3
3
  import type { RepoInfo } from "../github/client.mts";
4
+ import type { CheckExecutionContext } from "./check-execution-context.mts";
4
5
  export interface UnreportedRequiredFields {
5
6
  unreportedRequiredChecks?: string[];
6
7
  trunkBehindBy?: number;
@@ -20,9 +21,9 @@ export declare function collectUnreportedRequired(input: {
20
21
  name: string;
21
22
  pr: number;
22
23
  relevantEvents: readonly string[];
23
- }): Promise<UnreportedRequiredFields>;
24
+ }, context?: CheckExecutionContext): Promise<UnreportedRequiredFields>;
24
25
  /**
25
26
  * A fingerprint hit can keep a stale trunk `behindBy`. Refresh the trunk compare
26
27
  * and the required-context diff without refetching the whole batch.
27
28
  */
28
- export declare function refreshCachedUnreported(report: ShepherdReport, repo: RepoInfo): Promise<ShepherdReport>;
29
+ export declare function refreshCachedUnreported(report: ShepherdReport, repo: RepoInfo, context?: CheckExecutionContext): Promise<ShepherdReport>;
@@ -1,7 +1,7 @@
1
1
  import { actionsWorkflowInProgress, reportedCheckNames, unreportedRequiredContexts, } from "../checks/unreported-required.mjs";
2
2
  import { loadBaseBehindBy, loadMergeTargetStatus } from "../github/merge-target-rules.mjs";
3
3
  /** Load trunk rules for a stack and diff them against the head's check names. */
4
- export async function collectUnreportedRequired(input) {
4
+ export async function collectUnreportedRequired(input, context) {
5
5
  const localContexts = input.batchData.branchRules?.requiredStatusCheckContexts ??
6
6
  input.batchData.branchProtection?.requiredStatusCheckContexts ??
7
7
  [];
@@ -13,7 +13,7 @@ export async function collectUnreportedRequired(input) {
13
13
  headRefName: input.batchData.headRefName,
14
14
  localContexts,
15
15
  stack: input.batchData.stack,
16
- });
16
+ }, context);
17
17
  const unreported = unreportedRequiredContexts(target.contexts, reportedCheckNames(input.checks));
18
18
  const actionsRunning = actionsWorkflowInProgress(input.suites, new Set(input.relevantEvents));
19
19
  const baseBehindBy = unreported.length > 0 && !input.batchData.stack
@@ -34,7 +34,7 @@ export async function collectUnreportedRequired(input) {
34
34
  * A fingerprint hit can keep a stale trunk `behindBy`. Refresh the trunk compare
35
35
  * and the required-context diff without refetching the whole batch.
36
36
  */
37
- export async function refreshCachedUnreported(report, repo) {
37
+ export async function refreshCachedUnreported(report, repo, context) {
38
38
  const stack = report.mergeStatus?.mergeRequirements?.stack;
39
39
  if (!report.headRefName)
40
40
  return report;
@@ -48,7 +48,7 @@ export async function refreshCachedUnreported(report, repo) {
48
48
  headRefName: report.headRefName,
49
49
  localContexts: report.mergeStatus.mergeRequirements?.requiredStatusChecks?.contexts ?? [],
50
50
  stack,
51
- });
51
+ }, context);
52
52
  const unreported = unreportedRequiredContexts(target.contexts, reportedCheckNames(reportChecks(report)));
53
53
  const next = { ...report };
54
54
  if (unreported.length > 0)
@@ -1,3 +1,4 @@
1
+ import type { CheckExecutionContext } from "./check-execution-context.mts";
1
2
  import type { GlobalOptions, ShepherdReport } from "../types.mts";
2
3
  export declare function runCheck(opts: GlobalOptions & {
3
4
  autoResolve?: boolean;
@@ -6,4 +7,4 @@ export declare function runCheck(opts: GlobalOptions & {
6
7
  persistSeen?: boolean;
7
8
  fingerprintCache?: boolean;
8
9
  merge?: boolean;
9
- }): Promise<ShepherdReport>;
10
+ }, context?: CheckExecutionContext): Promise<ShepherdReport>;
@@ -7,6 +7,7 @@ import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
7
7
  import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
8
8
  import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
9
9
  import { fetchStartupFailureChecks, triageFailingChecks } from "../checks/triage.mjs";
10
+ import { TriageBudget } from "../checks/triage-budget.mjs";
10
11
  import { deriveMergeStatus } from "../merge-status/derive.mjs";
11
12
  import { loadConfig } from "../config/load.mjs";
12
13
  import { classifyVisibleComments } from "../comments/visible-comments.mjs";
@@ -26,7 +27,7 @@ import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
26
27
  import { buildClassifyIndex, partitionBatch } from "../classify/apply.mjs";
27
28
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
28
29
  import { getEffectiveCwd } from "../execution-context.mjs";
29
- export async function runCheck(opts) {
30
+ export async function runCheck(opts, context) {
30
31
  const repo = opts.targetRepository ?? (await getRepoInfo());
31
32
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
32
33
  if (prNumber === null) {
@@ -38,10 +39,15 @@ export async function runCheck(opts) {
38
39
  if (reuseFingerprint) {
39
40
  const cached = await tryReuseFingerprintReport(prNumber, repo, stateKey, config);
40
41
  if (cached)
41
- return refreshCachedUnreported(cached, repo);
42
+ return refreshCachedUnreported(cached, repo, context);
42
43
  }
43
44
  const paginateApprovedReviews = config.iterate.minimizeApprovals;
44
- const result = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews });
45
+ const includeReceiptSummary = (await context?.wantsReceiptSummary(prNumber, repo)) ?? false;
46
+ const result = await fetchPrBatch(prNumber, repo, {
47
+ paginateApprovedReviews,
48
+ ...(includeReceiptSummary && { includeReceiptSummary: true }),
49
+ });
50
+ context?.setReceiptSummary(result.receiptSummary ?? null);
45
51
  let batchData = result.data;
46
52
  const unknownRefresh = await refreshUnknownMergeability(prNumber, repo, batchData);
47
53
  batchData = unknownRefresh.batchData;
@@ -55,9 +61,10 @@ export async function runCheck(opts) {
55
61
  return terminal;
56
62
  }
57
63
  const startupFailuresNeedAttempt = batchData.checks.some((check) => check.source === "startup_failure" && check.runAttempt === undefined);
64
+ const triageBudget = new TriageBudget();
58
65
  const startupFailureChecks = result.checkSuitesComplete && !startupFailuresNeedAttempt
59
66
  ? []
60
- : await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber, stateKey);
67
+ : await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber, stateKey, triageBudget);
61
68
  const allChecks = mergeStartupFailureChecks(batchData.checks, startupFailureChecks);
62
69
  const classifiedPrChecks = classifyChecks(allChecks);
63
70
  const latestRemoval = batchData.latestMergeQueueRemoval;
@@ -103,8 +110,10 @@ export async function runCheck(opts) {
103
110
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
104
111
  const ignored = classifiedChecks.filter((c) => c.category === "ignored");
105
112
  const triagedBase = failing.length > 0 && !opts.skipTriage
106
- ? await triageFailingChecks(failing, repo, stateKey)
113
+ ? await triageFailingChecks(failing, repo, stateKey, triageBudget, classifiedChecks.filter((c) => c.category !== "failing"))
107
114
  : failing;
115
+ triageBudget.throwIfSecondary();
116
+ triageBudget.reportOmissionIfNeeded();
108
117
  const seenMap = await loadSeenMap(stateKey);
109
118
  const botUsernames = normalizeBotUsernames(config.botUsernames);
110
119
  const ruleSet = await loadRules(discoverRuleFiles(getEffectiveCwd()));
@@ -168,7 +177,7 @@ export async function runCheck(opts) {
168
177
  name: repo.name,
169
178
  pr: prNumber,
170
179
  relevantEvents: config.checks.ciTriggerEvents,
171
- });
180
+ }, context);
172
181
  let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount, unreported.hasUnreportedRequired);
173
182
  // Resolve any pending mergeability refresh (and the resulting MERGED/CLOSED short-circuit)
174
183
  // before deciding what to persist below — deferWhileQueued must see the same final,
@@ -251,6 +260,12 @@ export async function runCheck(opts) {
251
260
  ruleAutoResolveCommentIds: partition.ruleAutoResolveCommentIds.filter((id) => batchData.comments.find((comment) => comment.id === id)?.viewerCanMinimize === true),
252
261
  ruleAutoResolveReviewSummaryIds: partition.ruleAutoResolveReviewSummaryIds.filter((id) => batchData.reviewSummaries.find((review) => review.id === id)?.viewerCanMinimize === true),
253
262
  };
263
+ if (opts.autoMinimizeSuppressed === true &&
264
+ (authorizedPartition.ruleAutoResolveThreadIds.length > 0 ||
265
+ authorizedPartition.ruleAutoResolveCommentIds.length > 0 ||
266
+ authorizedPartition.ruleAutoResolveReviewSummaryIds.length > 0)) {
267
+ context?.invalidateReceiptSummary();
268
+ }
254
269
  const { threadIds: authorizedRuleAutoResolveThreadIds, commentIds: ruleAutoResolveCommentIds, reviewSummaryIds: ruleAutoResolveReviewSummaryIds, autoResolved, autoMinimized, autoResolveErrors, errorReasons, } = await applySuppressedRuleAutoResolve({
255
270
  enabled: opts.autoMinimizeSuppressed === true,
256
271
  partition: authorizedPartition,
@@ -1,4 +1,5 @@
1
1
  import { buildPrShepherdCommand } from "../cli/runner.mjs";
2
+ import { playbookPointer } from "./playbook-pointer.mjs";
2
3
  /**
3
4
  * Build the `build-suggestion-patches` instruction step for agent consumers.
4
5
  * Currently emitted by iterate `fix_code` for suggestion review threads. The CLI keeps
@@ -19,5 +20,5 @@ export function buildCommitSuggestionInstruction(prReference, sectionName) {
19
20
  "<one-sentence headline>",
20
21
  "--format=json",
21
22
  ]).text;
22
- return `For all threads marked \`[suggestion]\` under \`${sectionName}\`, run one \`${command}\` command, repeating the \`--thread-id <id> --message <one-sentence headline>\` group in displayed order, then apply the returned patches in order. See "Suggestion patches" in the pr-shepherd skill for refusals and drift.`;
23
+ return `For every \`[suggestion]\` thread under \`${sectionName}\`, run one \`${command}\`, repeating \`--thread-id\` and \`--message\` in displayed order. ${playbookPointer("Suggestion patches")}`;
23
24
  }
@@ -2,15 +2,18 @@ import type { AgentCheck, ResolveCommand, Review } from "../../types.mts";
2
2
  /** Build the stale-CR clause appended to the `## Changes-requested reviews` instruction. */
3
3
  export declare function buildCrStaleClause(reviews: Review[]): string;
4
4
  /**
5
- * Build the optional behind-base push hint. Empty unless the branch is actually behind its base
6
- * and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
5
+ * Build the optional branch-update hint. Empty unless the branch is behind or conflicts with its
6
+ * base and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
7
7
  * rebase/merge mechanics itself (see "Keep skills and loop prompts minimal" in AGENTS.md); this
8
8
  * only echoes back the caller's own configured pointer. `hint` is trimmed and type-checked at the
9
9
  * point of use (rather than at config load) so a malformed rc file value (non-string, or
10
10
  * whitespace-only) degrades to "no hint" instead of rendering garbage into agent-facing text or
11
11
  * discarding the rest of the user's config.
12
12
  */
13
- export declare function buildBehindBaseHintInstruction(baseBranch: string, hint: string, isBehind: boolean): string[];
13
+ export declare function buildBehindBaseHintInstruction(baseBranch: string, hint: string, branch: {
14
+ isBehind: boolean;
15
+ hasConflicts: boolean;
16
+ }): string[];
14
17
  /**
15
18
  * Give one branch-refresh recovery path after Shepherd's single workflow rerun has failed.
16
19
  * The fetched PR base branch is raw context; the caller still owns repository-specific git
@@ -31,8 +34,9 @@ export declare function buildRepeatedWorkflowBranchRecoveryInstructions(baseBran
31
34
  * tells the agent that playbook exists.
32
35
  */
33
36
  export declare function buildResolveCommandInstruction(resolveCommand: ResolveCommand): string[];
34
- /** Build the CI-triage pointer; the skill limits follow-up actions to included evidence. */
37
+ /** One pointer. Conclusion, rerun, and bare-check rules live in the CI playbook. */
35
38
  export declare function buildFailingCheckInstructions(checks: AgentCheck[]): string[];
36
39
  /** Update the PR branch after an external blocker merges or closes. Never rerun that job. */
37
40
  export declare function buildReleasedBlockerInstruction(prNumber: number): string;
38
- export declare function buildFixCompletionInstruction(checks: AgentCheck[], hasConflicts?: boolean, hasShaGatedReviewMutations?: boolean, pushesRewrittenStack?: boolean): string;
41
+ /** Recurrence only. Commit, push, rerun, and SHA steps are earlier instructions. */
42
+ export declare function buildFixCompletionInstruction(): string;
@@ -1,3 +1,5 @@
1
+ import { playbookPointer } from "../playbook-pointer.mjs";
2
+ const FIX_CODE_CONTINUATION = "`[FIX_CODE]` is non-terminal. Iterate immediately with the same options.";
1
3
  /** Build the stale-CR clause appended to the `## Changes-requested reviews` instruction. */
2
4
  export function buildCrStaleClause(reviews) {
3
5
  const human = reviews.some((r) => r.staleReview && !r.staleBotCr)
@@ -6,19 +8,20 @@ export function buildCrStaleClause(reviews) {
6
8
  return human;
7
9
  }
8
10
  /**
9
- * Build the optional behind-base push hint. Empty unless the branch is actually behind its base
10
- * and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
11
+ * Build the optional branch-update hint. Empty unless the branch is behind or conflicts with its
12
+ * base and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
11
13
  * rebase/merge mechanics itself (see "Keep skills and loop prompts minimal" in AGENTS.md); this
12
14
  * only echoes back the caller's own configured pointer. `hint` is trimmed and type-checked at the
13
15
  * point of use (rather than at config load) so a malformed rc file value (non-string, or
14
16
  * whitespace-only) degrades to "no hint" instead of rendering garbage into agent-facing text or
15
17
  * discarding the rest of the user's config.
16
18
  */
17
- export function buildBehindBaseHintInstruction(baseBranch, hint, isBehind) {
19
+ export function buildBehindBaseHintInstruction(baseBranch, hint, branch) {
18
20
  const trimmedHint = typeof hint === "string" ? hint.trim() : "";
19
- if (!isBehind || trimmedHint === "")
21
+ if ((!branch.isBehind && !branch.hasConflicts) || trimmedHint === "")
20
22
  return [];
21
- return [`The branch is behind PR base branch \`${baseBranch}\`. ${trimmedHint} before pushing.`];
23
+ const state = branch.hasConflicts ? "conflicts with" : "is behind";
24
+ return [`The branch ${state} PR base branch \`${baseBranch}\`. ${trimmedHint} before pushing.`];
22
25
  }
23
26
  /**
24
27
  * Give one branch-refresh recovery path after Shepherd's single workflow rerun has failed.
@@ -53,48 +56,25 @@ export function buildResolveCommandInstruction(resolveCommand) {
53
56
  return [];
54
57
  const instructions = [];
55
58
  if (resolveCommand.requiresHeadSha) {
56
- instructions.push("If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)`, which must equal the current remote PR head. If you changed code, commit and push to the PR head branch first, then replace `$HEAD_SHA` with the pushed commit SHA.");
59
+ instructions.push("If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)` (it must equal the remote PR head). If you did, use the pushed SHA.");
57
60
  }
58
61
  if (resolveCommand.requiresDismissMessage) {
59
62
  instructions.push("Replace `$DISMISS_MESSAGE` with one sentence describing what changed.");
60
63
  }
61
- instructions.push('Run the `apply review:` command shown above. See "Review-mutation mechanics" in the pr-shepherd skill for dismiss-ID retention.');
64
+ instructions.push(`Run the \`apply review:\` command above. ${playbookPointer("Review-mutation mechanics")}`);
62
65
  return instructions;
63
66
  }
64
- /** Build the CI-triage pointer; the skill limits follow-up actions to included evidence. */
67
+ /** One pointer. Conclusion, rerun, and bare-check rules live in the CI playbook. */
65
68
  export function buildFailingCheckInstructions(checks) {
66
69
  if (checks.length === 0)
67
70
  return [];
68
- const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
69
- const hasTriageable = checks.some((c) => c.runId || c.detailsUrl);
70
- const hasRerunAuthorized = checks.some((c) => c.rerunCommand);
71
- const instructions = [];
72
- if (hasTriageable) {
73
- instructions.push('Triage every failure under `## Failing checks`. See "CI failure triage" in the pr-shepherd skill for read-only inspection rules.');
74
- }
75
- if (hasRerunAuthorized) {
76
- instructions.push('A `[rerun authorized]` check includes a `rerun:` command. See "CI failure triage" in the pr-shepherd skill for which conclusions warrant a rerun versus a code fix.');
77
- }
78
- if (hasBare) {
79
- instructions.push("For each `(no runId)` failure, preserve the displayed metadata; Shepherd will escalate when no other autonomous work remains.");
80
- }
81
- return instructions;
71
+ return [`Triage \`## Failing checks\`. ${playbookPointer("CI failure triage")}`];
82
72
  }
83
73
  /** Update the PR branch after an external blocker merges or closes. Never rerun that job. */
84
74
  export function buildReleasedBlockerInstruction(prNumber) {
85
75
  return `Update this PR branch from its base with \`gh pr update-branch ${prNumber} --rebase\`. Do not rerun the job; a rerun retests the old merge ref.`;
86
76
  }
87
- export function buildFixCompletionInstruction(checks, hasConflicts = false, hasShaGatedReviewMutations = false, pushesRewrittenStack = false) {
88
- const push = pushesRewrittenStack
89
- ? "push the rewritten stack with `gh stack push`"
90
- : "push to the PR head branch";
91
- if (hasConflicts)
92
- return `\`[FIX_CODE]\` is non-terminal: resolve the conflicts, commit, ${push}, then iterate immediately with the same options.`;
93
- if (hasShaGatedReviewMutations) {
94
- return `\`[FIX_CODE]\` is non-terminal: if you changed code, commit and ${push}, then run the review mutations using the pushed commit SHA and iterate immediately with the same options; if you did not change code, complete the authorized review mutations and iterate immediately with the same options.`;
95
- }
96
- if (checks.some((check) => check.rerunCommand)) {
97
- return "`[FIX_CODE]` is non-terminal. Run any warranted reruns for `[rerun authorized]` checks (or apply code fixes for real failures), then iterate immediately with the same options to continue.";
98
- }
99
- return "`[FIX_CODE]` is non-terminal. After completing these steps, iterate immediately with the same options to continue.";
77
+ /** Recurrence only. Commit, push, rerun, and SHA steps are earlier instructions. */
78
+ export function buildFixCompletionInstruction() {
79
+ return FIX_CODE_CONTINUATION;
100
80
  }
@@ -1,3 +1,5 @@
1
+ /* eslint-disable max-lines */
2
+ import { renderRelatedJobLines } from "../../cli/related-jobs-format.mjs";
1
3
  import { loadConfig } from "../../config/load.mjs";
2
4
  import { renderResolveCommand } from "./render.mjs";
3
5
  function renderEscalateAuthor(item) {
@@ -52,6 +54,7 @@ function renderEscalateCheck(check) {
52
54
  lines.push(` > ${check.summary}`);
53
55
  if (check.logExcerpt)
54
56
  lines.push(...renderBlockquoteLines(check.logExcerpt, " "));
57
+ lines.push(...renderRelatedJobLines(check.relatedJobs));
55
58
  if (check.rerunCommand)
56
59
  lines.push(` rerun: \`${check.rerunCommand}\``);
57
60
  for (const annotation of check.annotations ?? []) {
@@ -19,6 +19,10 @@ import { canRerunWorkflows } from "../../checks/conclusions.mjs";
19
19
  import { loadConfig } from "../../config/load.mjs";
20
20
  import { formatPrUrl } from "../../pr-reference.mjs";
21
21
  const EMPTY_RELEASED = new Set();
22
+ function hasLogEvidence(check) {
23
+ return (Boolean(check.logExcerpt?.trim()) ||
24
+ (check.relatedJobs ?? []).some((job) => Boolean(job.logExcerpt?.trim())));
25
+ }
22
26
  function checkRequiresHumanFollowUp(check) {
23
27
  if (check.rerunCommand)
24
28
  return false;
@@ -30,13 +34,13 @@ function checkRequiresHumanFollowUp(check) {
30
34
  // identify a code or configuration fix for the agent. Only a later failure with
31
35
  // no actionable evidence needs a human handoff.
32
36
  if (check.runAttempt !== undefined && check.runAttempt > 1)
33
- return !check.logExcerpt?.trim();
37
+ return !hasLogEvidence(check);
34
38
  // An external check's direct URL is actionable evidence: the agent can inspect the
35
39
  // provider and/or reproduce the reported failure locally. Only a truly bare check
36
40
  // has no autonomous investigation path.
37
41
  if (check.runId === null)
38
42
  return !check.detailsUrl?.trim();
39
- return !check.logExcerpt?.trim();
43
+ return !hasLogEvidence(check);
40
44
  }
41
45
  function nextFixAttempts(stored, threads, countAttempt) {
42
46
  const threadAttempts = stored ? { ...stored.threadAttempts } : {};
@@ -80,6 +80,7 @@ export function buildRelevantChecks(report) {
80
80
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
81
81
  ...(c.summary !== undefined && { summary: c.summary }),
82
82
  ...(c.logExcerpt !== undefined && { logExcerpt: c.logExcerpt }),
83
+ ...(c.relatedJobs !== undefined && { relatedJobs: c.relatedJobs }),
83
84
  ...(c.annotations !== undefined && { annotations: c.annotations }),
84
85
  ...(c.scope !== undefined && { scope: c.scope }),
85
86
  ...(c.commitOid !== undefined && { commitOid: c.commitOid }),
@@ -27,6 +27,7 @@ import { stackDraftHold } from "./parent-first.mjs";
27
27
  import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
28
28
  import { annotateBlockedWait, resolveCheckBlockerGate } from "./check-blocker-gate.mjs";
29
29
  import { buildUnreportedFixResult, planUnreportedRequired } from "./unreported-required.mjs";
30
+ import { createCheckExecutionContext } from "../check-execution-context.mjs";
30
31
  export function runIterate(opts) {
31
32
  return withIterateApiUsage(opts, () => runIterateCore(opts));
32
33
  }
@@ -40,6 +41,7 @@ async function runIterateCore(opts) {
40
41
  throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
41
42
  }
42
43
  const neverCancelRuns = opts.neverCancelRuns ?? config.actions.neverCancelRuns;
44
+ const checkContext = createCheckExecutionContext(readyDelaySeconds);
43
45
  const report = await runCheck({
44
46
  ...opts,
45
47
  prNumber,
@@ -48,7 +50,7 @@ async function runIterateCore(opts) {
48
50
  // compatibility key in the config loader.
49
51
  autoResolve: true,
50
52
  autoMinimizeSuppressed: config.actions.autoMinimizeSuppressed,
51
- });
53
+ }, checkContext);
52
54
  const [repoOwner, repoName] = report.repo.split("/");
53
55
  if (!repoOwner || !repoName) {
54
56
  throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
@@ -84,6 +86,7 @@ async function runIterateCore(opts) {
84
86
  // the apply command remains a working fallback instead of silently dropping it.
85
87
  let reviewSummaryIds = minimizeIds;
86
88
  if (selfMinimizeIds.length > 0) {
89
+ checkContext.invalidateReceiptSummary();
87
90
  const { minimized } = await autoMinimizeComments(selfMinimizeIds);
88
91
  const minimizedIds = new Set(minimized);
89
92
  const unminimized = selfMinimizeIds.filter((id) => !minimizedIds.has(id));
@@ -110,13 +113,13 @@ async function runIterateCore(opts) {
110
113
  const staleAncestry = await findStaleNativeStackAncestry(report, {
111
114
  owner: repoOwner,
112
115
  name: repoName,
113
- });
116
+ }, checkContext);
114
117
  const isCleanReadyState = report.status === "READY" &&
115
118
  !report.mergeStatus.isDraft &&
116
119
  !hasReadinessWork &&
117
120
  !activeMerge &&
118
121
  staleAncestry === null;
119
- const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork);
122
+ const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, checkContext);
120
123
  const headSha = report.headSha ?? "unknown";
121
124
  // The elapsed marker survives a hidden-comment acknowledgement tick and is
122
125
  // consumed only when this tick cancels or merges. A receipt that is still
@@ -233,7 +236,7 @@ async function runIterateCore(opts) {
233
236
  if (markReadyResult)
234
237
  return markReadyResult;
235
238
  if (readyState.shouldCancel && !report.mergeStatus.isDraft) {
236
- const receiptWritten = receiptCurrent || (await recordReadyReceipt(receiptKey, report));
239
+ const receiptWritten = receiptCurrent || (await recordReadyReceipt(receiptKey, report, checkContext));
237
240
  // Aggregate --stack routing trusts only a layer's receipt, so an unwritten
238
241
  // one keeps the elapsed marker and retries next tick. A one-PR receipt
239
242
  // only lets a rerun skip the wait, so its failure never changes the action.
@@ -279,7 +282,7 @@ async function runIterateCore(opts) {
279
282
  };
280
283
  return annotateBlockedWait(await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, reportForWork, reviewSummaryIds), blockerGate);
281
284
  }
282
- async function recordReadyReceipt(key, report) {
285
+ async function recordReadyReceipt(key, report, context) {
283
286
  if (report.status !== "READY" ||
284
287
  report.mergeStatus.state !== "OPEN" ||
285
288
  report.mergeStatus.isDraft ||
@@ -287,7 +290,7 @@ async function recordReadyReceipt(key, report) {
287
290
  !report.baseRefOid)
288
291
  return false;
289
292
  try {
290
- const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
293
+ const raw = await receiptSummaryForReport(key, report, context);
291
294
  if (fingerprintRawSummaryPr(raw) === null ||
292
295
  raw.state !== "OPEN" ||
293
296
  raw.isDraft ||
@@ -327,7 +330,7 @@ async function recordReadyReceipt(key, report) {
327
330
  }
328
331
  }
329
332
  /** Clear a stale READY receipt; return whether a current receipt remains. */
330
- async function revalidateReadyReceipt(key, report, hasReadinessWork) {
333
+ async function revalidateReadyReceipt(key, report, hasReadinessWork, context) {
331
334
  const receipt = await readReadyReceipt(key);
332
335
  if (!receipt)
333
336
  return false;
@@ -344,7 +347,7 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork) {
344
347
  return false;
345
348
  }
346
349
  try {
347
- const raw = await fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
350
+ const raw = await receiptSummaryForReport(key, report, context);
348
351
  const summary = await summarizePollSummaryPr(raw, { owner: key.owner, name: key.repo }, { stackPrNumber: report.pr });
349
352
  if (annotationProbeUnavailable(raw)) {
350
353
  const sameHead = raw.headRefOid === report.headSha;
@@ -385,3 +388,12 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork) {
385
388
  return false;
386
389
  }
387
390
  }
391
+ /** A mismatched or invalidated same-request hint falls back to the original fresh read. */
392
+ async function receiptSummaryForReport(key, report, context) {
393
+ const candidate = context?.getReceiptSummary();
394
+ if (candidate?.number === report.pr &&
395
+ candidate.headRefOid === report.headSha &&
396
+ candidate.baseRefOid === report.baseRefOid)
397
+ return candidate;
398
+ return fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
399
+ }
@@ -1,3 +1,4 @@
1
+ import { playbookPointer } from "../playbook-pointer.mjs";
1
2
  /**
2
3
  * One gh-stack rebase step. A native stack layer must not be rebased or merged from its base
3
4
  * branch alone: that rewrites one branch and strands every layer above it.
@@ -15,8 +16,8 @@ export function buildNativeStackRebaseInstruction(repo, stackNumber, start) {
15
16
  : "bottomPr" in start
16
17
  ? [`check out the head branch of PR #${start.bottomPr}`, "gh stack rebase"]
17
18
  : [`check out the bottom open layer whose base is \`${start.trunk}\``, "gh stack rebase"];
18
- 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`;
19
- 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\`.`;
19
+ const prepare = `if \`gh stack\` does not track stack #${stackNumber} locally, import it with \`gh stack checkout ${stackNumber}\``;
20
+ return `From a clean checkout of \`${repo}\`, ${prepare}. Then ${checkout} and run \`${command}\`. ${playbookPointer("Branch update")}`;
20
21
  }
21
22
  /**
22
23
  * The stack-aware branch update for a native stack layer (a conflict, or a behind branch whose