pr-shepherd 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +2 -0
  4. package/{dist → bin}/cache/file-cache.mjs +2 -1
  5. package/{dist → bin}/cli/args.mjs +56 -36
  6. package/{dist → bin}/cli.mjs +15 -14
  7. package/{dist → bin}/commands/iterate.mjs +6 -0
  8. package/{dist → bin}/commands/resolve.mjs +5 -5
  9. package/{dist → bin}/comments/resolve.mjs +2 -4
  10. package/{dist → bin}/config/load.mjs +19 -15
  11. package/{dist → bin}/github/client.mjs +0 -1
  12. package/bin/pr-shepherd +2 -0
  13. package/package.json +7 -7
  14. package/skills/check/SKILL.md +11 -13
  15. package/skills/monitor/SKILL.md +1 -1
  16. /package/{dist → bin}/cache/fix-attempts.mjs +0 -0
  17. /package/{dist → bin}/checks/classify.mjs +0 -0
  18. /package/{dist → bin}/checks/triage.mjs +0 -0
  19. /package/{dist → bin}/commands/check.mjs +0 -0
  20. /package/{dist → bin}/commands/ready-delay.mjs +0 -0
  21. /package/{dist → bin}/commands/status.mjs +0 -0
  22. /package/{dist → bin}/comments/outdated.mjs +0 -0
  23. /package/{dist → bin}/config.json +0 -0
  24. /package/{dist → bin}/github/batch.mjs +0 -0
  25. /package/{dist → bin}/github/gql/batch-pr.gql +0 -0
  26. /package/{dist → bin}/github/gql/dismiss-review.gql +0 -0
  27. /package/{dist → bin}/github/gql/minimize-comment.gql +0 -0
  28. /package/{dist → bin}/github/gql/multi-pr-status-paged.gql +0 -0
  29. /package/{dist → bin}/github/gql/multi-pr-status.gql +0 -0
  30. /package/{dist → bin}/github/gql/resolve-thread.gql +0 -0
  31. /package/{dist → bin}/github/pagination.mjs +0 -0
  32. /package/{dist → bin}/github/queries.mjs +0 -0
  33. /package/{dist → bin}/index.mjs +0 -0
  34. /package/{dist → bin}/merge-status/derive.mjs +0 -0
  35. /package/{dist → bin}/reporters/agent.mjs +0 -0
  36. /package/{dist → bin}/reporters/json.mjs +0 -0
  37. /package/{dist → bin}/reporters/text.mjs +0 -0
  38. /package/{dist → bin}/types.mjs +0 -0
  39. /package/{dist → bin}/util/path-segment.mjs +0 -0
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3
+ "name": "jonathanong",
4
+ "description": "Jonathan Ong's Claude Code plugins",
5
+ "owner": {
6
+ "name": "Jonathan Ong",
7
+ "email": "jonathanrichardong@gmail.com"
8
+ },
9
+ "plugins": [
10
+ {
11
+ "name": "pr-shepherd",
12
+ "description": "Autonomous PR CI monitor and review-comment resolver",
13
+ "source": ".claude-plugin",
14
+ "category": "productivity",
15
+ "homepage": "https://github.com/jonathanong/pr-shepherd"
16
+ }
17
+ ]
18
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -28,6 +28,8 @@ claude /plugin marketplace add jonathanong/pr-shepherd
28
28
  claude /plugin install pr-shepherd
29
29
  ```
30
30
 
31
+ This repo ships two `marketplace.json` files that serve different install flows: the root `marketplace.json` resolves the plugin from the npm registry (used by the `claude /plugin marketplace add` command above); `.claude-plugin/marketplace.json` is the owner-level registry manifest that resolves the plugin from the local plugin directory (used when Claude Code installs from a local or git-based source). Both files are needed to support these two install paths.
32
+
31
33
  See [Usage](#usage) below.
32
34
 
33
35
  ### Without the plugin — custom slash command
@@ -36,7 +36,8 @@ export async function cacheGet(key, opts = {}) {
36
36
  }
37
37
  }
38
38
  /**
39
- * Write a value to the cache (fire-and-forgetnever throws).
39
+ * Write a value to the cache. Errors are swallowed callers can await
40
+ * to know when the write is done, but the write never rejects.
40
41
  */
41
42
  export async function cacheSet(key, value, opts = {}) {
42
43
  if (opts.disabled)
@@ -2,9 +2,11 @@
2
2
  * CLI argument-parsing helpers extracted from cli.mts for testability.
3
3
  * Note: parseCommonArgs calls loadConfig() for cache TTL defaults.
4
4
  */
5
+ import { parseArgs } from "node:util";
5
6
  import { loadConfig } from "../config/load.mjs";
6
7
  import { deriveVerdict } from "../commands/status.mjs";
7
- // Flags that consume the next argument as their value.
8
+ // Flags that consume the next argument as their value (used for PR-number
9
+ // detection only — prevents a flag's value from being mistaken for a PR number).
8
10
  const FLAGS_WITH_VALUES = new Set([
9
11
  "--format",
10
12
  "--cache-ttl",
@@ -14,33 +16,54 @@ const FLAGS_WITH_VALUES = new Set([
14
16
  "--require-sha",
15
17
  "--message",
16
18
  ]);
19
+ // ---------------------------------------------------------------------------
20
+ // Strict integer parsing
21
+ // ---------------------------------------------------------------------------
22
+ export function parseIntStrict(value, flag) {
23
+ if (!/^-?\d+$/.test(value.trim())) {
24
+ throw new Error(`Invalid value for ${flag}: "${value}" is not an integer`);
25
+ }
26
+ return parseInt(value, 10);
27
+ }
17
28
  export function parseCommonArgs(args) {
18
29
  const config = loadConfig();
19
- const format = (getFlag(args, "--format") ?? "text");
20
- const noCache = hasFlag(args, "--no-cache");
21
- const cacheTtlStr = getFlag(args, "--cache-ttl");
22
- const cacheTtlSeconds = cacheTtlStr ? parseInt(cacheTtlStr, 10) : config.cache.ttlSeconds;
23
- // Only global flags are stripped from `extra`; subcommand-specific flags
24
- // must remain so handlers like handleIterate/handleResolve can read them.
25
- const globalFlagsWithValues = new Set(["--format", "--cache-ttl"]);
26
- const skipForPrDetect = new Set(); // indices to skip when finding PR number
27
- const excludeFromExtra = new Set(); // indices to strip from extra (global only)
28
- for (let i = 0; i < args.length; i += 1) {
29
- const arg = args[i];
30
- if (arg === "--no-cache") {
31
- skipForPrDetect.add(i);
32
- excludeFromExtra.add(i);
33
- }
34
- else if (globalFlagsWithValues.has(arg)) {
35
- skipForPrDetect.add(i);
36
- excludeFromExtra.add(i);
37
- if (i + 1 < args.length) {
38
- skipForPrDetect.add(i + 1);
39
- excludeFromExtra.add(i + 1);
30
+ const { values, tokens } = parseArgs({
31
+ args,
32
+ strict: false,
33
+ allowPositionals: true,
34
+ tokens: true,
35
+ options: {
36
+ format: { type: "string" },
37
+ "cache-ttl": { type: "string" },
38
+ "no-cache": { type: "boolean" },
39
+ },
40
+ });
41
+ const format = (values.format ?? "text");
42
+ const noCache = (values["no-cache"] ?? false);
43
+ const cacheTtlStr = values["cache-ttl"];
44
+ const cacheTtlSeconds = cacheTtlStr
45
+ ? parseIntStrict(cacheTtlStr, "--cache-ttl")
46
+ : config.cache.ttlSeconds;
47
+ // Build the set of arg indices consumed by global flags so we can strip
48
+ // them from `extra`. Subcommand-specific flags are left untouched.
49
+ const consumedIndices = new Set();
50
+ for (const tok of tokens ?? []) {
51
+ if (tok.kind === "option" &&
52
+ (tok.name === "format" || tok.name === "cache-ttl" || tok.name === "no-cache")) {
53
+ consumedIndices.add(tok.index);
54
+ // When the value is a separate arg (--flag value, not --flag=value),
55
+ // inlineValue is false and the value occupies tok.index + 1.
56
+ if ("inlineValue" in tok && tok.inlineValue === false && tok.value != null) {
57
+ consumedIndices.add(tok.index + 1);
40
58
  }
41
- i += 1;
42
59
  }
43
- else if (FLAGS_WITH_VALUES.has(arg)) {
60
+ }
61
+ // Find the first positional arg that looks like a PR number, skipping values
62
+ // that belong to flags in FLAGS_WITH_VALUES (subcommand flags included).
63
+ const skipForPrDetect = new Set();
64
+ for (let i = 0; i < args.length; i += 1) {
65
+ const arg = args[i];
66
+ if (FLAGS_WITH_VALUES.has(arg)) {
44
67
  skipForPrDetect.add(i);
45
68
  if (i + 1 < args.length)
46
69
  skipForPrDetect.add(i + 1);
@@ -48,21 +71,18 @@ export function parseCommonArgs(args) {
48
71
  }
49
72
  else {
50
73
  const eqIdx = arg.indexOf("=");
51
- if (eqIdx > 0) {
52
- const flagName = arg.slice(0, eqIdx);
53
- if (FLAGS_WITH_VALUES.has(flagName)) {
54
- skipForPrDetect.add(i);
55
- if (globalFlagsWithValues.has(flagName))
56
- excludeFromExtra.add(i);
57
- }
74
+ if (eqIdx > 0 && FLAGS_WITH_VALUES.has(arg.slice(0, eqIdx))) {
75
+ skipForPrDetect.add(i);
58
76
  }
59
77
  }
60
78
  }
61
- // First non-skipped positional arg that looks like a PR number.
62
- const prArg = args.find((a, index) => !skipForPrDetect.has(index) && !a.startsWith("--") && /^\d+$/.test(a));
63
- const prNumber = prArg ? parseInt(prArg, 10) : undefined;
64
- // Only strip global flags from extra — subcommand flags are passed through.
65
- const extra = args.filter((_, index) => !excludeFromExtra.has(index));
79
+ const prIndex = args.findIndex((a, index) => !skipForPrDetect.has(index) && !a.startsWith("--") && /^\d+$/.test(a));
80
+ const prNumber = prIndex !== -1 ? parseInt(args[prIndex], 10) : undefined;
81
+ // Remove consumed global-flag indices (and the PR number itself) from extra.
82
+ if (prIndex !== -1) {
83
+ consumedIndices.add(prIndex);
84
+ }
85
+ const extra = args.filter((_, i) => !consumedIndices.has(i));
66
86
  return {
67
87
  prNumber,
68
88
  global: { format, noCache, cacheTtlSeconds },
@@ -17,7 +17,7 @@ import { getRepoInfo } from "./github/client.mjs";
17
17
  import { formatJson } from "./reporters/json.mjs";
18
18
  import { formatText } from "./reporters/text.mjs";
19
19
  import { loadConfig } from "./config/load.mjs";
20
- import { parseCommonArgs, getFlag, hasFlag, parseList, parseStatusPrNumbers, parseDurationToMinutes, statusToExitCode, iterateActionToExitCode, deriveSimpleReady, } from "./cli/args.mjs";
20
+ import { parseCommonArgs, getFlag, hasFlag, parseList, parseStatusPrNumbers, parseDurationToMinutes, parseIntStrict, statusToExitCode, iterateActionToExitCode, deriveSimpleReady, } from "./cli/args.mjs";
21
21
  // ---------------------------------------------------------------------------
22
22
  // Entry
23
23
  // ---------------------------------------------------------------------------
@@ -40,7 +40,8 @@ export async function main(argv) {
40
40
  default:
41
41
  process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
42
42
  process.stderr.write("Usage: pr-shepherd <check|resolve|iterate|status> [options]\n");
43
- process.exit(1);
43
+ process.exitCode = 1;
44
+ return;
44
45
  }
45
46
  }
46
47
  // ---------------------------------------------------------------------------
@@ -51,7 +52,8 @@ async function handleCheck(args) {
51
52
  const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
52
53
  const output = globalOpts.format === "json" ? formatJson(report) : formatText(report);
53
54
  process.stdout.write(`${output}\n`);
54
- process.exit(statusToExitCode(report.status));
55
+ process.exitCode = statusToExitCode(report.status);
56
+ return;
55
57
  }
56
58
  async function handleResolve(args) {
57
59
  const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
@@ -88,13 +90,15 @@ async function handleResolve(args) {
88
90
  async function handleIterate(args) {
89
91
  const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
90
92
  const lastPushTimeStr = getFlag(extra, "--last-push-time");
91
- const lastPushTime = lastPushTimeStr ? parseInt(lastPushTimeStr, 10) : undefined;
93
+ const lastPushTime = lastPushTimeStr
94
+ ? parseIntStrict(lastPushTimeStr, "--last-push-time")
95
+ : undefined;
92
96
  const readyDelayStr = getFlag(extra, "--ready-delay");
93
97
  const cfg = loadConfig();
94
98
  const readyDelaySeconds = parseDurationToMinutes(readyDelayStr ?? "", cfg.watch.readyDelayMinutes) * 60;
95
99
  const cooldownSecondsStr = getFlag(extra, "--cooldown-seconds");
96
100
  const cooldownSeconds = cooldownSecondsStr
97
- ? parseInt(cooldownSecondsStr, 10)
101
+ ? parseIntStrict(cooldownSecondsStr, "--cooldown-seconds")
98
102
  : cfg.iterate.cooldownSeconds;
99
103
  const noAutoRerun = hasFlag(extra, "--no-auto-rerun");
100
104
  const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
@@ -115,14 +119,16 @@ async function handleIterate(args) {
115
119
  else {
116
120
  process.stdout.write(`${formatIterateResult(result)}\n`);
117
121
  }
118
- process.exit(iterateActionToExitCode(result.action));
122
+ process.exitCode = iterateActionToExitCode(result.action);
123
+ return;
119
124
  }
120
125
  async function handleStatus(args) {
121
126
  const { global: globalOpts } = parseCommonArgs(args);
122
127
  const prNumbers = parseStatusPrNumbers(args);
123
128
  if (prNumbers.length === 0) {
124
129
  process.stderr.write("Usage: pr-shepherd status PR1 [PR2 …]\n");
125
- process.exit(1);
130
+ process.exitCode = 1;
131
+ return;
126
132
  }
127
133
  const repo = await getRepoInfo();
128
134
  const summaries = await runStatus({ ...globalOpts, prNumbers });
@@ -131,19 +137,14 @@ async function handleStatus(args) {
131
137
  : formatStatusTable(summaries, `${repo.owner}/${repo.name}`);
132
138
  process.stdout.write(`${output}\n`);
133
139
  const allReady = summaries.every((s) => deriveSimpleReady(s));
134
- process.exit(allReady ? 0 : 1);
140
+ process.exitCode = allReady ? 0 : 1;
141
+ return;
135
142
  }
136
143
  // ---------------------------------------------------------------------------
137
144
  // Output formatters
138
145
  // ---------------------------------------------------------------------------
139
146
  function formatFetchResult(result) {
140
147
  const lines = [];
141
- if (result.autoResolved.length > 0) {
142
- lines.push(`Auto-resolved outdated (${result.autoResolved.length}):`);
143
- for (const t of result.autoResolved) {
144
- lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author})`);
145
- }
146
- }
147
148
  if (result.actionableThreads.length > 0) {
148
149
  lines.push(`\nActionable Review Threads (${result.actionableThreads.length}):`);
149
150
  for (const t of result.actionableThreads) {
@@ -88,6 +88,9 @@ export async function runIterate(opts) {
88
88
  }
89
89
  // Step 3: Ready-delay state machine.
90
90
  const [repoOwner, repoName] = report.repo.split("/");
91
+ if (!repoOwner || !repoName) {
92
+ throw new Error(`Unexpected repo format: "${report.repo}" (expected "owner/name")`);
93
+ }
91
94
  const isReady = report.status === "READY";
92
95
  const readyState = await updateReadyDelay(report.pr, isReady, readyDelaySeconds, repoOwner, repoName);
93
96
  const base = {
@@ -239,6 +242,9 @@ async function tryCancelRun(runId) {
239
242
  }
240
243
  catch (err) {
241
244
  const msg = err instanceof Error ? err.message : String(err);
245
+ // gh returns this when the run reached a terminal state — expected, not worth logging.
246
+ if (/already completed|cannot cancel a workflow run that is completed/i.test(msg))
247
+ return null;
242
248
  process.stderr.write(`pr-shepherd: gh run cancel ${runId} failed (ignored): ${msg}\n`);
243
249
  return null;
244
250
  }
@@ -32,15 +32,15 @@ export async function runResolveFetch(opts) {
32
32
  const visibleComments = data.comments.filter((c) => !c.isMinimized);
33
33
  // Auto-resolve outdated.
34
34
  const outdated = getOutdatedThreads(unresolvedThreads);
35
- let autoResolved = [];
36
35
  if (outdated.length > 0) {
37
- const { resolved: resolvedIds } = await autoResolveOutdated(outdated.map((t) => t.id));
38
- autoResolved = outdated.filter((t) => resolvedIds.includes(t.id));
36
+ const { errors } = await autoResolveOutdated(outdated.map((t) => t.id));
37
+ if (errors.length > 0) {
38
+ throw new Error(`Failed to auto-resolve outdated threads: ${errors.join(", ")}`);
39
+ }
39
40
  }
40
41
  const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
41
42
  return {
42
- autoResolved,
43
- actionableThreads: activeThreads,
43
+ actionableThreads: activeThreads.map(({ isResolved, isOutdated, ...rest }) => rest),
44
44
  actionableComments: visibleComments,
45
45
  changesRequestedReviews: data.changesRequestedReviews,
46
46
  };
@@ -14,10 +14,6 @@
14
14
  import { graphql, getPrHeadSha } from "../github/client.mjs";
15
15
  import { RESOLVE_THREAD_MUTATION, MINIMIZE_COMMENT_MUTATION, DISMISS_REVIEW_MUTATION, } from "../github/queries.mjs";
16
16
  import { loadConfig } from "../config/load.mjs";
17
- const config = loadConfig();
18
- const CONCURRENCY = config.resolve.concurrency;
19
- const SHA_POLL_INTERVAL_MS = config.resolve.shaPoll.intervalMs;
20
- const SHA_POLL_MAX_ATTEMPTS = config.resolve.shaPoll.maxAttempts;
21
17
  /**
22
18
  * Execute all requested resolve/minimize/dismiss mutations.
23
19
  *
@@ -69,6 +65,7 @@ async function dismissReview(reviewId, message) {
69
65
  // Concurrency helper
70
66
  // ---------------------------------------------------------------------------
71
67
  async function runBatched(ids, fn, successList, errorList) {
68
+ const CONCURRENCY = loadConfig().resolve.concurrency;
72
69
  // Process in chunks of CONCURRENCY.
73
70
  for (let i = 0; i < ids.length; i += CONCURRENCY) {
74
71
  const chunk = ids.slice(i, i + CONCURRENCY);
@@ -88,6 +85,7 @@ async function runBatched(ids, fn, successList, errorList) {
88
85
  // SHA polling
89
86
  // ---------------------------------------------------------------------------
90
87
  async function waitForSha(pr, repo, expectedSha) {
88
+ const { intervalMs: SHA_POLL_INTERVAL_MS, maxAttempts: SHA_POLL_MAX_ATTEMPTS } = loadConfig().resolve.shaPoll;
91
89
  for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
92
90
  try {
93
91
  // eslint-disable-next-line no-await-in-loop
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, statSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { parse } from "yaml";
@@ -9,13 +9,9 @@ function findRcFile(startDir) {
9
9
  let current = startDir;
10
10
  while (true) {
11
11
  const candidate = join(current, RC_FILENAME);
12
- try {
13
- readFileSync(candidate);
12
+ if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) {
14
13
  return candidate;
15
14
  }
16
- catch {
17
- // not found here
18
- }
19
15
  if (current === home || current === dirname(current))
20
16
  return null;
21
17
  current = dirname(current);
@@ -56,7 +52,10 @@ function applyCompat(raw) {
56
52
  const iterate = out["iterate"];
57
53
  if (iterate && "maxFixAttempts" in iterate) {
58
54
  process.stderr.write(`pr-shepherd: config key "iterate.maxFixAttempts" renamed to "iterate.fixAttemptsPerThread".\n`);
59
- out["iterate"] = { fixAttemptsPerThread: iterate["maxFixAttempts"], ...iterate };
55
+ out["iterate"] = {
56
+ ...iterate,
57
+ fixAttemptsPerThread: iterate["fixAttemptsPerThread"] ?? iterate["maxFixAttempts"],
58
+ };
60
59
  delete out["iterate"]["maxFixAttempts"];
61
60
  }
62
61
  // Renamed watch keys
@@ -65,17 +64,18 @@ function applyCompat(raw) {
65
64
  const watchOut = { ...watch };
66
65
  if ("intervalDefault" in watch) {
67
66
  process.stderr.write(`pr-shepherd: config key "watch.intervalDefault" renamed to "watch.interval".\n`);
68
- watchOut["interval"] = watch["intervalDefault"];
67
+ watchOut["interval"] = watchOut["interval"] ?? watch["intervalDefault"];
69
68
  delete watchOut["intervalDefault"];
70
69
  }
71
70
  if ("readyDelayMinutesDefault" in watch) {
72
71
  process.stderr.write(`pr-shepherd: config key "watch.readyDelayMinutesDefault" renamed to "watch.readyDelayMinutes".\n`);
73
- watchOut["readyDelayMinutes"] = watch["readyDelayMinutesDefault"];
72
+ watchOut["readyDelayMinutes"] =
73
+ watchOut["readyDelayMinutes"] ?? watch["readyDelayMinutesDefault"];
74
74
  delete watchOut["readyDelayMinutesDefault"];
75
75
  }
76
76
  if ("expiresHoursDefault" in watch) {
77
77
  process.stderr.write(`pr-shepherd: config key "watch.expiresHoursDefault" renamed to "watch.expiresHours".\n`);
78
- watchOut["expiresHours"] = watch["expiresHoursDefault"];
78
+ watchOut["expiresHours"] = watchOut["expiresHours"] ?? watch["expiresHoursDefault"];
79
79
  delete watchOut["expiresHoursDefault"];
80
80
  }
81
81
  out["watch"] = watchOut;
@@ -88,13 +88,17 @@ function applyCompat(raw) {
88
88
  let shaPollChanged = false;
89
89
  if ("shaPollIntervalMs" in resolve) {
90
90
  process.stderr.write(`pr-shepherd: config key "resolve.shaPollIntervalMs" moved to "resolve.shaPoll.intervalMs".\n`);
91
- shaPollOut["intervalMs"] = resolve["shaPollIntervalMs"];
91
+ shaPollOut["intervalMs"] =
92
+ resolve["shaPoll"]?.["intervalMs"] ??
93
+ resolve["shaPollIntervalMs"];
92
94
  delete resolveOut["shaPollIntervalMs"];
93
95
  shaPollChanged = true;
94
96
  }
95
97
  if ("shaPollMaxAttempts" in resolve) {
96
98
  process.stderr.write(`pr-shepherd: config key "resolve.shaPollMaxAttempts" moved to "resolve.shaPoll.maxAttempts".\n`);
97
- shaPollOut["maxAttempts"] = resolve["shaPollMaxAttempts"];
99
+ shaPollOut["maxAttempts"] =
100
+ resolve["shaPoll"]?.["maxAttempts"] ??
101
+ resolve["shaPollMaxAttempts"];
98
102
  delete resolveOut["shaPollMaxAttempts"];
99
103
  shaPollChanged = true;
100
104
  }
@@ -112,17 +116,17 @@ function applyCompat(raw) {
112
116
  const checksOut = { ...checks };
113
117
  if ("relevantEvents" in checks) {
114
118
  process.stderr.write(`pr-shepherd: config key "checks.relevantEvents" renamed to "checks.ciTriggerEvents".\n`);
115
- checksOut["ciTriggerEvents"] = checks["relevantEvents"];
119
+ checksOut["ciTriggerEvents"] = checksOut["ciTriggerEvents"] ?? checks["relevantEvents"];
116
120
  delete checksOut["relevantEvents"];
117
121
  }
118
122
  if ("logLinesKept" in checks) {
119
123
  process.stderr.write(`pr-shepherd: config key "checks.logLinesKept" renamed to "checks.logMaxLines".\n`);
120
- checksOut["logMaxLines"] = checks["logLinesKept"];
124
+ checksOut["logMaxLines"] = checksOut["logMaxLines"] ?? checks["logLinesKept"];
121
125
  delete checksOut["logLinesKept"];
122
126
  }
123
127
  if ("logExcerptMaxChars" in checks) {
124
128
  process.stderr.write(`pr-shepherd: config key "checks.logExcerptMaxChars" renamed to "checks.logMaxChars".\n`);
125
- checksOut["logMaxChars"] = checks["logExcerptMaxChars"];
129
+ checksOut["logMaxChars"] = checksOut["logMaxChars"] ?? checks["logExcerptMaxChars"];
126
130
  delete checksOut["logExcerptMaxChars"];
127
131
  }
128
132
  out["checks"] = checksOut;
@@ -89,7 +89,6 @@ export async function getCurrentPrNumber() {
89
89
  }
90
90
  }
91
91
  async function getCurrentBranch() {
92
- await runGh(["--version"]); // warm up gh CLI before git call
93
92
  // Use git directly for branch name — gh doesn't expose it.
94
93
  const { stdout } = await execFile("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
95
94
  return stdout.trim();
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import("./index.mjs")
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
7
7
  "type": "module",
8
8
  "bin": {
9
- "pr-shepherd": "./dist/index.mjs"
9
+ "pr-shepherd": "bin/index.mjs"
10
10
  },
11
11
  "files": [
12
- "dist/**",
12
+ "bin/**",
13
13
  "skills/**",
14
14
  ".claude-plugin/**",
15
15
  "marketplace.json",
@@ -17,21 +17,21 @@
17
17
  "LICENSE"
18
18
  ],
19
19
  "engines": {
20
- "node": ">=24.0.0"
20
+ "node": ">=22.0.0"
21
21
  },
22
22
  "dependencies": {
23
23
  "yaml": "^2.7.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^25.6.0",
27
- "oxfmt": "latest",
28
- "oxlint": "latest",
27
+ "oxfmt": "^0.45.0",
28
+ "oxlint": "^1.60.0",
29
29
  "typescript": "^6.0.3",
30
30
  "vitest": "^4.1.4",
31
31
  "@vitest/coverage-v8": "^4.1.4"
32
32
  },
33
33
  "scripts": {
34
- "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/config.json dist/ && mkdir -p dist/github && cp -r src/github/gql dist/github/",
34
+ "build": "node scripts/build.mjs",
35
35
  "prepare": "npm run build",
36
36
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
37
37
  "typecheck": "tsc --noEmit",
@@ -40,24 +40,22 @@ Parse the JSON output and report all three:
40
40
 
41
41
  ## Rebase policy
42
42
 
43
- ```bash
44
- BASE_BRANCH=$(gh pr view <N> --json baseRefName --jq '.baseRefName')
45
- ```
43
+ The CLI already determines whether a rebase is warranted. Read `report.mergeStatus.status` directly:
46
44
 
47
- Rebase (`git fetch origin && git rebase origin/<BASE_BRANCH> && git push --force-with-lease`) when:
45
+ - `CONFLICTS` a rebase is required to resolve the merge conflict before the PR can land.
46
+ - `BEHIND` — a rebase may be appropriate; a `flaky` failure while `BEHIND` is the canonical rebase signal. If all checks pass but the PR is `BEHIND`, a rebase is optional.
47
+ - Any other status — no rebase needed.
48
48
 
49
- - Merge conflicts with main (`report.mergeStatus.status == 'CONFLICTS'`), OR
50
- - About to push commits and branch is behind main, OR
51
- - `failureKind == 'flaky'` AND branch is behind main
52
-
53
- Do NOT rebase when nothing to push, no conflicts, and no flaky failures.
49
+ Do not re-derive these conditions from raw branch state. For automated monitoring that acts on these signals, use `/pr-shepherd:monitor` — it handles rebase decisions end-to-end.
54
50
 
55
51
  ## CI budget policy
56
52
 
57
- - **actionable**: Summarize errors. Fix in next step.
58
- - **infrastructure**: Re-run: `gh run rerun <runId> --failed`
59
- - **timeout**: Re-run: `gh run rerun <runId> --failed`
60
- - **flaky**: Do NOT cancel. Rebase if behind main.
53
+ Each entry in `report.checks` carries a `failureKind` field. Read it directly rather than re-classifying failures:
54
+
55
+ - `actionable` — the failure is code-level and needs a fix.
56
+ - `infrastructure` transient infra problem; re-run with `gh run rerun <runId> --failed`.
57
+ - `timeout` — job exceeded the time limit; re-run with `gh run rerun <runId> --failed`.
58
+ - `flaky` — known-flaky test; do NOT cancel. Rebase first if `mergeStatus.status` is `BEHIND`.
61
59
 
62
60
  ## Never declare ready to merge
63
61
 
@@ -79,7 +79,7 @@ Parse the `action` field and act:
79
79
  1. For each item in `fix.threads` and `fix.comments`: read the referenced file/line and apply the fix (Edit/Write tools).
80
80
  2. For each item in `fix.checks`:
81
81
  - If `runId` is non-null: fetch the failure log with `gh run view <runId> --log-failed` (dangerouslyDisableSandbox: true), scan the output to identify the failure (e.g. grep for `FAIL` for test failures, `error:` for type/compile errors, lint rule names for lint failures), then read the relevant file and apply the fix (Edit/Write tools).
82
- - If `runId` is null: the failed check is an external status check that cannot be inspected via run logs. Escalate — tell the user to open `detailsUrl` in the PR checks UI, inspect the failure manually, and rerun `/pr-shepherd:monitor 13` after addressing it. Do not attempt to fix these automatically.
82
+ - If `runId` is null: the failed check is an external status check that cannot be inspected via run logs. Escalate — tell the user to open `detailsUrl` in the PR checks UI, inspect the failure manually, and rerun `/pr-shepherd:monitor <PR_NUMBER>` after addressing it. Do not attempt to fix these automatically.
83
83
  3. For each item in `fix.changesRequestedReviews`: read the review body and apply the requested changes.
84
84
  4. If files were changed, `git add <files> && git commit -m "<appropriate commit message>"`
85
85
  5. `git fetch origin && git rebase origin/<BASE_BRANCH> && git push --force-with-lease` (dangerouslyDisableSandbox: true)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes