pr-shepherd 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.13.1",
4
+ "version": "0.14.1",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -20,9 +20,9 @@ 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 adapts monitor instructions to the calling agent. Claude Code gets `/loop` bootstrap instructions. Codex is detected with `AGENT=codex` or the current Codex CLI signal `CODEX_CI=1`; it gets a reusable `npx pr-shepherd <PR>` command and explicit goal-friendly iterate instructions because Codex does not provide `/loop` scheduling in this workflow.
23
+ The CLI adapts monitor instructions to the calling agent. Claude Code gets `/loop` bootstrap instructions. Codex is detected with `AGENT=codex` or the current Codex CLI signal `CODEX_CI=1`; it gets a reusable pr-shepherd command and explicit goal-friendly iterate instructions because Codex does not provide `/loop` scheduling in this workflow. Generated commands use `cli.runner` from `.pr-shepherdrc.yml`: `auto` (default), `npx`, `pnpm`, or `yarn`.
24
24
 
25
- At a high level, to start the monitor, the skill/command invokes a CLI that returns a prompt to be ingested by the agent _(schematic — paraphrased for brevity; actual output is more detailed)_:
25
+ At a high level, to start the monitor, the skill/command invokes a CLI that returns a prompt to be ingested by the agent _(schematic — paraphrased for brevity; actual output is more detailed; examples use `npx` as the default spelling — a pnpm or yarn repo would emit `pnpm exec` or `yarn run` instead)_:
26
26
 
27
27
  ```
28
28
  /pr-shepherd:monitor
@@ -32,14 +32,14 @@ At a high level, to start the monitor, the skill/command invokes a CLI that retu
32
32
  # PR #123 [MONITOR]
33
33
 
34
34
  Loop tag: `#pr-shepherd-loop:pr=123:`
35
- Loop args: `4m`
36
35
 
37
36
  ## Loop prompt
38
37
 
39
38
  #pr-shepherd-loop:pr=123:
40
39
 
41
- **IMPORTANT — recurrence rules:** Do not call ScheduleWakeup or /loop. End the turn
42
- after completing the actions below. The cron job handles the next fire.
40
+ **IMPORTANT — dynamic recurrence rules:** Do not invoke `/loop` again from inside
41
+ this prompt. For nonterminal iterations, call `ScheduleWakeup` with `delaySeconds`
42
+ between 60 and 240 and this same prompt body.
43
43
 
44
44
  Run in a single Bash call:
45
45
  npx pr-shepherd 123
@@ -48,11 +48,10 @@ Run in a single Bash call:
48
48
 
49
49
  ## Instructions
50
50
 
51
- 1. Run `CronList`. If any job's prompt contains the loop tag, run the ## Loop prompt inline then stop.
52
- 2. Otherwise, invoke the /loop skill with Loop args and the full ## Loop prompt body.
51
+ 1. Invoke the /loop skill with the full ## Loop prompt body and no fixed interval.
53
52
  ```
54
53
 
55
- Each iteration calls `npx pr-shepherd <PR>`, which provides actionable feedback directly to the agent:
54
+ Each iteration calls `pr-shepherd <PR>` through the selected package runner, which provides actionable feedback directly to the agent:
56
55
 
57
56
  ```
58
57
  > npx pr-shepherd 123
@@ -127,7 +126,7 @@ Some other workflow improvements:
127
126
 
128
127
  Recommendations:
129
128
 
130
- - Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. In Claude Code, `/pr-shepherd:monitor` uses `/loop` and continues working when your rate limit window is reset. In Codex, keep an active goal cycling the reusable `npx pr-shepherd <PR>` command every `watch.interval` (default 4m) until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures).
129
+ - Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. In Claude Code, `/pr-shepherd:monitor` uses `/loop` and continues working when your rate limit window is reset. In Codex, keep an active goal cycling the reusable command every `watch.interval` (default 4m) until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures).
131
130
  - 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.
132
131
  - 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.
133
132
  - Avoid having automation edit comments, reviews, or threads in place because updated items get minimized. Instead, always make a new review, comment, thread, etc.
@@ -144,16 +143,15 @@ Recommendations:
144
143
 
145
144
  ### Monitor a PR
146
145
 
147
- In Claude Code, creates a cron loop that fires every 4 minutes, checks CI and review comments, fixes issues, and marks the PR ready for review when clean. The loop cancels automatically when the PR is merged or closed.
146
+ In Claude Code, creates a fixed-interval loop (default every 4 minutes, configurable via `watch.interval`) that checks CI and review comments, fixes issues, and marks the PR ready for review when clean. The loop runs as a cron job that fires on schedule — agents end each turn cleanly and the cron fires again automatically. The loop stops automatically when the PR is merged or closed (auto-expires after 7 days).
148
147
 
149
- In Codex, run `npx pr-shepherd monitor <PR>` once to emit the goal-friendly recurrence prompt, then follow that prompt. Its reusable follow-up command is `npx pr-shepherd <PR>`.
148
+ In Codex, run `pr-shepherd monitor <PR>` once through the repo package runner to emit the goal-friendly recurrence prompt, then follow that prompt. Its reusable follow-up command is emitted by the CLI.
150
149
 
151
150
  Claude Code:
152
151
 
153
152
  ```
154
153
  /pr-shepherd:monitor # infer PR from current branch
155
154
  /pr-shepherd:monitor 42
156
- /pr-shepherd:monitor 42 every 8m
157
155
  /pr-shepherd:monitor 42 --ready-delay 15m
158
156
  ```
159
157
 
@@ -193,13 +191,15 @@ See [docs/skills.md](docs/skills.md) for full argument reference.
193
191
 
194
192
  ## Iterate decision loop
195
193
 
196
- On each tick (4-minute default, tunable via `watch.interval`): fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (`fix_code`, `mark_ready`, `cancel`, `escalate`, `wait`, or `cooldown`). 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.
194
+ On each dynamic tick: fetch PR state in one GraphQL batch → classify CI, comments, and merge status → take one action (`fix_code`, `mark_ready`, `cancel`, `escalate`, `wait`, or `cooldown`). Claude and Codex both choose a fresh wait between 1 and 4 minutes for each nonterminal recurrence. 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.
197
195
 
198
196
  ## Install
199
197
 
200
- > **Note:** Skill and plugin install methods add the skill definitions only — they do not install the `pr-shepherd` CLI. The skills invoke `npx pr-shepherd`, so you also need the CLI available. If you're using `pr-shepherd` as development tooling for your repo, install it as a dev dependency so `npx` resolves it without prompting:
198
+ > **Note:** Skill and plugin install methods add the skill definitions only — they do not install the `pr-shepherd` CLI. The skills invoke `pr-shepherd` through the repo package runner, so you also need the CLI available. If you're using `pr-shepherd` as development tooling for your repo, install it as a dev dependency so the selected runner resolves it without prompting:
201
199
  >
202
200
  > ```bash
201
+ > pnpm add -D pr-shepherd # pnpm repos
202
+ > yarn add -D pr-shepherd # yarn repos
203
203
  > npm install --save-dev pr-shepherd
204
204
  > ```
205
205
  >
@@ -252,6 +252,8 @@ After adding the marketplace, open the Codex plugin directory, choose the `jonat
252
252
  Install the CLI where Codex will run it:
253
253
 
254
254
  ```bash
255
+ pnpm add -D pr-shepherd # pnpm repos
256
+ yarn add -D pr-shepherd # yarn repos
255
257
  npm install --save-dev pr-shepherd
256
258
  ```
257
259
 
@@ -263,19 +265,21 @@ If your Codex environment does not already set `CODEX_CI=1`, set `AGENT=codex` s
263
265
  export AGENT=codex
264
266
  ```
265
267
 
266
- Then start a PR monitor from Codex:
268
+ Then start a PR monitor from Codex with the target repository's package runner:
267
269
 
268
270
  ```bash
269
- npx pr-shepherd monitor 42
271
+ <runner> pr-shepherd monitor 42
270
272
  ```
271
273
 
274
+ For example, a repo like `~/filaments` that declares `packageManager: "pnpm@..."` and has `pnpm-lock.yaml` should use `pnpm exec pr-shepherd monitor 42`. For npm repos, use `npx --no-install pr-shepherd monitor 42`.
275
+
272
276
  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 monitor bootstrap runs one tick and prints the reusable follow-up command, usually:
273
277
 
274
278
  ```bash
275
- npx pr-shepherd 42
279
+ <runner> pr-shepherd 42
276
280
  ```
277
281
 
278
- For an active Codex goal, rerun that command every `watch.interval` (default 4m) until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures). `npx pr-shepherd iterate 42` remains supported for existing workflows. There is no background `/loop` scheduler in Codex.
282
+ For an active Codex goal, rerun that command every `watch.interval` (default 4m) until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures). `pr-shepherd iterate 42` remains supported for existing workflows. There is no background `/loop` scheduler in Codex.
279
283
 
280
284
  ### Without the plugin
281
285
 
@@ -7,6 +7,7 @@ export function formatFixCodeResult(header, result, opts) {
7
7
  const runtime = opts?.runtime ?? "claude";
8
8
  const readyDelaySuffix = opts?.readyDelaySuffix;
9
9
  const retryInterval = opts?.retryInterval;
10
+ const runner = opts?.runner;
10
11
  const sections = [header];
11
12
  if (result.fix.threads.length > 0) {
12
13
  sections.push("## Review threads");
@@ -117,7 +118,7 @@ export function formatFixCodeResult(header, result, opts) {
117
118
  }
118
119
  sections.push(postFixLines.join("\n"));
119
120
  sections.push("## Instructions");
120
- sections.push(numberInstructions(adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, retryInterval)));
121
+ sections.push(numberInstructions(adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, retryInterval, runner)));
121
122
  return joinSections(sections);
122
123
  }
123
124
  function blockquote(body) {
@@ -108,7 +108,32 @@ export function formatMutateResult(result) {
108
108
  lines.push(`Minimized comments (${result.minimizedComments.length}): ${result.minimizedComments.join(", ")}`);
109
109
  if (result.dismissedReviews.length)
110
110
  lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
111
- if (result.errors.length)
112
- lines.push(`Errors:\n ${result.errors.join("\n ")}`);
111
+ if (result.rateLimit) {
112
+ const details = [
113
+ result.rateLimit.retryAfterSeconds !== undefined
114
+ ? `retry after ${result.rateLimit.retryAfterSeconds}s`
115
+ : null,
116
+ result.rateLimit.remaining !== undefined && result.rateLimit.limit !== undefined
117
+ ? `remaining ${result.rateLimit.remaining}/${result.rateLimit.limit}`
118
+ : null,
119
+ result.rateLimit.resetAt !== undefined
120
+ ? `reset at ${new Date(result.rateLimit.resetAt * 1000).toISOString()}`
121
+ : null,
122
+ ]
123
+ .filter(Boolean)
124
+ .join(", ");
125
+ lines.push(`Stopped: GitHub rate limit hit — ${result.rateLimit.message}${details ? ` (${details})` : ""}`);
126
+ }
127
+ if (result.unresolvedThreads?.length)
128
+ lines.push(`Not resolved due to rate limit (${result.unresolvedThreads.length}): ${result.unresolvedThreads.join(", ")}`);
129
+ if (result.unminimizedComments?.length)
130
+ lines.push(`Not minimized due to rate limit (${result.unminimizedComments.length}): ${result.unminimizedComments.join(", ")}`);
131
+ if (result.undismissedReviews?.length)
132
+ lines.push(`Not dismissed due to rate limit (${result.undismissedReviews.length}): ${result.undismissedReviews.join(", ")}`);
133
+ const errors = result.rateLimit
134
+ ? result.errors.filter((e) => !e.startsWith("rate limit:"))
135
+ : result.errors;
136
+ if (errors.length)
137
+ lines.push(`Errors:\n ${errors.join("\n ")}`);
113
138
  return lines.join("\n");
114
139
  }
@@ -63,7 +63,12 @@ export async function handleIterate(args) {
63
63
  noAutoMarkReady,
64
64
  noAutoCancelActionable,
65
65
  });
66
- const projectionOpts = { runtime, readyDelaySuffix, retryInterval: cfg.watch.interval };
66
+ const projectionOpts = {
67
+ runtime,
68
+ readyDelaySuffix,
69
+ retryInterval: cfg.watch.interval,
70
+ runner: cfg.cli?.runner,
71
+ };
67
72
  if (globalOpts.format === "json") {
68
73
  const output = globalOpts.verbose
69
74
  ? projectIterateVerbose(result, projectionOpts)
@@ -71,10 +76,8 @@ export async function handleIterate(args) {
71
76
  process.stdout.write(`${JSON.stringify(output)}\n`);
72
77
  }
73
78
  else {
74
- process.stdout.write(`${formatIterateResult(result, {
75
- verbose: globalOpts.verbose,
76
- ...projectionOpts,
77
- })}\n`);
79
+ const text = formatIterateResult(result, { verbose: globalOpts.verbose, ...projectionOpts });
80
+ process.stdout.write(`${text}\n`);
78
81
  }
79
82
  process.exitCode = iterateActionToExitCode(result.action);
80
83
  }
@@ -21,6 +21,7 @@ export function formatIterateResult(result, opts) {
21
21
  const runtime = opts?.runtime ?? "claude";
22
22
  const readyDelaySuffix = opts?.readyDelaySuffix;
23
23
  const retryInterval = opts?.retryInterval;
24
+ const runner = opts?.runner;
24
25
  const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
25
26
  const reviewDecisionSeg = result.mergeStatus === "BLOCKED" && result.reviewDecision
26
27
  ? ` · **reviewDecision** \`${result.reviewDecision}\``
@@ -56,33 +57,38 @@ export function formatIterateResult(result, opts) {
56
57
  return joinSections([
57
58
  verbose ? header : heading,
58
59
  adaptIterateLog(result.log, runtime),
59
- `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval))}`,
60
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval, runner))}`,
60
61
  ]);
61
62
  case "wait":
62
63
  return joinSections([
63
64
  header,
64
65
  adaptIterateLog(result.log, runtime),
65
- `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval))}`,
66
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval, runner))}`,
66
67
  ]);
67
68
  case "mark_ready":
68
69
  return joinSections([
69
70
  header,
70
71
  adaptIterateLog(result.log, runtime),
71
- `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval))}`,
72
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval, runner))}`,
72
73
  ]);
73
74
  case "cancel":
74
75
  return joinSections([
75
76
  [`${heading} — ${result.reason}`, "", baseLine, summaryLine].join("\n"),
76
77
  adaptIterateLog(result.log, runtime),
77
- `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval))}`,
78
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval, runner))}`,
78
79
  ]);
79
80
  case "escalate":
80
81
  return joinSections([
81
82
  header,
82
83
  result.escalate.humanMessage,
83
- `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval))}`,
84
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval, runner))}`,
84
85
  ]);
85
86
  case "fix_code":
86
- return formatFixCodeResult(header, result, { runtime, readyDelaySuffix, retryInterval });
87
+ return formatFixCodeResult(header, result, {
88
+ runtime,
89
+ readyDelaySuffix,
90
+ retryInterval,
91
+ runner,
92
+ });
87
93
  }
88
94
  }
@@ -1,48 +1,50 @@
1
1
  import { FIX_INSTRUCTION_END_ITERATION, FIX_INSTRUCTION_STOP_AFTER_PUSH, FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK, } from "../commands/iterate/render.mjs";
2
+ import { buildPrShepherdCommand } from "./runner.mjs";
2
3
  const DEFAULT_CODEX_RETRY_INTERVAL = "4m";
3
4
  const VALID_RETRY_INTERVAL = /^\d+[smhd]$/;
4
- export function buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval) {
5
- const rerunCommand = buildCodexIterateCommand(result.pr, readyDelaySuffix);
5
+ const CLAUDE_DYNAMIC_WAKEUP = "Schedule the next dynamic wakeup with `ScheduleWakeup` using `delaySeconds` between 60 and 240 and the same loop prompt (skip if running under a fixed-interval cron loop — the next scheduled fire handles it), then end this iteration.";
6
+ export function buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval, runner) {
7
+ const rerunCommand = buildCodexIterateCommand(result.pr, readyDelaySuffix, runner);
6
8
  const retryDelay = buildCodexRetryDelay(retryInterval);
7
9
  switch (result.action) {
8
10
  case "cooldown":
9
11
  return [
10
12
  runtime === "codex"
11
13
  ? `Continue the active Codex goal — wait about ${retryDelay}, then rerun \`${rerunCommand}\` after CI starts reporting.`
12
- : "End this iteration the next cron fire will recheck once CI starts reporting.",
14
+ : `CI still needs time to start reporting. ${CLAUDE_DYNAMIC_WAKEUP}`,
13
15
  ];
14
16
  case "wait":
15
17
  return [
16
18
  runtime === "codex"
17
19
  ? `Continue the active Codex goal — wait about ${retryDelay}, then rerun \`${rerunCommand}\` to recheck.`
18
- : "End this iteration — the next cron fire will recheck.",
20
+ : CLAUDE_DYNAMIC_WAKEUP,
19
21
  ];
20
22
  case "mark_ready":
21
23
  return [
22
24
  runtime === "codex"
23
25
  ? `The CLI already marked the PR ready for review. Continue the active Codex goal until the ready-delay completes — wait about ${retryDelay}, then rerun \`${rerunCommand}\` to recheck.`
24
- : "The CLI already marked the PR ready for review — end this iteration.",
26
+ : `The CLI already marked the PR ready for review. ${CLAUDE_DYNAMIC_WAKEUP}`,
25
27
  ];
26
28
  case "cancel":
27
29
  return [
28
30
  runtime === "codex"
29
31
  ? "Stop — no recurring Codex monitor is running to cancel."
30
- : "Invoke `/loop cancel` via the Skill tool.",
32
+ : `Stop — do not schedule another dynamic wakeup. If this loop was started with a fixed-interval \`/loop\` schedule, call \`CronList\`, find the job whose prompt contains \`#pr-shepherd-loop:pr=${result.pr}:\`, and cancel it with \`CronDelete\`.`,
31
33
  "Stop.",
32
34
  ];
33
35
  case "escalate":
34
36
  return [
35
37
  runtime === "codex"
36
38
  ? "Stop — no recurring Codex monitor is running to cancel."
37
- : "Invoke `/loop cancel` via the Skill tool.",
39
+ : `Stop — do not schedule another dynamic wakeup. If this loop was started with a fixed-interval \`/loop\` schedule, call \`CronList\`, find the job whose prompt contains \`#pr-shepherd-loop:pr=${result.pr}:\`, and cancel it with \`CronDelete\`.`,
38
40
  "Stop — the PR needs human direction before monitoring can resume.",
39
41
  ];
40
42
  }
41
43
  }
42
- export function adaptFixCodeInstructions(instructions, pr, runtime, readyDelaySuffix, retryInterval) {
44
+ export function adaptFixCodeInstructions(instructions, pr, runtime, readyDelaySuffix, retryInterval, runner) {
43
45
  if (runtime !== "codex")
44
46
  return instructions;
45
- const rerunCommand = buildCodexIterateCommand(pr, readyDelaySuffix);
47
+ const rerunCommand = buildCodexIterateCommand(pr, readyDelaySuffix, runner);
46
48
  const retryDelay = buildCodexRetryDelay(retryInterval);
47
49
  return instructions.map((instruction) => {
48
50
  if (instruction === FIX_INSTRUCTION_STOP_AFTER_PUSH) {
@@ -60,9 +62,11 @@ export function adaptIterateLog(log, runtime) {
60
62
  return log;
61
63
  return log.replace(/\s+—\s+\d+s until auto-cancel/g, "");
62
64
  }
63
- export function buildCodexIterateCommand(pr, readyDelaySuffix) {
65
+ export function buildCodexIterateCommand(pr, readyDelaySuffix, runner) {
64
66
  const suffix = readyDelaySuffix?.trim();
65
- return `npx pr-shepherd ${pr}${suffix ? ` --ready-delay ${suffix}` : ""}`;
67
+ return buildPrShepherdCommand([String(pr), ...(suffix ? ["--ready-delay", suffix] : [])], {
68
+ runner,
69
+ }).text;
66
70
  }
67
71
  export function buildCodexRetryDelay(retryInterval) {
68
72
  const interval = typeof retryInterval === "string" ? retryInterval.trim() : "";
@@ -8,7 +8,8 @@ export function projectIterateLean(result, opts) {
8
8
  const runtime = opts?.runtime ?? "claude";
9
9
  const readyDelaySuffix = opts?.readyDelaySuffix;
10
10
  const retryInterval = opts?.retryInterval;
11
- const simpleInstructions = (r) => buildSimpleIterateInstructions(r, runtime, readyDelaySuffix, retryInterval);
11
+ const runner = opts?.runner;
12
+ const simpleInstructions = (r) => buildSimpleIterateInstructions(r, runtime, readyDelaySuffix, retryInterval, runner);
12
13
  const base = {
13
14
  action: result.action,
14
15
  pr: result.pr,
@@ -101,7 +102,7 @@ export function projectIterateLean(result, opts) {
101
102
  }),
102
103
  resolveCommand: result.fix.resolveCommand,
103
104
  ...(result.fix.instructions.length > 0 && {
104
- instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, retryInterval),
105
+ instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, retryInterval, runner),
105
106
  }),
106
107
  },
107
108
  };
@@ -134,12 +135,13 @@ export function projectIterateVerbose(result, opts) {
134
135
  const runtime = opts?.runtime ?? "claude";
135
136
  const readyDelaySuffix = opts?.readyDelaySuffix;
136
137
  const retryInterval = opts?.retryInterval;
138
+ const runner = opts?.runner;
137
139
  if (result.action === "fix_code") {
138
140
  return {
139
141
  ...result,
140
142
  fix: {
141
143
  ...result.fix,
142
- instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, retryInterval),
144
+ instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, retryInterval, runner),
143
145
  },
144
146
  };
145
147
  }
@@ -149,6 +151,6 @@ export function projectIterateVerbose(result, opts) {
149
151
  return {
150
152
  ...result,
151
153
  ...log,
152
- instructions: buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval),
154
+ instructions: buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval, runner),
153
155
  };
154
156
  }
@@ -0,0 +1,109 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ export function buildPrShepherdCommand(args, opts) {
5
+ const runner = resolveCliRunner(opts.runner, opts.cwd);
6
+ const argv = baseArgvForRunner(runner).concat(args);
7
+ return { argv, text: renderShellCommand(argv) };
8
+ }
9
+ export function resolveCliRunner(runner, cwd = process.cwd()) {
10
+ const configured = parseCliRunner(runner);
11
+ return configured === "auto" ? detectPackageRunner(cwd) : configured;
12
+ }
13
+ export function parseCliRunner(runner) {
14
+ if (runner === undefined)
15
+ return "auto";
16
+ if (typeof runner !== "string") {
17
+ throw new Error(`Invalid config: cli.runner must be one of "auto", "npx", "pnpm", or "yarn", got ${JSON.stringify(runner)}`);
18
+ }
19
+ const value = runner.trim();
20
+ if (value === "auto" || value === "npx" || value === "pnpm" || value === "yarn")
21
+ return value;
22
+ throw new Error(`Invalid config: cli.runner must be one of "auto", "npx", "pnpm", or "yarn", got ${JSON.stringify(runner)}`);
23
+ }
24
+ export function renderShellCommand(argv) {
25
+ return argv.map(renderShellArg).join(" ");
26
+ }
27
+ function baseArgvForRunner(runner) {
28
+ switch (runner) {
29
+ case "npx":
30
+ return ["npx", "pr-shepherd"];
31
+ case "pnpm":
32
+ return ["pnpm", "exec", "pr-shepherd"];
33
+ case "yarn":
34
+ return ["yarn", "run", "pr-shepherd"];
35
+ }
36
+ }
37
+ const runnerCache = new Map();
38
+ export function __resetRunnerCache() {
39
+ runnerCache.clear();
40
+ }
41
+ function detectPackageRunner(startDir) {
42
+ const cached = runnerCache.get(startDir);
43
+ if (cached)
44
+ return cached;
45
+ const home = homedir();
46
+ const repoRoot = findRepoRoot(startDir);
47
+ let current = startDir;
48
+ while (true) {
49
+ const atBoundary = current === repoRoot || current === home || current === dirname(current);
50
+ // Read signals for this directory unless it is home without being the repo root.
51
+ // This lets dotfiles repos (repoRoot === home) detect their own package manager
52
+ // while still preventing home's lockfiles from influencing unrelated projects.
53
+ if (!atBoundary || current === repoRoot) {
54
+ const packageManager = readPackageManager(current);
55
+ if (packageManager?.startsWith("pnpm@"))
56
+ return cacheRunner(startDir, "pnpm");
57
+ if (packageManager?.startsWith("yarn@"))
58
+ return cacheRunner(startDir, "yarn");
59
+ if (packageManager?.startsWith("npm@"))
60
+ return cacheRunner(startDir, "npx");
61
+ if (isFile(join(current, "pnpm-lock.yaml")))
62
+ return cacheRunner(startDir, "pnpm");
63
+ if (isFile(join(current, "yarn.lock")))
64
+ return cacheRunner(startDir, "yarn");
65
+ if (isFile(join(current, "package-lock.json")))
66
+ return cacheRunner(startDir, "npx");
67
+ }
68
+ if (atBoundary)
69
+ return cacheRunner(startDir, "npx");
70
+ current = dirname(current);
71
+ }
72
+ }
73
+ function cacheRunner(startDir, runner) {
74
+ runnerCache.set(startDir, runner);
75
+ return runner;
76
+ }
77
+ function readPackageManager(packageDir) {
78
+ try {
79
+ const parsed = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
80
+ return typeof parsed.packageManager === "string" ? parsed.packageManager.trim() : null;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ function findRepoRoot(startDir) {
87
+ let current = startDir;
88
+ while (true) {
89
+ if (statSync(join(current, ".git"), { throwIfNoEntry: false }))
90
+ return current;
91
+ if (current === dirname(current))
92
+ return null;
93
+ current = dirname(current);
94
+ }
95
+ }
96
+ function isFile(path) {
97
+ return statSync(path, { throwIfNoEntry: false })?.isFile() === true;
98
+ }
99
+ function renderShellArg(arg) {
100
+ if (/^\$[A-Z_][A-Z0-9_]*$/.test(arg))
101
+ return `"${arg}"`;
102
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg))
103
+ return arg;
104
+ if (!/["$`\\]/.test(arg))
105
+ return `"${arg}"`;
106
+ if (!arg.includes("'"))
107
+ return `'${arg}'`;
108
+ throw new Error(`Unexpected character in shell arg: ${JSON.stringify(arg)}`);
109
+ }
@@ -31,6 +31,7 @@ import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
31
31
  import { handleCommitSuggestion, handleIterate, handleMonitor, handleStatus, } from "./cli/handlers.mjs";
32
32
  import { setupLog } from "./log/setup.mjs";
33
33
  import { detectAgentRuntime } from "./agent-runtime.mjs";
34
+ import { loadConfig } from "./config/load.mjs";
34
35
  // ---------------------------------------------------------------------------
35
36
  // Entry
36
37
  // ---------------------------------------------------------------------------
@@ -92,10 +93,11 @@ function readVersion() {
92
93
  async function handleCheck(args) {
93
94
  const { prNumber, global: globalOpts } = parseCommonArgs(args);
94
95
  const runtime = detectAgentRuntime();
96
+ const cfg = loadConfig();
95
97
  const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
96
98
  const output = globalOpts.format === "json"
97
- ? formatJson(report, { runtime })
98
- : formatText(report, { runtime });
99
+ ? formatJson(report, { runtime, runner: cfg.cli?.runner })
100
+ : formatText(report, { runtime, runner: cfg.cli?.runner });
99
101
  process.stdout.write(`${output}\n`);
100
102
  process.exitCode = statusToExitCode(report.status);
101
103
  }
@@ -5,6 +5,8 @@ import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/cli
5
5
  import { fetchPrBatch } from "../github/batch.mjs";
6
6
  import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
7
7
  import { buildUnifiedDiff } from "../suggestions/patch.mjs";
8
+ import { loadConfig } from "../config/load.mjs";
9
+ import { buildPrShepherdCommand } from "../cli/runner.mjs";
8
10
  const execFile = promisify(execFileCb);
9
11
  export async function runCommitSuggestion(opts) {
10
12
  if (!opts.threadId) {
@@ -91,11 +93,12 @@ export async function runCommitSuggestion(opts) {
91
93
  `-m ${sq(commitMessageArg)}`,
92
94
  ...commitBodyArg.split("\n\n").map((p) => `-m ${sq(p)}`),
93
95
  ].join(" ");
96
+ const resolveCommand = buildPrShepherdCommand(["resolve", String(prNumber), "--resolve-thread-ids", opts.threadId], { runner: loadConfig().cli?.runner }).text;
94
97
  const postActionInstructions = [
95
98
  `Apply the patch to \`${filePath}\`: run \`git apply\` with the diff shown above, or edit the file directly using the line range (${range}).`,
96
99
  `Stage the file: \`git add -- ${quotedPath}\``,
97
100
  `Commit: \`${commitCmd}\``,
98
- `Resolve the thread on GitHub: \`npx pr-shepherd resolve ${prNumber} --resolve-thread-ids ${opts.threadId}\``,
101
+ `Resolve the thread on GitHub: \`${resolveCommand}\``,
99
102
  `Push when ready: \`git push\` (or \`git push --force-with-lease\` after rebasing).`,
100
103
  ];
101
104
  return {
@@ -1,3 +1,4 @@
1
+ import { buildPrShepherdCommand } from "../../cli/runner.mjs";
1
2
  export function classifyReviewSummaries(summaries, approvals, minimizeApprovals) {
2
3
  // First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
3
4
  // they are already minimized server-side (body changed after minimize was applied).
@@ -20,8 +21,8 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals)
20
21
  surfacedApprovals: approvals,
21
22
  };
22
23
  }
23
- export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber) {
24
- const argv = ["npx", "pr-shepherd", "resolve", String(prNumber)];
24
+ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber, runner) {
25
+ const argv = buildPrShepherdCommand(["resolve", String(prNumber)], { runner }).argv;
25
26
  const threadIds = [...threads.map((t) => t.id), ...resolutionOnlyThreads.map((t) => t.id)];
26
27
  if (threadIds.length > 0) {
27
28
  argv.push("--resolve-thread-ids", threadIds.join(","));
@@ -6,7 +6,7 @@ import { buildFixInstructions } from "./render.mjs";
6
6
  import { applyStallGuard } from "./stall.mjs";
7
7
  import { tryCancelRun, buildInProgressRunIds } from "./helpers.mjs";
8
8
  export async function handleFixCode(ctx) {
9
- const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = ctx;
9
+ const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, cliRunner, } = ctx;
10
10
  const failingChecks = report.checks.failing;
11
11
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
12
12
  const isNewSha = stored?.headSha !== headSha;
@@ -57,7 +57,7 @@ export async function handleFixCode(ctx) {
57
57
  ? buildInProgressRunIds(report, cancelledSet)
58
58
  : [];
59
59
  const allCommentIds = [...actionableComments.map((c) => c.id), ...reviewSummaryIds];
60
- const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber);
60
+ const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, cliRunner);
61
61
  if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
62
62
  const fallbackEscalateBase = {
63
63
  triggers: ["base-branch-unknown"],
@@ -77,7 +77,7 @@ export async function handleFixCode(ctx) {
77
77
  }
78
78
  const firstLookThreads = report.threads.firstLook;
79
79
  const firstLookComments = report.comments.firstLook;
80
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads);
80
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, cliRunner);
81
81
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
82
82
  ...base,
83
83
  baseBranch: baseLookup.branch,
@@ -117,6 +117,7 @@ export async function runIterate(opts) {
117
117
  firstLookSummaries,
118
118
  editedSummaries,
119
119
  surfacedApprovals,
120
+ cliRunner: config.cli?.runner,
120
121
  });
121
122
  }
122
123
  const canMarkReady = report.status === "READY" &&