pr-shepherd 0.47.0 → 0.49.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 (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -0
  3. package/bin/api.d.mts +19 -3
  4. package/bin/api.mjs +57 -8
  5. package/bin/classify/apply.d.mts +2 -0
  6. package/bin/classify/apply.mjs +1 -1
  7. package/bin/cli/args.mjs +1 -0
  8. package/bin/cli/default-poll.mjs +2 -0
  9. package/bin/cli/help-command-pages.d.mts +1 -1
  10. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  11. package/bin/cli/help-iterate-poll-pages.mjs +16 -10
  12. package/bin/cli/help-top-page.d.mts +1 -1
  13. package/bin/cli/help-top-page.mjs +10 -7
  14. package/bin/cli/help.d.mts +2 -2
  15. package/bin/cli/poll-handler.mjs +40 -11
  16. package/bin/cli/poll-summary-emitter.d.mts +4 -0
  17. package/bin/cli/poll-summary-emitter.mjs +22 -0
  18. package/bin/cli/poll-summary-formatter.d.mts +2 -0
  19. package/bin/cli/poll-summary-formatter.mjs +96 -0
  20. package/bin/cli/poll-targets.d.mts +15 -0
  21. package/bin/cli/poll-targets.mjs +114 -0
  22. package/bin/cli/validate-default-args.mjs +1 -4
  23. package/bin/commands/iterate/api-usage.d.mts +1 -1
  24. package/bin/commands/iterate/api-usage.mjs +6 -2
  25. package/bin/commands/iterate/run.mjs +1 -1
  26. package/bin/commands/poll-run.d.mts +1 -1
  27. package/bin/commands/poll-run.mjs +2 -2
  28. package/bin/commands/poll-summary.d.mts +10 -0
  29. package/bin/commands/poll-summary.mjs +163 -0
  30. package/bin/commands/poll.mjs +2 -1
  31. package/bin/commands/ready-delay.d.mts +3 -1
  32. package/bin/commands/ready-delay.mjs +3 -2
  33. package/bin/config/load.d.mts +7 -0
  34. package/bin/config/load.mjs +70 -19
  35. package/bin/config.json +9 -3
  36. package/bin/github/gql/poll-stack-summary.gql +33 -0
  37. package/bin/github/gql/poll-summary-fragment.gql +198 -0
  38. package/bin/github/poll-summary-checks.d.mts +3 -0
  39. package/bin/github/poll-summary-checks.mjs +61 -0
  40. package/bin/github/poll-summary-projector.d.mts +4 -0
  41. package/bin/github/poll-summary-projector.mjs +81 -0
  42. package/bin/github/poll-summary-raw.d.mts +134 -0
  43. package/bin/github/poll-summary-raw.mjs +1 -0
  44. package/bin/github/poll-summary-review.d.mts +4 -0
  45. package/bin/github/poll-summary-review.mjs +85 -0
  46. package/bin/github/poll-summary-route.d.mts +4 -0
  47. package/bin/github/poll-summary-route.mjs +47 -0
  48. package/bin/github/poll-summary.d.mts +7 -0
  49. package/bin/github/poll-summary.mjs +110 -0
  50. package/bin/github/queries.d.mts +4 -0
  51. package/bin/github/queries.mjs +4 -0
  52. package/bin/mcp/server.mjs +39 -6
  53. package/bin/pr-reference.d.mts +2 -0
  54. package/bin/pr-reference.mjs +4 -0
  55. package/bin/quota-warning.mjs +1 -1
  56. package/bin/types/iterate.d.mts +3 -3
  57. package/bin/types/poll-summary.d.mts +82 -0
  58. package/bin/types/poll-summary.mjs +1 -0
  59. package/bin/types.d.mts +1 -0
  60. package/bin/types.mjs +1 -0
  61. package/package.json +1 -1
  62. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  63. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  64. package/plugins/pr-shepherd/.mcp.json +1 -1
  65. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +2 -2
@@ -0,0 +1,96 @@
1
+ import { formatApiUsage, formatQuotaWarning } from "./api-usage-formatter.mjs";
2
+ import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
3
+ export function formatPollSummaryResult(result) {
4
+ const selection = result.selection.kind === "stack"
5
+ ? `stack #${result.selection.stackNumber} anchored at PR #${result.selection.anchor} (${result.selection.stackSize} PRs)`
6
+ : `PRs ${result.selection.requested.map((pr) => `#${pr}`).join(", ")}`;
7
+ const lines = [
8
+ `# Poll summary [${result.reason.toUpperCase()}]`,
9
+ "",
10
+ `**repo** \`${result.repo}\` · **selection** ${selection} · **mode** \`${result.mode}\``,
11
+ "",
12
+ "## Pull requests",
13
+ "",
14
+ ...result.prs.map(formatItem),
15
+ ];
16
+ const apiUsage = result.apiUsage ? formatApiUsage(result.apiUsage) : null;
17
+ const quotaWarning = formatQuotaWarning(result.quotaWarning);
18
+ if (quotaWarning)
19
+ lines.push("", quotaWarning);
20
+ if (apiUsage)
21
+ lines.push("", apiUsage);
22
+ lines.push("", "## Instructions", "", ...formatInstructions(result));
23
+ return lines.join("\n");
24
+ }
25
+ function formatInstructions(result) {
26
+ if (result.reason === "all_terminal")
27
+ return ["1. Stop — every selected PR is terminal."];
28
+ if (result.quotaWarning && result.reason !== "actionable") {
29
+ return [
30
+ buildQuotaAwareContinuation(result.quotaWarning, "1. This aggregate selection is non-terminal. Before continuing,"),
31
+ ];
32
+ }
33
+ if (result.reason === "waiting" || result.reason === "timeout") {
34
+ return ["1. Run this aggregate selector again when the caller is ready to recheck."];
35
+ }
36
+ const instructions = [
37
+ "1. Choose each non-WAIT, non-CANCEL row that can proceed independently and run or delegate its exact `pollCommand`.",
38
+ "2. Follow each selected one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
39
+ "3. Run this aggregate poll again after selected work completes; one row's `ESCALATE` does not stop work on other rows.",
40
+ ];
41
+ if (result.quotaWarning) {
42
+ instructions[2] = buildQuotaAwareContinuation(result.quotaWarning, "3. After selected work completes,");
43
+ }
44
+ return instructions;
45
+ }
46
+ function formatItem(item) {
47
+ const flags = [item.isDraft ? "draft" : null, item.isInMergeQueue ? "queued" : null]
48
+ .filter((value) => value !== null)
49
+ .join(", ");
50
+ const stack = item.stack
51
+ ? ` · stack \`${item.stack.number}\` position \`${item.stack.position}/${item.stack.size}\` base \`${item.stack.baseRefName}\``
52
+ : "";
53
+ const reviewDecision = item.reviewDecision ? ` · reviewDecision \`${item.reviewDecision}\`` : "";
54
+ const stateFlags = flags ? ` · flags \`${flags}\`` : "";
55
+ const blockingReviewer = item.blockingReviewerInProgress
56
+ ? " · blocking reviewer `in progress`"
57
+ : "";
58
+ const readyDelay = item.remainingSeconds !== undefined ? ` · ready delay \`${item.remainingSeconds}s\`` : "";
59
+ const checks = item.checks;
60
+ const review = item.review;
61
+ return [
62
+ `- [PR #${item.pr}: ${escapeMarkdownText(item.title)}](${item.url}) [${item.action.toUpperCase()}]`,
63
+ ` - state \`${item.state}\` · mergeable \`${item.mergeable}\` · merge \`${item.mergeStateStatus}\`${reviewDecision}${stateFlags}${blockingReviewer}${readyDelay}${stack}`,
64
+ ` - head \`${item.headRefName}\` at \`${item.headRefOid}\` · base \`${item.baseRefName}\``,
65
+ ...(checks
66
+ ? [` - checks: ${formatCounts(checks, checks.incomplete ? ", incomplete" : "")}`]
67
+ : []),
68
+ ...(review
69
+ ? [` - review: ${formatCounts(review, review.incomplete ? ", incomplete" : "")}`]
70
+ : []),
71
+ ` - reasons: ${item.reasons.map((reason) => `\`${reason}\``).join(", ")}`,
72
+ ...(item.pollCommand ? [` - pollCommand: \`${item.pollCommand}\``] : []),
73
+ ].join("\n");
74
+ }
75
+ function formatCounts(counts, suffix) {
76
+ const plural = { inProgress: "in progress" };
77
+ const singular = {
78
+ comments: "comment",
79
+ inProgress: "in progress",
80
+ reviews: "review",
81
+ threads: "thread",
82
+ };
83
+ const rendered = Object.entries(counts)
84
+ .filter(([, count]) => typeof count === "number")
85
+ .map(([name, count]) => `${count} ${count === 1 ? (singular[name] ?? name) : (plural[name] ?? name)}`)
86
+ .join(", ");
87
+ return rendered ? `${rendered}${suffix}` : suffix.replace(/^, /, "");
88
+ }
89
+ function escapeMarkdownText(value) {
90
+ return value
91
+ .replace(/&/g, "&")
92
+ .replace(/</g, "&lt;")
93
+ .replace(/>/g, "&gt;")
94
+ .replace(/[\r\n]+/g, " ")
95
+ .replace(/([\\[\]])/g, "\\$1");
96
+ }
@@ -0,0 +1,15 @@
1
+ import { type RepoInfo } from "../github/client.mts";
2
+ import { type ParsedPrReference } from "../pr-reference.mts";
3
+ import type { GlobalOptions } from "../types.mts";
4
+ export interface ParsedPollTargets {
5
+ refs: ParsedPrReference[];
6
+ stack: ParsedPrReference | undefined;
7
+ global: GlobalOptions;
8
+ extra: string[];
9
+ }
10
+ export declare function parsePollTargets(args: string[]): ParsedPollTargets | null;
11
+ export declare function resolvePollTargets(parsed: ParsedPollTargets): Promise<{
12
+ prNumbers: number[];
13
+ stackPrNumber?: number;
14
+ targetRepository?: RepoInfo;
15
+ }>;
@@ -0,0 +1,114 @@
1
+ import { parseArgs } from "node:util";
2
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
3
+ import { getRepoInfo } from "../github/client.mjs";
4
+ import { parseCliPrReference, normalizeRepositoryIdentity, resolveParsedPrTarget, } from "../pr-reference.mjs";
5
+ const VALUE_FLAGS = new Set([
6
+ "--format",
7
+ "--ready-delay",
8
+ "--stall-timeout",
9
+ "--interval",
10
+ "--timeout",
11
+ "--debounce",
12
+ "--stack",
13
+ ]);
14
+ export function parsePollTargets(args) {
15
+ const { values } = parseArgs({
16
+ args,
17
+ strict: false,
18
+ allowPositionals: true,
19
+ options: { format: { type: "string" }, verbose: { type: "boolean" } },
20
+ });
21
+ const consumed = new Set();
22
+ const refs = [];
23
+ let stack;
24
+ for (let index = 0; index < args.length; index += 1) {
25
+ const arg = args[index];
26
+ if (VALUE_FLAGS.has(arg)) {
27
+ if (arg === "--format" || arg === "--stack") {
28
+ consumed.add(index);
29
+ if (index + 1 < args.length)
30
+ consumed.add(index + 1);
31
+ }
32
+ if (arg === "--stack") {
33
+ const parsed = parseCliPrReference(args[index + 1] ?? "");
34
+ if (!parsed)
35
+ return usage(`invalid --stack PR reference: ${args[index + 1] ?? "(missing)"}`);
36
+ if (stack)
37
+ return usage("--stack may only be specified once");
38
+ stack = parsed;
39
+ }
40
+ index += 1;
41
+ continue;
42
+ }
43
+ const equals = arg.indexOf("=");
44
+ const name = equals < 0 ? arg : arg.slice(0, equals);
45
+ if (VALUE_FLAGS.has(name)) {
46
+ if (name === "--format" || name === "--stack")
47
+ consumed.add(index);
48
+ if (name === "--stack") {
49
+ const parsed = parseCliPrReference(arg.slice(equals + 1));
50
+ if (!parsed)
51
+ return usage(`invalid --stack PR reference: ${arg.slice(equals + 1)}`);
52
+ if (stack)
53
+ return usage("--stack may only be specified once");
54
+ stack = parsed;
55
+ }
56
+ continue;
57
+ }
58
+ if (arg === "--verbose") {
59
+ consumed.add(index);
60
+ continue;
61
+ }
62
+ if (arg.startsWith("--"))
63
+ continue;
64
+ const parsed = parseCliPrReference(arg);
65
+ if (!parsed)
66
+ continue;
67
+ refs.push(parsed);
68
+ consumed.add(index);
69
+ }
70
+ if (stack && refs.length > 0)
71
+ return usage("--stack cannot be combined with explicit PRs");
72
+ const format = (values.format ?? "text");
73
+ return {
74
+ refs,
75
+ stack,
76
+ global: { format, verbose: values.verbose === true },
77
+ extra: args.filter((_, index) => !consumed.has(index)),
78
+ };
79
+ }
80
+ export async function resolvePollTargets(parsed) {
81
+ const all = parsed.stack ? [parsed.stack] : parsed.refs;
82
+ if (all.length === 0)
83
+ return { prNumbers: [] };
84
+ const checkoutRepo = all.some((ref) => ref.repository === undefined)
85
+ ? await getRepoInfo()
86
+ : undefined;
87
+ let selectedRepo;
88
+ const prNumbers = [];
89
+ for (const ref of all) {
90
+ const target = resolveParsedPrTarget(ref);
91
+ if (target.prNumber === undefined)
92
+ return usageThrow("PR number is required");
93
+ const repo = target.targetRepository ?? checkoutRepo;
94
+ if (selectedRepo &&
95
+ normalizeRepositoryIdentity(`${selectedRepo.owner}/${selectedRepo.name}`) !==
96
+ normalizeRepositoryIdentity(`${repo.owner}/${repo.name}`)) {
97
+ return usageThrow("aggregate poll only supports PRs from one repository");
98
+ }
99
+ selectedRepo = repo;
100
+ if (!prNumbers.includes(target.prNumber))
101
+ prNumbers.push(target.prNumber);
102
+ }
103
+ return parsed.stack
104
+ ? { prNumbers: [], stackPrNumber: prNumbers[0], targetRepository: selectedRepo }
105
+ : { prNumbers, targetRepository: selectedRepo };
106
+ }
107
+ function usage(message) {
108
+ process.stderr.write(`pr-shepherd: ${message}\n`);
109
+ process.exitCode = EXIT.USAGE;
110
+ return null;
111
+ }
112
+ function usageThrow(message) {
113
+ throw new ShepherdError(message, EXIT.USAGE);
114
+ }
@@ -5,7 +5,6 @@ import { parsePrNumber } from "./args.mjs";
5
5
  * offending arg so callers can print their own usage message.
6
6
  */
7
7
  export function validateDefaultArgs(args, flagsWithValues, booleanFlags, onError) {
8
- let sawPr = false;
9
8
  for (let i = 0; i < args.length; i += 1) {
10
9
  const arg = args[i];
11
10
  if (flagsWithValues.has(arg)) {
@@ -21,10 +20,8 @@ export function validateDefaultArgs(args, flagsWithValues, booleanFlags, onError
21
20
  continue;
22
21
  if (booleanFlags.has(arg))
23
22
  continue;
24
- if (parsePrNumber(arg) !== null && !sawPr) {
25
- sawPr = true;
23
+ if (parsePrNumber(arg) !== null)
26
24
  continue;
27
- }
28
25
  onError(arg);
29
26
  return false;
30
27
  }
@@ -1,2 +1,2 @@
1
1
  import type { IterateResult } from "../../types.mts";
2
- export declare function attachApiUsage(result: IterateResult, persistWarning: boolean, preservePersistedWarning?: boolean): Promise<IterateResult>;
2
+ export declare function attachApiUsage(result: IterateResult, persistWarning: boolean, preservePersistedWarning?: boolean, minimumPollIntervalMinutes?: number): Promise<IterateResult>;
@@ -5,7 +5,7 @@ import { buildQuotaAwareContinuation } from "../../quota-warning.mjs";
5
5
  function shouldWarn(result) {
6
6
  return ["wait", "mark_ready", "merge", "fix_code"].includes(result.action);
7
7
  }
8
- export async function attachApiUsage(result, persistWarning, preservePersistedWarning = false) {
8
+ export async function attachApiUsage(result, persistWarning, preservePersistedWarning = false, minimumPollIntervalMinutes = 0) {
9
9
  const apiUsage = summarizeApiTelemetry();
10
10
  if (apiUsage === undefined)
11
11
  return result;
@@ -13,7 +13,11 @@ export async function attachApiUsage(result, persistWarning, preservePersistedWa
13
13
  if (quotaWarning === undefined && apiUsage.graphql !== undefined && shouldWarn(result)) {
14
14
  const [owner, repo] = result.repo.split("/");
15
15
  if (owner && repo) {
16
- quotaWarning = await evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, loadConfig().watch.graphqlQuotaWarnings, apiUsage.graphql, persistWarning);
16
+ const bands = loadConfig().watch.graphqlQuotaWarnings.map((band) => ({
17
+ ...band,
18
+ pollIntervalMinutes: Math.max(band.pollIntervalMinutes, minimumPollIntervalMinutes),
19
+ }));
20
+ quotaWarning = await evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands, apiUsage.graphql, persistWarning);
17
21
  }
18
22
  }
19
23
  const { quotaWarning: _deferredWarning, ...baseResult } = result;
@@ -3,6 +3,6 @@ import { attachApiUsage } from "./api-usage.mjs";
3
3
  export function withIterateApiUsage(opts, runCore) {
4
4
  return withApiTelemetryScope(async () => {
5
5
  const result = await runCore();
6
- return attachApiUsage(result, opts.deferQuotaWarning !== true);
6
+ return attachApiUsage(result, opts.deferQuotaWarning !== true, false, opts.quotaWarningMinimumPollIntervalMinutes);
7
7
  });
8
8
  }
@@ -1,2 +1,2 @@
1
1
  import type { IterateResult } from "../types.mts";
2
- export declare function withPollApiUsage(runCore: () => Promise<IterateResult>, preservePersistedWarning: boolean): Promise<IterateResult>;
2
+ export declare function withPollApiUsage(runCore: () => Promise<IterateResult>, preservePersistedWarning: boolean, minimumPollIntervalMinutes: number): Promise<IterateResult>;
@@ -1,8 +1,8 @@
1
1
  import { withApiTelemetryScope } from "../github/api-telemetry.mjs";
2
2
  import { attachApiUsage } from "./iterate/api-usage.mjs";
3
- export function withPollApiUsage(runCore, preservePersistedWarning) {
3
+ export function withPollApiUsage(runCore, preservePersistedWarning, minimumPollIntervalMinutes) {
4
4
  return withApiTelemetryScope(async () => {
5
5
  const result = await runCore();
6
- return attachApiUsage(result, true, preservePersistedWarning);
6
+ return attachApiUsage(result, true, preservePersistedWarning, minimumPollIntervalMinutes);
7
7
  });
8
8
  }
@@ -0,0 +1,10 @@
1
+ import type { PollSummaryCommandOptions, PollSummaryResult } from "../types.mts";
2
+ export interface AggregatePollCommandOptions extends PollSummaryCommandOptions {
3
+ intervalSeconds: number;
4
+ timeoutSeconds: number;
5
+ debounceSeconds?: number;
6
+ quietStatus?: boolean;
7
+ untilTerminal?: boolean;
8
+ }
9
+ export declare function runPollSummary(opts: PollSummaryCommandOptions): Promise<PollSummaryResult>;
10
+ export declare function runAggregatePoll(opts: AggregatePollCommandOptions): Promise<PollSummaryResult>;
@@ -0,0 +1,163 @@
1
+ import { loadConfig } from "../config/load.mjs";
2
+ import { ShepherdError } from "../exit-codes.mjs";
3
+ import { getRepoInfo } from "../github/client.mjs";
4
+ import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-telemetry.mjs";
5
+ import { fetchPollSummary } from "../github/poll-summary.mjs";
6
+ import { sleep } from "../util/sleep.mjs";
7
+ import { graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs } from "./poll-quota.mjs";
8
+ import { evaluateWorktreeGraphqlQuotaWarning } from "../state/graphql-quota-warnings.mjs";
9
+ const MAX_TIMER_MS = 2 ** 31 - 1;
10
+ const TIMER_DRIFT_TOLERANCE_MS = 500;
11
+ export function runPollSummary(opts) {
12
+ return withApiTelemetryScope(async () => attachUsage(await runPollSummaryCore(opts)));
13
+ }
14
+ export function runAggregatePoll(opts) {
15
+ return withApiTelemetryScope(() => runAggregatePollCore(opts));
16
+ }
17
+ async function runPollSummaryCore(opts) {
18
+ const repo = opts.targetRepository ?? (await getRepoInfo());
19
+ const fetched = await fetchPollSummary(opts, repo);
20
+ const allTerminal = fetched.prs.every((item) => item.action === "cancel");
21
+ const actionable = fetched.prs.some((item) => item.action !== "wait" && item.action !== "cancel");
22
+ return {
23
+ mode: "summary",
24
+ repo: `${repo.owner}/${repo.name}`,
25
+ selection: fetched.selection,
26
+ reason: allTerminal ? "all_terminal" : actionable ? "actionable" : "waiting",
27
+ prs: fetched.prs,
28
+ };
29
+ }
30
+ async function runAggregatePollCore(opts) {
31
+ const intervalMs = Math.min(opts.intervalSeconds * 1000, MAX_TIMER_MS);
32
+ const timeoutMs = Math.min(opts.timeoutSeconds * 1000, MAX_TIMER_MS);
33
+ const debounceMs = Math.min((opts.debounceSeconds ?? 60) * 1000, MAX_TIMER_MS);
34
+ const quotaBands = loadConfig().watch.graphqlQuotaWarnings;
35
+ const start = Date.now();
36
+ let tick = 0;
37
+ let debounceUntil = null;
38
+ let last;
39
+ let lastStatusSignature = null;
40
+ let rateLimitRetries = 0;
41
+ let pendingQuotaWarning;
42
+ while (true) {
43
+ tick += 1;
44
+ try {
45
+ last = await runPollSummaryCore(opts);
46
+ rateLimitRetries = 0;
47
+ }
48
+ catch (error) {
49
+ if (last?.selection.kind === "stack" && isMissingStack(error)) {
50
+ const explicit = await runPollSummaryCore({
51
+ ...opts,
52
+ stackPrNumber: undefined,
53
+ prNumbers: last.prs.map((item) => item.pr),
54
+ });
55
+ if (explicit.prs.every((item) => item.action === "cancel")) {
56
+ const warning = await aggregateQuotaWarning(explicit, quotaBands, opts.intervalSeconds);
57
+ if (warning)
58
+ pendingQuotaWarning = warning;
59
+ return attachUsage({
60
+ ...explicit,
61
+ reason: "all_terminal",
62
+ ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
63
+ });
64
+ }
65
+ }
66
+ const retryMs = opts.untilTerminal ? pollGraphQlRetryAfterMs(error) : null;
67
+ if (retryMs === null || rateLimitRetries >= 1)
68
+ throw error;
69
+ rateLimitRetries += 1;
70
+ process.stderr.write(`[aggregate poll tick ${tick} / +${Math.round((Date.now() - start) / 1000)}s] GraphQL rate limit — retrying in ${Math.round(retryMs / 1000)}s\n`);
71
+ await sleep(retryMs);
72
+ continue;
73
+ }
74
+ const allTerminal = last.prs.every((item) => item.action === "cancel");
75
+ const immediate = last.prs.some((item) => ["escalate", "merge", "mark_ready"].includes(item.action));
76
+ const hasFix = last.prs.some((item) => item.action === "fix_code");
77
+ const warning = await aggregateQuotaWarning(last, quotaBands, opts.intervalSeconds);
78
+ if (warning)
79
+ pendingQuotaWarning = warning;
80
+ if (allTerminal) {
81
+ return attachUsage({
82
+ ...last,
83
+ reason: "all_terminal",
84
+ ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
85
+ });
86
+ }
87
+ if (immediate) {
88
+ return attachUsage({
89
+ ...last,
90
+ reason: "actionable",
91
+ ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
92
+ });
93
+ }
94
+ if (hasFix) {
95
+ if (debounceMs === 0)
96
+ return attachUsage({
97
+ ...last,
98
+ reason: "actionable",
99
+ ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
100
+ });
101
+ debounceUntil ??= Date.now() + debounceMs;
102
+ if (Date.now() >= debounceUntil)
103
+ return attachUsage({
104
+ ...last,
105
+ reason: "actionable",
106
+ ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
107
+ });
108
+ }
109
+ else {
110
+ debounceUntil = null;
111
+ if (opts.untilTerminal && pendingQuotaWarning) {
112
+ return attachUsage({ ...last, quotaWarning: pendingQuotaWarning });
113
+ }
114
+ }
115
+ const elapsedMs = Date.now() - start;
116
+ const sleepMs = debounceUntil
117
+ ? Math.min(intervalMs, Math.max(debounceUntil - Date.now(), 0))
118
+ : graphqlQuotaPollIntervalMs(quotaBands, summarizeApiTelemetry()?.graphql, intervalMs, MAX_TIMER_MS);
119
+ if (!opts.untilTerminal && debounceUntil === null) {
120
+ const remainingMs = timeoutMs - elapsedMs;
121
+ if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
122
+ return attachUsage({ ...last, reason: "timeout" });
123
+ }
124
+ }
125
+ const statusSignature = summaryStatusSignature(last);
126
+ if (!opts.quietStatus || hasFix || statusSignature !== lastStatusSignature) {
127
+ process.stderr.write(`[aggregate poll tick ${tick} / +${Math.round(elapsedMs / 1000)}s] ${last.prs
128
+ .map((item) => `#${item.pr} ${item.action.toUpperCase()}`)
129
+ .join(", ")}\n`);
130
+ }
131
+ lastStatusSignature = statusSignature;
132
+ await sleep(sleepMs);
133
+ }
134
+ }
135
+ async function aggregateQuotaWarning(result, bands, intervalSeconds) {
136
+ const usage = summarizeApiTelemetry()?.graphql;
137
+ const [owner, repo] = result.repo.split("/");
138
+ if (!usage || !owner || !repo)
139
+ return undefined;
140
+ return evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands.map((band) => ({
141
+ ...band,
142
+ pollIntervalMinutes: Math.max(band.pollIntervalMinutes, intervalSeconds / 60),
143
+ })), usage, true);
144
+ }
145
+ function summaryStatusSignature(result) {
146
+ return JSON.stringify(result.prs.map((item) => ({
147
+ pr: item.pr,
148
+ action: item.action,
149
+ state: item.state,
150
+ mergeable: item.mergeable,
151
+ mergeStateStatus: item.mergeStateStatus,
152
+ reviewDecision: item.reviewDecision,
153
+ checks: item.checks,
154
+ review: item.review,
155
+ })));
156
+ }
157
+ function isMissingStack(error) {
158
+ return (error instanceof ShepherdError && error.message.includes("not part of a native GitHub stack"));
159
+ }
160
+ function attachUsage(result) {
161
+ const apiUsage = summarizeApiTelemetry();
162
+ return apiUsage ? { ...result, apiUsage } : result;
163
+ }
@@ -9,7 +9,7 @@ const MAX_TIMER_MS = 2 ** 31 - 1;
9
9
  const TIMER_DRIFT_TOLERANCE_MS = 500;
10
10
  /** @deprecated Hidden implementation for the legacy `poll` alias. */
11
11
  export function runPoll(opts) {
12
- return withPollApiUsage(() => runPollCore(opts), opts.untilTerminal === true);
12
+ return withPollApiUsage(() => runPollCore(opts), opts.untilTerminal === true, opts.intervalSeconds / 60);
13
13
  }
14
14
  async function runPollCore(opts) {
15
15
  const { intervalSeconds, timeoutSeconds, debounceSeconds: debounceSecondsOpt, quietStatus: quietStatusOpt, untilTerminal: untilTerminalOpt, ...iterateOpts } = opts;
@@ -45,6 +45,7 @@ async function runPollCore(opts) {
45
45
  persistSeen: debounceSeconds === 0 || pastDebounce,
46
46
  fingerprintCache,
47
47
  deferQuotaWarning: !untilTerminal,
48
+ quotaWarningMinimumPollIntervalMinutes: intervalSeconds / 60,
48
49
  });
49
50
  const runTick = async (fingerprintCache) => {
50
51
  try {
@@ -25,5 +25,7 @@ interface ReadyDelayState {
25
25
  * When `shouldCancel == true`, the formatter tells loop-capable agents to cancel
26
26
  * the loop and tells one-shot agents to stop.
27
27
  */
28
- export declare function updateReadyDelay(prNumber: number, isReady: boolean, readyDelaySeconds: number, owner: string, repo: string): Promise<ReadyDelayState>;
28
+ export declare function updateReadyDelay(prNumber: number, isReady: boolean, readyDelaySeconds: number, owner: string, repo: string, options?: {
29
+ retainElapsed?: boolean;
30
+ }): Promise<ReadyDelayState>;
29
31
  export {};
@@ -18,7 +18,7 @@ import { resolvePrStatePath } from "../state/base.mjs";
18
18
  * When `shouldCancel == true`, the formatter tells loop-capable agents to cancel
19
19
  * the loop and tells one-shot agents to stop.
20
20
  */
21
- export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo) {
21
+ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo, options = {}) {
22
22
  const markerPath = readySincePath(prNumber, owner, repo);
23
23
  if (!isReady) {
24
24
  // Reset the timer.
@@ -47,7 +47,8 @@ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, own
47
47
  const elapsed = now - readySince;
48
48
  const remaining = readyDelaySeconds - elapsed;
49
49
  if (remaining <= 0) {
50
- await safeUnlink(markerPath);
50
+ if (!options.retainElapsed)
51
+ await safeUnlink(markerPath);
51
52
  return { isReady: true, shouldCancel: true, remainingSeconds: 0 };
52
53
  }
53
54
  return { isReady: true, shouldCancel: false, remainingSeconds: remaining };
@@ -6,6 +6,12 @@ export interface GraphqlQuotaWarningBand {
6
6
  remainingPercent: number;
7
7
  pollIntervalMinutes: number;
8
8
  }
9
+ interface PollConfig {
10
+ intervalSeconds: number;
11
+ timeoutSeconds: number;
12
+ debounceSeconds: number;
13
+ quietStatus: boolean;
14
+ }
9
15
  export interface PrShepherdConfig {
10
16
  /** Optional user classification configuration; preserved for rule consumers. */
11
17
  classify?: unknown;
@@ -40,6 +46,7 @@ export interface PrShepherdConfig {
40
46
  */
41
47
  resolveOtherHumanThreads: ResolveOtherHumanThreads;
42
48
  };
49
+ poll: PollConfig;
43
50
  watch: {
44
51
  readyDelayMinutes: number;
45
52
  graphqlQuotaWarnings: GraphqlQuotaWarningBand[];