pr-shepherd 0.51.1 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +29 -18
  3. package/bin/cli/help-command-pages.d.mts +1 -1
  4. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  5. package/bin/cli/help-iterate-poll-pages.mjs +4 -3
  6. package/bin/cli/help-top-page.d.mts +1 -1
  7. package/bin/cli/help-top-page.mjs +3 -2
  8. package/bin/cli/help.d.mts +2 -2
  9. package/bin/cli/iterate-instructions.mjs +41 -0
  10. package/bin/cli/iterate-lean.mjs +1 -3
  11. package/bin/cli/poll-summary-emitter.mjs +2 -3
  12. package/bin/cli/poll-summary-formatter.mjs +12 -3
  13. package/bin/cli/runner.d.mts +1 -0
  14. package/bin/cli/runner.mjs +3 -1
  15. package/bin/commands/check.mjs +14 -4
  16. package/bin/commands/clean.mjs +7 -13
  17. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  18. package/bin/commands/iterate/check-instructions.mjs +11 -7
  19. package/bin/commands/iterate/escalate.mjs +4 -13
  20. package/bin/commands/iterate/fix-code.d.mts +2 -0
  21. package/bin/commands/iterate/fix-code.mjs +20 -43
  22. package/bin/commands/iterate/helpers.d.mts +0 -1
  23. package/bin/commands/iterate/helpers.mjs +0 -15
  24. package/bin/commands/iterate/index.mjs +178 -11
  25. package/bin/commands/iterate/merge-state.mjs +35 -18
  26. package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
  27. package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
  28. package/bin/commands/iterate/parent-first.d.mts +25 -0
  29. package/bin/commands/iterate/parent-first.mjs +70 -0
  30. package/bin/commands/iterate/render.d.mts +1 -1
  31. package/bin/commands/iterate/render.mjs +7 -12
  32. package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
  33. package/bin/commands/iterate/stale-ancestry.mjs +40 -0
  34. package/bin/commands/iterate/stall.mjs +40 -3
  35. package/bin/commands/poll-progress.d.mts +5 -1
  36. package/bin/commands/poll-progress.mjs +7 -1
  37. package/bin/commands/poll-summary-instructions.d.mts +6 -1
  38. package/bin/commands/poll-summary-instructions.mjs +151 -107
  39. package/bin/commands/poll-summary.mjs +32 -6
  40. package/bin/commands/poll.mjs +3 -1
  41. package/bin/commands/ready-delay.d.mts +9 -4
  42. package/bin/commands/ready-delay.mjs +34 -20
  43. package/bin/commands/shepherd-journal.mjs +4 -1
  44. package/bin/commands/stack-drain.d.mts +35 -0
  45. package/bin/commands/stack-drain.mjs +129 -0
  46. package/bin/commands/stack-layer-readiness.d.mts +7 -0
  47. package/bin/commands/stack-layer-readiness.mjs +35 -0
  48. package/bin/commands/stack-stall.d.mts +14 -0
  49. package/bin/commands/stack-stall.mjs +69 -0
  50. package/bin/commands/stack-work.d.mts +32 -0
  51. package/bin/commands/stack-work.mjs +39 -0
  52. package/bin/config/load.d.mts +2 -0
  53. package/bin/config/load.mjs +10 -0
  54. package/bin/config.json +1 -0
  55. package/bin/exit-codes.d.mts +2 -0
  56. package/bin/exit-codes.mjs +2 -0
  57. package/bin/github/batch-parsers.mjs +1 -0
  58. package/bin/github/batch-raw-types.d.mts +2 -0
  59. package/bin/github/errors.d.mts +5 -0
  60. package/bin/github/errors.mjs +4 -0
  61. package/bin/github/gql/batch-pr.gql +1 -0
  62. package/bin/github/gql/poll-stack-summary.gql +8 -2
  63. package/bin/github/gql/poll-stack-topology.gql +38 -0
  64. package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
  65. package/bin/github/gql/poll-summary-check-page.gql +23 -0
  66. package/bin/github/gql/poll-summary-fragment.gql +53 -56
  67. package/bin/github/merge-queue-checks.mjs +10 -1
  68. package/bin/github/poll-summary-check-hydration.d.mts +12 -0
  69. package/bin/github/poll-summary-check-hydration.mjs +55 -0
  70. package/bin/github/poll-summary-fingerprint.d.mts +8 -0
  71. package/bin/github/poll-summary-fingerprint.mjs +48 -0
  72. package/bin/github/poll-summary-projector.mjs +70 -16
  73. package/bin/github/poll-summary-queue-removal.d.mts +5 -0
  74. package/bin/github/poll-summary-queue-removal.mjs +25 -0
  75. package/bin/github/poll-summary-raw.d.mts +52 -31
  76. package/bin/github/poll-summary-readiness.d.mts +6 -0
  77. package/bin/github/poll-summary-readiness.mjs +25 -0
  78. package/bin/github/poll-summary-route.mjs +8 -5
  79. package/bin/github/poll-summary.d.mts +3 -0
  80. package/bin/github/poll-summary.mjs +21 -73
  81. package/bin/github/queries.d.mts +7 -0
  82. package/bin/github/queries.mjs +9 -1
  83. package/bin/github/queue-removal-freshness.d.mts +16 -0
  84. package/bin/github/queue-removal-freshness.mjs +26 -0
  85. package/bin/github/stack-read.d.mts +34 -0
  86. package/bin/github/stack-read.mjs +92 -0
  87. package/bin/log/log-file.d.mts +1 -1
  88. package/bin/log/log-file.mjs +4 -17
  89. package/bin/state/base.d.mts +18 -1
  90. package/bin/state/base.mjs +65 -13
  91. package/bin/state/fix-attempts.d.mts +1 -1
  92. package/bin/state/fix-attempts.mjs +1 -1
  93. package/bin/state/graphql-quota-warnings.mjs +2 -6
  94. package/bin/state/iterate-stall.d.mts +7 -15
  95. package/bin/state/iterate-stall.mjs +6 -64
  96. package/bin/state/ready-receipts.d.mts +45 -0
  97. package/bin/state/ready-receipts.mjs +86 -0
  98. package/bin/state/rest-cache.d.mts +1 -1
  99. package/bin/state/rest-cache.mjs +1 -1
  100. package/bin/state/stack-stall.d.mts +16 -0
  101. package/bin/state/stack-stall.mjs +12 -0
  102. package/bin/state/stall-state-store.d.mts +37 -0
  103. package/bin/state/stall-state-store.mjs +74 -0
  104. package/bin/types/escalate.d.mts +2 -3
  105. package/bin/types/github.d.mts +2 -0
  106. package/bin/types/iterate.d.mts +2 -1
  107. package/bin/types/merge-requirements.d.mts +19 -0
  108. package/bin/types/poll-summary.d.mts +17 -2
  109. package/bin/types/report.d.mts +2 -0
  110. package/package.json +2 -2
  111. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  112. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  113. package/plugins/pr-shepherd/.mcp.json +1 -1
  114. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
  115. package/bin/state/bot-cr-seen.d.mts +0 -51
  116. package/bin/state/bot-cr-seen.mjs +0 -100
@@ -0,0 +1,70 @@
1
+ import { fetchPollSummary } from "../../github/poll-summary.mjs";
2
+ import { stackLayerBlockReason } from "../stack-layer-readiness.mjs";
3
+ /**
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.
8
+ */
9
+ export async function findParentMarkReadyBlock(report, repo) {
10
+ const stack = report.mergeStatus.mergeRequirements?.stack;
11
+ if (!stack || stack.position === 1)
12
+ return undefined;
13
+ if (stack.position < 1)
14
+ return "unverifiable";
15
+ try {
16
+ const summary = await fetchPollSummary({ stackPrNumber: report.pr }, repo);
17
+ const child = summary.prs.find((item) => item.pr === report.pr);
18
+ const lowerLayers = summary.prs
19
+ .filter((item) => (item.stack?.position ?? Number.MAX_SAFE_INTEGER) < stack.position)
20
+ .sort((left, right) => (left.stack?.position ?? Number.MAX_SAFE_INTEGER) -
21
+ (right.stack?.position ?? Number.MAX_SAFE_INTEGER));
22
+ if (!child || child.state !== "OPEN" || lowerLayers.length !== stack.position - 1)
23
+ return "unverifiable";
24
+ // A stale boundary means that layer is no longer based on the parent it was
25
+ // reviewed against. Gaps above this child do not affect its promotion boundary.
26
+ const staleChildren = new Set(summary.stackAncestry?.map((gap) => gap.childPr));
27
+ for (const layer of lowerLayers) {
28
+ // A merged layer is already satisfied; GitHub may have retargeted the
29
+ // layer above it to the trunk as part of the merge.
30
+ if (layer.state === "MERGED")
31
+ continue;
32
+ const reason = staleChildren.has(layer.pr) ? "stale-ancestry" : stackLayerBlockReason(layer);
33
+ if (reason)
34
+ return { pr: layer.pr, reason };
35
+ }
36
+ // This layer's own stale boundary belongs to its own session's repair; the
37
+ // earlier stale-ancestry read missed it, so this snapshot is unsettled.
38
+ return staleChildren.has(report.pr) ? "unverifiable" : undefined;
39
+ }
40
+ catch {
41
+ // Never convert a child draft based on an unverifiable parent.
42
+ return "unverifiable";
43
+ }
44
+ }
45
+ /**
46
+ * A native stack draft this one-PR session cannot promote: a lower layer blocks it, or
47
+ * automatic mark-ready is off. Undefined when the session can still advance the PR by
48
+ * iterating.
49
+ */
50
+ export function stackDraftHold(report, autoMarkReady, parentBlock) {
51
+ if (!report.mergeStatus.mergeRequirements?.stack || !report.mergeStatus.isDraft)
52
+ return undefined;
53
+ // Any lower-layer block outranks the session flag: even with automatic
54
+ // mark-ready enabled, this draft cannot advance until that layer does, and a
55
+ // lower-layer read that failed must not hide behind the flag.
56
+ if (parentBlock === "unverifiable")
57
+ return { kind: "lower-layer-not-ready" };
58
+ if (parentBlock)
59
+ return { kind: "lower-layer-not-ready", lowerLayer: parentBlock };
60
+ return autoMarkReady ? undefined : { kind: "auto-mark-ready-disabled" };
61
+ }
62
+ /**
63
+ * The lower layer a held draft waits on. That layer's own session owns this draft's
64
+ * progress, so the draft neither stalls nor keeps polling while it waits.
65
+ */
66
+ export function heldByLowerLayer(result) {
67
+ return result.action === "wait" && result.stackDraftHold?.kind === "lower-layer-not-ready"
68
+ ? result.stackDraftHold.lowerLayer
69
+ : undefined;
70
+ }
@@ -2,4 +2,4 @@ import type { AgentThread, AgentComment, AgentCheck, Review, ResolveCommand, Fir
2
2
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
3
3
  export declare function renderResolveCommand(rc: ResolveCommand): string;
4
4
  export declare function buildFixInstructions(threads: AgentThread[], actionableComments: AgentComment[], checks: AgentCheck[], changesRequestedReviews: Review[], baseBranch: string, resolveCommand: ResolveCommand, hasConflicts: boolean, prReference: string | number, _cancelledCount: number, firstLookThreads?: FirstLookThread[], firstLookComments?: FirstLookComment[], firstLookSummaries?: Review[], editedSummaries?: Review[], _inProgressRunIds?: string[], resolutionOnlyThreads?: ReviewThread[], resolveOnlyCommand?: ResolveCommand, behindBaseHint?: string, // iterate.behindBaseHint — see buildBehindBaseHintInstruction
5
- isBehind?: boolean, viewerCanUpdate?: boolean, hasExhaustedWorkflowRerun?: boolean): string[];
5
+ isBehind?: boolean, viewerCanUpdate?: boolean, hasExhaustedWorkflowRerun?: boolean, stackRebase?: string): string[];
@@ -4,6 +4,7 @@ import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, buildShepherdJournalInstruction,
4
4
  import { isFailingAgentCheck } from "../../checks/conclusions.mjs";
5
5
  import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
6
6
  import { partitionFixThreads, reviewSectionRefs } from "./fix-instruction-threads.mjs";
7
+ import { buildBranchPushInstruction, buildConflictInstruction } from "./native-stack-rebase.mjs";
7
8
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
8
9
  export function renderResolveCommand(rc) {
9
10
  const parts = [...rc.argv];
@@ -12,14 +13,11 @@ export function renderResolveCommand(rc) {
12
13
  return renderShellCommand(parts);
13
14
  }
14
15
  export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prReference, _cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], _inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
15
- isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
16
+ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, stackRebase) {
16
17
  const instructions = [];
17
18
  const { locatedThreads, unlocatedMutatedThreads, unlocatedThreads } = partitionFixThreads(threads, resolveCommand, resolveOnlyCommand);
18
19
  const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
19
- const repeatedWorkflowBranchRecoveryInstructions = buildRepeatedWorkflowBranchRecoveryInstructions(baseBranch, hasExhaustedWorkflowRerun, {
20
- isBehind,
21
- hasConflicts,
22
- });
20
+ const repeatedWorkflowBranchRecoveryInstructions = buildRepeatedWorkflowBranchRecoveryInstructions(baseBranch, hasExhaustedWorkflowRerun, { isBehind, hasConflicts }, stackRebase);
23
21
  const hasRepeatedWorkflowBranchRecovery = repeatedWorkflowBranchRecoveryInstructions.length > 0;
24
22
  const hasAnnotations = checks.some((c) => (c.annotations?.length ?? 0) > 0);
25
23
  const hasNonConflictHints = threads.length > 0 ||
@@ -41,7 +39,7 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
41
39
  instructions.push(`Review each item ${sectionRef} and decide whether it needs a code change.`);
42
40
  }
43
41
  if (hasConflicts && !hasRepeatedWorkflowBranchRecovery) {
44
- instructions.push("The branch has merge conflicts (see `**branch**` above). Resolve them before committing.");
42
+ instructions.push(buildConflictInstruction(stackRebase));
45
43
  }
46
44
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
47
45
  if (firstLookTotal > 0) {
@@ -82,11 +80,8 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
82
80
  instructions.push(...buildBehindBaseHintInstruction(baseBranch, behindBaseHint, isBehind));
83
81
  const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
84
82
  const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
85
- if (hasConflicts) {
86
- instructions.push(`Commit any remaining conflict-resolution changes and push to the PR head branch${mutationSuffix}.`);
87
- }
88
- else if (hasRepeatedWorkflowBranchRecovery) {
89
- instructions.push("Push the updated PR head branch before iterating immediately.");
83
+ if (hasConflicts || hasRepeatedWorkflowBranchRecovery) {
84
+ instructions.push(buildBranchPushInstruction(stackRebase, hasConflicts, mutationSuffix));
90
85
  }
91
86
  else if (hasNonConflictHints) {
92
87
  instructions.push("If you changed code, commit any remaining changes and push to the PR head branch, then run the remaining review mutations using the pushed commit SHA and iterate immediately with the same options. If you did not change code, do not commit and continue with the remaining steps.");
@@ -101,6 +96,6 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
101
96
  }
102
97
  if (resolveOnlyCommand?.hasMutations)
103
98
  instructions.push("Run the `resolve-only:` command shown above.");
104
- instructions.push(...buildResolveCommandInstruction(resolveCommand), buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha));
99
+ instructions.push(...buildResolveCommandInstruction(resolveCommand), buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha, stackRebase !== undefined));
105
100
  return instructions;
106
101
  }
@@ -0,0 +1,22 @@
1
+ import type { RepoInfo } from "../../github/client.mts";
2
+ import type { PollSummaryStackAncestry } from "../../types.mts";
3
+ import type { ShepherdReport } from "../../types/report.mts";
4
+ /**
5
+ * A verified stale boundary for the PR being shepherded.
6
+ *
7
+ * The stack topology read is authoritative for this check: it compares the
8
+ * child's recorded base OID with the current head OID of its immediate open
9
+ * parent. GitHub can report both PRs CLEAN while this boundary is stale.
10
+ */
11
+ export interface StaleNativeStackAncestry extends PollSummaryStackAncestry {
12
+ instructions: string[];
13
+ }
14
+ /**
15
+ * Find a stale immediate parent boundary for one PR.
16
+ *
17
+ * This helper is intentionally read-only. A null result means either the PR is
18
+ * not a non-root native-stack layer, its boundary is current, or the boundary
19
+ * could not be verified. Callers must not emit a repair command from an
20
+ * unverified snapshot.
21
+ */
22
+ export declare function findStaleNativeStackAncestry(report: Pick<ShepherdReport, "pr" | "mergeStatus">, repo: RepoInfo): Promise<StaleNativeStackAncestry | null>;
@@ -0,0 +1,40 @@
1
+ import { readStackTopology, stackAncestryGaps } from "../../github/stack-read.mjs";
2
+ import { buildNativeStackRebaseInstruction } from "./native-stack-rebase.mjs";
3
+ /**
4
+ * Find a stale immediate parent boundary for one PR.
5
+ *
6
+ * This helper is intentionally read-only. A null result means either the PR is
7
+ * not a non-root native-stack layer, its boundary is current, or the boundary
8
+ * could not be verified. Callers must not emit a repair command from an
9
+ * unverified snapshot.
10
+ */
11
+ export async function findStaleNativeStackAncestry(report, repo) {
12
+ const stack = report.mergeStatus.mergeRequirements?.stack;
13
+ if (!stack || stack.position <= 1)
14
+ return null;
15
+ try {
16
+ const topology = await readStackTopology(report.pr, repo);
17
+ const ancestry = stackAncestryGaps(topology.ordered).find((gap) => gap.childPr === report.pr);
18
+ if (!ancestry)
19
+ return null;
20
+ return {
21
+ ...ancestry,
22
+ instructions: buildStaleNativeStackAncestryInstructions(repo, stack.number, ancestry),
23
+ };
24
+ }
25
+ catch {
26
+ // A stale repair is safe only when both OIDs were observed together. Let
27
+ // the caller retain the ordinary WAIT/ESCALATE path on an unreadable stack.
28
+ return null;
29
+ }
30
+ }
31
+ /** Build the one-PR repair guidance after a stale boundary was verified. */
32
+ function buildStaleNativeStackAncestryInstructions(repo, stackNumber, ancestry) {
33
+ return [
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}\`.`,
35
+ buildNativeStackRebaseInstruction(`${repo.owner}/${repo.name}`, stackNumber, {
36
+ parentBranch: ancestry.parentHeadRefName,
37
+ }),
38
+ "Push the rewritten stack with `gh stack push`.",
39
+ ];
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,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
+ };
@@ -1,139 +1,183 @@
1
+ /* eslint-disable max-lines */
1
2
  import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
2
3
  import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
4
+ import { appendAutonomousInstructions, appendHumanHandoffInstructions, findHumanHandoffs, idleWaitPlan, isStackLayerReady, planBottomDrain, retargetWaitPlan, stackPosition, } from "./stack-drain.mjs";
5
+ import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
3
6
  /** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
4
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) {
5
12
  if (result.selection.kind !== "stack") {
6
- return { ...result, instructions: explicitInstructions(result) };
13
+ return { result: { ...result, instructions: explicitInstructions(result) } };
7
14
  }
8
- const planned = planStack(result, mergeRequested);
9
- const reason = planned.action === "cancel"
10
- ? "all_terminal"
11
- : planned.action === "wait"
12
- ? result.reason === "timeout"
13
- ? "timeout"
14
- : "waiting"
15
- : "actionable";
15
+ const prs = [...result.prs].sort((left, right) => stackPosition(left) - stackPosition(right));
16
+ const staleChildren = new Set(result.stackAncestry?.map((gap) => gap.childPr) ?? []);
17
+ const firstUnready = prs.find((item) => item.state === "OPEN" && (!isStackLayerReady(item) || staleChildren.has(item.pr)));
18
+ const blocked = prs.map((item) => firstUnready && item.state === "OPEN" && stackPosition(item) > stackPosition(firstUnready)
19
+ ? {
20
+ ...item,
21
+ ...(["cancel", "mark_ready", "merge"].includes(item.action) && {
22
+ action: "wait",
23
+ reasons: [...item.reasons, "lower-layer-not-ready"],
24
+ }),
25
+ blockedByPr: firstUnready.pr,
26
+ ...(item.isDraft &&
27
+ item.pollCommand && {
28
+ pollCommand: item.pollCommand.replace(" --until-terminal", " --timeout 1s --debounce 0s") +
29
+ (item.pollCommand.includes("--no-auto-mark-ready") ? "" : " --no-auto-mark-ready"),
30
+ pollProbe: true,
31
+ }),
32
+ }
33
+ : item.state === "OPEN" &&
34
+ (!isStackLayerReady(item) || staleChildren.has(item.pr)) &&
35
+ ["cancel", "merge"].includes(item.action)
36
+ ? {
37
+ ...item,
38
+ action: "fix_code",
39
+ reasons: [
40
+ ...item.reasons,
41
+ staleChildren.has(item.pr) ? "stale-ancestry" : "ready-receipt-required",
42
+ ],
43
+ }
44
+ : item);
45
+ const projected = {
46
+ ...result,
47
+ prs: blocked.map((item) => {
48
+ if (item.state === "OPEN" && item.isInMergeQueue && item.action === "cancel") {
49
+ return {
50
+ ...item,
51
+ action: "wait",
52
+ reasons: [...item.reasons, "already-in-merge-queue"],
53
+ };
54
+ }
55
+ if (item.state === "CLOSED" && closedDependency(blocked, item.pr)) {
56
+ return {
57
+ ...item,
58
+ action: "escalate",
59
+ reasons: [...item.reasons, "closed-unmerged-dependency"],
60
+ };
61
+ }
62
+ if (item.state !== "OPEN" && item.state !== "MERGED") {
63
+ return {
64
+ ...item,
65
+ action: "escalate",
66
+ reasons: [...item.reasons, "unverified-stack-state"],
67
+ };
68
+ }
69
+ return item;
70
+ }),
71
+ };
72
+ const planned = planStack(projected, mergeRequested);
16
73
  const instructions = [...planned.instructions];
17
- if (result.quotaWarning && planned.action !== "wait" && planned.action !== "cancel") {
74
+ if (result.quotaWarning && planned.action === "shepherd") {
18
75
  instructions.push(buildQuotaAwareContinuation(result.quotaWarning, `${instructions.length + 1}. After completing the stack action,`));
19
76
  }
20
- return { ...result, reason, nextAction: planned.action, instructions };
77
+ return {
78
+ result: {
79
+ ...projected,
80
+ reason: planned.action === "cancel"
81
+ ? "all_terminal"
82
+ : planned.waiting
83
+ ? result.reason === "timeout"
84
+ ? "timeout"
85
+ : "waiting"
86
+ : "actionable",
87
+ stackMergeable: planned.stackMergeable,
88
+ nextAction: planned.action,
89
+ instructions,
90
+ },
91
+ ...(planned.idle && { idle: planned.idle }),
92
+ };
21
93
  }
22
94
  function planStack(result, mergeRequested) {
23
95
  const open = result.prs.filter((item) => item.state === "OPEN");
24
- if (open.length === 0) {
25
- return { action: "cancel", instructions: ["1. Stop — every selected PR is terminal."] };
96
+ const gaps = result.stackAncestry ?? [];
97
+ const staleChildren = new Set(gaps.map((gap) => gap.childPr));
98
+ const stackMergeable = gaps.length === 0 && open.every(isStackLayerReady);
99
+ const work = splitStackWork(open.filter((item) => (!isStackLayerReady(item) || staleChildren.has(item.pr)) && item.action !== "escalate"), staleChildren);
100
+ const runnableCandidates = work.sessions.filter((item) => item.pollCommand);
101
+ const missingCommands = work.sessions.filter((item) => !item.pollCommand);
102
+ const agentWork = runnableCandidates.length > 0 || work.markReady.length > 0;
103
+ const drain = planBottomDrain(result, mergeRequested);
104
+ if (drain)
105
+ return drain;
106
+ const handoffs = findHumanHandoffs(result);
107
+ if (handoffs) {
108
+ const instructions = [];
109
+ appendAutonomousInstructions(instructions, runnableCandidates);
110
+ appendMarkReadyInstructions(instructions, work.markReady);
111
+ const stop = !agentWork;
112
+ appendHumanHandoffInstructions(instructions, handoffs, stop);
113
+ for (const item of missingCommands) {
114
+ instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. Ask for direction.`);
115
+ }
116
+ if (agentWork) {
117
+ 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.`);
118
+ }
119
+ return { action: stop ? "escalate" : "shepherd", stackMergeable: false, instructions };
26
120
  }
27
- const lastOpenPosition = positionOf(result, open.at(-1).pr);
28
- const closedBelowOpen = result.prs.find((item) => item.state === "CLOSED" && positionOf(result, item.pr) < lastOpenPosition);
29
- if (closedBelowOpen) {
121
+ if (open.length === 0) {
30
122
  return {
31
- action: "escalate",
32
- instructions: [
33
- `1. PR #${closedBelowOpen.pr} is closed without merging below an open stack layer. Stop stack merge and rebase operations here; the closed dependency must be restored or the higher branches rebuilt on a valid base.`,
34
- "2. Ask the stack owner which recovery path to take, then rerun the same aggregate `--stack` selector after the stack is repaired.",
35
- ],
123
+ action: "cancel",
124
+ stackMergeable: true,
125
+ instructions: ["1. Stop — every stack layer is merged."],
36
126
  };
37
127
  }
38
- const gap = result.stackAncestry?.[0];
39
- const firstOpen = open[0];
40
- if (firstOpen.mergeStateStatus === "BEHIND")
41
- return rebaseWholeStack(result, firstOpen);
42
- if (mergeRequested) {
43
- const mergeTarget = readyLowerStackTarget(open, result.stackAncestry ?? []);
44
- if (mergeTarget) {
45
- const stackNumber = result.selection.kind === "stack" ? result.selection.stackNumber : 0;
46
- return {
47
- action: "merge",
48
- instructions: [
49
- `1. The contiguous ready lower stack ends at PR #${mergeTarget.pr}. Merge the native stack through that PR with \`gh stack merge --squash ${mergeTarget.pr}\`; verify that the selector names PR #${mergeTarget.pr} in stack #${stackNumber} before running it. This includes still-open lower layers and leaves higher layers open.`,
50
- "2. After GitHub completes the stack merge and updates the remaining branches, rerun the same aggregate `--stack` selector. If an ancestry mismatch remains, follow the rebase instructions returned then.",
51
- ],
52
- };
128
+ if (!stackMergeable) {
129
+ if (missingCommands.length > 0) {
130
+ const instructions = [];
131
+ appendAutonomousInstructions(instructions, runnableCandidates);
132
+ appendMarkReadyInstructions(instructions, work.markReady);
133
+ for (const item of missingCommands) {
134
+ 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.`);
135
+ }
136
+ if (agentWork) {
137
+ 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.`);
138
+ }
139
+ return { action: agentWork ? "shepherd" : "escalate", stackMergeable: false, instructions };
53
140
  }
54
- if (firstOpen.action === "wait") {
55
- return waitingStack(result);
56
- }
57
- }
58
- const firstWork = open.find((item) => ["fix_code", "mark_ready", "escalate"].includes(item.action));
59
- if (firstWork && (!gap || positionOf(result, firstWork.pr) <= positionOf(result, gap.childPr))) {
60
- return pollOneLayer(firstWork);
141
+ if (!agentWork)
142
+ return idleWaitPlan(work.idle);
143
+ const instructions = [];
144
+ appendAutonomousInstructions(instructions, work.sessions);
145
+ appendMarkReadyInstructions(instructions, work.markReady);
146
+ instructions.push(`${instructions.length + 1}. After the selected one-PR sessions, rerun this same \`--stack\` selector.`);
147
+ return { action: "shepherd", stackMergeable: false, instructions };
61
148
  }
62
- if (gap) {
149
+ if (open.some((item) => item.isInMergeQueue)) {
63
150
  return {
64
- action: "fix_code",
151
+ action: "wait",
152
+ stackMergeable: true,
153
+ waiting: true,
65
154
  instructions: [
66
- `1. PR #${gap.childPr} still records base \`${gap.childBaseRefName}\` at \`${gap.childBaseRefOid}\`, while parent PR #${gap.parentPr} now ends at \`${gap.parentHeadRefName}\` \`${gap.parentHeadRefOid}\`. From a clean checkout of \`${result.repo}\`, check out the parent stack branch \`${gap.parentHeadRefName}\`.`,
67
- "2. Rebase the upstack branches onto that parent with `gh stack rebase --upstack --no-trunk`, resolve any conflicts, and push the rewritten branches with `gh stack push`.",
68
- "3. Rerun the same aggregate `--stack` selector and follow the next returned action.",
155
+ "1. The stack is in the merge queue. Recheck at the configured polling cadence; finish only after every layer is merged, and route any ejected layer to its one-PR session.",
69
156
  ],
70
157
  };
71
158
  }
72
- const behind = open.find((item) => item.mergeStateStatus === "BEHIND");
73
- if (behind)
74
- return rebaseWholeStack(result, behind);
75
- if (firstWork)
76
- return pollOneLayer(firstWork);
77
- if (!mergeRequested && open.every((item) => item.action === "cancel")) {
159
+ if (!mergeRequested && result.prs.every((item) => item.action === "cancel")) {
78
160
  return {
79
161
  action: "cancel",
80
- instructions: ["1. Stop — every open stack layer is ready and the stack is linear."],
162
+ stackMergeable: true,
163
+ instructions: ["1. Stop — every stack layer is terminal or fully READY."],
81
164
  };
82
165
  }
83
- return waitingStack(result);
84
- }
85
- function rebaseWholeStack(result, behind) {
86
- return {
87
- action: "fix_code",
88
- instructions: [
89
- `1. GitHub reports PR #${behind.pr} is behind its base \`${behind.baseRefName}\`. From a clean checkout of \`${result.repo}\`, check out its stack branch \`${behind.headRefName}\`.`,
90
- "2. Rebase that native stack from its trunk with `gh stack rebase`, resolving any conflicts.",
91
- "3. Push the updated stack with `gh stack push` and rerun the same aggregate `--stack` selector.",
92
- ],
93
- };
94
- }
95
- function readyLowerStackTarget(open, gaps) {
96
- const mismatchedChildren = new Set(gaps.map((gap) => gap.childPr));
97
- let target;
98
- for (const item of open) {
99
- if (mismatchedChildren.has(item.pr) || item.action !== "merge")
100
- break;
101
- target = item;
102
- }
103
- return target;
104
- }
105
- function positionOf(result, pr) {
106
- return result.prs.find((item) => item.pr === pr)?.stack?.position ?? Number.MAX_SAFE_INTEGER;
107
- }
108
- function pollOneLayer(item) {
109
- if (!item.pollCommand) {
166
+ if (!mergeRequested) {
167
+ const instructions = [];
168
+ appendAutonomousInstructions(instructions, open.filter((item) => item.action !== "cancel"));
169
+ instructions.push(`${instructions.length + 1}. After the selected one-PR sessions, rerun this same \`--stack\` selector.`);
110
170
  return {
111
- action: "escalate",
112
- instructions: [
113
- `1. PR #${item.pr} needs attention, but GitHub returned no one-PR poll command.`,
114
- ],
171
+ action: "shepherd",
172
+ stackMergeable,
173
+ instructions,
115
174
  };
116
175
  }
117
- return {
118
- action: item.action,
119
- instructions: [
120
- `1. Work on the lowest actionable layer, PR #${item.pr}: run \`${item.pollCommand}\`.`,
121
- "2. Follow that one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
122
- "3. Rerun the aggregate `--stack` selector before acting on a higher layer.",
123
- ],
124
- };
176
+ return retargetWaitPlan(open[0]);
125
177
  }
126
- function waitingStack(result) {
127
- if (result.quotaWarning) {
128
- return {
129
- action: "wait",
130
- instructions: [
131
- buildQuotaAwareContinuation(result.quotaWarning, "1. This native stack is non-terminal. Before continuing,"),
132
- ],
133
- };
134
- }
135
- return {
136
- action: "wait",
137
- instructions: ["1. Recheck this native stack after the lowest open layer changes state."],
138
- };
178
+ function closedDependency(items, pr) {
179
+ const open = items.filter((item) => item.state === "OPEN");
180
+ const lastOpen = open.at(-1);
181
+ return Boolean(lastOpen &&
182
+ items.some((item) => item.pr === pr && item.state === "CLOSED" && stackPosition(item) < stackPosition(lastOpen)));
139
183
  }