pr-shepherd 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.33.0",
4
+ "version": "0.34.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -228,6 +228,11 @@ Ready-to-use examples for common patterns are in [`examples/classification/`](ex
228
228
 
229
229
  Full reference: [docs/README.md](docs/README.md).
230
230
 
231
- ## License
231
+ ## Harness Ecosystem
232
232
 
233
- [MIT](LICENSE)
233
+ This is part of the following harness ecosystem:
234
+
235
+ - [auto-harness](https://github.com/jonathanong/auto-harness) - non-interactive agent CLI orchestration across sandboxes
236
+ - [agent-blackboard](https://github.com/jonathanong/agent-blackboard) - session-scoped telemetry for autonomous agents
237
+ - [pr-shepherd](https://github.com/jonathanong/pr-shepherd) - autonomous pull request shepherd
238
+ - [no-mistakes](https://github.com/jonathanong/no-mistakes) - deterministic AST-based codebase intelligence, test selection, and linting for agents
@@ -1,3 +1,4 @@
1
+ import { EXIT } from "../exit-codes.mjs";
1
2
  import { parsePrNumber } from "./args.mjs";
2
3
  import { validateDefaultArgs } from "./validate-default-args.mjs";
3
4
  import { USAGE } from "./help.mjs";
@@ -30,5 +31,5 @@ function isDefaultPollFlag(arg) {
30
31
  function writeDefaultUsageError(arg) {
31
32
  process.stderr.write(`Unknown subcommand: ${arg}\n`);
32
33
  process.stderr.write(`${USAGE.top}\n`);
33
- process.exitCode = 1;
34
+ process.exitCode = EXIT.USAGE;
34
35
  }
@@ -1,11 +1,12 @@
1
- import { parseSecondsDurationParts } from "./exit-codes.mjs";
1
+ import { EXIT } from "../exit-codes.mjs";
2
+ import { parseSecondsDurationParts } from "./duration.mjs";
2
3
  export function validateSecondsDurationFlag(command, flag, value, presentAsSeparateArg, opts = {}) {
3
4
  const bareUnit = opts.defaultUnit === "m" ? "minutes" : "seconds";
4
5
  const example = opts.defaultUnit === "m" ? "15m" : "30s";
5
6
  if (value === null) {
6
7
  if (presentAsSeparateArg) {
7
8
  process.stderr.write(`${command}: ${flag} requires a value (e.g. ${flag} ${example})\n`);
8
- process.exitCode = 1;
9
+ process.exitCode = EXIT.USAGE;
9
10
  return null;
10
11
  }
11
12
  return undefined;
@@ -13,12 +14,12 @@ export function validateSecondsDurationFlag(command, flag, value, presentAsSepar
13
14
  const trimmed = value.trim();
14
15
  if (trimmed.startsWith("--")) {
15
16
  process.stderr.write(`${command}: ${flag} requires a value (e.g. ${flag} ${example})\n`);
16
- process.exitCode = 1;
17
+ process.exitCode = EXIT.USAGE;
17
18
  return null;
18
19
  }
19
20
  if (!parseSecondsDurationParts(trimmed, opts)) {
20
21
  process.stderr.write(`${command}: invalid ${flag}: ${value}. Expected a duration like 30s, 4.5m, 1h, or a bare number (${bareUnit}).\n`);
21
- process.exitCode = 1;
22
+ process.exitCode = EXIT.USAGE;
22
23
  return null;
23
24
  }
24
25
  return trimmed;
@@ -40,29 +40,3 @@ export function parseDurationToSeconds(s, defaultSeconds, opts = {}) {
40
40
  return parsed.value * 60;
41
41
  return parsed.value;
42
42
  }
43
- export function statusToExitCode(status) {
44
- switch (status) {
45
- case "MERGED":
46
- case "CLOSED":
47
- case "READY":
48
- return 0;
49
- case "IN_PROGRESS":
50
- return 2;
51
- case "UNRESOLVED_COMMENTS":
52
- return 3;
53
- default:
54
- return 1;
55
- }
56
- }
57
- export function iterateActionToExitCode(action) {
58
- switch (action) {
59
- case "fix_code":
60
- return 1;
61
- case "cancel":
62
- return 2;
63
- case "escalate":
64
- return 3;
65
- default:
66
- return 0;
67
- }
68
- }
@@ -3,6 +3,7 @@ import { runMarkFilesAsViewed } from "../commands/mark-files-as-viewed.mjs";
3
3
  import { runIterate } from "../commands/iterate/index.mjs";
4
4
  import { runClean } from "../commands/clean.mjs";
5
5
  import { loadConfig } from "../config/load.mjs";
6
+ import { EXIT } from "../exit-codes.mjs";
6
7
  import { parseCommonArgs, getFlag } from "./args.mjs";
7
8
  import { USAGE } from "./help.mjs";
8
9
  import { formatCommitSuggestionResult, formatCleanResult, formatMarkFilesAsViewedResult, } from "./formatters.mjs";
@@ -14,7 +15,7 @@ export async function handleClean(args) {
14
15
  const variant = args[0];
15
16
  if (!variant || !CLEAN_VARIANTS.has(variant)) {
16
17
  process.stderr.write(`${USAGE.clean}\n`);
17
- process.exitCode = 1;
18
+ process.exitCode = EXIT.USAGE;
18
19
  return;
19
20
  }
20
21
  const rest = args.slice(1);
@@ -24,7 +25,7 @@ export async function handleClean(args) {
24
25
  if (a === "--dry-run" || a === "--format" || a.startsWith("--format="))
25
26
  continue;
26
27
  process.stderr.write(`pr-shepherd: clean: unknown flag: "${a}"\n`);
27
- process.exitCode = 1;
28
+ process.exitCode = EXIT.USAGE;
28
29
  return;
29
30
  }
30
31
  const fmtIdx = rest.indexOf("--format");
@@ -38,7 +39,7 @@ export async function handleClean(args) {
38
39
  }
39
40
  if (formatValue !== undefined && formatValue !== "text" && formatValue !== "json") {
40
41
  process.stderr.write(`pr-shepherd: clean: invalid --format value: "${formatValue}". Expected "text" or "json".\n`);
41
- process.exitCode = 1;
42
+ process.exitCode = EXIT.USAGE;
42
43
  return;
43
44
  }
44
45
  const jsonOut = formatValue === "json";
@@ -52,14 +53,14 @@ export async function handleClean(args) {
52
53
  const positionals = rest.filter((a, i) => !flagConsumedIndices.has(i) && !a.startsWith("--"));
53
54
  if (positionals.length > 1) {
54
55
  process.stderr.write(`pr-shepherd: clean: too many positional arguments (expected at most 1, got ${positionals.length})\n`);
55
- process.exitCode = 1;
56
+ process.exitCode = EXIT.USAGE;
56
57
  return;
57
58
  }
58
59
  const value = positionals[0];
59
60
  const result = await runClean({ variant: variant, value, dryRun });
60
61
  if (!result.ok) {
61
62
  process.stderr.write(`pr-shepherd: clean: ${result.error}\n`);
62
- process.exitCode = 1;
63
+ process.exitCode = EXIT.SOFTWARE;
63
64
  return;
64
65
  }
65
66
  process.stdout.write(jsonOut ? `${JSON.stringify(result, null, 2)}\n` : `${formatCleanResult(result)}\n`);
@@ -69,13 +70,13 @@ export async function handleCommitSuggestion(args) {
69
70
  const threadId = getFlag(extra, "--thread-id");
70
71
  if (!threadId) {
71
72
  process.stderr.write(`${USAGE["commit-suggestion"]}\n`);
72
- process.exitCode = 1;
73
+ process.exitCode = EXIT.USAGE;
73
74
  return;
74
75
  }
75
76
  const message = getFlag(extra, "--message") ?? undefined;
76
77
  if (!message || message.trim() === "") {
77
78
  process.stderr.write("--message is required and must be non-empty\n");
78
- process.exitCode = 1;
79
+ process.exitCode = EXIT.USAGE;
79
80
  return;
80
81
  }
81
82
  const description = getFlag(extra, "--description") ?? undefined;
@@ -115,7 +116,7 @@ export async function handleMarkFilesAsViewed(args) {
115
116
  const parsed = parseMarkFilesAsViewedArgs(extra);
116
117
  if (!parsed.ok) {
117
118
  process.stderr.write(`pr-shepherd: mark-files-as-viewed: ${parsed.error}\n`);
118
- process.exitCode = 1;
119
+ process.exitCode = EXIT.USAGE;
119
120
  return;
120
121
  }
121
122
  const result = await runMarkFilesAsViewed({
@@ -1,4 +1,5 @@
1
1
  import { LOG_FILE_USAGE } from "./help-log-file-page.mjs";
2
+ import { ITERATE_USAGE, POLL_USAGE } from "./help-iterate-poll-pages.mjs";
2
3
  export const COMMAND_USAGE = {
3
4
  resolve: `pr-shepherd resolve
4
5
 
@@ -28,7 +29,7 @@ At least one non-empty action flag is required:
28
29
  --reply-thread-ids, --resolve-thread-ids, --minimize-comment-ids, or --dismiss-review-ids.
29
30
 
30
31
  PR may be a number or GitHub pull request URL. When omitted, the current branch PR is inferred.
31
- Exit code: 0 on success; 1 on validation, lookup, or mutation failure.`,
32
+ Exit code: 0 on success; nonzero on failure (sysexits.h see docs/exit-codes.md).`,
32
33
  "commit-suggestion": `pr-shepherd commit-suggestion
33
34
 
34
35
  Build a patch and commit instructions for one GitHub review thread containing a suggestion block.
@@ -49,8 +50,10 @@ Preconditions:
49
50
  The current branch must match the PR head ref, and local HEAD must match the PR head SHA.
50
51
 
51
52
  Exit codes:
52
- 0 suggestion patch and instructions produced
53
- 1 validation, lookup, precondition, or suggestion parsing failure`,
53
+ 0 suggestion patch and instructions produced
54
+ 64 usage error (missing/invalid flag)
55
+ 69 precondition unmet (thread ineligible, branch/SHA mismatch, no open PR)
56
+ See docs/exit-codes.md for the full sysexits.h table.`,
54
57
  "mark-files-as-viewed": `pr-shepherd mark-files-as-viewed
55
58
 
56
59
  Mark changed files as viewed in the GitHub pull request diff.
@@ -70,73 +73,9 @@ Flags:
70
73
  --help, -h Print this help and exit before GitHub I/O.
71
74
 
72
75
  PR may be a number or GitHub pull request URL. When omitted, the current branch PR is inferred.
73
- Exit code: 0 on success; 1 on validation or lookup failure.`,
74
- iterate: `pr-shepherd iterate
75
-
76
- Run one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.
77
- The output contains one action and an action-specific ## Instructions section.
78
-
79
- Usage:
80
- pr-shepherd iterate [PR] [iterate-flags]
81
-
82
- Iterate flags:
83
- --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.
84
- --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.
85
- --no-auto-mark-ready Do not convert draft PRs to ready for review.
86
- --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.
87
- --format text|json Output Markdown text or JSON. Default: text.
88
- --verbose Include verbose iterate fields.
89
- --help, -h Print this help and exit before GitHub, git, config, or log I/O.
90
-
91
- Durations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).
92
-
93
- Actions:
94
- WAIT No immediate code action; recheck later or use pr-shepherd poll.
95
- MARK_READY Draft PR was marked ready for review.
96
- FIX_CODE Apply fixes, commit, push, and run the printed resolve command.
97
- CANCEL Terminal state: merged/closed or ready-delay elapsed.
98
- ESCALATE Terminal state requiring human direction.
99
-
100
- Exit codes:
101
- 0 WAIT or MARK_READY
102
- 1 FIX_CODE, or a command/validation error
103
- 2 CANCEL
104
- 3 ESCALATE`,
105
- poll: `pr-shepherd poll
106
-
107
- Run iterate repeatedly while the action is WAIT. Print only the final tick to stdout.
108
- Poll exits as soon as iterate returns MARK_READY, FIX_CODE, CANCEL, or ESCALATE, or when timeout
109
- returns the last WAIT result. With --until-terminal, poll also continues through MARK_READY.
110
-
111
- Usage:
112
- pr-shepherd poll [PR] [poll-flags] [iterate-flags]
113
-
114
- Poll flags:
115
- --interval <duration> Sleep between WAIT ticks. Bare number = seconds. Default: 60s.
116
- --timeout <duration> Maximum wall-clock wait. Bare number = seconds. Default: 4.5m.
117
- --quiet-status During WAIT polling, print only changed status snapshots.
118
- --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.
119
-
120
- Forwarded iterate flags:
121
- --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.
122
- --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.
123
- --no-auto-mark-ready Do not convert draft PRs to ready for review.
124
- --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.
125
- --format text|json Output Markdown text or JSON. Default: text.
126
- --verbose Include verbose iterate fields and detailed per-tick lines.
127
- --help, -h Print this help and exit before GitHub, git, config, or log I/O.
128
-
129
- Durations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number uses each flag's default unit (seconds
130
- for --interval/--timeout, minutes for --ready-delay/--stall-timeout); decimals are allowed only with
131
- an explicit unit (4.5m).
132
- Each WAIT tick writes a single dot to stderr by default; --quiet-status prints only changed WAIT snapshots, and --verbose emits detailed per-tick lines.
133
- With --until-terminal, --timeout is ignored for WAIT ticks and polling continues until FIX_CODE, CANCEL, or ESCALATE.
134
-
135
- Exit codes:
136
- 0 WAIT timeout or MARK_READY
137
- 1 FIX_CODE, or a command/validation error
138
- 2 CANCEL
139
- 3 ESCALATE`,
76
+ Exit code: 0 on success; nonzero on failure (sysexits.h see docs/exit-codes.md).`,
77
+ iterate: ITERATE_USAGE,
78
+ poll: POLL_USAGE,
140
79
  clean: `pr-shepherd clean
141
80
 
142
81
  Remove pr-shepherd state files from PR_SHEPHERD_STATE_DIR.
@@ -161,7 +100,7 @@ Flags:
161
100
  --format text|json Output format. Default: text.
162
101
  --help, -h Print this help and exit before any cleanup.
163
102
 
164
- Exit code: 0 on success; 1 on validation or cleanup failure.`,
103
+ Exit code: 0 on success (including a no-op --dry-run on a nonexistent target); nonzero on failure (sysexits.h see docs/exit-codes.md).`,
165
104
  journal: `pr-shepherd journal
166
105
 
167
106
  Append a list item to the ## Shepherd Journal section of a PR body.
@@ -185,6 +124,6 @@ Flags:
185
124
  --format text|json Output format. Default: text.
186
125
  --help, -h Print this help and exit before any GitHub I/O.
187
126
 
188
- Exit code: 0 on success (including no-change no-op); 1 on validation, lookup, or mutation failure.`,
127
+ Exit code: 0 on success (including no-change no-op); nonzero on failure (sysexits.h see docs/exit-codes.md).`,
189
128
  "log-file": LOG_FILE_USAGE,
190
129
  };
@@ -0,0 +1,72 @@
1
+ export const ITERATE_USAGE = `pr-shepherd iterate
2
+
3
+ Run one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.
4
+ The output contains one action and an action-specific ## Instructions section.
5
+
6
+ Usage:
7
+ pr-shepherd iterate [PR] [iterate-flags]
8
+
9
+ Iterate flags:
10
+ --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.
11
+ --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.
12
+ --no-auto-mark-ready Do not convert draft PRs to ready for review.
13
+ --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.
14
+ --format text|json Output Markdown text or JSON. Default: text.
15
+ --verbose Include verbose iterate fields.
16
+ --help, -h Print this help and exit before GitHub, git, config, or log I/O.
17
+
18
+ Durations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).
19
+
20
+ Actions:
21
+ WAIT No immediate code action; recheck later or use pr-shepherd poll.
22
+ MARK_READY Draft PR was marked ready for review.
23
+ FIX_CODE Apply fixes, commit, push, and run the printed resolve command.
24
+ CANCEL Terminal state: merged/closed or ready-delay elapsed.
25
+ ESCALATE Terminal state requiring human direction.
26
+
27
+ Exit codes:
28
+ 0 CANCEL (merged or ready-delay elapsed)
29
+ 10 WAIT
30
+ 11 MARK_READY
31
+ 12 FIX_CODE
32
+ 13 ESCALATE
33
+ 14 CANCEL (closed without merging)
34
+ A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).`;
35
+ export const POLL_USAGE = `pr-shepherd poll
36
+
37
+ Run iterate repeatedly while the action is WAIT. Print only the final tick to stdout.
38
+ Poll exits as soon as iterate returns MARK_READY, FIX_CODE, CANCEL, or ESCALATE, or when timeout
39
+ returns the last WAIT result. With --until-terminal, poll also continues through MARK_READY.
40
+
41
+ Usage:
42
+ pr-shepherd poll [PR] [poll-flags] [iterate-flags]
43
+
44
+ Poll flags:
45
+ --interval <duration> Sleep between WAIT ticks. Bare number = seconds. Default: 60s.
46
+ --timeout <duration> Maximum wall-clock wait. Bare number = seconds. Default: 4.5m.
47
+ --quiet-status During WAIT polling, print only changed status snapshots.
48
+ --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.
49
+
50
+ Forwarded iterate flags:
51
+ --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.
52
+ --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.
53
+ --no-auto-mark-ready Do not convert draft PRs to ready for review.
54
+ --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.
55
+ --format text|json Output Markdown text or JSON. Default: text.
56
+ --verbose Include verbose iterate fields and detailed per-tick lines.
57
+ --help, -h Print this help and exit before GitHub, git, config, or log I/O.
58
+
59
+ Durations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number uses each flag's default unit (seconds
60
+ for --interval/--timeout, minutes for --ready-delay/--stall-timeout); decimals are allowed only with
61
+ an explicit unit (4.5m).
62
+ Each WAIT tick writes a single dot to stderr by default; --quiet-status prints only changed WAIT snapshots, and --verbose emits detailed per-tick lines.
63
+ With --until-terminal, --timeout is ignored for WAIT ticks and polling continues until FIX_CODE, CANCEL, or ESCALATE.
64
+
65
+ Exit codes: same as iterate (the final tick's action/reason decides the code).
66
+ 0 CANCEL (merged or ready-delay elapsed)
67
+ 10 WAIT (including a WAIT returned by --timeout)
68
+ 11 MARK_READY
69
+ 12 FIX_CODE
70
+ 13 ESCALATE
71
+ 14 CANCEL (closed without merging)
72
+ A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).`;
@@ -54,11 +54,14 @@ Clean variants:
54
54
  repo Remove all state for the current repository.
55
55
  all Remove all pr-shepherd state.
56
56
 
57
- Exit codes for iterate and poll:
58
- 0 WAIT or MARK_READY
59
- 1 FIX_CODE, or a command/validation error
60
- 2 CANCEL
61
- 3 ESCALATE
57
+ Exit codes: 0 done, 10-19 PR state, 64-78 shepherd failed (sysexits.h).
58
+ 0 CANCEL (merged or ready-delay elapsed)
59
+ 10 WAIT
60
+ 11 MARK_READY
61
+ 12 FIX_CODE
62
+ 13 ESCALATE
63
+ 14 CANCEL (closed without merging)
64
+ See docs/exit-codes.md for the full sysexits.h error-code table.
62
65
 
63
66
  Duration examples: 30s, 4.5m, 1h. A bare number uses each flag's default unit (see above); decimals are allowed with an explicit unit (4.5m).
64
67
 
@@ -1,4 +1,4 @@
1
- import { iterateActionToExitCode } from "./exit-codes.mjs";
1
+ import { iterateResultToExitCode } from "../exit-codes.mjs";
2
2
  import { formatIterateResult, projectIterateLean, projectIterateVerbose } from "./formatters.mjs";
3
3
  export function emitIterateResult(result, opts) {
4
4
  const projectionOpts = {
@@ -14,5 +14,5 @@ export function emitIterateResult(result, opts) {
14
14
  const text = formatIterateResult(result, { verbose: opts.verbose, ...projectionOpts });
15
15
  process.stdout.write(`${text}\n`);
16
16
  }
17
- process.exitCode = iterateActionToExitCode(result.action);
17
+ process.exitCode = iterateResultToExitCode(result);
18
18
  }
@@ -1,5 +1,5 @@
1
1
  import { getFlag, hasFlag } from "./args.mjs";
2
- import { parseDurationToSeconds } from "./exit-codes.mjs";
2
+ import { parseDurationToSeconds } from "./duration.mjs";
3
3
  import { validateSecondsDurationFlag } from "./duration-flag.mjs";
4
4
  // --ready-delay and --stall-timeout are minute-family flags: a bare number means minutes, and 0 is a
5
5
  // valid value (it disables the ready-delay settle window / stall-timeout escalation, respectively).
@@ -1,4 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { EXIT, errorToExitCode } from "../exit-codes.mjs";
2
3
  import { runJournal } from "../commands/journal/index.mjs";
3
4
  import { getFlag, parsePrNumber } from "./args.mjs";
4
5
  import { USAGE } from "./help.mjs";
@@ -11,14 +12,14 @@ export async function handleJournal(args) {
11
12
  if (a === "--file" || a.startsWith("--file="))
12
13
  continue;
13
14
  process.stderr.write(`pr-shepherd: journal: unknown flag: "${a}"\n`);
14
- process.exitCode = 1;
15
+ process.exitCode = EXIT.USAGE;
15
16
  return;
16
17
  }
17
18
  const { prNumber, extra } = parseJournalArgs(args);
18
19
  const filePath = getFlag(args, "--file");
19
20
  if (filePath !== null && extra[0]) {
20
21
  process.stderr.write(`pr-shepherd: journal: provide the entry as a positional argument or via --file, not both\n`);
21
- process.exitCode = 1;
22
+ process.exitCode = EXIT.USAGE;
22
23
  return;
23
24
  }
24
25
  let rawItem;
@@ -27,12 +28,12 @@ export async function handleJournal(args) {
27
28
  }
28
29
  catch (e) {
29
30
  process.stderr.write(`pr-shepherd: journal: ${String(e)}\n`);
30
- process.exitCode = 1;
31
+ process.exitCode = EXIT.NOINPUT;
31
32
  return;
32
33
  }
33
34
  if (rawItem === undefined) {
34
35
  process.stderr.write(`${USAGE.journal}\n`);
35
- process.exitCode = 1;
36
+ process.exitCode = EXIT.USAGE;
36
37
  return;
37
38
  }
38
39
  const dryRun = args.includes("--dry-run");
@@ -49,7 +50,7 @@ export async function handleJournal(args) {
49
50
  }
50
51
  catch (e) {
51
52
  process.stderr.write(`pr-shepherd: journal: ${String(e)}\n`);
52
- process.exitCode = 1;
53
+ process.exitCode = errorToExitCode(e);
53
54
  }
54
55
  }
55
56
  /** Reads the journal entry from a file, or from stdin when `filePath` is `-`. */
@@ -1,7 +1,7 @@
1
1
  import { runPoll } from "../commands/poll.mjs";
2
2
  import { loadConfig } from "../config/load.mjs";
3
3
  import { parseCommonArgs, getFlag, hasFlag } from "./args.mjs";
4
- import { parseDurationToSeconds } from "./exit-codes.mjs";
4
+ import { parseDurationToSeconds } from "./duration.mjs";
5
5
  import { validateSecondsDurationFlag } from "./duration-flag.mjs";
6
6
  import { parseIterateFlags } from "./iterate-flags.mjs";
7
7
  import { emitIterateResult } from "./iterate-emitter.mjs";
@@ -1,8 +1,9 @@
1
+ import { EXIT } from "../exit-codes.mjs";
1
2
  export function rejectPrrcMinimizeIds(ids) {
2
3
  const prrcIds = ids.filter((id) => id.startsWith("PRRC_"));
3
4
  if (prrcIds.length > 0) {
4
5
  process.stderr.write(`pr-shepherd: resolve: --minimize-comment-ids contains thread comment IDs (PRRC_*): ${prrcIds.join(", ")}. Thread comments cannot be minimized individually — resolve the parent thread using --resolve-thread-ids with the PRRT_* thread ID instead.\n`);
5
- process.exitCode = 1;
6
+ process.exitCode = EXIT.DATAERR;
6
7
  }
7
8
  return prrcIds;
8
9
  }
@@ -19,6 +20,6 @@ export function validateRequireSha(sha) {
19
20
  if (/^[0-9a-f]{40}$/.test(sha))
20
21
  return true;
21
22
  process.stderr.write(`pr-shepherd: resolve: --require-sha must be a full 40-character lowercase hex SHA, got "${sha}". Short SHAs will never match GitHub's headRefOid. Use $(git rev-parse HEAD) to get the full SHA.\n`);
22
- process.exitCode = 1;
23
+ process.exitCode = EXIT.DATAERR;
23
24
  return false;
24
25
  }
@@ -1,5 +1,6 @@
1
1
  /** CLI argument parsing and subcommand dispatch for pr-shepherd. See --help for usage. */
2
2
  import { readFileSync } from "node:fs";
3
+ import { EXIT, errorToExitCode } from "./exit-codes.mjs";
3
4
  import { runResolveMutate } from "./commands/resolve.mjs";
4
5
  import { runLogFile } from "./commands/log-file.mjs";
5
6
  import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
@@ -76,7 +77,7 @@ export async function main(argv) {
76
77
  default:
77
78
  process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
78
79
  process.stderr.write(`${USAGE.top}\n`);
79
- process.exitCode = 1;
80
+ process.exitCode = EXIT.USAGE;
80
81
  return;
81
82
  }
82
83
  }
@@ -102,7 +103,7 @@ async function handleLogFile(args) {
102
103
  }
103
104
  catch (e) {
104
105
  process.stderr.write(`pr-shepherd: log-file: ${String(e)}\n`);
105
- process.exitCode = 1;
106
+ process.exitCode = errorToExitCode(e);
106
107
  }
107
108
  }
108
109
  async function handleResolve(args) {
@@ -120,7 +121,7 @@ async function handleResolve(args) {
120
121
  return;
121
122
  if (hasFlag(extra, "--fetch")) {
122
123
  process.stderr.write("pr-shepherd: resolve: --fetch has been removed; run pr-shepherd iterate or poll to fetch the next action.\n");
123
- process.exitCode = 1;
124
+ process.exitCode = EXIT.USAGE;
124
125
  return;
125
126
  }
126
127
  const hasAction = resolveThreadIds.length > 0 ||
@@ -129,7 +130,7 @@ async function handleResolve(args) {
129
130
  dismissReviewIds.length > 0;
130
131
  if (!hasAction) {
131
132
  process.stderr.write("pr-shepherd: resolve: an action flag is required (--reply-thread-ids, --resolve-thread-ids, --minimize-comment-ids, or --dismiss-review-ids).\n");
132
- process.exitCode = 1;
133
+ process.exitCode = EXIT.USAGE;
133
134
  return;
134
135
  }
135
136
  const result = await runResolveMutate({
@@ -19,11 +19,12 @@ import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers
19
19
  import { normalizeBotUsernames } from "../comments/authors.mjs";
20
20
  import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
21
21
  import { buildClassifyIndex, partitionBatch } from "../classify/apply.mjs";
22
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
22
23
  export async function runCheck(opts) {
23
24
  const repo = await getRepoInfo();
24
25
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
25
26
  if (prNumber === null) {
26
- throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
27
+ throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
27
28
  }
28
29
  const config = loadConfig();
29
30
  const paginateApprovedReviews = config.iterate.minimizeApprovals;
@@ -5,50 +5,51 @@ 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 { EXIT, ShepherdError } from "../exit-codes.mjs";
8
9
  import { buildPrShepherdCommand } from "../cli/runner.mjs";
9
10
  const execFile = promisify(execFileCb);
10
11
  export async function runCommitSuggestion(opts) {
11
12
  if (!opts.threadId) {
12
- throw new Error("--thread-id is required");
13
+ throw new ShepherdError("--thread-id is required", EXIT.USAGE);
13
14
  }
14
15
  if (!opts.message || opts.message.trim() === "") {
15
- throw new Error("--message is required and must be non-empty");
16
+ throw new ShepherdError("--message is required and must be non-empty", EXIT.USAGE);
16
17
  }
17
18
  const repo = await getRepoInfo();
18
19
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
19
20
  if (prNumber === null) {
20
- throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
21
+ throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
21
22
  }
22
23
  const currentBranch = await getCurrentBranch();
23
24
  const { stdout: localHeadOut } = await execFile("git", ["rev-parse", "HEAD"]);
24
25
  const localHeadSha = localHeadOut.trim();
25
26
  const { data } = await fetchPrBatch(prNumber, repo);
26
27
  if (!data.headRepoWithOwner) {
27
- throw new Error(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`);
28
+ throw new ShepherdError(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`, EXIT.UNAVAILABLE);
28
29
  }
29
30
  if (currentBranch !== data.headRefName) {
30
- throw new Error(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
31
- `Check out "${data.headRefName}" before applying suggestions.`);
31
+ throw new ShepherdError(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
32
+ `Check out "${data.headRefName}" before applying suggestions.`, EXIT.UNAVAILABLE);
32
33
  }
33
34
  if (localHeadSha !== data.headRefOid) {
34
- throw new Error(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
35
- `Pull/rebase "${data.headRefName}" to the latest PR head and try again.`);
35
+ throw new ShepherdError(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
36
+ `Pull/rebase "${data.headRefName}" to the latest PR head and try again.`, EXIT.UNAVAILABLE);
36
37
  }
37
38
  const thread = data.reviewThreads.find((t) => t.id === opts.threadId);
38
39
  if (!thread) {
39
- throw new Error(`Thread ${opts.threadId} not found on PR #${prNumber}.`);
40
+ throw new ShepherdError(`Thread ${opts.threadId} not found on PR #${prNumber}.`, EXIT.UNAVAILABLE);
40
41
  }
41
42
  if (thread.isResolved) {
42
- throw new Error(`Thread ${opts.threadId} is already resolved.`);
43
+ throw new ShepherdError(`Thread ${opts.threadId} is already resolved.`, EXIT.UNAVAILABLE);
43
44
  }
44
45
  if (thread.isOutdated) {
45
- throw new Error(`Thread ${opts.threadId} is outdated.`);
46
+ throw new ShepherdError(`Thread ${opts.threadId} is outdated.`, EXIT.UNAVAILABLE);
46
47
  }
47
48
  if (thread.isMinimized) {
48
- throw new Error(`Thread ${opts.threadId} is minimized.`);
49
+ throw new ShepherdError(`Thread ${opts.threadId} is minimized.`, EXIT.UNAVAILABLE);
49
50
  }
50
51
  if (!thread.path || thread.line === null) {
51
- throw new Error(`Thread ${opts.threadId} has no file/line anchor.`);
52
+ throw new ShepherdError(`Thread ${opts.threadId} has no file/line anchor.`, EXIT.UNAVAILABLE);
52
53
  }
53
54
  // Validate the target file is clean before generating the patch, so the emitted
54
55
  // `git add -- <file>` instruction cannot accidentally stage unrelated local edits.
@@ -59,15 +60,15 @@ export async function runCommitSuggestion(opts) {
59
60
  thread.path,
60
61
  ]);
61
62
  if (fileStatus.trim() !== "") {
62
- throw new Error(`${thread.path} has uncommitted changes. Commit or stash them before running commit-suggestion.`);
63
+ throw new ShepherdError(`${thread.path} has uncommitted changes. Commit or stash them before running commit-suggestion.`, EXIT.UNAVAILABLE);
63
64
  }
64
65
  const parsed = parseSuggestion(thread.body);
65
66
  if (!parsed) {
66
- throw new Error(`Thread ${opts.threadId} has no suggestion block in the comment body.`);
67
+ throw new ShepherdError(`Thread ${opts.threadId} has no suggestion block in the comment body.`, EXIT.UNAVAILABLE);
67
68
  }
68
69
  if (!isCommittableSuggestion(parsed)) {
69
- throw new Error(`Thread ${opts.threadId}'s suggestion body contains nested suggestion fencing or unbalanced ` +
70
- `3+ backtick fences — refusing to apply (could silently truncate).`);
70
+ throw new ShepherdError(`Thread ${opts.threadId}'s suggestion body contains nested suggestion fencing or unbalanced ` +
71
+ `3+ backtick fences — refusing to apply (could silently truncate).`, EXIT.UNAVAILABLE);
71
72
  }
72
73
  const startLine = thread.startLine ?? thread.line;
73
74
  const endLine = thread.line;
@@ -4,6 +4,7 @@ import { getCurrentPrNumber } from "../../github/client.mjs";
4
4
  import { graphql } from "../../github/http.mjs";
5
5
  import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
6
6
  import { loadConfig } from "../../config/load.mjs";
7
+ import { EXIT, ShepherdError } from "../../exit-codes.mjs";
7
8
  import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, } from "./helpers.mjs";
8
9
  import { classifyReviewSummaries } from "./classify.mjs";
9
10
  import { applyStallGuard } from "./stall.mjs";
@@ -17,8 +18,9 @@ export async function runIterate(opts) {
17
18
  const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
18
19
  const stallTimeoutSeconds = opts.stallTimeoutSeconds ?? config.iterate.stallTimeoutMinutes * 60;
19
20
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
20
- if (prNumber === null)
21
- throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
21
+ if (prNumber === null) {
22
+ throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
23
+ }
22
24
  const neverCancelRuns = opts.neverCancelRuns ?? config.actions.neverCancelRuns;
23
25
  const report = await runCheck({
24
26
  ...opts,
@@ -28,7 +30,7 @@ export async function runIterate(opts) {
28
30
  });
29
31
  const [repoOwner, repoName] = report.repo.split("/");
30
32
  if (!repoOwner || !repoName) {
31
- throw new Error(`Unexpected repo format: "${report.repo}" (expected "owner/name")`);
33
+ throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
32
34
  }
33
35
  const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
34
36
  if (report.mergeStatus.state !== "OPEN") {
@@ -2,6 +2,7 @@
2
2
  import { graphql, graphqlWithRateLimit, getCurrentPrNumber, getRepoInfo, } from "../github/client.mjs";
3
3
  import { paginateForward } from "../github/pagination.mjs";
4
4
  import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "../comments/rate-limit.mjs";
5
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
5
6
  const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
6
7
  repository(owner: $owner, name: $repo) {
7
8
  pullRequest(number: $pr) {
@@ -25,8 +26,9 @@ const BULK_CHUNK_SIZE = 10;
25
26
  export async function runMarkFilesAsViewed(opts) {
26
27
  const repo = await getRepoInfo();
27
28
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
28
- if (!prNumber)
29
- throw new Error("No PR number provided and no current branch PR found");
29
+ if (!prNumber) {
30
+ throw new ShepherdError("No PR number provided and no current branch PR found", EXIT.UNAVAILABLE);
31
+ }
30
32
  const matchPatterns = opts.matchPatterns ?? [];
31
33
  const matchRegexes = matchPatterns.map((pattern) => compilePattern(pattern));
32
34
  const fetched = await fetchPullRequestFiles(prNumber, repo);
@@ -61,7 +63,7 @@ async function fetchPullRequestFiles(pr, repo) {
61
63
  });
62
64
  const raw = first.data.repository?.pullRequest;
63
65
  if (!raw)
64
- throw new Error(`PR #${pr} not found`);
66
+ throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
65
67
  let files = raw.files.nodes;
66
68
  if (raw.files.pageInfo.hasNextPage && raw.files.pageInfo.endCursor) {
67
69
  const extra = await paginateForward(async (cursor) => {
@@ -73,7 +75,7 @@ async function fetchPullRequestFiles(pr, repo) {
73
75
  });
74
76
  const pr2 = res.data.repository?.pullRequest;
75
77
  if (!pr2)
76
- throw new Error(`PR #${pr} not found`);
78
+ throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
77
79
  return pr2.files;
78
80
  }, raw.files.pageInfo.endCursor);
79
81
  files = [...files, ...extra];
@@ -86,7 +88,7 @@ function compilePattern(pattern) {
86
88
  }
87
89
  catch (e) {
88
90
  const msg = e instanceof Error ? e.message : String(e);
89
- throw new Error(`Invalid --match regex ${JSON.stringify(pattern)}: ${msg}`);
91
+ throw new ShepherdError(`Invalid --match regex ${JSON.stringify(pattern)}: ${msg}`, EXIT.USAGE);
90
92
  }
91
93
  }
92
94
  function selectChangedFiles(changedFiles, opts) {
@@ -6,11 +6,12 @@ import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "..
6
6
  import { markReplySeen } from "../state/seen-comments.mjs";
7
7
  import { threadTranscriptBody } from "../threads/transcript.mjs";
8
8
  import { addPrShepherdMarker } from "../comments/marker.mjs";
9
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
9
10
  export async function runResolveMutate(opts) {
10
11
  const repo = await getRepoInfo();
11
12
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
12
13
  if (prNumber === null) {
13
- throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
14
+ throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
14
15
  }
15
16
  const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
16
17
  const config = loadConfig();
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Process exit codes for the pr-shepherd CLI.
3
+ *
4
+ * Three bands:
5
+ * 0 done — shepherd finished and the PR is in a good terminal state
6
+ * 10-19 shepherd RAN successfully; the code reports PR state
7
+ * 64-78 shepherd FAILED (BSD `sysexits.h` codes)
8
+ *
9
+ * Caller rule: `$? >= 64` means shepherd itself failed. `0` or `10-19` means it
10
+ * ran to completion and is reporting PR state. See docs/exit-codes.md.
11
+ */
12
+ export const EXIT = Object.freeze({
13
+ /** `cancel` + `merged` or `ready-delay-elapsed` — shepherd finished cleanly. */
14
+ OK: 0,
15
+ /** Nothing to do yet; CI still in progress. */
16
+ WAIT: 10,
17
+ /** Draft PR converted to ready for review. */
18
+ MARK_READY: 11,
19
+ /** Agent work required. */
20
+ FIX_CODE: 12,
21
+ /** Human attention required. */
22
+ ESCALATE: 13,
23
+ /** `cancel` + `closed` — PR closed without merging. */
24
+ CLOSED: 14,
25
+ /** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
26
+ USAGE: 64,
27
+ /** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
28
+ DATAERR: 65,
29
+ /** Input file/stdin could not be read. */
30
+ NOINPUT: 66,
31
+ /** Precondition unmet: no open PR for branch, thread not eligible, unclassified 4xx. */
32
+ UNAVAILABLE: 69,
33
+ /** Unexpected/unclassified internal error — the fallback. */
34
+ SOFTWARE: 70,
35
+ /** Retryable GitHub failure: 429, 5xx, rate limit exhausted, `Retry-After` present. */
36
+ TEMPFAIL: 75,
37
+ /** GitHub 401/403 — missing token or insufficient PAT scopes. */
38
+ NOPERM: 77,
39
+ /** `.pr-shepherdrc.yml` validation failure. */
40
+ CONFIG: 78,
41
+ });
42
+ /** An error that carries its own exit code, so the top-level handler doesn't have to guess. */
43
+ export class ShepherdError extends Error {
44
+ exitCode;
45
+ constructor(message, exitCode, opts) {
46
+ super(message, opts);
47
+ this.name = "ShepherdError";
48
+ this.exitCode = exitCode;
49
+ }
50
+ }
51
+ const CANCEL_REASON_EXIT_CODE = {
52
+ merged: EXIT.OK,
53
+ "ready-delay-elapsed": EXIT.OK,
54
+ closed: EXIT.CLOSED,
55
+ };
56
+ export function iterateResultToExitCode(result) {
57
+ switch (result.action) {
58
+ case "cancel":
59
+ return CANCEL_REASON_EXIT_CODE[result.reason];
60
+ case "wait":
61
+ return EXIT.WAIT;
62
+ case "mark_ready":
63
+ return EXIT.MARK_READY;
64
+ case "fix_code":
65
+ return EXIT.FIX_CODE;
66
+ case "escalate":
67
+ return EXIT.ESCALATE;
68
+ }
69
+ }
70
+ export function errorToExitCode(err) {
71
+ if (err instanceof ShepherdError)
72
+ return err.exitCode;
73
+ return EXIT.SOFTWARE;
74
+ }
@@ -1,16 +1,21 @@
1
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
1
2
  import { GitHubRequestError } from "./errors.mjs";
2
3
  export function requireRawPr(response, pr, repo) {
3
4
  if (!response?.repository) {
4
5
  throw new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
5
6
  }
6
- if (!response.repository.pullRequest)
7
- throw new Error(`PR #${pr} not found`);
7
+ if (!response.repository.pullRequest) {
8
+ throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
9
+ }
8
10
  return response.repository.pullRequest;
9
11
  }
10
12
  export function requireContextNodes(nodes) {
11
13
  const nullIndex = nodes.findIndex((node) => node === null);
12
14
  if (nullIndex !== -1) {
13
- throw new GitHubRequestError(`Malformed GitHub GraphQL response: null check context at repository.pullRequest.commits.nodes.0.commit.statusCheckRollup.contexts.nodes.${nullIndex}`, { status: 200 });
15
+ // A null context node is an unexpected/malformed shape, not a precondition or
16
+ // permission problem — force EX_SOFTWARE rather than falling through to the
17
+ // (200-status-derived) EX_UNAVAILABLE default.
18
+ throw new GitHubRequestError(`Malformed GitHub GraphQL response: null check context at repository.pullRequest.commits.nodes.0.commit.statusCheckRollup.contexts.nodes.${nullIndex}`, { status: 200, exitCodeOverride: EXIT.SOFTWARE });
14
19
  }
15
20
  return nodes;
16
21
  }
@@ -1,10 +1,34 @@
1
- export class GitHubRequestError extends Error {
1
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
+ // GitHub's GraphQL API reports field-level permission failures (e.g. a fine-grained
3
+ // PAT missing a scope) as an `errors[].message` entry at HTTP 200, not as an HTTP
4
+ // 401/403 — the transport-level request succeeded even though one field could not
5
+ // be resolved. Status alone can't see this, so classification must also inspect the
6
+ // GraphQL error messages themselves.
7
+ const GRAPHQL_PERMISSION_ERROR = /resource not accessible/i;
8
+ function hasPermissionError(graphqlErrors) {
9
+ return graphqlErrors?.some((e) => GRAPHQL_PERMISSION_ERROR.test(e.message)) ?? false;
10
+ }
11
+ function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
12
+ // Retry signals take priority over everything else: GitHub's secondary rate limit
13
+ // returns 403 with a Retry-After header, which is a transient throttle — not the
14
+ // permission-denied 403 a bad/missing token produces. Treat any retry signal as
15
+ // TEMPFAIL first so it isn't shadowed by the checks below.
16
+ const rateLimitExhausted = rateLimit !== undefined && rateLimit.remaining <= 0;
17
+ if (status === 429 || status >= 500 || retryAfterSeconds !== undefined || rateLimitExhausted) {
18
+ return EXIT.TEMPFAIL;
19
+ }
20
+ if (status === 401 || status === 403 || hasPermissionError(graphqlErrors))
21
+ return EXIT.NOPERM;
22
+ return EXIT.UNAVAILABLE;
23
+ }
24
+ export class GitHubRequestError extends ShepherdError {
2
25
  status;
3
26
  rateLimit;
4
27
  retryAfterSeconds;
5
28
  graphqlErrors;
6
29
  constructor(message, opts) {
7
- super(message);
30
+ super(message, opts.exitCodeOverride ??
31
+ classifyStatus(opts.status, opts.rateLimit, opts.retryAfterSeconds, opts.graphqlErrors));
8
32
  this.name = "GitHubRequestError";
9
33
  this.status = opts.status;
10
34
  this.rateLimit = opts.rateLimit;
@@ -1,3 +1,4 @@
1
+ import { EXIT } from "../exit-codes.mjs";
1
2
  import { GitHubRequestError } from "./errors.mjs";
2
3
  export function parseGraphQlPayload(parsed, status, rateLimit, retryAfterSeconds) {
3
4
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
@@ -34,9 +35,13 @@ export function formatGraphQlErrors(errors) {
34
35
  .join("; ");
35
36
  }
36
37
  function malformedGraphQlResponse(detail, status, rateLimit, retryAfterSeconds) {
38
+ // A response that fails to parse as valid GraphQL shape is an internal/unexpected
39
+ // failure, not a precondition or permission problem — force EX_SOFTWARE rather
40
+ // than letting the (likely 200) status fall through to EX_UNAVAILABLE.
37
41
  return new GitHubRequestError(`Malformed GitHub GraphQL response: ${detail}`, {
38
42
  status,
39
43
  rateLimit: rateLimit ?? undefined,
40
44
  retryAfterSeconds,
45
+ exitCodeOverride: EXIT.SOFTWARE,
41
46
  });
42
47
  }
@@ -1,5 +1,6 @@
1
1
  import { execFile as execFileCb } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
3
4
  const execFile = promisify(execFileCb);
4
5
  let _token;
5
6
  export function _resetTokenCache() {
@@ -35,7 +36,7 @@ async function resolveToken() {
35
36
  _token = codexToken;
36
37
  return _token;
37
38
  }
38
- throw new Error("No GitHub token found. Set GH_TOKEN, GITHUB_TOKEN, or GITHUB_PERSONAL_ACCESS_TOKEN, or run `gh auth login`.");
39
+ throw new ShepherdError("No GitHub token found. Set GH_TOKEN, GITHUB_TOKEN, or GITHUB_PERSONAL_ACCESS_TOKEN, or run `gh auth login`.", EXIT.NOPERM);
39
40
  }
40
41
  export async function makeHeaders() {
41
42
  return {
@@ -1,8 +1,9 @@
1
1
  import { appendEntry, nextEntry } from "../log/log-file.mjs";
2
2
  import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
3
+ import { GitHubRequestError } from "./errors.mjs";
3
4
  import { makeHeaders } from "./http-auth.mjs";
4
5
  import { requestWithTokenRetry } from "./http-request.mjs";
5
- import { redactToken, redactUrl, sanitizeBody } from "./http-utils.mjs";
6
+ import { parseRateLimit, parseRetryAfter, redactToken, redactUrl, sanitizeBody, } from "./http-utils.mjs";
6
7
  const BASE_URL = "https://api.github.com";
7
8
  export async function rest(method, path, body) {
8
9
  const url = `${BASE_URL}${path}`;
@@ -28,7 +29,11 @@ export async function rest(method, path, body) {
28
29
  textBody: redactToken(text),
29
30
  attempt: attempt > 1 ? attempt : undefined,
30
31
  }));
31
- throw new Error(`GitHub REST ${method} ${path} failed: ${res.status} ${sanitizeBody(text)}`);
32
+ throw new GitHubRequestError(`GitHub REST ${method} ${path} failed: ${res.status} ${sanitizeBody(text)}`, {
33
+ status: res.status,
34
+ rateLimit: parseRateLimit(res.headers) ?? undefined,
35
+ retryAfterSeconds: parseRetryAfter(res.headers),
36
+ });
32
37
  }
33
38
  if (ct.includes("application/json")) {
34
39
  const json = (await res.json());
@@ -80,7 +85,11 @@ export async function restText(path) {
80
85
  durationMs,
81
86
  attempt: attempt > 1 ? attempt : undefined,
82
87
  }));
83
- throw new Error(`GitHub REST GET ${path} failed: ${res.status} ${sanitizeBody(text)}`);
88
+ throw new GitHubRequestError(`GitHub REST GET ${path} failed: ${res.status} ${sanitizeBody(text)}`, {
89
+ status: res.status,
90
+ rateLimit: parseRateLimit(res.headers) ?? undefined,
91
+ retryAfterSeconds: parseRetryAfter(res.headers),
92
+ });
84
93
  }
85
94
  appendEntry(formatResponseEntry({
86
95
  n,
@@ -121,8 +130,13 @@ async function followRestTextRedirect(res, entry) {
121
130
  durationMs: Math.round(performance.now() - t1),
122
131
  contentLength: parseContentLength(redirectRes.headers),
123
132
  }));
124
- if (!redirectRes.ok)
125
- throw new Error(`redirect target ${location} failed: ${redirectRes.status}`);
133
+ if (!redirectRes.ok) {
134
+ throw new GitHubRequestError(`redirect target ${location} failed: ${redirectRes.status}`, {
135
+ status: redirectRes.status,
136
+ rateLimit: parseRateLimit(redirectRes.headers) ?? undefined,
137
+ retryAfterSeconds: parseRetryAfter(redirectRes.headers),
138
+ });
139
+ }
126
140
  return redirectRes.text();
127
141
  }
128
142
  function parseContentLength(headers) {
package/bin/index.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  * pr-shepherd iterate [PR]
9
9
  */
10
10
  import { main } from "./cli-parser.mjs";
11
+ import { errorToExitCode } from "./exit-codes.mjs";
11
12
  function formatCause(cause, seen = new Set(), depth = 0) {
12
13
  if (depth > 5 || seen.has(cause))
13
14
  return "[circular or deep cause chain]";
@@ -23,5 +24,5 @@ main(process.argv).catch((err) => {
23
24
  const msg = err instanceof Error ? err.message : String(err);
24
25
  const causeStr = err instanceof Error && err.cause != null ? formatCause(err.cause) : null;
25
26
  process.stderr.write(`pr-shepherd error: ${msg}${causeStr !== null ? ` (cause: ${causeStr})` : ""}\n`);
26
- process.exit(1);
27
+ process.exit(errorToExitCode(err));
27
28
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -38,9 +38,9 @@
38
38
  "@vitest/coverage-v8": "^4.1.4",
39
39
  "husky": "^9.1.7",
40
40
  "knip": "^6.14.1",
41
- "oxfmt": "^0.57.0",
41
+ "oxfmt": "^0.60.0",
42
42
  "oxlint": "^1.60.0",
43
- "typescript": "^6.0.3",
43
+ "typescript": "^7.0.2",
44
44
  "vitest": "^4.1.4"
45
45
  },
46
46
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -20,7 +20,7 @@ Poll dispatcher for iterating a PR to completion.
20
20
 
21
21
  3. **Loop:** Run the poll, print its full output, and follow its `## Instructions` section exactly. Then run the poll again. Repeat until the CLI emits `[CANCEL]` or `[ESCALATE]`, unless the human directs you to stop. `[FIX_CODE]` is non-terminal: do its instructions, then poll again. The poll already waits between ticks via `--interval`; do not add manual `sleep`s between ticks.
22
22
 
23
- 4. **Nonzero exit codes:** Treat a nonzero poll exit as PR state only when the output contains a matching `# PR #$N [ACTION]` heading. Exit `1` can also mean a command or validation failure; if there is no `[ACTION]` heading, surface the error and stop instead of looping.
23
+ 4. **Exit codes:** an exit code of `64` or higher means the `pr-shepherd` command itself failed (bad flag, GitHub auth/permission error, transient failure, etc.) surface the error and stop instead of looping. Any other exit code (`0` or `10`–`19`) means the command ran and the output above is real PR state — proceed to step 5.
24
24
 
25
25
  5. **Terminal states (stop):**
26
26
  - `[CANCEL]` — ready-delay completed, or PR merged/closed.