pr-shepherd 0.16.4 → 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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -13
  3. package/bin/cli/args.mjs +2 -0
  4. package/bin/cli/clean-formatter.mjs +20 -0
  5. package/bin/cli/default-iterate.mjs +1 -1
  6. package/bin/cli/duration-flag.mjs +22 -0
  7. package/bin/cli/exit-codes.mjs +14 -0
  8. package/bin/cli/fix-formatter.mjs +4 -12
  9. package/bin/cli/formatters.mjs +6 -10
  10. package/bin/cli/handlers.mjs +72 -36
  11. package/bin/cli/iterate-emitter.mjs +19 -0
  12. package/bin/cli/iterate-flags.mjs +21 -0
  13. package/bin/cli/iterate-formatter.mjs +50 -13
  14. package/bin/cli/iterate-instructions.mjs +13 -17
  15. package/bin/cli/iterate-lean.mjs +9 -12
  16. package/bin/cli/list-formatters.mjs +21 -1
  17. package/bin/cli/poll-handler.mjs +42 -0
  18. package/bin/cli/runner.mjs +13 -3
  19. package/bin/cli-parser.clean.test-support.mjs +45 -0
  20. package/bin/cli-parser.iterate-fix.test-support.mjs +0 -4
  21. package/bin/cli-parser.iterate-fixtures.mjs +5 -2
  22. package/bin/cli-parser.iterate.test-support.mjs +0 -4
  23. package/bin/cli-parser.mjs +13 -2
  24. package/bin/commands/check-terminal-report.mjs +1 -0
  25. package/bin/commands/check.mjs +1 -0
  26. package/bin/commands/check.test-support.mjs +1 -0
  27. package/bin/commands/clean.mjs +156 -0
  28. package/bin/commands/clean.test-support.mjs +48 -0
  29. package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -0
  30. package/bin/commands/commit-suggestion.test-support.mjs +1 -0
  31. package/bin/commands/iterate/check-instructions.mjs +19 -17
  32. package/bin/commands/iterate/classify.mjs +37 -9
  33. package/bin/commands/iterate/escalate.mjs +26 -28
  34. package/bin/commands/iterate/fix-code.mjs +28 -6
  35. package/bin/commands/iterate/index.mjs +2 -0
  36. package/bin/commands/iterate/render.mjs +51 -40
  37. package/bin/commands/iterate-test-support.mjs +1 -0
  38. package/bin/commands/poll.mjs +32 -0
  39. package/bin/commands/poll.test-support.mjs +77 -0
  40. package/bin/commands/resolve-instructions.mjs +2 -5
  41. package/bin/comments/resolve.mjs +70 -11
  42. package/bin/github/batch-parser-helpers.mjs +38 -0
  43. package/bin/github/batch-parsers.mjs +12 -38
  44. package/bin/github/client.mjs +10 -1
  45. package/bin/github/gql/batch-pr.gql +9 -0
  46. package/bin/state/base.mjs +2 -1
  47. package/bin/state/seen-comments.mjs +29 -15
  48. package/package.json +1 -1
  49. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  50. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +16 -5
  51. 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.16.4",
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`, or `yarn`.
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,19 @@ 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
+
139
+ ## Cleaning state
140
+
141
+ `pr-shepherd` accumulates state under `$PR_SHEPHERD_STATE_DIR` (seen markers, fix-attempt counters, stall fingerprints, etc.). To reset it:
142
+
143
+ ```sh
144
+ npx pr-shepherd clean current # remove state for the current branch's PR
145
+ npx pr-shepherd clean repo # remove all state for this repo
146
+ npx pr-shepherd clean all # remove all pr-shepherd state
147
+ ```
148
+
149
+ Add `--dry-run` to preview what would be removed. See [docs/cli-usage.md](docs/cli-usage.md) for the full `clean` reference.
138
150
 
139
151
  ## Install
140
152
 
@@ -143,6 +155,7 @@ On each tick: fetch PR state in one GraphQL batch → classify CI, comments, and
143
155
  > ```bash
144
156
  > pnpm add -D pr-shepherd # pnpm repos
145
157
  > yarn add -D pr-shepherd # yarn repos
158
+ > bun add -d pr-shepherd # bun repos
146
159
  > npm install --save-dev pr-shepherd
147
160
  > ```
148
161
  >
@@ -197,26 +210,21 @@ Install the CLI where Codex will run it:
197
210
  ```bash
198
211
  pnpm add -D pr-shepherd # pnpm repos
199
212
  yarn add -D pr-shepherd # yarn repos
213
+ bun add -d pr-shepherd # bun repos
200
214
  npm install --save-dev pr-shepherd
201
215
  ```
202
216
 
203
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`.
204
218
 
205
- 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:
206
-
207
- ```bash
208
- export AGENT=codex
209
- ```
210
-
211
- 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:
212
220
 
213
221
  ```bash
214
222
  <runner> pr-shepherd iterate 42
215
223
  ```
216
224
 
217
- For example, a repo like `~/filaments` that declares `packageManager: "pnpm@..."` and has `pnpm-lock.yaml` should use `pnpm exec pr-shepherd iterate 42`. For npm repos, use `npx pr-shepherd iterate 42`.
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`.
218
226
 
219
- 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.
220
228
 
221
229
  ### As a global CLI
222
230
 
@@ -241,7 +249,7 @@ actions:
241
249
  autoMarkReady: false # disable to stay draft until you manually promote
242
250
  ```
243
251
 
244
- 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).
245
253
 
246
254
  See [docs/configuration.md](docs/configuration.md) for full semantics and deprecated-key migration.
247
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
@@ -0,0 +1,20 @@
1
+ import { joinSections } from "../util/markdown.mjs";
2
+ export function formatCleanResult(result) {
3
+ if (!result.ok) {
4
+ return `Error: ${result.error ?? "unknown error"}`;
5
+ }
6
+ const heading = result.dryRun ? "## Would clean" : "## Cleaned";
7
+ const paths = result.deleted;
8
+ if (result.skipped.length > 0) {
9
+ const label = result.dryRun ? "Nothing to clean (dry-run)" : "Nothing to clean";
10
+ return `${label} — ${result.target} does not exist.`;
11
+ }
12
+ const sections = [
13
+ heading,
14
+ paths.map((p) => `- ${p}`).join("\n"),
15
+ result.dryRun
16
+ ? `Would remove ${paths.length} item(s) under ${result.target}`
17
+ : `Removed ${paths.length} item(s) under ${result.target}`,
18
+ ];
19
+ return joinSections(sections);
20
+ }
@@ -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":
@@ -1,10 +1,9 @@
1
1
  import { renderResolveCommand } from "../commands/iterate/render.mjs";
2
2
  import { joinSections } from "../util/markdown.mjs";
3
3
  import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mjs";
4
- import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, renderThreadResolutionStatusTag, renderAuthor, } from "./list-formatters.mjs";
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];
@@ -92,15 +91,8 @@ export function formatFixCodeResult(header, result, opts) {
92
91
  const firstLookTotal = result.fix.firstLookThreads.length + result.fix.firstLookComments.length;
93
92
  if (firstLookTotal > 0) {
94
93
  sections.push(`## First-look items (${firstLookTotal}) — acknowledge status before acting`);
95
- const bullets = [];
96
- for (const t of result.fix.firstLookThreads) {
97
- bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
98
- }
99
- for (const c of result.fix.firstLookComments) {
100
- const editedSuffix = c.edited ? ", edited" : "";
101
- bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
102
- }
103
- sections.push(bullets.join("\n"));
94
+ const resolutionOnlyIds = new Set(result.fix.resolutionOnlyThreads.map((t) => t.id));
95
+ sections.push(buildFirstLookBullets(result.fix.firstLookThreads, resolutionOnlyIds, result.fix.firstLookComments).join("\n"));
104
96
  }
105
97
  if (result.fix.inProgressRunIds.length > 0) {
106
98
  sections.push("## In-progress runs");
@@ -117,7 +109,7 @@ export function formatFixCodeResult(header, result, opts) {
117
109
  }
118
110
  sections.push(postFixLines.join("\n"));
119
111
  sections.push("## Instructions");
120
- sections.push(numberInstructions(adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, runner)));
112
+ sections.push(numberInstructions(adaptFixCodeInstructions(result.fix.instructions, result.pr, readyDelaySuffix, runner)));
121
113
  return joinSections(sections);
122
114
  }
123
115
  function blockquote(body) {
@@ -1,7 +1,8 @@
1
1
  export { formatIterateResult } from "./iterate-formatter.mjs";
2
2
  export { projectIterateLean, projectIterateVerbose } from "./iterate-lean.mjs";
3
+ export { formatCleanResult } from "./clean-formatter.mjs";
3
4
  import { safeFence } from "./fence.mjs";
4
- import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, renderThreadResolutionStatusTag, } from "./list-formatters.mjs";
5
+ import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderThreadResolutionStatusTag, buildFirstLookBullets, } from "./list-formatters.mjs";
5
6
  import { joinSections } from "../util/markdown.mjs";
6
7
  export function formatFetchResult(result) {
7
8
  const activeTotal = result.actionableThreads.length +
@@ -46,15 +47,8 @@ export function formatFetchResult(result) {
46
47
  }
47
48
  if (firstLookTotal > 0) {
48
49
  sections.push(`## First-look items (${firstLookTotal}) — acknowledge status before acting`);
49
- const bullets = [];
50
- for (const t of result.firstLookThreads) {
51
- bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
52
- }
53
- for (const c of result.firstLookComments) {
54
- const editedSuffix = c.edited ? ", edited" : "";
55
- bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
56
- }
57
- sections.push(bullets.join("\n"));
50
+ const resolutionOnlyIds = new Set(result.resolutionOnlyThreads.map((t) => t.id));
51
+ sections.push(buildFirstLookBullets(result.firstLookThreads, resolutionOnlyIds, result.firstLookComments).join("\n"));
58
52
  }
59
53
  sections.push("## Summary");
60
54
  sections.push(total === 0
@@ -108,6 +102,8 @@ export function formatMutateResult(result) {
108
102
  lines.push(`Minimized comments (${result.minimizedComments.length}): ${result.minimizedComments.join(", ")}`);
109
103
  if (result.dismissedReviews.length)
110
104
  lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
105
+ if (result.skippedDismissals?.length)
106
+ lines.push(`Skipped dismissals (${result.skippedDismissals.length}): ${result.skippedDismissals.join(", ")}`);
111
107
  if (result.rateLimit) {
112
108
  const details = [
113
109
  result.rateLimit.retryAfterSeconds !== undefined
@@ -1,11 +1,66 @@
1
1
  import { runCommitSuggestion } from "../commands/commit-suggestion.mjs";
2
2
  import { runIterate } from "../commands/iterate/index.mjs";
3
+ import { runClean } from "../commands/clean.mjs";
3
4
  import { loadConfig } from "../config/load.mjs";
4
- import { detectAgentRuntime } from "../agent-runtime.mjs";
5
- import { parseCommonArgs, getFlag, hasFlag } from "./args.mjs";
6
- import { parseDurationToMinutes, iterateActionToExitCode } from "./exit-codes.mjs";
7
- import { formatCommitSuggestionResult, formatIterateResult, projectIterateLean, projectIterateVerbose, } from "./formatters.mjs";
8
- 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";
9
+ const CLEAN_VARIANTS = new Set(["pr", "branch", "current", "repo", "all"]);
10
+ export async function handleClean(args) {
11
+ const variant = args[0];
12
+ if (!variant || !CLEAN_VARIANTS.has(variant)) {
13
+ process.stderr.write("Usage: pr-shepherd clean <pr|branch|current|repo|all> [value] [--dry-run] [--format text|json]\n");
14
+ process.exitCode = 1;
15
+ return;
16
+ }
17
+ const rest = args.slice(1);
18
+ for (const a of rest) {
19
+ if (!a.startsWith("--"))
20
+ continue;
21
+ if (a === "--dry-run" || a === "--format" || a.startsWith("--format="))
22
+ continue;
23
+ process.stderr.write(`pr-shepherd: clean: unknown flag: "${a}"\n`);
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+ const fmtIdx = rest.indexOf("--format");
28
+ const fmtEqEntry = rest.find((a) => a.startsWith("--format="));
29
+ let formatValue;
30
+ if (fmtEqEntry !== undefined) {
31
+ formatValue = fmtEqEntry.slice("--format=".length);
32
+ }
33
+ else if (fmtIdx !== -1 && fmtIdx + 1 < rest.length && !rest[fmtIdx + 1].startsWith("--")) {
34
+ formatValue = rest[fmtIdx + 1];
35
+ }
36
+ if (formatValue !== undefined && formatValue !== "text" && formatValue !== "json") {
37
+ process.stderr.write(`pr-shepherd: clean: invalid --format value: "${formatValue}". Expected "text" or "json".\n`);
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+ const jsonOut = formatValue === "json";
42
+ const dryRun = rest.includes("--dry-run");
43
+ // Skip the value consumed by --format <value> so it isn't mistaken for the positional.
44
+ const flagConsumedIndices = new Set();
45
+ if (fmtIdx !== -1 && fmtIdx + 1 < rest.length && !rest[fmtIdx + 1].startsWith("--")) {
46
+ flagConsumedIndices.add(fmtIdx);
47
+ flagConsumedIndices.add(fmtIdx + 1);
48
+ }
49
+ const positionals = rest.filter((a, i) => !flagConsumedIndices.has(i) && !a.startsWith("--"));
50
+ if (positionals.length > 1) {
51
+ process.stderr.write(`pr-shepherd: clean: too many positional arguments (expected at most 1, got ${positionals.length})\n`);
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+ const value = positionals[0];
56
+ const result = await runClean({ variant: variant, value, dryRun });
57
+ if (!result.ok) {
58
+ process.stderr.write(`pr-shepherd: clean: ${result.error}\n`);
59
+ process.exitCode = 1;
60
+ return;
61
+ }
62
+ process.stdout.write(jsonOut ? `${JSON.stringify(result, null, 2)}\n` : `${formatCleanResult(result)}\n`);
63
+ }
9
64
  export async function handleCommitSuggestion(args) {
10
65
  const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
11
66
  const threadId = getFlag(extra, "--thread-id");
@@ -34,41 +89,22 @@ export async function handleCommitSuggestion(args) {
34
89
  }
35
90
  export async function handleIterate(args) {
36
91
  const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
37
- const runtime = detectAgentRuntime();
38
- const readyDelayStr = getFlag(extra, "--ready-delay");
39
- const readyDelaySuffix = validateDurationFlag("pr-shepherd", "--ready-delay", readyDelayStr, hasFlag(extra, "--ready-delay"));
40
- if (readyDelaySuffix === null)
41
- return;
42
92
  const cfg = loadConfig();
43
- const readyDelaySeconds = parseDurationToMinutes(readyDelaySuffix ?? "", cfg.watch.readyDelayMinutes) * 60;
44
- const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
45
- const noAutoCancelActionable = hasFlag(extra, "--no-auto-cancel-actionable");
46
- const stallTimeoutStr = getFlag(extra, "--stall-timeout");
47
- const stallTimeoutSeconds = stallTimeoutStr
48
- ? parseDurationToMinutes(stallTimeoutStr, cfg.iterate.stallTimeoutMinutes) * 60
49
- : cfg.iterate.stallTimeoutMinutes * 60;
93
+ const flags = parseIterateFlags(extra, cfg);
94
+ if (flags.readyDelaySuffix === null)
95
+ return;
50
96
  const result = await runIterate({
51
97
  ...globalOpts,
52
98
  prNumber,
53
- readyDelaySeconds,
54
- stallTimeoutSeconds,
55
- noAutoMarkReady,
56
- noAutoCancelActionable,
99
+ readyDelaySeconds: flags.readyDelaySeconds,
100
+ stallTimeoutSeconds: flags.stallTimeoutSeconds,
101
+ noAutoMarkReady: flags.noAutoMarkReady,
102
+ noAutoCancelActionable: flags.noAutoCancelActionable,
57
103
  });
58
- const projectionOpts = {
59
- runtime,
60
- readyDelaySuffix,
104
+ emitIterateResult(result, {
105
+ format: globalOpts.format,
106
+ verbose: globalOpts.verbose ?? false,
107
+ readyDelaySuffix: flags.readyDelaySuffix ?? undefined,
61
108
  runner: cfg.cli?.runner,
62
- };
63
- if (globalOpts.format === "json") {
64
- const output = globalOpts.verbose
65
- ? projectIterateVerbose(result, projectionOpts)
66
- : projectIterateLean(result, projectionOpts);
67
- process.stdout.write(`${JSON.stringify(output)}\n`);
68
- }
69
- else {
70
- const text = formatIterateResult(result, { verbose: globalOpts.verbose, ...projectionOpts });
71
- process.stdout.write(`${text}\n`);
72
- }
73
- process.exitCode = iterateActionToExitCode(result.action);
109
+ });
74
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,39 +1,35 @@
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 `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."];
20
17
  case "escalate":
21
- return ["Stop — the PR needs human direction before iterating can resume."];
18
+ return [
19
+ "Stop — the PR needs human direction before iterating can resume. This is a manual handoff; do not continue automated fix attempts.",
20
+ ];
22
21
  }
23
22
  }
24
- export function adaptFixCodeInstructions(instructions, pr, runtime, readyDelaySuffix, runner) {
23
+ export function adaptFixCodeInstructions(instructions, pr, readyDelaySuffix, runner) {
25
24
  const rerunCommand = buildIterateCommand(pr, readyDelaySuffix, runner);
26
25
  return instructions.map((instruction) => {
27
- if (instruction === FIX_INSTRUCTION_STOP_AFTER_PUSH) {
28
- return `CI needs time to run on the new push. ${buildRecheckInstruction(runtime, rerunCommand, "recheck")}`;
29
- }
30
- if (instruction === FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK) {
31
- return buildRecheckInstruction(runtime, rerunCommand, "recheck");
26
+ if (instruction === FIX_INSTRUCTION_STOP) {
27
+ return `${instruction} ${buildRecheckInstruction(rerunCommand, "recheck")}`;
32
28
  }
33
29
  return instruction;
34
30
  });
35
31
  }
36
- export function adaptIterateLog(log, _runtime) {
32
+ export function adaptIterateLog(log) {
37
33
  return log.replace(/\s+—\s+\d+s until auto-cancel/g, "");
38
34
  }
39
35
  export function buildIterateCommand(pr, readyDelaySuffix, runner) {