pr-shepherd 0.13.0 → 0.14.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.
@@ -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.0",
4
+ "version": "0.14.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -9,12 +9,6 @@
9
9
  "homepage": "https://github.com/jonathanong/pr-shepherd",
10
10
  "repository": "https://github.com/jonathanong/pr-shepherd",
11
11
  "license": "MIT",
12
- "keywords": [
13
- "github",
14
- "pull-request",
15
- "ci",
16
- "code-review",
17
- "automation"
18
- ],
12
+ "keywords": ["github", "pull-request", "ci", "code-review", "automation"],
19
13
  "skills": "./plugin/skills/"
20
14
  }
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,11 +191,11 @@ 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
203
201
  > npm install --save-dev pr-shepherd
@@ -275,7 +273,7 @@ Or ask Codex to use the `pr-shepherd` skill, for example: `run pr-shepherd until
275
273
  npx pr-shepherd 42
276
274
  ```
277
275
 
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.
276
+ 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
277
 
280
278
  ### Without the plugin
281
279
 
@@ -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) {
@@ -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" &&
@@ -1,34 +1,36 @@
1
+ import { buildPrShepherdCommand, renderShellCommand } from "../../cli/runner.mjs";
1
2
  export const FIX_INSTRUCTION_STOP_AFTER_PUSH = "Stop this iteration — CI needs time to run on the new push before the next tick.";
2
3
  export const FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK = "Stop this iteration before the next tick.";
3
4
  export const FIX_INSTRUCTION_END_ITERATION = "End this iteration.";
4
5
  /**
5
- * Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE` and whitespace-bearing
6
- * argv entries in double quotes for placeholder substitution. Throws if argv contains `"`, `$`,
7
- * `` ` ``, or `\`. `$HEAD_SHA` is appended separately when `requiresHeadSha` is set.
6
+ * Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE`, `$HEAD_SHA`, and
7
+ * whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is appended separately
8
+ * when `requiresHeadSha` is set.
8
9
  */
9
10
  export function renderResolveCommand(rc) {
10
- const needsQuoting = (arg) => {
11
- if (arg === "$DISMISS_MESSAGE")
12
- return true;
13
- if (/["$`\\]/.test(arg)) {
14
- throw new Error(`Unexpected character in argv arg that needsQuoting can't handle: ${JSON.stringify(arg)}`);
15
- }
16
- return /\s/.test(arg);
17
- };
18
- const parts = rc.argv.map((a) => (needsQuoting(a) ? `"${a}"` : a));
11
+ const parts = [...rc.argv];
19
12
  if (rc.requiresHeadSha) {
20
- parts.push("--require-sha", '"$HEAD_SHA"');
13
+ parts.push("--require-sha", "$HEAD_SHA");
21
14
  }
22
- return parts.join(" ");
15
+ return renderShellCommand(parts);
23
16
  }
24
- export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = []) {
17
+ export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner) {
25
18
  const instructions = [];
26
19
  if (inProgressRunIds.length > 0) {
27
20
  instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id>\`. Do this before applying any code fixes — the push at the end of this iteration will supersede those runs anyway, so letting them continue burns CI minutes for results no one will read. If \`gh\` reports a run is already completed, ignore it and continue with the next ID.`);
28
21
  }
29
22
  const hasSuggestions = threads.some((t) => t.suggestion);
30
23
  if (hasSuggestions) {
31
- instructions.push(`For each thread marked \`[suggestion]\` under \`## Review threads\`: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the \`resolve:\` command below (the thread is not auto-resolved). If the patch fails to apply, fall through to the manual-edit step. Do not retry the same command.`);
24
+ const commitSuggestionCommand = buildPrShepherdCommand([
25
+ "commit-suggestion",
26
+ String(prNumber),
27
+ "--thread-id",
28
+ "<id>",
29
+ "--message",
30
+ "<one-sentence headline>",
31
+ "--format=json",
32
+ ], { runner }).text;
33
+ instructions.push(`For each thread marked \`[suggestion]\` under \`## Review threads\`: run \`${commitSuggestionCommand}\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the \`resolve:\` command below (the thread is not auto-resolved). If the patch fails to apply, fall through to the manual-edit step. Do not retry the same command.`);
32
34
  }
33
35
  if (threads.length > 0 || actionableComments.length > 0) {
34
36
  const suggestionFallback = hasSuggestions
@@ -1,6 +1,7 @@
1
1
  import { getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { loadConfig } from "../config/load.mjs";
3
3
  import { joinSections } from "../util/markdown.mjs";
4
+ import { buildPrShepherdCommand } from "../cli/runner.mjs";
4
5
  export async function runMonitor(opts) {
5
6
  const config = loadConfig();
6
7
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
@@ -18,8 +19,8 @@ export async function runMonitor(opts) {
18
19
  // block depend on this exact string — don't change the format.
19
20
  const loopTag = `#pr-shepherd-loop:pr=${prNumber}:`;
20
21
  const loopArgs = interval;
21
- const reusableCommand = buildIterateCommand(prNumber, opts.readyDelaySuffix);
22
- const loopPrompt = buildLoopPrompt(prNumber, loopTag, reusableCommand, loopArgs, opts.runtime ?? "claude");
22
+ const reusableCommand = buildIterateCommand(prNumber, opts.readyDelaySuffix, config.cli?.runner);
23
+ const loopPrompt = buildLoopPrompt(prNumber, loopTag, reusableCommand, loopArgs, opts.runtime ?? "claude", config.cli?.runner);
23
24
  return {
24
25
  prNumber,
25
26
  loopTag,
@@ -72,11 +73,11 @@ function validateReadyDelaySuffix(readyDelaySuffix) {
72
73
  }
73
74
  return trimmed;
74
75
  }
75
- function buildIterateCommand(prNumber, readyDelaySuffix) {
76
+ function buildIterateCommand(prNumber, readyDelaySuffix, runner) {
76
77
  const validatedDelay = validateReadyDelaySuffix(readyDelaySuffix);
77
- return `npx pr-shepherd ${prNumber}${validatedDelay ? ` --ready-delay ${validatedDelay}` : ""}`;
78
+ return buildPrShepherdCommand([String(prNumber), ...(validatedDelay ? ["--ready-delay", validatedDelay] : [])], { runner }).text;
78
79
  }
79
- function buildLoopPrompt(prNumber, loopTag, iterateCmd, loopArgs, runtime = "claude") {
80
+ function buildLoopPrompt(prNumber, loopTag, iterateCmd, loopArgs, runtime = "claude", runner) {
80
81
  if (runtime === "codex") {
81
82
  return [
82
83
  loopTag,
@@ -85,7 +86,7 @@ function buildLoopPrompt(prNumber, loopTag, iterateCmd, loopArgs, runtime = "cla
85
86
  "- Run the command below once and follow its `## Instructions` exactly.",
86
87
  `- If the output tells you to continue the active Codex goal, wait about the configured interval (${loopArgs}) and rerun the reusable command from the monitor output.`,
87
88
  "- Stop only when Shepherd emits `[CANCEL]` because the ready-delay completed or the PR was merged/closed, or when Shepherd emits `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures).",
88
- "- Do not call `/loop`, `ScheduleWakeup`, `CronCreate`, or `npx pr-shepherd monitor`; Codex recurrence is explicit `iterate` command cycles.",
89
+ `- Do not call \`/loop\`, \`ScheduleWakeup\`, \`CronCreate\`, or \`${buildPrShepherdCommand(["monitor", String(prNumber)], { runner }).text}\`; Codex recurrence is explicit \`iterate\` command cycles.`,
89
90
  "",
90
91
  "Run in a single Bash call:",
91
92
  ` ${iterateCmd}`,
@@ -1,9 +1,10 @@
1
+ import { buildPrShepherdCommand } from "../cli/runner.mjs";
1
2
  /**
2
3
  * Build the numbered triage/fix/resolve instruction steps for the agent to follow.
3
4
  * Steps are conditionally emitted based on what the fetch returned (mirrors
4
5
  * `buildFixInstructions` in `commands/iterate/render.mts`).
5
6
  */
6
- export function buildFetchInstructions(prNumber, result) {
7
+ export function buildFetchInstructions(prNumber, result, runner) {
7
8
  const { actionableThreads, resolutionOnlyThreads, firstLookThreads, actionableComments, firstLookComments, changesRequestedReviews, reviewSummaries, commitSuggestionsEnabled, } = result;
8
9
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
9
10
  const total = actionableThreads.length +
@@ -30,7 +31,16 @@ export function buildFetchInstructions(prNumber, result) {
30
31
  instructions.push(`First-look bullets tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body before deciding whether any matching \`## Review threads to resolve\` item should be resolved.`);
31
32
  }
32
33
  if (hasSuggestions) {
33
- instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the resolve command below (the thread is not auto-resolved). If the patch fails to apply (drift since the suggestion was written), fall through to the manual fix step. Do not retry the same \`commit-suggestion\` invocation.`);
34
+ const commitSuggestionCommand = buildPrShepherdCommand([
35
+ "commit-suggestion",
36
+ String(prNumber),
37
+ "--thread-id",
38
+ "<id>",
39
+ "--message",
40
+ "<one-sentence headline>",
41
+ "--format=json",
42
+ ], { runner }).text;
43
+ instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`${commitSuggestionCommand}\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the resolve command below (the thread is not auto-resolved). If the patch fails to apply (drift since the suggestion was written), fall through to the manual fix step. Do not retry the same \`commit-suggestion\` invocation.`);
34
44
  }
35
45
  if (hasCodeItems) {
36
46
  instructions.push(`Read and edit each file referenced under \`## Actionable Review Threads\`, \`## Actionable PR Comments\`, and \`## Pending CHANGES_REQUESTED reviews\` above. Reclassify each fixed item as Fixed. If an item is too complex to address, leave it as Actionable for the final report.`);
@@ -50,7 +60,10 @@ export function buildFetchInstructions(prNumber, result) {
50
60
  : reviewSummaries.length > 0
51
61
  ? ` Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`.`
52
62
  : "";
53
- instructions.push(`Run \`npx pr-shepherd resolve ${prNumber} [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids> --message "<reason>"]\` with only the non-empty flag subsets. Skip the command entirely if all three ID lists are empty.${requireShaHint}${dismissNote}`);
63
+ const resolveCommand = `${buildPrShepherdCommand(["resolve", String(prNumber)], {
64
+ runner,
65
+ }).text} [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids> --message "<reason>"]`;
66
+ instructions.push(`Run \`${resolveCommand}\` with only the non-empty flag subsets. Skip the command entirely if all three ID lists are empty.${requireShaHint}${dismissNote}`);
54
67
  instructions.push(`For any large decisions or rejections you made this iteration, add or update a \`## Shepherd Journal\` section in the PR description (\`gh pr edit ${prNumber} --body …\`) summarizing each decision. For threads and comments, use the markdown link shown in each item's bullet above; for reviews, reference the review ID.`);
55
68
  instructions.push(`Report: echo the CLI's mutation output, then one line per Acknowledged item: \`Acknowledged <id> (@<author>): <reason>\`. If any fetched item was neither resolved nor acknowledged, stop and escalate: "<N> item(s) fetched but not acted on or acknowledged — need human direction before closing".`);
56
69
  return instructions;
@@ -126,7 +126,7 @@ export async function runResolveFetch(opts) {
126
126
  reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
127
127
  commitSuggestionsEnabled: cfg.actions.commitSuggestions,
128
128
  };
129
- return { ...result, instructions: buildFetchInstructions(prNumber, result) };
129
+ return { ...result, instructions: buildFetchInstructions(prNumber, result, cfg.cli?.runner) };
130
130
  }
131
131
  export async function runResolveMutate(opts) {
132
132
  const repo = await getRepoInfo();
@@ -3,6 +3,7 @@ import { join, dirname } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { parse } from "yaml";
5
5
  import builtins from "../config.json" with { type: "json" };
6
+ import { parseCliRunner } from "../cli/runner.mjs";
6
7
  const RC_FILENAME = ".pr-shepherdrc.yml";
7
8
  function findRcFile(startDir) {
8
9
  const home = homedir();
@@ -51,6 +52,15 @@ export function loadConfig() {
51
52
  const raw = readFileSync(rcPath, "utf8");
52
53
  const parsed = (parse(raw) ?? {});
53
54
  const config = deepMerge(defaults, parsed);
55
+ // Validate cli and watch at load time so misconfigurations are caught once
56
+ // with the rc file path in context, rather than during instruction rendering.
57
+ if (config.cli === null || Array.isArray(config.cli) || typeof config.cli !== "object") {
58
+ throw new Error(`Invalid config: cli must be a plain object, got ${JSON.stringify(config.cli)}`);
59
+ }
60
+ config.cli.runner = parseCliRunner(config.cli.runner);
61
+ if (typeof config.watch?.interval !== "string" || !/^\d+[smhd]$/.test(config.watch.interval)) {
62
+ throw new Error(`Invalid config: watch.interval must be a duration string like "4m" or "1h", got ${JSON.stringify(config.watch?.interval)}`);
63
+ }
54
64
  configCache.set(cwd, config);
55
65
  return config;
56
66
  }
package/bin/config.json CHANGED
@@ -1,4 +1,7 @@
1
1
  {
2
+ "cli": {
3
+ "runner": "auto"
4
+ },
2
5
  "iterate": {
3
6
  "cooldownSeconds": 30,
4
7
  "fixAttemptsPerThread": 3,
@@ -1,3 +1,4 @@
1
+ import { buildPrShepherdCommand } from "../cli/runner.mjs";
1
2
  /**
2
3
  * Build the numbered instruction steps for the agent to follow after a `check` run.
3
4
  * All rebase policy, CI budget policy, and ready-to-merge gating live here so the
@@ -59,7 +60,7 @@ export function buildCheckInstructions(report, opts) {
59
60
  // 5. Continuous monitoring pointer (suppressed only when truly ready to merge)
60
61
  if (!isReady) {
61
62
  instructions.push(runtime === "codex"
62
- ? `This is a one-shot check. For follow-up monitoring, run \`npx pr-shepherd ${report.pr}\`.`
63
+ ? `This is a one-shot check. For follow-up monitoring, run \`${buildPrShepherdCommand([String(report.pr)], { runner: opts?.runner }).text}\`.`
63
64
  : "This is a one-shot check. For continuous monitoring that acts on these signals automatically, use `/pr-shepherd:monitor`.");
64
65
  }
65
66
  return instructions;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -29,7 +29,7 @@
29
29
  "@types/node": "^25.6.0",
30
30
  "@vitest/coverage-v8": "^4.1.4",
31
31
  "husky": "^9.1.7",
32
- "oxfmt": "^0.46.0",
32
+ "oxfmt": "^0.47.0",
33
33
  "oxlint": "^1.60.0",
34
34
  "typescript": "^6.0.3",
35
35
  "vitest": "^4.1.4"
@@ -4,7 +4,18 @@ description: "Start continuous CI monitoring — marks PR ready for review when
4
4
  argument-hint: "[PR number or URL]"
5
5
  user-invocable: true
6
6
  allowed-tools:
7
- ["Bash", "Read", "Grep", "Edit", "Write", "Glob", "Skill", "CronCreate", "CronList", "CronDelete"]
7
+ [
8
+ "Bash",
9
+ "Read",
10
+ "Grep",
11
+ "Edit",
12
+ "Write",
13
+ "Glob",
14
+ "Skill",
15
+ "ScheduleWakeup",
16
+ "CronList",
17
+ "CronDelete",
18
+ ]
8
19
  ---
9
20
 
10
21
  # pr-shepherd monitor — Continuous PR Monitor
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -10,14 +10,7 @@
10
10
  "homepage": "https://github.com/jonathanong/pr-shepherd",
11
11
  "repository": "https://github.com/jonathanong/pr-shepherd",
12
12
  "license": "MIT",
13
- "keywords": [
14
- "github",
15
- "pull-request",
16
- "ci",
17
- "code-review",
18
- "automation",
19
- "codex"
20
- ],
13
+ "keywords": ["github", "pull-request", "ci", "code-review", "automation", "codex"],
21
14
  "skills": "../skills/",
22
15
  "interface": {
23
16
  "displayName": "pr-shepherd",
@@ -25,10 +18,7 @@
25
18
  "longDescription": "Use pr-shepherd from Codex to check pull requests, resolve review feedback, and keep explicit iterate cycles running until a PR is ready, merged, closed, or escalated.",
26
19
  "developerName": "Jonathan Ong",
27
20
  "category": "Coding",
28
- "capabilities": [
29
- "Interactive",
30
- "Write"
31
- ],
21
+ "capabilities": ["Interactive", "Write"],
32
22
  "websiteURL": "https://github.com/jonathanong/pr-shepherd",
33
23
  "privacyPolicyURL": "https://github.com/jonathanong/pr-shepherd",
34
24
  "termsOfServiceURL": "https://github.com/jonathanong/pr-shepherd",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pr-shepherd
3
- description: 'Codex-only skill for checking, updating, monitoring, or resolving a GitHub pull request with pr-shepherd. Use for requests like "check this PR", "use pr-shepherd", "iterate PR #123", "resolve this PR''s comments", or "run pr-shepherd until this PR is ready". For open-ended requests, create a Codex goal and run explicit `npx --no-install pr-shepherd PR_NUMBER` cycles every configured interval until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` including repeated unchanged CI failures.'
3
+ description: 'Codex-only skill for checking, updating, monitoring, or resolving a GitHub pull request with pr-shepherd. Use for requests like "check this PR", "use pr-shepherd", "iterate PR #123", "resolve this PR''s comments", or "run pr-shepherd until this PR is ready". For open-ended requests, create a Codex goal and run explicit `npx --no-install pr-shepherd PR_NUMBER` cycles, picking a fresh sleep/timeout between 1 and 4 minutes before each rerun until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` including repeated unchanged CI failures.'
4
4
  ---
5
5
 
6
6
  # pr-shepherd
@@ -19,7 +19,7 @@ Codex-only workflow for getting actionable PR updates from `pr-shepherd`.
19
19
  2. Decide whether this is one cycle or an open-ended goal.
20
20
  - For one-off requests such as "check this PR", "run pr-shepherd once", or "resolve this PR's comments", run one explicit CLI command.
21
21
  - For requests such as "continue", "until ready", "until this PR is ready", or "keep iterating", create a Codex goal before the first recurring cycle with this objective:
22
- `Run npx --no-install pr-shepherd PR_NUMBER cycles every configured interval until Shepherd emits [CANCEL] for ready-delay completion or PR #PR_NUMBER is merged/closed, or pr-shepherd escalates, including repeated unchanged CI failures.`
22
+ `Run npx --no-install pr-shepherd PR_NUMBER cycles, picking a fresh sleep/timeout between 1 and 4 minutes before each rerun, until Shepherd emits [CANCEL] for ready-delay completion or PR #PR_NUMBER is merged/closed, or pr-shepherd escalates, including repeated unchanged CI failures.`
23
23
 
24
24
  3. Verify the CLI is available.
25
25
  - In the pr-shepherd source checkout, before any `npx --no-install pr-shepherd` invocation, verify `bin/` and `node_modules/` exist. If either is missing, run:
@@ -41,7 +41,7 @@ Codex-only workflow for getting actionable PR updates from `pr-shepherd`.
41
41
 
42
42
  6. Do not call `/loop`, `ScheduleWakeup`, `CronCreate`, or `npx pr-shepherd monitor` for recurrence. Codex does explicit `pr-shepherd PR_NUMBER` cycles.
43
43
 
44
- 7. For open-ended goal requests, complete the CLI-provided instructions for the current cycle. If the output says to continue the active Codex goal, wait for the configured interval named in the output and run another explicit `pr-shepherd PR_NUMBER` cycle.
44
+ 7. For open-ended goal requests, complete the CLI-provided instructions for the current cycle. If the output says to continue the active Codex goal, pick a fresh sleep/timeout between 1 and 4 minutes, wait that long, and run another explicit `pr-shepherd PR_NUMBER` cycle.
45
45
 
46
46
  8. Do not stop an open-ended goal only because the output is `[WAIT]`, `[COOLDOWN]`, `[MARK_READY]`, or a post-fix CI wait. These are nonterminal Codex recurrence states.
47
47