pr-shepherd 0.17.0 → 0.18.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 (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -12
  3. package/bin/cli/args.mjs +2 -0
  4. package/bin/cli/default-iterate.mjs +1 -1
  5. package/bin/cli/duration-flag.mjs +22 -0
  6. package/bin/cli/exit-codes.mjs +14 -0
  7. package/bin/cli/fix-formatter.mjs +1 -2
  8. package/bin/cli/handlers.mjs +16 -36
  9. package/bin/cli/iterate-emitter.mjs +19 -0
  10. package/bin/cli/iterate-flags.mjs +21 -0
  11. package/bin/cli/iterate-formatter.mjs +50 -13
  12. package/bin/cli/iterate-instructions.mjs +10 -16
  13. package/bin/cli/iterate-lean.mjs +9 -12
  14. package/bin/cli/poll-handler.mjs +42 -0
  15. package/bin/cli-parser.iterate-fix.test-support.mjs +0 -4
  16. package/bin/cli-parser.iterate-fixtures.mjs +4 -1
  17. package/bin/cli-parser.iterate.test-support.mjs +0 -4
  18. package/bin/cli-parser.mjs +8 -1
  19. package/bin/commands/check-terminal-report.mjs +1 -0
  20. package/bin/commands/check.mjs +1 -0
  21. package/bin/commands/check.test-support.mjs +1 -0
  22. package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -0
  23. package/bin/commands/commit-suggestion.test-support.mjs +1 -0
  24. package/bin/commands/iterate/check-instructions.mjs +19 -17
  25. package/bin/commands/iterate/escalate.mjs +17 -21
  26. package/bin/commands/iterate/fix-code.mjs +22 -11
  27. package/bin/commands/iterate/index.mjs +2 -0
  28. package/bin/commands/iterate/render.mjs +50 -45
  29. package/bin/commands/iterate-test-support.mjs +1 -0
  30. package/bin/commands/poll.mjs +32 -0
  31. package/bin/commands/poll.test-support.mjs +77 -0
  32. package/bin/commands/resolve-instructions.mjs +2 -5
  33. package/bin/github/batch-parser-helpers.mjs +38 -0
  34. package/bin/github/batch-parsers.mjs +12 -38
  35. package/bin/github/gql/batch-pr.gql +9 -0
  36. package/bin/state/seen-comments.mjs +29 -15
  37. package/package.json +1 -1
  38. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  39. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +13 -2
  40. package/bin/agent-runtime.mjs +0 -7
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.17.0",
4
+ "version": "0.18.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -20,7 +20,7 @@ Example Workflow:
20
20
 
21
21
  `pr-shepherd` optimizes token management, rate limits, and agentic orchestration by moving **ALL** deterministic logic and prompts to code via a CLI tool, enshrining what would be a large skill or command prompt (of which the agent would inevitably make mistakes) into the code and returning a clear, actionable prompt.
22
22
 
23
- The CLI emits runtime-specific retry instructions. Claude-compatible output schedules exactly one next session-only iteration after a fresh delay between 30 seconds and 4 minutes, then ends the turn. Codex-compatible output sleeps inline for that delay, then reruns the configured pr-shepherd command. Codex is detected with `AGENT=codex` or the current Codex CLI signal `CODEX_CI=1`. Generated commands use `cli.runner` from `.pr-shepherdrc.yml`: `auto` (default), `npx`, `pnpm`, `yarn`, or `bun`.
23
+ The CLI emits unified recheck instructions. Generated commands use `cli.runner` from `.pr-shepherdrc.yml`: `auto` (default), `npx`, `pnpm`, `yarn`, or `bun`.
24
24
 
25
25
  At a high level, the skill invokes `pr-shepherd <PR>` through the selected package runner, which provides actionable feedback directly to the agent:
26
26
 
@@ -97,7 +97,7 @@ Some other workflow improvements:
97
97
 
98
98
  Recommendations:
99
99
 
100
- - Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. Keep an active goal cycling the reusable command until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures). Claude schedules one next session-only iteration per tick; Codex sleeps inline and reruns.
100
+ - Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. Keep an active goal cycling the reusable command until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures).
101
101
  - Instruct your agents to write comments in a single review (comment, changes requested, or approved). This allows the review's comments/threads to be minimized or resolved together, keeping your pull request history clean. If you write inline comments outside of a review, each comment would still show up in the pull request history and take up space.
102
102
  - Avoid sticky comments as they will continue to be hidden. Instead, just make a new comment, especially on reviews. If you really want sticky comments, instruct your agent to unhide/unminimize them when updating them.
103
103
  - Avoid having automation edit comments, reviews, or threads in place because updated items get minimized. Instead, always make a new review, comment, thread, etc.
@@ -134,7 +134,7 @@ npx pr-shepherd iterate 42 # legacy-compatible spelling
134
134
 
135
135
  ## Iterate decision loop
136
136
 
137
- On each tick: fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (`fix_code`, `mark_ready`, `cancel`, `escalate`, or `wait`). Claude schedules one next session-only iteration after a fresh 30s-4m delay; Codex sleeps inline for that delay and reruns. See [docs/iterate-flow.md](docs/iterate-flow.md) for the decision table and [docs/flow.md](docs/flow.md) for the end-to-end flow diagram.
137
+ On each tick: fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (`fix_code`, `mark_ready`, `cancel`, `escalate`, or `wait`). See [docs/iterate-flow.md](docs/iterate-flow.md) for the decision table and [docs/flow.md](docs/flow.md) for the end-to-end flow diagram.
138
138
 
139
139
  ## Cleaning state
140
140
 
@@ -216,13 +216,7 @@ npm install --save-dev pr-shepherd
216
216
 
217
217
  The plugin only installs the skill; it does not install the CLI into target repositories. To install the CLI globally instead, use `npm install -g pr-shepherd`.
218
218
 
219
- If your Codex environment does not already set `CODEX_CI=1`, set `AGENT=codex` so `pr-shepherd` emits Codex-compatible instructions instead of Claude `/loop` instructions:
220
-
221
- ```bash
222
- export AGENT=codex
223
- ```
224
-
225
- Then iterate a PR from Codex with the target repository's package runner:
219
+ Iterate a PR from Codex with the target repository's package runner:
226
220
 
227
221
  ```bash
228
222
  <runner> pr-shepherd iterate 42
@@ -230,7 +224,7 @@ Then iterate a PR from Codex with the target repository's package runner:
230
224
 
231
225
  For example, a repo like `~/filaments` that declares `packageManager: "pnpm@..."` and has `pnpm-lock.yaml` should use `pnpm exec pr-shepherd iterate 42`. For Bun repos (with `bun.lock` or `bun.lockb`), use `bunx pr-shepherd iterate 42`. For npm repos, use `npx pr-shepherd iterate 42`.
232
226
 
233
- Or ask Codex to use the `pr-shepherd` skill, for example: `run pr-shepherd until this PR is ready`. Follow the output's `## Instructions`. The skill runs one tick and Codex-compatible instructions tell you to pick a fresh sleep/timeout between 30 seconds and 4 minutes before the next rerun. Continue until Shepherd emits `[CANCEL]` or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures). `pr-shepherd iterate 42` remains supported for existing workflows.
227
+ Or ask Codex to use the `pr-shepherd` skill, for example: `run pr-shepherd until this PR is ready`. Follow the output's `## Instructions`. Continue until Shepherd emits `[CANCEL]` or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures). `pr-shepherd iterate 42` remains supported for existing workflows.
234
228
 
235
229
  ### As a global CLI
236
230
 
@@ -255,7 +249,7 @@ actions:
255
249
  autoMarkReady: false # disable to stay draft until you manually promote
256
250
  ```
257
251
 
258
- Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`, then `GITHUB_PERSONAL_ACCESS_TOKEN`), `PR_SHEPHERD_STATE_DIR` (override loop-state and log base dir), `PR_SHEPHERD_LOG_DISABLED=1` (disable the per-worktree debug log), `AGENT=codex` or `CODEX_CI=1` (emit Codex-compatible monitor instructions).
252
+ Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`, then `GITHUB_PERSONAL_ACCESS_TOKEN`), `PR_SHEPHERD_STATE_DIR` (override loop-state and log base dir), `PR_SHEPHERD_LOG_DISABLED=1` (disable the per-worktree debug log).
259
253
 
260
254
  See [docs/configuration.md](docs/configuration.md) for full semantics and deprecated-key migration.
261
255
 
package/bin/cli/args.mjs CHANGED
@@ -15,6 +15,8 @@ const FLAGS_WITH_VALUES = new Set([
15
15
  "--resolve-thread-ids",
16
16
  "--minimize-comment-ids",
17
17
  "--dismiss-review-ids",
18
+ "--interval",
19
+ "--timeout",
18
20
  ]);
19
21
  // Boolean flags that do NOT consume the next argument. Any --flag not in this
20
22
  // set and not in FLAGS_WITH_VALUES is treated conservatively as value-taking
@@ -43,7 +43,7 @@ function isDefaultIterateFlag(arg) {
43
43
  function writeDefaultUsageError(arg) {
44
44
  process.stderr.write(`Unknown subcommand: ${arg}\n`);
45
45
  process.stderr.write("Usage: pr-shepherd [PR] [options]\n" +
46
- " pr-shepherd <resolve|commit-suggestion|iterate|log-file> [options]\n" +
46
+ " pr-shepherd <resolve|commit-suggestion|iterate|poll|log-file|clean> [options]\n" +
47
47
  " pr-shepherd --version | -v\n");
48
48
  process.exitCode = 1;
49
49
  }
@@ -1,3 +1,25 @@
1
+ export function validateSecondsDurationFlag(command, flag, value, presentAsSeparateArg) {
2
+ if (value === null) {
3
+ if (presentAsSeparateArg) {
4
+ process.stderr.write(`${command}: ${flag} requires a value (e.g. ${flag} 30s)\n`);
5
+ process.exitCode = 1;
6
+ return null;
7
+ }
8
+ return undefined;
9
+ }
10
+ const trimmed = value.trim();
11
+ if (trimmed.startsWith("--")) {
12
+ process.stderr.write(`${command}: ${flag} requires a value (e.g. ${flag} 30s)\n`);
13
+ process.exitCode = 1;
14
+ return null;
15
+ }
16
+ if (!/^[1-9]\d*(?:s|sec|seconds?|m|min|minutes?|h|hours?)?$/.test(trimmed)) {
17
+ process.stderr.write(`${command}: invalid ${flag}: ${value}. Expected a duration like 30s, 5m, 1h, or bare seconds (e.g. 30).\n`);
18
+ process.exitCode = 1;
19
+ return null;
20
+ }
21
+ return trimmed;
22
+ }
1
23
  export function validateDurationFlag(command, flag, value, presentAsSeparateArg) {
2
24
  if (value === null) {
3
25
  if (presentAsSeparateArg) {
@@ -9,6 +9,20 @@ export function parseDurationToMinutes(s, defaultMinutes) {
9
9
  return n * 60;
10
10
  return n;
11
11
  }
12
+ export function parseDurationToSeconds(s, defaultSeconds) {
13
+ const m = /^(\d+)(s|sec|seconds?|m|min|minutes?|h|hours?)?$/.exec(s.trim());
14
+ if (!m)
15
+ return defaultSeconds;
16
+ const n = parseInt(m[1], 10);
17
+ if (!Number.isFinite(n))
18
+ return defaultSeconds;
19
+ const unit = m[2] ?? "s";
20
+ if (unit.startsWith("h"))
21
+ return n * 3600;
22
+ if (unit.startsWith("m"))
23
+ return n * 60;
24
+ return n;
25
+ }
12
26
  export function statusToExitCode(status) {
13
27
  switch (status) {
14
28
  case "MERGED":
@@ -4,7 +4,6 @@ import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mj
4
4
  import { renderThreadBullet, renderReviewBullet, renderThreadResolutionStatusTag, renderAuthor, buildFirstLookBullets, } from "./list-formatters.mjs";
5
5
  import { adaptFixCodeInstructions, numberInstructions } from "./iterate-instructions.mjs";
6
6
  export function formatFixCodeResult(header, result, opts) {
7
- const runtime = opts?.runtime ?? "claude";
8
7
  const readyDelaySuffix = opts?.readyDelaySuffix;
9
8
  const runner = opts?.runner;
10
9
  const sections = [header];
@@ -110,7 +109,7 @@ export function formatFixCodeResult(header, result, opts) {
110
109
  }
111
110
  sections.push(postFixLines.join("\n"));
112
111
  sections.push("## Instructions");
113
- sections.push(numberInstructions(adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, runner)));
112
+ sections.push(numberInstructions(adaptFixCodeInstructions(result.fix.instructions, result.pr, readyDelaySuffix, runner)));
114
113
  return joinSections(sections);
115
114
  }
116
115
  function blockquote(body) {
@@ -2,11 +2,10 @@ import { runCommitSuggestion } from "../commands/commit-suggestion.mjs";
2
2
  import { runIterate } from "../commands/iterate/index.mjs";
3
3
  import { runClean } from "../commands/clean.mjs";
4
4
  import { loadConfig } from "../config/load.mjs";
5
- import { detectAgentRuntime } from "../agent-runtime.mjs";
6
- import { parseCommonArgs, getFlag, hasFlag } from "./args.mjs";
7
- import { parseDurationToMinutes, iterateActionToExitCode } from "./exit-codes.mjs";
8
- import { formatCommitSuggestionResult, formatCleanResult, formatIterateResult, projectIterateLean, projectIterateVerbose, } from "./formatters.mjs";
9
- import { validateDurationFlag } from "./duration-flag.mjs";
5
+ import { parseCommonArgs, getFlag } from "./args.mjs";
6
+ import { formatCommitSuggestionResult, formatCleanResult } from "./formatters.mjs";
7
+ import { parseIterateFlags } from "./iterate-flags.mjs";
8
+ import { emitIterateResult } from "./iterate-emitter.mjs";
10
9
  const CLEAN_VARIANTS = new Set(["pr", "branch", "current", "repo", "all"]);
11
10
  export async function handleClean(args) {
12
11
  const variant = args[0];
@@ -90,41 +89,22 @@ export async function handleCommitSuggestion(args) {
90
89
  }
91
90
  export async function handleIterate(args) {
92
91
  const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
93
- const runtime = detectAgentRuntime();
94
- const readyDelayStr = getFlag(extra, "--ready-delay");
95
- const readyDelaySuffix = validateDurationFlag("pr-shepherd", "--ready-delay", readyDelayStr, hasFlag(extra, "--ready-delay"));
96
- if (readyDelaySuffix === null)
97
- return;
98
92
  const cfg = loadConfig();
99
- const readyDelaySeconds = parseDurationToMinutes(readyDelaySuffix ?? "", cfg.watch.readyDelayMinutes) * 60;
100
- const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
101
- const noAutoCancelActionable = hasFlag(extra, "--no-auto-cancel-actionable");
102
- const stallTimeoutStr = getFlag(extra, "--stall-timeout");
103
- const stallTimeoutSeconds = stallTimeoutStr
104
- ? parseDurationToMinutes(stallTimeoutStr, cfg.iterate.stallTimeoutMinutes) * 60
105
- : cfg.iterate.stallTimeoutMinutes * 60;
93
+ const flags = parseIterateFlags(extra, cfg);
94
+ if (flags.readyDelaySuffix === null)
95
+ return;
106
96
  const result = await runIterate({
107
97
  ...globalOpts,
108
98
  prNumber,
109
- readyDelaySeconds,
110
- stallTimeoutSeconds,
111
- noAutoMarkReady,
112
- noAutoCancelActionable,
99
+ readyDelaySeconds: flags.readyDelaySeconds,
100
+ stallTimeoutSeconds: flags.stallTimeoutSeconds,
101
+ noAutoMarkReady: flags.noAutoMarkReady,
102
+ noAutoCancelActionable: flags.noAutoCancelActionable,
113
103
  });
114
- const projectionOpts = {
115
- runtime,
116
- readyDelaySuffix,
104
+ emitIterateResult(result, {
105
+ format: globalOpts.format,
106
+ verbose: globalOpts.verbose ?? false,
107
+ readyDelaySuffix: flags.readyDelaySuffix ?? undefined,
117
108
  runner: cfg.cli?.runner,
118
- };
119
- if (globalOpts.format === "json") {
120
- const output = globalOpts.verbose
121
- ? projectIterateVerbose(result, projectionOpts)
122
- : projectIterateLean(result, projectionOpts);
123
- process.stdout.write(`${JSON.stringify(output)}\n`);
124
- }
125
- else {
126
- const text = formatIterateResult(result, { verbose: globalOpts.verbose, ...projectionOpts });
127
- process.stdout.write(`${text}\n`);
128
- }
129
- process.exitCode = iterateActionToExitCode(result.action);
109
+ });
130
110
  }
@@ -0,0 +1,19 @@
1
+ import { iterateActionToExitCode } from "./exit-codes.mjs";
2
+ import { formatIterateResult, projectIterateLean, projectIterateVerbose } from "./formatters.mjs";
3
+ export function emitIterateResult(result, opts) {
4
+ const projectionOpts = {
5
+ readyDelaySuffix: opts.readyDelaySuffix,
6
+ runner: opts.runner,
7
+ };
8
+ if (opts.format === "json") {
9
+ const output = opts.verbose
10
+ ? projectIterateVerbose(result, projectionOpts)
11
+ : projectIterateLean(result, projectionOpts);
12
+ process.stdout.write(`${JSON.stringify(output)}\n`);
13
+ }
14
+ else {
15
+ const text = formatIterateResult(result, { verbose: opts.verbose, ...projectionOpts });
16
+ process.stdout.write(`${text}\n`);
17
+ }
18
+ process.exitCode = iterateActionToExitCode(result.action);
19
+ }
@@ -0,0 +1,21 @@
1
+ import { getFlag, hasFlag } from "./args.mjs";
2
+ import { parseDurationToMinutes } from "./exit-codes.mjs";
3
+ import { validateDurationFlag } from "./duration-flag.mjs";
4
+ export function parseIterateFlags(extra, cfg) {
5
+ const readyDelayStr = getFlag(extra, "--ready-delay");
6
+ const readyDelaySuffix = validateDurationFlag("pr-shepherd", "--ready-delay", readyDelayStr, hasFlag(extra, "--ready-delay"));
7
+ const readyDelaySeconds = parseDurationToMinutes(readyDelaySuffix ?? "", cfg.watch.readyDelayMinutes) * 60;
8
+ const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
9
+ const noAutoCancelActionable = hasFlag(extra, "--no-auto-cancel-actionable");
10
+ const stallTimeoutStr = getFlag(extra, "--stall-timeout");
11
+ const stallTimeoutSeconds = stallTimeoutStr
12
+ ? parseDurationToMinutes(stallTimeoutStr, cfg.iterate.stallTimeoutMinutes) * 60
13
+ : cfg.iterate.stallTimeoutMinutes * 60;
14
+ return {
15
+ readyDelaySuffix,
16
+ readyDelaySeconds,
17
+ stallTimeoutSeconds,
18
+ noAutoMarkReady,
19
+ noAutoCancelActionable,
20
+ };
21
+ }
@@ -17,7 +17,6 @@ import { adaptIterateLog, buildSimpleIterateInstructions, numberInstructions, }
17
17
  */
18
18
  export function formatIterateResult(result, opts) {
19
19
  const verbose = opts?.verbose ?? false;
20
- const runtime = opts?.runtime ?? "claude";
21
20
  const readyDelaySuffix = opts?.readyDelaySuffix;
22
21
  const runner = opts?.runner;
23
22
  const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
@@ -27,7 +26,14 @@ export function formatIterateResult(result, opts) {
27
26
  const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\`${reviewDecisionSeg} · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
28
27
  let summaryLine;
29
28
  if (verbose) {
30
- summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress · **remainingSeconds** ${result.remainingSeconds} · **blockingBotReviewInProgress** ${result.blockingBotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}`;
29
+ let verboseBranch = "";
30
+ if (result.mergeStatus === "BEHIND" && result.baseBranch) {
31
+ verboseBranch = ` · **branch** behind \`origin/${result.baseBranch}\``;
32
+ }
33
+ else if (result.mergeStatus === "CONFLICTS" && result.baseBranch) {
34
+ verboseBranch = ` · **branch** conflicts with \`origin/${result.baseBranch}\``;
35
+ }
36
+ summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress · **remainingSeconds** ${result.remainingSeconds} · **blockingBotReviewInProgress** ${result.blockingBotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}${verboseBranch}`;
31
37
  }
32
38
  else {
33
39
  const counts = [`${result.summary.passing} passing`];
@@ -45,37 +51,68 @@ export function formatIterateResult(result, opts) {
45
51
  segs.push(`**blockingBotReviewInProgress**`);
46
52
  if (result.isDraft)
47
53
  segs.push(`**isDraft**`);
54
+ if (result.mergeStatus === "BEHIND" && result.baseBranch) {
55
+ segs.push(`**branch** behind \`origin/${result.baseBranch}\``);
56
+ }
57
+ else if (result.mergeStatus === "CONFLICTS" && result.baseBranch) {
58
+ segs.push(`**branch** conflicts with \`origin/${result.baseBranch}\``);
59
+ }
48
60
  summaryLine = segs.join(" · ");
49
61
  }
50
- const header = [heading, "", baseLine, summaryLine].join("\n");
62
+ const bp = result.branchProtection;
63
+ const requiredParts = [];
64
+ if (bp) {
65
+ if (bp.requiresApprovingReviews && bp.requiredApprovingReviewCount > 0) {
66
+ requiredParts.push(`approvals \`${bp.requiredApprovingReviewCount}\``);
67
+ }
68
+ if (bp.requiresConversationResolution) {
69
+ requiredParts.push("conversation-resolution required");
70
+ }
71
+ if (bp.requiresStatusChecks) {
72
+ if (bp.requiredStatusCheckContexts.length > 0) {
73
+ requiredParts.push(`checks: ${bp.requiredStatusCheckContexts.map((c) => `\`${c}\``).join(", ")}`);
74
+ }
75
+ else {
76
+ requiredParts.push("status checks required");
77
+ }
78
+ }
79
+ }
80
+ const requiredLine = requiredParts.length > 0 ? `**required** ${requiredParts.join(", ")}` : null;
81
+ const headerLines = [heading, "", baseLine, summaryLine];
82
+ if (requiredLine)
83
+ headerLines.push(requiredLine);
84
+ const header = headerLines.join("\n");
51
85
  switch (result.action) {
52
86
  case "wait":
53
87
  return joinSections([
54
88
  header,
55
- adaptIterateLog(result.log, runtime),
56
- `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, runner))}`,
89
+ adaptIterateLog(result.log),
90
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, readyDelaySuffix, runner))}`,
57
91
  ]);
58
92
  case "mark_ready":
59
93
  return joinSections([
60
94
  header,
61
- adaptIterateLog(result.log, runtime),
62
- `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, runner))}`,
95
+ adaptIterateLog(result.log),
96
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, readyDelaySuffix, runner))}`,
63
97
  ]);
64
- case "cancel":
98
+ case "cancel": {
99
+ const cancelHeaderLines = [`${heading} — ${result.reason}`, "", baseLine, summaryLine];
100
+ if (requiredLine)
101
+ cancelHeaderLines.push(requiredLine);
65
102
  return joinSections([
66
- [`${heading} — ${result.reason}`, "", baseLine, summaryLine].join("\n"),
67
- adaptIterateLog(result.log, runtime),
68
- `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, runner))}`,
103
+ cancelHeaderLines.join("\n"),
104
+ adaptIterateLog(result.log),
105
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, readyDelaySuffix, runner))}`,
69
106
  ]);
107
+ }
70
108
  case "escalate":
71
109
  return joinSections([
72
110
  header,
73
111
  result.escalate.humanMessage,
74
- `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, runner))}`,
112
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, readyDelaySuffix, runner))}`,
75
113
  ]);
76
114
  case "fix_code":
77
115
  return formatFixCodeResult(header, result, {
78
- runtime,
79
116
  readyDelaySuffix,
80
117
  runner,
81
118
  });
@@ -1,19 +1,16 @@
1
- import { FIX_INSTRUCTION_STOP_AFTER_PUSH, FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK, } from "../commands/iterate/render.mjs";
1
+ import { FIX_INSTRUCTION_STOP } from "../commands/iterate/render.mjs";
2
2
  import { buildPrShepherdCommand } from "./runner.mjs";
3
- function buildRecheckInstruction(runtime, rerunCommand, purpose) {
4
- if (runtime === "codex") {
5
- return `Pick a fresh sleep/timeout between 30 seconds and 4 minutes, wait that long, then rerun \`${rerunCommand}\` to ${purpose}.`;
6
- }
7
- return `Single-shot continuation: schedule one session-only follow-up task to run \`${rerunCommand}\` to ${purpose} once after a fresh delay between 30 seconds and 4 minutes, then end this turn. Do not sleep or rerun inline.`;
3
+ function buildRecheckInstruction(rerunCommand, purpose) {
4
+ return `Recheck: rerun \`${rerunCommand}\` to ${purpose} once after a fresh 30s–4m delay.`;
8
5
  }
9
- export function buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, runner) {
6
+ export function buildSimpleIterateInstructions(result, readyDelaySuffix, runner) {
10
7
  const rerunCommand = buildIterateCommand(result.pr, readyDelaySuffix, runner);
11
8
  switch (result.action) {
12
9
  case "wait":
13
- return [buildRecheckInstruction(runtime, rerunCommand, "continue the active goal")];
10
+ return [buildRecheckInstruction(rerunCommand, "continue the active goal")];
14
11
  case "mark_ready":
15
12
  return [
16
- `The CLI already marked the PR ready for review. ${buildRecheckInstruction(runtime, rerunCommand, "recheck")}`,
13
+ `The CLI already marked the PR ready for review. ${buildRecheckInstruction(rerunCommand, "recheck")}`,
17
14
  ];
18
15
  case "cancel":
19
16
  return ["Stop — the active goal is complete."];
@@ -23,19 +20,16 @@ export function buildSimpleIterateInstructions(result, runtime, readyDelaySuffix
23
20
  ];
24
21
  }
25
22
  }
26
- export function adaptFixCodeInstructions(instructions, pr, runtime, readyDelaySuffix, runner) {
23
+ export function adaptFixCodeInstructions(instructions, pr, readyDelaySuffix, runner) {
27
24
  const rerunCommand = buildIterateCommand(pr, readyDelaySuffix, runner);
28
25
  return instructions.map((instruction) => {
29
- if (instruction === FIX_INSTRUCTION_STOP_AFTER_PUSH) {
30
- return `CI needs time to run on the new push. ${buildRecheckInstruction(runtime, rerunCommand, "recheck")}`;
31
- }
32
- if (instruction === FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK) {
33
- return buildRecheckInstruction(runtime, rerunCommand, "recheck");
26
+ if (instruction === FIX_INSTRUCTION_STOP) {
27
+ return `${instruction} ${buildRecheckInstruction(rerunCommand, "recheck")}`;
34
28
  }
35
29
  return instruction;
36
30
  });
37
31
  }
38
- export function adaptIterateLog(log, _runtime) {
32
+ export function adaptIterateLog(log) {
39
33
  return log.replace(/\s+—\s+\d+s until auto-cancel/g, "");
40
34
  }
41
35
  export function buildIterateCommand(pr, readyDelaySuffix, runner) {
@@ -5,10 +5,9 @@ import { adaptIterateLog, adaptFixCodeInstructions, buildSimpleIterateInstructio
5
5
  * outside the state where they are meaningful.
6
6
  */
7
7
  export function projectIterateLean(result, opts) {
8
- const runtime = opts?.runtime ?? "claude";
9
8
  const readyDelaySuffix = opts?.readyDelaySuffix;
10
9
  const runner = opts?.runner;
11
- const simpleInstructions = (r) => buildSimpleIterateInstructions(r, runtime, readyDelaySuffix, runner);
10
+ const simpleInstructions = (r) => buildSimpleIterateInstructions(r, readyDelaySuffix, runner);
12
11
  const base = {
13
12
  action: result.action,
14
13
  pr: result.pr,
@@ -32,26 +31,27 @@ export function projectIterateLean(result, opts) {
32
31
  remainingSeconds: result.remainingSeconds,
33
32
  }),
34
33
  ...(result.baseBranch && { baseBranch: result.baseBranch }),
34
+ ...(result.branchProtection !== null && { branchProtection: result.branchProtection }),
35
35
  };
36
36
  switch (result.action) {
37
37
  case "wait":
38
38
  return {
39
39
  ...base,
40
- log: adaptIterateLog(result.log, runtime),
40
+ log: adaptIterateLog(result.log),
41
41
  instructions: simpleInstructions(result),
42
42
  };
43
43
  case "cancel":
44
44
  return {
45
45
  ...base,
46
46
  reason: result.reason,
47
- log: adaptIterateLog(result.log, runtime),
47
+ log: adaptIterateLog(result.log),
48
48
  instructions: simpleInstructions(result),
49
49
  };
50
50
  case "mark_ready":
51
51
  // drop markedReady — always true, redundant with action discriminator
52
52
  return {
53
53
  ...base,
54
- log: adaptIterateLog(result.log, runtime),
54
+ log: adaptIterateLog(result.log),
55
55
  instructions: simpleInstructions(result),
56
56
  };
57
57
  case "fix_code":
@@ -94,7 +94,7 @@ export function projectIterateLean(result, opts) {
94
94
  }),
95
95
  resolveCommand: result.fix.resolveCommand,
96
96
  ...(result.fix.instructions.length > 0 && {
97
- instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, runner),
97
+ instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, readyDelaySuffix, runner),
98
98
  }),
99
99
  },
100
100
  };
@@ -124,7 +124,6 @@ export function projectIterateLean(result, opts) {
124
124
  }
125
125
  }
126
126
  export function projectIterateVerbose(result, opts) {
127
- const runtime = opts?.runtime ?? "claude";
128
127
  const readyDelaySuffix = opts?.readyDelaySuffix;
129
128
  const runner = opts?.runner;
130
129
  if (result.action === "fix_code") {
@@ -132,16 +131,14 @@ export function projectIterateVerbose(result, opts) {
132
131
  ...result,
133
132
  fix: {
134
133
  ...result.fix,
135
- instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, runner),
134
+ instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, readyDelaySuffix, runner),
136
135
  },
137
136
  };
138
137
  }
139
- const log = "log" in result && typeof result.log === "string"
140
- ? { log: adaptIterateLog(result.log, runtime) }
141
- : {};
138
+ const log = "log" in result && typeof result.log === "string" ? { log: adaptIterateLog(result.log) } : {};
142
139
  return {
143
140
  ...result,
144
141
  ...log,
145
- instructions: buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, runner),
142
+ instructions: buildSimpleIterateInstructions(result, readyDelaySuffix, runner),
146
143
  };
147
144
  }
@@ -0,0 +1,42 @@
1
+ import { runPoll } from "../commands/poll.mjs";
2
+ import { loadConfig } from "../config/load.mjs";
3
+ import { parseCommonArgs, getFlag, hasFlag } from "./args.mjs";
4
+ import { parseDurationToSeconds } from "./exit-codes.mjs";
5
+ import { validateSecondsDurationFlag } from "./duration-flag.mjs";
6
+ import { parseIterateFlags } from "./iterate-flags.mjs";
7
+ import { emitIterateResult } from "./iterate-emitter.mjs";
8
+ const DEFAULT_POLL_INTERVAL_SECONDS = 30;
9
+ const DEFAULT_POLL_TIMEOUT_SECONDS = 300;
10
+ export async function handlePoll(args) {
11
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
12
+ const cfg = loadConfig();
13
+ const flags = parseIterateFlags(extra, cfg);
14
+ if (flags.readyDelaySuffix === null)
15
+ return;
16
+ const intervalStr = getFlag(extra, "--interval");
17
+ const intervalSuffix = validateSecondsDurationFlag("pr-shepherd poll", "--interval", intervalStr, hasFlag(extra, "--interval"));
18
+ if (intervalSuffix === null)
19
+ return;
20
+ const intervalSeconds = parseDurationToSeconds(intervalSuffix ?? "", DEFAULT_POLL_INTERVAL_SECONDS);
21
+ const timeoutStr = getFlag(extra, "--timeout");
22
+ const timeoutSuffix = validateSecondsDurationFlag("pr-shepherd poll", "--timeout", timeoutStr, hasFlag(extra, "--timeout"));
23
+ if (timeoutSuffix === null)
24
+ return;
25
+ const timeoutSeconds = parseDurationToSeconds(timeoutSuffix ?? "", DEFAULT_POLL_TIMEOUT_SECONDS);
26
+ const result = await runPoll({
27
+ ...globalOpts,
28
+ prNumber,
29
+ readyDelaySeconds: flags.readyDelaySeconds,
30
+ stallTimeoutSeconds: flags.stallTimeoutSeconds,
31
+ noAutoMarkReady: flags.noAutoMarkReady,
32
+ noAutoCancelActionable: flags.noAutoCancelActionable,
33
+ intervalSeconds,
34
+ timeoutSeconds,
35
+ });
36
+ emitIterateResult(result, {
37
+ format: globalOpts.format,
38
+ verbose: globalOpts.verbose ?? false,
39
+ readyDelaySuffix: flags.readyDelaySuffix ?? undefined,
40
+ runner: cfg.cli?.runner,
41
+ });
42
+ }
@@ -32,15 +32,11 @@ export function registerHooks() {
32
32
  beforeEach(() => {
33
33
  vi.clearAllMocks();
34
34
  process.exitCode = undefined;
35
- delete process.env.AGENT;
36
- delete process.env.CODEX_CI;
37
35
  stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
38
36
  stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
39
37
  });
40
38
  afterEach(() => {
41
39
  process.exitCode = undefined;
42
- delete process.env.AGENT;
43
- delete process.env.CODEX_CI;
44
40
  stdoutSpy.mockRestore();
45
41
  stderrSpy.mockRestore();
46
42
  });
@@ -13,6 +13,7 @@ export function makeIterateResult(action = "wait") {
13
13
  remainingSeconds: 60,
14
14
  summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 1 },
15
15
  baseBranch: "main",
16
+ branchProtection: null,
16
17
  checks: [],
17
18
  };
18
19
  if (action === "wait")
@@ -39,7 +40,9 @@ export function makeIterateResult(action = "wait") {
39
40
  requiresDismissMessage: false,
40
41
  hasMutations: false,
41
42
  },
42
- instructions: ["Stop this iteration before the next tick."],
43
+ instructions: [
44
+ "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.",
45
+ ],
43
46
  firstLookThreads: [],
44
47
  firstLookComments: [],
45
48
  inProgressRunIds: [],
@@ -33,15 +33,11 @@ export function registerHooks() {
33
33
  beforeEach(() => {
34
34
  vi.clearAllMocks();
35
35
  process.exitCode = undefined;
36
- delete process.env.AGENT;
37
- delete process.env.CODEX_CI;
38
36
  stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
39
37
  stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
40
38
  });
41
39
  afterEach(() => {
42
40
  process.exitCode = undefined;
43
- delete process.env.AGENT;
44
- delete process.env.CODEX_CI;
45
41
  stdoutSpy.mockRestore();
46
42
  stderrSpy.mockRestore();
47
43
  });
@@ -14,6 +14,9 @@
14
14
  * pr-shepherd iterate [PR] [--format text|json] [--ready-delay Nm]
15
15
  * [--stall-timeout <duration>] [--no-auto-mark-ready]
16
16
  * [--no-auto-cancel-actionable]
17
+ * pr-shepherd poll [PR] [--interval 30s] [--timeout 5m] [--format text|json] [--ready-delay Nm]
18
+ * [--stall-timeout <duration>] [--no-auto-mark-ready]
19
+ * [--no-auto-cancel-actionable]
17
20
  * pr-shepherd clean <pr|branch|current|repo|all> [value] [--dry-run] [--format text|json]
18
21
  */
19
22
  import { readFileSync } from "node:fs";
@@ -23,6 +26,7 @@ import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
23
26
  import { isDefaultIterateInvocation, validateDefaultIterateArgs } from "./cli/default-iterate.mjs";
24
27
  import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
25
28
  import { handleClean, handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
29
+ import { handlePoll } from "./cli/poll-handler.mjs";
26
30
  import { setupLog } from "./log/setup.mjs";
27
31
  // ---------------------------------------------------------------------------
28
32
  // Entry
@@ -57,12 +61,15 @@ export async function main(argv) {
57
61
  case "iterate":
58
62
  await handleIterate(args.slice(1));
59
63
  break;
64
+ case "poll":
65
+ await handlePoll(args.slice(1));
66
+ break;
60
67
  case "clean":
61
68
  await handleClean(args.slice(1));
62
69
  break;
63
70
  default:
64
71
  process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
65
- process.stderr.write("Usage: pr-shepherd <resolve|commit-suggestion|iterate|log-file|clean> [options]\n" +
72
+ process.stderr.write("Usage: pr-shepherd <resolve|commit-suggestion|iterate|poll|log-file|clean> [options]\n" +
66
73
  " pr-shepherd --version | -v\n");
67
74
  process.exitCode = 1;
68
75
  return;
@@ -31,5 +31,6 @@ export function buildTerminalReport(prNumber, repo, batchData, mergeStatus, stat
31
31
  firstLookSummaries: [],
32
32
  editedSummaries: [],
33
33
  approvedReviews: [],
34
+ branchProtection: batchData.branchProtection,
34
35
  };
35
36
  }