pr-shepherd 0.14.0 → 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.14.0",
4
+ "version": "0.14.1",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -198,6 +198,8 @@ On each dynamic tick: fetch PR state in one GraphQL batch → classify CI, comme
198
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:
199
199
  >
200
200
  > ```bash
201
+ > pnpm add -D pr-shepherd # pnpm repos
202
+ > yarn add -D pr-shepherd # yarn repos
201
203
  > npm install --save-dev pr-shepherd
202
204
  > ```
203
205
  >
@@ -250,6 +252,8 @@ After adding the marketplace, open the Codex plugin directory, choose the `jonat
250
252
  Install the CLI where Codex will run it:
251
253
 
252
254
  ```bash
255
+ pnpm add -D pr-shepherd # pnpm repos
256
+ yarn add -D pr-shepherd # yarn repos
253
257
  npm install --save-dev pr-shepherd
254
258
  ```
255
259
 
@@ -261,16 +265,18 @@ If your Codex environment does not already set `CODEX_CI=1`, set `AGENT=codex` s
261
265
  export AGENT=codex
262
266
  ```
263
267
 
264
- Then start a PR monitor from Codex:
268
+ Then start a PR monitor from Codex with the target repository's package runner:
265
269
 
266
270
  ```bash
267
- npx pr-shepherd monitor 42
271
+ <runner> pr-shepherd monitor 42
268
272
  ```
269
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
+
270
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:
271
277
 
272
278
  ```bash
273
- npx pr-shepherd 42
279
+ <runner> pr-shepherd 42
274
280
  ```
275
281
 
276
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.
@@ -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
  }
@@ -0,0 +1,20 @@
1
+ export function setPendingOps(result, ops) {
2
+ const resolved = new Set(result.resolvedThreads);
3
+ const minimized = new Set(result.minimizedComments);
4
+ const dismissed = new Set(result.dismissedReviews);
5
+ const unresolvedThreads = ops
6
+ .filter((op) => op.kind === "r" && !resolved.has(op.id))
7
+ .map((op) => op.id);
8
+ const unminimizedComments = ops
9
+ .filter((op) => op.kind === "m" && !minimized.has(op.id))
10
+ .map((op) => op.id);
11
+ const undismissedReviews = ops
12
+ .filter((op) => op.kind === "d" && !dismissed.has(op.id))
13
+ .map((op) => op.id);
14
+ if (unresolvedThreads.length > 0)
15
+ result.unresolvedThreads = unresolvedThreads;
16
+ if (unminimizedComments.length > 0)
17
+ result.unminimizedComments = unminimizedComments;
18
+ if (undismissedReviews.length > 0)
19
+ result.undismissedReviews = undismissedReviews;
20
+ }
@@ -0,0 +1,45 @@
1
+ export function rateLimitFromError(err, fallbackMessage) {
2
+ const maybe = err;
3
+ const message = err instanceof Error ? err.message : fallbackMessage;
4
+ const status = finiteNumber(maybe.status);
5
+ const hasRateLimitStatus = status === 403 || status === 429;
6
+ if (!isRateLimitMessage(message) &&
7
+ !(hasRateLimitStatus && maybe.retryAfterSeconds !== undefined) &&
8
+ maybe.rateLimit?.remaining !== 0)
9
+ return null;
10
+ return buildRateLimitStop(message, {
11
+ rateLimit: maybe.rateLimit,
12
+ retryAfterSeconds: maybe.retryAfterSeconds,
13
+ });
14
+ }
15
+ export function rateLimitFromGraphQlResult(messages, meta) {
16
+ const message = messages.find(isRateLimitMessage);
17
+ if (message)
18
+ return buildRateLimitStop(message, meta);
19
+ if (meta.stopOnZeroRemaining === true && meta.rateLimit?.remaining === 0) {
20
+ return buildRateLimitStop("GitHub GraphQL rate limit remaining is 0", meta);
21
+ }
22
+ return undefined;
23
+ }
24
+ function buildRateLimitStop(message, meta) {
25
+ const stop = { message };
26
+ const retryAfterSeconds = finiteNumber(meta.retryAfterSeconds);
27
+ const remaining = finiteNumber(meta.rateLimit?.remaining);
28
+ const limit = finiteNumber(meta.rateLimit?.limit);
29
+ const resetAt = finiteNumber(meta.rateLimit?.resetAt);
30
+ if (retryAfterSeconds !== undefined)
31
+ stop.retryAfterSeconds = retryAfterSeconds;
32
+ if (limit !== undefined)
33
+ stop.limit = limit;
34
+ if (remaining !== undefined)
35
+ stop.remaining = remaining;
36
+ if (resetAt !== undefined)
37
+ stop.resetAt = resetAt;
38
+ return stop;
39
+ }
40
+ function finiteNumber(value) {
41
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
42
+ }
43
+ export function isRateLimitMessage(message) {
44
+ return /rate limit|rate-limit|secondary limit|secondary rate/i.test(message);
45
+ }
@@ -1,5 +1,7 @@
1
- import { graphql, getPrHeadSha } from "../github/client.mjs";
2
- import { loadConfig } from "../config/load.mjs";
1
+ import { graphqlWithRateLimit } from "../github/client.mjs";
2
+ import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "./rate-limit.mjs";
3
+ import { setPendingOps } from "./pending-ops.mjs";
4
+ import { waitForSha } from "./sha-poll.mjs";
3
5
  export async function applyResolveOptions(pr, repo, opts) {
4
6
  if ((opts.dismissReviewIds?.length ?? 0) > 0 && !opts.dismissMessage) {
5
7
  throw new Error("--message is required when dismissing reviews");
@@ -28,8 +30,8 @@ export async function autoResolveOutdated(threadIds) {
28
30
  await bulkApply(threadIds, [], [], "", result);
29
31
  return { resolved: result.resolvedThreads, errors: result.errors };
30
32
  }
31
- // Chunk at 50 so a single oversized list never fails the entire call.
32
- const BULK_CHUNK_SIZE = 50;
33
+ // Keep mutation batches small so rate-limit stops leave a precise pending list.
34
+ const BULK_CHUNK_SIZE = 10;
33
35
  function buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage) {
34
36
  const ops = [];
35
37
  for (let i = 0; i < resolveIds.length; i++) {
@@ -52,70 +54,72 @@ async function bulkApply(resolveIds, minimizeIds, dismissIds, dismissMessage, re
52
54
  for (let i = 0; i < allOps.length; i += BULK_CHUNK_SIZE) {
53
55
  const chunk = allOps.slice(i, i + BULK_CHUNK_SIZE);
54
56
  // eslint-disable-next-line no-await-in-loop
55
- await bulkApplyChunk(chunk.filter((o) => o.kind === "r").map((o) => o.id), chunk.filter((o) => o.kind === "m").map((o) => o.id), chunk.filter((o) => o.kind === "d").map((o) => o.id), dismissMessage, result);
57
+ const stopped = await bulkApplyChunk(chunk.filter((o) => o.kind === "r").map((o) => o.id), chunk.filter((o) => o.kind === "m").map((o) => o.id), chunk.filter((o) => o.kind === "d").map((o) => o.id), dismissMessage, result, i + BULK_CHUNK_SIZE < allOps.length);
58
+ if (stopped) {
59
+ setPendingOps(result, allOps.slice(i));
60
+ return;
61
+ }
56
62
  }
57
63
  }
58
- async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
64
+ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessage, result, hasPendingAfter) {
59
65
  if (resolveIds.length === 0 && minimizeIds.length === 0 && dismissIds.length === 0)
60
- return;
66
+ return false;
61
67
  const doc = buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage);
62
68
  let data;
69
+ let rateLimitStop;
70
+ let suppressCurrentChunkErrors = false;
63
71
  try {
64
- const resp = await graphql(doc, {});
72
+ const resp = await graphqlWithRateLimit(doc, {});
65
73
  data = resp.data;
74
+ const graphQlErrorMessages = resp.errors?.map((e) => e.message) ?? [];
75
+ suppressCurrentChunkErrors = graphQlErrorMessages.some(isRateLimitMessage);
76
+ rateLimitStop = rateLimitFromGraphQlResult(graphQlErrorMessages, {
77
+ rateLimit: resp.rateLimit,
78
+ retryAfterSeconds: resp.retryAfterSeconds,
79
+ stopOnZeroRemaining: hasPendingAfter,
80
+ });
66
81
  }
67
82
  catch (err) {
68
83
  const msg = err instanceof Error ? err.message : String(err);
84
+ const stop = rateLimitFromError(err, msg);
85
+ if (stop) {
86
+ result.errors.push(`rate limit: ${stop.message}`);
87
+ result.rateLimit = stop;
88
+ return true;
89
+ }
69
90
  for (const id of resolveIds)
70
91
  result.errors.push(`${id}: ${msg}`);
71
92
  for (const id of minimizeIds)
72
93
  result.errors.push(`${id}: ${msg}`);
73
94
  for (const id of dismissIds)
74
95
  result.errors.push(`${id}: ${msg}`);
75
- return;
96
+ return false;
76
97
  }
77
98
  for (let i = 0; i < resolveIds.length; i++) {
78
99
  const r = data[`r${i}`];
79
100
  if (r?.thread?.isResolved === true)
80
101
  result.resolvedThreads.push(resolveIds[i]);
81
- else
102
+ else if (!suppressCurrentChunkErrors)
82
103
  result.errors.push(`${resolveIds[i]}: resolve returned null or thread not resolved`);
83
104
  }
84
105
  for (let i = 0; i < minimizeIds.length; i++) {
85
106
  const m = data[`m${i}`];
86
107
  if (m?.minimizedComment?.isMinimized === true)
87
108
  result.minimizedComments.push(minimizeIds[i]);
88
- else
109
+ else if (!suppressCurrentChunkErrors)
89
110
  result.errors.push(`${minimizeIds[i]}: minimize returned null or comment not minimized`);
90
111
  }
91
112
  for (let i = 0; i < dismissIds.length; i++) {
92
113
  const d = data[`d${i}`];
93
114
  if (d?.pullRequestReview != null)
94
115
  result.dismissedReviews.push(dismissIds[i]);
95
- else
116
+ else if (!suppressCurrentChunkErrors)
96
117
  result.errors.push(`${dismissIds[i]}: dismiss returned null`);
97
118
  }
98
- }
99
- async function waitForSha(pr, repo, expectedSha) {
100
- const { intervalMs: SHA_POLL_INTERVAL_MS, maxAttempts: SHA_POLL_MAX_ATTEMPTS } = loadConfig().resolve.shaPoll;
101
- for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
102
- try {
103
- // eslint-disable-next-line no-await-in-loop
104
- const currentSha = await getPrHeadSha(pr, repo.owner, repo.name);
105
- if (currentSha === expectedSha)
106
- return;
107
- }
108
- catch (err) {
109
- if (attempt === SHA_POLL_MAX_ATTEMPTS - 1)
110
- throw err;
111
- }
112
- if (attempt < SHA_POLL_MAX_ATTEMPTS - 1) {
113
- // eslint-disable-next-line no-await-in-loop
114
- await sleep(SHA_POLL_INTERVAL_MS);
115
- }
119
+ if (rateLimitStop) {
120
+ result.errors.push(`rate limit: ${rateLimitStop.message}`);
121
+ result.rateLimit = rateLimitStop;
122
+ return true;
116
123
  }
117
- throw new Error(`Timeout: GitHub PR #${pr} head SHA has not updated to ${expectedSha} after ${((SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS) / 1000}s. Push may still be in transit — retry shortly.`);
118
- }
119
- function sleep(ms) {
120
- return new Promise((resolve) => setTimeout(resolve, ms));
124
+ return false;
121
125
  }
@@ -0,0 +1,25 @@
1
+ import { getPrHeadSha } from "../github/client.mjs";
2
+ import { loadConfig } from "../config/load.mjs";
3
+ export async function waitForSha(pr, repo, expectedSha) {
4
+ const { intervalMs: SHA_POLL_INTERVAL_MS, maxAttempts: SHA_POLL_MAX_ATTEMPTS } = loadConfig().resolve.shaPoll;
5
+ for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
6
+ try {
7
+ // eslint-disable-next-line no-await-in-loop
8
+ const currentSha = await getPrHeadSha(pr, repo.owner, repo.name);
9
+ if (currentSha === expectedSha)
10
+ return;
11
+ }
12
+ catch (err) {
13
+ if (attempt === SHA_POLL_MAX_ATTEMPTS - 1)
14
+ throw err;
15
+ }
16
+ if (attempt < SHA_POLL_MAX_ATTEMPTS - 1) {
17
+ // eslint-disable-next-line no-await-in-loop
18
+ await sleep(SHA_POLL_INTERVAL_MS);
19
+ }
20
+ }
21
+ throw new Error(`Timeout: GitHub PR #${pr} head SHA has not updated to ${expectedSha} after ${((SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS) / 1000}s. Push may still be in transit — retry shortly.`);
22
+ }
23
+ function sleep(ms) {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
@@ -7,8 +7,9 @@
7
7
  */
8
8
  import { execFile as execFileCb } from "node:child_process";
9
9
  import { promisify } from "node:util";
10
- import { graphql as httpGraphql, rest } from "./http.mjs";
10
+ import { graphql as httpGraphql, rest, GitHubRequestError, } from "./http.mjs";
11
11
  import { PR_NUMBER_BY_BRANCH_QUERY, GET_PR_HEAD_SHA_QUERY } from "./queries.mjs";
12
+ export { GitHubRequestError };
12
13
  const execFile = promisify(execFileCb);
13
14
  // ---------------------------------------------------------------------------
14
15
  // GraphQL — thin re-exports so callers don't need to import http.mts directly
@@ -0,0 +1,12 @@
1
+ export class GitHubRequestError extends Error {
2
+ status;
3
+ rateLimit;
4
+ retryAfterSeconds;
5
+ constructor(message, opts) {
6
+ super(message);
7
+ this.name = "GitHubRequestError";
8
+ this.status = opts.status;
9
+ this.rateLimit = opts.rateLimit;
10
+ this.retryAfterSeconds = opts.retryAfterSeconds;
11
+ }
12
+ }
@@ -2,6 +2,8 @@ import { execFile as execFileCb } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { appendEntry, nextEntry } from "../log/log-file.mjs";
4
4
  import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
5
+ import { GitHubRequestError } from "./errors.mjs";
6
+ export { GitHubRequestError };
5
7
  const execFile = promisify(execFileCb);
6
8
  const BASE_URL = "https://api.github.com";
7
9
  // ---------------------------------------------------------------------------
@@ -105,6 +107,7 @@ async function graphqlInner(query, vars) {
105
107
  });
106
108
  const durationMs = Math.round(performance.now() - retryT0);
107
109
  const rateLimit = parseRateLimit(res.headers);
110
+ const retryAfterSeconds = parseRetryAfter(res.headers);
108
111
  if (!res.ok) {
109
112
  const body = await res.text();
110
113
  appendEntry(formatResponseEntry({
@@ -117,7 +120,7 @@ async function graphqlInner(query, vars) {
117
120
  textBody: redactToken(body),
118
121
  attempt: attempt > 1 ? attempt : undefined,
119
122
  }));
120
- throw new Error(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`);
123
+ throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, { status: res.status, rateLimit: rateLimit ?? undefined, retryAfterSeconds });
121
124
  }
122
125
  const parsed = (await res.json());
123
126
  appendEntry(formatResponseEntry({
@@ -132,21 +135,25 @@ async function graphqlInner(query, vars) {
132
135
  }));
133
136
  if (parsed.data == null) {
134
137
  const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
135
- throw new Error(`GitHub GraphQL error (no data): ${messages}`);
138
+ throw new GitHubRequestError(`GitHub GraphQL error (no data): ${messages}`, {
139
+ status: res.status,
140
+ rateLimit: rateLimit ?? undefined,
141
+ retryAfterSeconds,
142
+ });
136
143
  }
137
144
  if (parsed.errors?.length) {
138
145
  const messages = parsed.errors.map((e) => e.message).join("; ");
139
146
  process.stderr.write(`pr-shepherd: GraphQL non-fatal errors: ${messages}\n`);
140
147
  }
141
- return { data: parsed.data, rateLimit };
148
+ return { data: parsed.data, rateLimit, retryAfterSeconds, errors: parsed.errors };
142
149
  }
143
150
  export async function graphql(query, vars = {}) {
144
151
  const { data } = await graphqlInner(query, vars);
145
152
  return { data };
146
153
  }
147
154
  export async function graphqlWithRateLimit(query, vars = {}) {
148
- const { data, rateLimit } = await graphqlInner(query, vars);
149
- return { data, rateLimit: rateLimit ?? undefined };
155
+ const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars);
156
+ return { data, rateLimit: rateLimit ?? undefined, retryAfterSeconds, errors };
150
157
  }
151
158
  // ---------------------------------------------------------------------------
152
159
  // REST
@@ -305,3 +312,8 @@ function parseRateLimit(headers) {
305
312
  }
306
313
  return null;
307
314
  }
315
+ function parseRetryAfter(headers) {
316
+ const raw = headers.get("retry-after");
317
+ const seconds = Number(raw);
318
+ return raw !== null && Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
319
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -25,9 +25,11 @@ allowed-tools: ["Bash"]
25
25
  If `MERGED`, output: `PR #N is already merged. Nothing to check.` and skip.
26
26
 
27
27
  3. **Run the check and follow instructions:**
28
+ Use the repository package runner selected by `packageManager` or lockfile
29
+ (for example, `pnpm exec`, `yarn run`, or `npx --no-install`).
28
30
 
29
31
  ```bash
30
- npx pr-shepherd check <N>
32
+ <runner> pr-shepherd check <N>
31
33
  ```
32
34
 
33
35
  Print the full output. Follow the `## Instructions` section exactly.
@@ -30,9 +30,11 @@ allowed-tools:
30
30
  - If no PR found, report an error and stop.
31
31
 
32
32
  2. **Run the bootstrap command and follow its instructions:**
33
+ Use the repository package runner selected by `packageManager` or lockfile
34
+ (for example, `pnpm exec`, `yarn run`, or `npx --no-install`).
33
35
 
34
36
  ```bash
35
- npx pr-shepherd monitor <PR_NUMBER>
37
+ <runner> pr-shepherd monitor <PR_NUMBER>
36
38
  ```
37
39
 
38
40
  Print the full output. Follow the `## Instructions` section exactly.
@@ -28,9 +28,11 @@ Resolve unresolved review threads and minimize PR comments on the current PR —
28
28
  If `MERGED`, invoke `/loop cancel` via the Skill tool, output a merged message, and stop.
29
29
 
30
30
  3. **Fetch and follow instructions:**
31
+ Use the repository package runner selected by `packageManager` or lockfile
32
+ (for example, `pnpm exec`, `yarn run`, or `npx --no-install`).
31
33
 
32
34
  ```bash
33
- npx pr-shepherd resolve <N> --fetch
35
+ <runner> pr-shepherd resolve <N> --fetch
34
36
  ```
35
37
 
36
38
  Print the full output. Follow the `## Instructions` section exactly.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -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, 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.'
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 pr-shepherd cycles through the target repo package runner, 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,32 +19,37 @@ 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, 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.`
22
+ `Run pr-shepherd PR_NUMBER cycles through the target repo package runner, 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
- 3. Verify the CLI is available.
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:
24
+ 3. Select the package runner from the target repository root.
25
+ - Prefer `package.json` `packageManager`: `pnpm@...` -> `pnpm exec`, `yarn@...` -> `yarn run`, `npm@...` -> `npx --no-install`.
26
+ - If `packageManager` is absent, use lockfiles: `pnpm-lock.yaml` -> `pnpm exec`, `yarn.lock` -> `yarn run`, `package-lock.json` or no signal -> `npx --no-install`.
27
+ - Example: in `~/filaments`, use `pnpm exec pr-shepherd ...` because the root package declares `packageManager: "pnpm@..."` and has `pnpm-lock.yaml`.
28
+
29
+ 4. Verify the CLI is available.
30
+ - Only when the target repository itself is the pr-shepherd source checkout, verify `bin/` and `node_modules/` exist before any local CLI invocation. If either is missing, run the source checkout's package-manager install command. This repository currently uses npm, so run:
26
31
  `npm install`
27
- - In other repositories, use `npx --no-install pr-shepherd` so Codex does not install packages implicitly. If that fails because the package is missing, tell the user to install `pr-shepherd` in the target repo with `npm install --save-dev pr-shepherd`.
32
+ - In other repositories, run through the selected package runner so Codex does not install packages implicitly. If the package is missing, tell the user to install `pr-shepherd` with the matching dev-dependency command: `pnpm add -D pr-shepherd`, `yarn add -D pr-shepherd`, or `npm install --save-dev pr-shepherd`.
28
33
 
29
- 4. Run the appropriate command from the repository root.
34
+ 5. Run the appropriate command from the repository root.
30
35
  - For a status check:
31
- `npx --no-install pr-shepherd check PR_NUMBER`
36
+ `<runner> pr-shepherd check PR_NUMBER`
32
37
  - For review comment resolution:
33
- `npx --no-install pr-shepherd resolve PR_NUMBER --fetch`
38
+ `<runner> pr-shepherd resolve PR_NUMBER --fetch`
34
39
  - For a monitor bootstrap:
35
- `npx --no-install pr-shepherd monitor PR_NUMBER`
40
+ `<runner> pr-shepherd monitor PR_NUMBER`
36
41
  - For the recurring explicit monitor tick:
37
- `npx --no-install pr-shepherd PR_NUMBER`
38
- - `npx --no-install pr-shepherd iterate PR_NUMBER` remains supported as a legacy alias, but use the default `pr-shepherd PR_NUMBER` form for recurring Codex cycles.
42
+ `<runner> pr-shepherd PR_NUMBER`
43
+ - `pr-shepherd iterate PR_NUMBER` remains supported as a legacy alias, but use the default `pr-shepherd PR_NUMBER` form for recurring Codex cycles.
39
44
 
40
- 5. Print or summarize the important status, then follow the output's `## Instructions` exactly.
45
+ 6. Print or summarize the important status, then follow the output's `## Instructions` exactly.
41
46
 
42
- 6. Do not call `/loop`, `ScheduleWakeup`, `CronCreate`, or `npx pr-shepherd monitor` for recurrence. Codex does explicit `pr-shepherd PR_NUMBER` cycles.
47
+ 7. Do not call `/loop`, `ScheduleWakeup`, `CronCreate`, or `pr-shepherd monitor` for recurrence. Codex does explicit `pr-shepherd PR_NUMBER` cycles.
43
48
 
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.
49
+ 8. 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 through the same runner.
45
50
 
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.
51
+ 9. 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
52
 
48
- 9. Stop only when Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or when it emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures. If a Codex goal is active, mark it complete only when one of those terminal conditions is actually satisfied.
53
+ 10. Stop only when Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or when it emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures. If a Codex goal is active, mark it complete only when one of those terminal conditions is actually satisfied.
49
54
 
50
- 10. If the output includes fixes, pushes, rebases, or resolve commands, perform only the instructed scoped actions. Do not resolve, minimize, or dismiss comments until the CLI-provided post-push and `--require-sha` instructions are satisfied.
55
+ 11. If the output includes fixes, pushes, rebases, or resolve commands, perform only the instructed scoped actions. Do not resolve, minimize, or dismiss comments until the CLI-provided post-push and `--require-sha` instructions are satisfied.