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,65 +1,57 @@
1
1
  /* eslint-disable max-lines */
2
2
  import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
3
3
  import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
4
+ import { appendAutonomousInstructions, appendHumanHandoffInstructions, findHumanHandoffs, idleWaitPlan, isStackLayerReady, planPrefixDrain, retargetWaitPlan, stackPosition, } from "./stack-drain.mjs";
5
+ import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
4
6
  /** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
5
7
  export function withPollSummaryInstructions(result, mergeRequested) {
8
+ return planPollSummary(result, mergeRequested).result;
9
+ }
10
+ /** The projected summary, plus the idle layers when the stack plan can only wait on them. */
11
+ export function planPollSummary(result, mergeRequested) {
6
12
  if (result.selection.kind !== "stack") {
7
- return { ...result, instructions: explicitInstructions(result) };
13
+ return { result: { ...result, instructions: explicitInstructions(result) } };
8
14
  }
9
- const prs = [...result.prs].sort((left, right) => position(left) - position(right));
15
+ const prs = [...result.prs].sort((left, right) => stackPosition(left) - stackPosition(right));
10
16
  const staleChildren = new Set(result.stackAncestry?.map((gap) => gap.childPr) ?? []);
11
- const firstUnready = prs.find((item) => item.state === "OPEN" && (!isReady(item) || staleChildren.has(item.pr)));
12
- const blocked = prs.map((item) => firstUnready && item.state === "OPEN" && position(item) > position(firstUnready)
13
- ? {
14
- ...item,
15
- ...(["cancel", "mark_ready", "merge"].includes(item.action) && {
16
- action: "wait",
17
- reasons: [...item.reasons, "lower-layer-not-ready"],
18
- }),
19
- blockedByPr: firstUnready.pr,
20
- ...(item.isDraft &&
21
- item.pollCommand && {
22
- pollCommand: item.pollCommand.replace(" --until-terminal", " --timeout 1s --debounce 0s") +
23
- (item.pollCommand.includes("--no-auto-mark-ready") ? "" : " --no-auto-mark-ready"),
24
- }),
25
- }
26
- : item.state === "OPEN" &&
27
- (!isReady(item) || staleChildren.has(item.pr)) &&
28
- ["cancel", "merge"].includes(item.action)
29
- ? {
30
- ...item,
31
- action: "fix_code",
32
- reasons: [
33
- ...item.reasons,
34
- staleChildren.has(item.pr) ? "stale-ancestry" : "ready-receipt-required",
35
- ],
36
- }
37
- : item);
38
17
  const projected = {
39
18
  ...result,
40
- prs: blocked.map((item) => {
41
- if (item.state === "OPEN" && item.isInMergeQueue && item.action === "cancel") {
42
- return {
19
+ prs: prs.map((item) => {
20
+ const needsOwnSession = item.state === "OPEN" &&
21
+ (!isStackLayerReady(item) || staleChildren.has(item.pr)) &&
22
+ ["cancel", "merge"].includes(item.action);
23
+ const layer = needsOwnSession
24
+ ? {
43
25
  ...item,
26
+ action: "fix_code",
27
+ reasons: [
28
+ ...item.reasons,
29
+ staleChildren.has(item.pr) ? "stale-ancestry" : "ready-receipt-required",
30
+ ],
31
+ }
32
+ : item;
33
+ if (layer.state === "OPEN" && layer.isInMergeQueue && layer.action === "cancel") {
34
+ return {
35
+ ...layer,
44
36
  action: "wait",
45
- reasons: [...item.reasons, "already-in-merge-queue"],
37
+ reasons: [...layer.reasons, "already-in-merge-queue"],
46
38
  };
47
39
  }
48
- if (item.state === "CLOSED" && closedDependency(blocked, item.pr)) {
40
+ if (layer.state === "CLOSED" && closedDependency(prs, layer.pr)) {
49
41
  return {
50
- ...item,
42
+ ...layer,
51
43
  action: "escalate",
52
- reasons: [...item.reasons, "closed-unmerged-dependency"],
44
+ reasons: [...layer.reasons, "closed-unmerged-dependency"],
53
45
  };
54
46
  }
55
- if (item.state !== "OPEN" && item.state !== "MERGED") {
47
+ if (layer.state !== "OPEN" && layer.state !== "MERGED") {
56
48
  return {
57
- ...item,
49
+ ...layer,
58
50
  action: "escalate",
59
- reasons: [...item.reasons, "unverified-stack-state"],
51
+ reasons: [...layer.reasons, "unverified-stack-state"],
60
52
  };
61
53
  }
62
- return item;
54
+ return layer;
63
55
  }),
64
56
  };
65
57
  const planned = planStack(projected, mergeRequested);
@@ -68,50 +60,45 @@ export function withPollSummaryInstructions(result, mergeRequested) {
68
60
  instructions.push(buildQuotaAwareContinuation(result.quotaWarning, `${instructions.length + 1}. After completing the stack action,`));
69
61
  }
70
62
  return {
71
- ...projected,
72
- reason: planned.action === "cancel"
73
- ? "all_terminal"
74
- : planned.waiting
75
- ? result.reason === "timeout"
76
- ? "timeout"
77
- : "waiting"
78
- : "actionable",
79
- stackMergeable: planned.stackMergeable,
80
- nextAction: planned.action,
81
- instructions,
63
+ result: {
64
+ ...projected,
65
+ reason: planned.action === "cancel"
66
+ ? "all_terminal"
67
+ : planned.waiting
68
+ ? result.reason === "timeout"
69
+ ? "timeout"
70
+ : "waiting"
71
+ : "actionable",
72
+ stackMergeable: planned.stackMergeable,
73
+ nextAction: planned.action,
74
+ instructions,
75
+ },
76
+ ...(planned.idle && { idle: planned.idle }),
82
77
  };
83
78
  }
84
79
  function planStack(result, mergeRequested) {
85
80
  const open = result.prs.filter((item) => item.state === "OPEN");
86
- const lastOpen = open.at(-1);
87
- const closedDependencyPr = result.prs.find((item) => item.state === "CLOSED" && lastOpen && position(item) < position(lastOpen));
88
- const unverifiedLayer = result.prs.find((item) => item.state !== "OPEN" && item.state !== "MERGED");
89
81
  const gaps = result.stackAncestry ?? [];
90
- const stackMergeable = gaps.length === 0 && open.every(isReady);
91
- const candidates = open.filter((item) => !isReady(item) || gaps.some((gap) => gap.childPr === item.pr));
92
- const autonomousCandidates = candidates.filter((item) => item.action !== "escalate");
93
- const runnableCandidates = autonomousCandidates.filter((item) => item.pollCommand);
94
- const missingCommands = autonomousCandidates.filter((item) => !item.pollCommand);
95
- const escalated = result.prs.filter((item) => item.action === "escalate");
96
- if (closedDependencyPr || unverifiedLayer || escalated.length > 0) {
82
+ const staleChildren = new Set(gaps.map((gap) => gap.childPr));
83
+ const stackMergeable = gaps.length === 0 && open.every(isStackLayerReady);
84
+ const work = splitStackWork(open.filter((item) => (!isStackLayerReady(item) || staleChildren.has(item.pr)) && item.action !== "escalate"), staleChildren);
85
+ const runnableCandidates = work.sessions.filter((item) => item.pollCommand);
86
+ const missingCommands = work.sessions.filter((item) => !item.pollCommand);
87
+ const agentWork = runnableCandidates.length > 0 || work.markReady.length > 0;
88
+ const drain = planPrefixDrain(result, mergeRequested);
89
+ if (drain)
90
+ return drain;
91
+ const handoffs = findHumanHandoffs(result);
92
+ if (handoffs) {
97
93
  const instructions = [];
98
94
  appendAutonomousInstructions(instructions, runnableCandidates);
99
- const stop = runnableCandidates.length === 0;
100
- if (closedDependencyPr) {
101
- instructions.push(`${instructions.length + 1}. PR #${closedDependencyPr.pr} was closed without merging below an open layer. ${stop ? "Stop and ask" : "After autonomous shepherding, ask"} the stack owner whether to restore that dependency or rebuild the upper branches.`);
102
- }
103
- if (unverifiedLayer && unverifiedLayer.pr !== closedDependencyPr?.pr) {
104
- instructions.push(`${instructions.length + 1}. PR #${unverifiedLayer.pr} has state \`${unverifiedLayer.state}\` rather than open or merged. ${stop ? "Stop and ask" : "After autonomous shepherding, ask"} the stack owner to reconcile this layer before declaring the stack complete.`);
105
- }
106
- for (const item of escalated) {
107
- if (item.pr === closedDependencyPr?.pr || item.pr === unverifiedLayer?.pr)
108
- continue;
109
- instructions.push(`${instructions.length + 1}. PR #${item.pr} requires human action (${item.reasons.join(", ")}). ${stop ? "Stop for that decision." : "Keep shepherding other PRs before the handoff."}`);
110
- }
95
+ appendMarkReadyInstructions(instructions, work.markReady);
96
+ const stop = !agentWork;
97
+ appendHumanHandoffInstructions(instructions, handoffs, stop);
111
98
  for (const item of missingCommands) {
112
99
  instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. Ask for direction.`);
113
100
  }
114
- if (runnableCandidates.length > 0) {
101
+ if (agentWork) {
115
102
  instructions.push(`${instructions.length + 1}. After the listed one-PR sessions, rerun this same \`--stack\` selector. Stop for the human handoff only when no autonomous shepherding remains.`);
116
103
  }
117
104
  return { action: stop ? "escalate" : "shepherd", stackMergeable: false, instructions };
@@ -127,20 +114,20 @@ function planStack(result, mergeRequested) {
127
114
  if (missingCommands.length > 0) {
128
115
  const instructions = [];
129
116
  appendAutonomousInstructions(instructions, runnableCandidates);
117
+ appendMarkReadyInstructions(instructions, work.markReady);
130
118
  for (const item of missingCommands) {
131
- instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. ${runnableCandidates.length === 0 ? "Stop and ask" : "After autonomous shepherding, ask"} for direction.`);
119
+ instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. ${agentWork ? "After autonomous shepherding, ask" : "Stop and ask"} for direction.`);
132
120
  }
133
- if (runnableCandidates.length > 0) {
121
+ if (agentWork) {
134
122
  instructions.push(`${instructions.length + 1}. After the listed one-PR sessions, rerun this same \`--stack\` selector. Stop for the human handoff only when no autonomous shepherding remains.`);
135
123
  }
136
- return {
137
- action: runnableCandidates.length > 0 ? "shepherd" : "escalate",
138
- stackMergeable: false,
139
- instructions,
140
- };
124
+ return { action: agentWork ? "shepherd" : "escalate", stackMergeable: false, instructions };
141
125
  }
126
+ if (!agentWork)
127
+ return idleWaitPlan(work.idle);
142
128
  const instructions = [];
143
- appendAutonomousInstructions(instructions, autonomousCandidates);
129
+ appendAutonomousInstructions(instructions, work.sessions);
130
+ appendMarkReadyInstructions(instructions, work.markReady);
144
131
  instructions.push(`${instructions.length + 1}. After the selected one-PR sessions, rerun this same \`--stack\` selector.`);
145
132
  return { action: "shepherd", stackMergeable: false, instructions };
146
133
  }
@@ -171,49 +158,11 @@ function planStack(result, mergeRequested) {
171
158
  instructions,
172
159
  };
173
160
  }
174
- const stackNumber = result.selection.kind === "stack" ? result.selection.stackNumber : 0;
175
- return {
176
- action: "merge",
177
- stackMergeable: true,
178
- instructions: [
179
- "1. Check `gh stack merge --help`. If the `gh-stack` extension is unavailable, run `gh extension install github/gh-stack`, then rerun this same `--stack --merge` selector before merging.",
180
- `2. Stack #${stackNumber} in \`${result.repo}\` is mergeable through PR #${open.at(-1).pr}. Run \`GH_REPO=${result.repo} gh stack merge --yes --squash ${stackNumber}\` to merge the whole native stack or enqueue it when the base uses a merge queue.`,
181
- "3. After the merge attempt, rerun this same `--stack --merge` selector until every layer is merged (`CANCEL`); shepherd any layer that GitHub rejects or ejects.",
182
- ],
183
- };
161
+ return retargetWaitPlan(open[0]);
184
162
  }
185
163
  function closedDependency(items, pr) {
186
164
  const open = items.filter((item) => item.state === "OPEN");
187
165
  const lastOpen = open.at(-1);
188
166
  return Boolean(lastOpen &&
189
- items.some((item) => item.pr === pr && item.state === "CLOSED" && position(item) < position(lastOpen)));
190
- }
191
- function appendAutonomousInstructions(instructions, candidates) {
192
- if (candidates.length === 0)
193
- return;
194
- instructions.push(`${instructions.length + 1}. Start or delegate the relevant one-PR sessions below; review and CI work on separate layers can proceed concurrently.`);
195
- for (const item of candidates) {
196
- instructions.push(item.pollCommand
197
- ? `${instructions.length + 1}. Run \`${item.pollCommand}\` for PR #${item.pr}${item.blockedByPr ? ` (stack-blocked by PR #${item.blockedByPr})` : ""}${item.queueRemoval ? `; GitHub removed it from the merge queue (${item.queueRemoval.reason ?? "unknown reason"})` : ""}.`
198
- : `${instructions.length + 1}. PR #${item.pr} needs a one-PR Shepherd session, but no command was available.`);
199
- }
200
- instructions.push(`${instructions.length + 1}. Keep upper draft PRs in draft until every lower layer has completed Shepherd READY.`);
201
- }
202
- function isReady(item) {
203
- return (item.state === "OPEN" &&
204
- item.stack !== undefined &&
205
- item.readyReceipt === true &&
206
- !item.isDraft &&
207
- !item.queueRemoval &&
208
- item.mergeable !== "CONFLICTING" &&
209
- item.mergeStateStatus !== "DIRTY" &&
210
- (item.isInMergeQueue || item.mergeable === "MERGEABLE") &&
211
- (item.isInMergeQueue ||
212
- !["DIRTY", "BEHIND", "UNKNOWN", "BLOCKED", "HAS_HOOKS"].includes(item.mergeStateStatus)) &&
213
- (item.checks?.failing ?? 0) === 0 &&
214
- (item.isInMergeQueue || (item.checks?.inProgress ?? 0) === 0) &&
215
- (item.review?.actionable ?? 0) === 0);
216
- }
217
- function position(item) {
218
- return item.stack?.position ?? Number.MAX_SAFE_INTEGER;
167
+ items.some((item) => item.pr === pr && item.state === "CLOSED" && stackPosition(item) < stackPosition(lastOpen)));
219
168
  }
@@ -6,8 +6,9 @@ import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-tele
6
6
  import { fetchPollSummary } from "../github/poll-summary.mjs";
7
7
  import { sleep } from "../util/sleep.mjs";
8
8
  import { aggregateQuotaWarning, graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs, } from "./poll-quota.mjs";
9
- import { withPollSummaryInstructions } from "./poll-summary-instructions.mjs";
9
+ import { planPollSummary, withPollSummaryInstructions } from "./poll-summary-instructions.mjs";
10
10
  import { summaryStatusSignature } from "./poll-summary-signature.mjs";
11
+ import { applyStackStallGuard } from "./stack-stall.mjs";
11
12
  const MAX_TIMER_MS = 2 ** 31 - 1;
12
13
  const TIMER_DRIFT_TOLERANCE_MS = 500;
13
14
  export function runPollSummary(opts) {
@@ -21,7 +22,7 @@ async function runPollSummaryCore(opts) {
21
22
  const fetched = await fetchPollSummary(opts, repo);
22
23
  const allTerminal = fetched.prs.every((item) => item.action === "cancel");
23
24
  const actionable = fetched.prs.some((item) => item.action !== "wait" && item.action !== "cancel");
24
- return withPollSummaryInstructions({
25
+ const planned = planPollSummary({
25
26
  mode: "summary",
26
27
  repo: `${repo.owner}/${repo.name}`,
27
28
  selection: fetched.selection,
@@ -29,6 +30,7 @@ async function runPollSummaryCore(opts) {
29
30
  prs: fetched.prs,
30
31
  ...(fetched.stackAncestry?.length && { stackAncestry: fetched.stackAncestry }),
31
32
  }, opts.merge === true);
33
+ return applyStackStallGuard(planned, repo, opts.stallTimeoutSeconds ?? loadConfig().iterate.stallTimeoutMinutes * 60);
32
34
  }
33
35
  async function runAggregatePollCore(opts) {
34
36
  const intervalMs = Math.min(opts.intervalSeconds * 1000, MAX_TIMER_MS);
@@ -2,8 +2,10 @@
2
2
  * Ready-delay state machine for the shepherd iterate loop.
3
3
  *
4
4
  * When all READY conditions hold, shepherd writes a `ready-since.txt` marker
5
- * to the state dir. The loop continues until the PR has been READY for
6
- * `readyDelaySeconds` consecutively. Any not-READY result resets the timer.
5
+ * bound to the PR head to the state dir. The loop continues until the PR has
6
+ * been READY on that head for `readyDelaySeconds` consecutively. Any not-READY
7
+ * result, or a different head, resets the timer. An elapsed marker stays until
8
+ * the caller consumes it with `clearReadyDelay`.
7
9
  */
8
10
  interface ReadyDelayState {
9
11
  isReady: boolean;
@@ -25,7 +27,10 @@ interface ReadyDelayState {
25
27
  * When `shouldCancel == true`, the formatter tells loop-capable agents to cancel
26
28
  * the loop and tells one-shot agents to stop.
27
29
  */
28
- export declare function updateReadyDelay(prNumber: number, isReady: boolean, readyDelaySeconds: number, owner: string, repo: string, options?: {
29
- retainElapsed?: boolean;
30
+ export declare function updateReadyDelay(prNumber: number, isReady: boolean, readyDelaySeconds: number, owner: string, repo: string, options: {
31
+ headSha: string;
32
+ alreadyElapsed?: boolean;
30
33
  }): Promise<ReadyDelayState>;
34
+ /** Delete the ready-delay marker once an elapsed delay has been consumed. */
35
+ export declare function clearReadyDelay(prNumber: number, owner: string, repo: string): Promise<void>;
31
36
  export {};
@@ -2,8 +2,10 @@
2
2
  * Ready-delay state machine for the shepherd iterate loop.
3
3
  *
4
4
  * When all READY conditions hold, shepherd writes a `ready-since.txt` marker
5
- * to the state dir. The loop continues until the PR has been READY for
6
- * `readyDelaySeconds` consecutively. Any not-READY result resets the timer.
5
+ * bound to the PR head to the state dir. The loop continues until the PR has
6
+ * been READY on that head for `readyDelaySeconds` consecutively. Any not-READY
7
+ * result, or a different head, resets the timer. An elapsed marker stays until
8
+ * the caller consumes it with `clearReadyDelay`.
7
9
  */
8
10
  import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
9
11
  import { dirname } from "node:path";
@@ -18,47 +20,59 @@ import { resolvePrStatePath } from "../state/base.mjs";
18
20
  * When `shouldCancel == true`, the formatter tells loop-capable agents to cancel
19
21
  * the loop and tells one-shot agents to stop.
20
22
  */
21
- export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo, options = {}) {
23
+ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo, options) {
22
24
  const markerPath = readySincePath(prNumber, owner, repo);
23
25
  if (!isReady) {
24
26
  // Reset the timer.
25
27
  await safeUnlink(markerPath);
26
28
  return { isReady: false, shouldCancel: false, remainingSeconds: readyDelaySeconds };
27
29
  }
30
+ // Durable evidence (a current READY receipt) already proves the delay
31
+ // elapsed for this exact state, so a re-poll must not start a fresh timer.
32
+ if (options.alreadyElapsed) {
33
+ return { isReady: true, shouldCancel: true, remainingSeconds: 0 };
34
+ }
28
35
  // PR is READY — check or create the marker.
29
36
  const now = Math.floor(Date.now() / 1000);
30
- let readySince;
31
- try {
32
- const raw = await readFile(markerPath, "utf8");
33
- readySince = parseInt(raw.trim(), 10);
34
- // Reset if the stored value is not finite or is in the future (clock skew,
35
- // corrupted file, or manual edit). A future timestamp would produce a
36
- // negative elapsed value and an inflated remainingSeconds.
37
- if (!Number.isFinite(readySince) || readySince > now) {
38
- readySince = now;
39
- await safeWriteFile(markerPath, String(now));
40
- }
41
- }
42
- catch {
43
- // Marker doesn't exist yet — create it.
37
+ let readySince = await readReadySince(markerPath, options.headSha);
38
+ // Reset if the marker is missing, names another head, is not finite, or is
39
+ // in the future (clock skew, corrupted file, or manual edit). A future
40
+ // timestamp would produce a negative elapsed value and an inflated
41
+ // remainingSeconds.
42
+ if (readySince === null || readySince > now) {
44
43
  readySince = now;
45
- await safeWriteFile(markerPath, String(now));
44
+ await safeWriteFile(markerPath, `${now} ${options.headSha}`);
46
45
  }
47
46
  const elapsed = now - readySince;
48
47
  const remaining = readyDelaySeconds - elapsed;
49
48
  if (remaining <= 0) {
50
- if (!options.retainElapsed)
51
- await safeUnlink(markerPath);
52
49
  return { isReady: true, shouldCancel: true, remainingSeconds: 0 };
53
50
  }
54
51
  return { isReady: true, shouldCancel: false, remainingSeconds: remaining };
55
52
  }
53
+ /** Delete the ready-delay marker once an elapsed delay has been consumed. */
54
+ export async function clearReadyDelay(prNumber, owner, repo) {
55
+ await safeUnlink(readySincePath(prNumber, owner, repo));
56
+ }
56
57
  // ---------------------------------------------------------------------------
57
58
  // Helpers
58
59
  // ---------------------------------------------------------------------------
59
60
  function readySincePath(pr, owner, repo) {
60
61
  return resolvePrStatePath({ owner, repo, pr }, "ready-since.txt");
61
62
  }
63
+ /** The marker's start time, or null when it is missing, malformed, or bound to another head. */
64
+ async function readReadySince(path, headSha) {
65
+ let raw;
66
+ try {
67
+ raw = await readFile(path, "utf8");
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ const [since, head] = raw.trim().split(" ");
73
+ const readySince = Number(since);
74
+ return head === headSha && Number.isSafeInteger(readySince) ? readySince : null;
75
+ }
62
76
  async function safeUnlink(path) {
63
77
  try {
64
78
  await unlink(path);
@@ -1,3 +1,4 @@
1
+ import { buildPrShepherdCommand } from "../cli/runner.mjs";
1
2
  export const SHEPHERD_JOURNAL_SECTION = "Shepherd Journal";
2
3
  export const SHEPHERD_JOURNAL_SECTION_PATTERN = /^##\s+Shepherd\s+Journal$/;
3
4
  export const SHEPHERD_JOURNAL_DETAILS_OPEN = "<details>";
@@ -12,5 +13,7 @@ export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `##
12
13
  * of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts minimal").
13
14
  */
14
15
  export function buildShepherdJournalInstruction(prReference) {
15
- return `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`pr-shepherd apply journal ${prReference} '- <decision>'\`. See "Shepherd Journal" in the pr-shepherd skill for citation conventions.`;
16
+ // Single-quote the placeholder so a substituted decision stays literal in the shell.
17
+ const command = `${buildPrShepherdCommand(["apply", "journal", String(prReference)]).text} '- <decision>'`;
18
+ return `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`${command}\`. See "Shepherd Journal" in the pr-shepherd skill for citation conventions.`;
16
19
  }
@@ -0,0 +1,35 @@
1
+ import type { PollSummaryItem, PollSummaryResult, StackNextAction } from "../types.mts";
2
+ export interface StackPlan {
3
+ action: StackNextAction;
4
+ stackMergeable: boolean;
5
+ waiting?: boolean;
6
+ /** Set only by {@link idleWaitPlan}: the layers whose one-PR probes could only report waiting. */
7
+ idle?: PollSummaryItem[];
8
+ instructions: string[];
9
+ }
10
+ /**
11
+ * Merge the ready prefix by its highest PR number. `gh stack merge <n>` tries a
12
+ * stack number first, but stack numbers come from the repository's issue and
13
+ * pull request sequence, so a PR number never names a stack.
14
+ */
15
+ export declare function planPrefixDrain(result: PollSummaryResult, mergeRequested: boolean): StackPlan | undefined;
16
+ /**
17
+ * A merge-requested, fully ready stack whose bottom open layer still targets
18
+ * the merged layer below it until GitHub retargets it onto the stack base.
19
+ */
20
+ export declare function retargetWaitPlan(first: PollSummaryItem): StackPlan;
21
+ /** Every remaining layer only waits on CI or merge state. */
22
+ export declare function idleWaitPlan(idle: PollSummaryItem[]): StackPlan;
23
+ export declare function describeIdleLayers(idle: PollSummaryItem[]): string;
24
+ export declare function appendAutonomousInstructions(instructions: string[], candidates: PollSummaryItem[]): void;
25
+ /** Layers only a human can resolve: a closed dependency, an unverified state, or an escalation. */
26
+ export interface HumanHandoffs {
27
+ closedDependency?: PollSummaryItem;
28
+ unverified?: PollSummaryItem;
29
+ escalated: PollSummaryItem[];
30
+ }
31
+ export declare function findHumanHandoffs(result: PollSummaryResult): HumanHandoffs | undefined;
32
+ /** Name every human handoff; `stop` when no autonomous work precedes it. */
33
+ export declare function appendHumanHandoffInstructions(instructions: string[], { closedDependency, unverified, escalated }: HumanHandoffs, stop: boolean): void;
34
+ export declare function isStackLayerReady(item: PollSummaryItem): boolean;
35
+ export declare function stackPosition(item: PollSummaryItem): number;
@@ -0,0 +1,138 @@
1
+ import { stackLayerBlockReason } from "./stack-layer-readiness.mjs";
2
+ import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
3
+ /**
4
+ * Highest open layer such that it and every open layer below it are ready to
5
+ * merge together. `gh stack merge <PR>` lands that PR and every unmerged layer
6
+ * below it. A merge queue accepts the same prefix and evaluates each layer
7
+ * from the bottom; a failure ejects that layer and those above it.
8
+ */
9
+ function readyPrefixTop(result) {
10
+ const layers = [...result.prs].sort((left, right) => stackPosition(left) - stackPosition(right));
11
+ if (layers.some((item) => item.state === "OPEN" && item.isInMergeQueue))
12
+ return undefined;
13
+ const open = layers.filter((item) => item.state === "OPEN");
14
+ const bottom = open[0];
15
+ if (!bottom || !isStackLayer(bottom) || bottom.state !== "OPEN")
16
+ return undefined;
17
+ if (bottom.baseRefName !== bottom.stack.baseRefName)
18
+ return undefined;
19
+ const stale = new Set((result.stackAncestry ?? []).map((gap) => gap.childPr));
20
+ let top;
21
+ for (const item of open) {
22
+ if (!isStackLayer(item))
23
+ break;
24
+ if (item.action === "escalate" || !isStackLayerReady(item) || stale.has(item.pr))
25
+ break;
26
+ if (layers.some((layer) => layer.state === "CLOSED" && stackPosition(layer) < stackPosition(item)))
27
+ break;
28
+ top = item;
29
+ }
30
+ return top;
31
+ }
32
+ /**
33
+ * Merge the ready prefix by its highest PR number. `gh stack merge <n>` tries a
34
+ * stack number first, but stack numbers come from the repository's issue and
35
+ * pull request sequence, so a PR number never names a stack.
36
+ */
37
+ export function planPrefixDrain(result, mergeRequested) {
38
+ if (!mergeRequested)
39
+ return undefined;
40
+ const top = readyPrefixTop(result);
41
+ if (!top)
42
+ return undefined;
43
+ const gaps = result.stackAncestry ?? [];
44
+ const staleChildren = new Set(gaps.map((gap) => gap.childPr));
45
+ const open = result.prs
46
+ .filter((item) => item.state === "OPEN")
47
+ .sort((left, right) => stackPosition(left) - stackPosition(right));
48
+ const above = splitStackWork(open.filter((item) => stackPosition(item) > stackPosition(top) &&
49
+ item.action !== "escalate" &&
50
+ (!isStackLayerReady(item) || staleChildren.has(item.pr))), staleChildren);
51
+ const span = open[0]?.pr === top.pr ? "that layer alone" : `PR #${top.pr} and every unmerged layer below it`;
52
+ const instructions = [
53
+ `1. PR #${top.pr} is the highest open layer of stack #${top.stack.number} in \`${result.repo}\` whose open lower layers are all ready. Run \`GH_REPO=${result.repo} gh stack merge ${top.pr} --yes --squash\` to merge ${span}. When the base uses a merge queue, the same command queues that prefix together and GitHub evaluates each layer from the bottom; a failure ejects that layer and the layers above it. If \`gh stack\` is an unknown command, run \`gh extension install github/gh-stack\` first.`,
54
+ ];
55
+ appendAutonomousInstructions(instructions, above.sessions);
56
+ appendMarkReadyInstructions(instructions, above.markReady);
57
+ const handoffs = findHumanHandoffs(result);
58
+ if (handoffs)
59
+ appendHumanHandoffInstructions(instructions, handoffs, false);
60
+ instructions.push(`${instructions.length + 1}. After the merge attempt, rerun this same \`--stack --merge\` selector; GitHub retargets the next layer onto \`${top.stack.baseRefName}\`. Shepherd any layer that GitHub rejects or ejects.`);
61
+ return {
62
+ action: "merge",
63
+ stackMergeable: gaps.length === 0 && open.every(isStackLayerReady),
64
+ instructions,
65
+ };
66
+ }
67
+ /**
68
+ * A merge-requested, fully ready stack whose bottom open layer still targets
69
+ * the merged layer below it until GitHub retargets it onto the stack base.
70
+ */
71
+ export function retargetWaitPlan(first) {
72
+ return {
73
+ action: "wait",
74
+ stackMergeable: true,
75
+ waiting: true,
76
+ instructions: [
77
+ `1. PR #${first.pr} still targets \`${first.baseRefName}\` rather than \`${first.stack?.baseRefName}\`; wait for GitHub to retarget it before merging. Recheck at the configured polling cadence.`,
78
+ ],
79
+ };
80
+ }
81
+ /** Every remaining layer only waits on CI or merge state. */
82
+ export function idleWaitPlan(idle) {
83
+ return {
84
+ action: "wait",
85
+ stackMergeable: false,
86
+ waiting: true,
87
+ idle,
88
+ instructions: [
89
+ `1. No one-PR session can advance the stack yet: ${describeIdleLayers(idle)}. Recheck at the configured polling cadence.`,
90
+ ],
91
+ };
92
+ }
93
+ export function describeIdleLayers(idle) {
94
+ return idle.map((item) => `PR #${item.pr} (${item.reasons.join(", ")})`).join("; ");
95
+ }
96
+ export function appendAutonomousInstructions(instructions, candidates) {
97
+ if (candidates.length === 0)
98
+ return;
99
+ instructions.push(`${instructions.length + 1}. Start or delegate the relevant one-PR sessions below; review and CI work on separate layers can proceed concurrently.`);
100
+ for (const item of candidates) {
101
+ instructions.push(item.pollCommand
102
+ ? `${instructions.length + 1}. Run \`${item.pollCommand}\` for PR #${item.pr}${item.queueRemoval ? `; GitHub removed it from the merge queue (${item.queueRemoval.reason ?? "unknown reason"})` : ""}.`
103
+ : `${instructions.length + 1}. PR #${item.pr} needs a one-PR Shepherd session, but no command was available.`);
104
+ }
105
+ }
106
+ export function findHumanHandoffs(result) {
107
+ const lastOpen = result.prs.filter((item) => item.state === "OPEN").at(-1);
108
+ const closedDependency = result.prs.find((item) => item.state === "CLOSED" && lastOpen && stackPosition(item) < stackPosition(lastOpen));
109
+ const unverified = result.prs.find((item) => item.state !== "OPEN" && item.state !== "MERGED");
110
+ const escalated = result.prs.filter((item) => item.action === "escalate");
111
+ return closedDependency || unverified || escalated.length > 0
112
+ ? { closedDependency, unverified, escalated }
113
+ : undefined;
114
+ }
115
+ /** Name every human handoff; `stop` when no autonomous work precedes it. */
116
+ export function appendHumanHandoffInstructions(instructions, { closedDependency, unverified, escalated }, stop) {
117
+ const ask = stop ? "Stop and ask" : "After autonomous shepherding, ask";
118
+ if (closedDependency) {
119
+ instructions.push(`${instructions.length + 1}. PR #${closedDependency.pr} was closed without merging below an open layer. ${ask} the stack owner whether to restore that dependency or rebuild the upper branches.`);
120
+ }
121
+ if (unverified && unverified.pr !== closedDependency?.pr) {
122
+ instructions.push(`${instructions.length + 1}. PR #${unverified.pr} has state \`${unverified.state}\` rather than open or merged. ${ask} the stack owner to reconcile this layer before declaring the stack complete.`);
123
+ }
124
+ for (const item of escalated) {
125
+ if (item.pr === closedDependency?.pr || item.pr === unverified?.pr)
126
+ continue;
127
+ instructions.push(`${instructions.length + 1}. PR #${item.pr} requires human action (${item.reasons.join(", ")}). ${stop ? "Stop for that decision." : "Keep shepherding other PRs before the handoff."}`);
128
+ }
129
+ }
130
+ export function isStackLayerReady(item) {
131
+ return item.stack !== undefined && stackLayerBlockReason(item) === undefined;
132
+ }
133
+ export function stackPosition(item) {
134
+ return item.stack?.position ?? Number.MAX_SAFE_INTEGER;
135
+ }
136
+ function isStackLayer(item) {
137
+ return item.stack !== undefined;
138
+ }
@@ -0,0 +1,6 @@
1
+ import type { PollSummaryItem, StackLayerBlockReason } from "../types.mts";
2
+ /**
3
+ * Why a native stack layer is not ready to merge, or undefined when it is.
4
+ * A draft is marked ready by its own session; this predicate does not gate that.
5
+ */
6
+ export declare function stackLayerBlockReason(item: PollSummaryItem): StackLayerBlockReason | undefined;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Why a native stack layer is not ready to merge, or undefined when it is.
3
+ * A draft is marked ready by its own session; this predicate does not gate that.
4
+ */
5
+ export function stackLayerBlockReason(item) {
6
+ if (item.state !== "OPEN")
7
+ return "closed";
8
+ if (item.isDraft)
9
+ return "draft";
10
+ if (item.mergeable === "CONFLICTING" || item.mergeStateStatus === "DIRTY")
11
+ return "conflicting";
12
+ // A receipt only establishes readiness after a merge-queue removal once
13
+ // the one-PR session has observed and acknowledged that exact removal.
14
+ // The aggregate projection preserves an unacknowledged removal here, so
15
+ // the layer is not ready to merge.
16
+ if (item.queueRemoval)
17
+ return "queue-removal";
18
+ if ((item.checks?.failing ?? 0) > 0)
19
+ return "failing-checks";
20
+ if ((item.review?.actionable ?? 0) > 0)
21
+ return "review-work";
22
+ // Merge-group checks and the queue's own merge state supersede the source
23
+ // PR's while it is queued.
24
+ if (!item.isInMergeQueue) {
25
+ if ((item.checks?.inProgress ?? 0) > 0)
26
+ return "checks-in-progress";
27
+ if (item.mergeable !== "MERGEABLE" ||
28
+ ["BEHIND", "UNKNOWN", "BLOCKED", "HAS_HOOKS"].includes(item.mergeStateStatus))
29
+ return "merge-state";
30
+ }
31
+ // A layer that looks ready but has not completed its own one-PR receipt
32
+ // is not ready to merge.
33
+ return item.readyReceipt === true ? undefined : "no-ready-receipt";
34
+ }