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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +22 -18
- package/bin/cli/fix-formatter.mjs +2 -1
- package/bin/cli/formatters.mjs +27 -2
- package/bin/cli/handlers.mjs +8 -5
- package/bin/cli/iterate-formatter.mjs +12 -6
- package/bin/cli/iterate-instructions.mjs +15 -11
- package/bin/cli/iterate-lean.mjs +6 -4
- package/bin/cli/runner.mjs +109 -0
- package/bin/cli-parser.mjs +4 -2
- package/bin/commands/commit-suggestion.mjs +4 -1
- package/bin/commands/iterate/classify.mjs +3 -2
- package/bin/commands/iterate/fix-code.mjs +3 -3
- package/bin/commands/iterate/index.mjs +1 -0
- package/bin/commands/iterate/render.mjs +18 -16
- package/bin/commands/monitor.mjs +7 -6
- package/bin/commands/resolve-instructions.mjs +16 -3
- package/bin/commands/resolve.mjs +1 -1
- package/bin/comments/pending-ops.mjs +20 -0
- package/bin/comments/rate-limit.mjs +45 -0
- package/bin/comments/resolve.mjs +38 -34
- package/bin/comments/sha-poll.mjs +25 -0
- package/bin/config/load.mjs +10 -0
- package/bin/config.json +3 -0
- package/bin/github/client.mjs +2 -1
- package/bin/github/errors.mjs +12 -0
- package/bin/github/http.mjs +17 -5
- package/bin/reporters/check-instructions.mjs +2 -1
- package/package.json +2 -2
- package/plugin/skills/check/SKILL.md +3 -1
- package/plugin/skills/monitor/SKILL.md +15 -2
- package/plugin/skills/resolve/SKILL.md +3 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +22 -17
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
|
|
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
|
|
42
|
-
|
|
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.
|
|
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 `
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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). `
|
|
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) {
|
package/bin/cli/formatters.mjs
CHANGED
|
@@ -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.
|
|
112
|
-
|
|
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
|
}
|
package/bin/cli/handlers.mjs
CHANGED
|
@@ -63,7 +63,12 @@ export async function handleIterate(args) {
|
|
|
63
63
|
noAutoMarkReady,
|
|
64
64
|
noAutoCancelActionable,
|
|
65
65
|
});
|
|
66
|
-
const projectionOpts = {
|
|
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
|
-
|
|
75
|
-
|
|
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, {
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
|
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() : "";
|
package/bin/cli/iterate-lean.mjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/bin/cli-parser.mjs
CHANGED
|
@@ -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:
|
|
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 = ["
|
|
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,
|