pr-shepherd 0.16.4 → 0.18.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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -13
  3. package/bin/cli/args.mjs +2 -0
  4. package/bin/cli/clean-formatter.mjs +20 -0
  5. package/bin/cli/default-iterate.mjs +1 -1
  6. package/bin/cli/duration-flag.mjs +22 -0
  7. package/bin/cli/exit-codes.mjs +14 -0
  8. package/bin/cli/fix-formatter.mjs +4 -12
  9. package/bin/cli/formatters.mjs +6 -10
  10. package/bin/cli/handlers.mjs +72 -36
  11. package/bin/cli/iterate-emitter.mjs +19 -0
  12. package/bin/cli/iterate-flags.mjs +21 -0
  13. package/bin/cli/iterate-formatter.mjs +50 -13
  14. package/bin/cli/iterate-instructions.mjs +13 -17
  15. package/bin/cli/iterate-lean.mjs +9 -12
  16. package/bin/cli/list-formatters.mjs +21 -1
  17. package/bin/cli/poll-handler.mjs +42 -0
  18. package/bin/cli/runner.mjs +13 -3
  19. package/bin/cli-parser.clean.test-support.mjs +45 -0
  20. package/bin/cli-parser.iterate-fix.test-support.mjs +0 -4
  21. package/bin/cli-parser.iterate-fixtures.mjs +5 -2
  22. package/bin/cli-parser.iterate.test-support.mjs +0 -4
  23. package/bin/cli-parser.mjs +13 -2
  24. package/bin/commands/check-terminal-report.mjs +1 -0
  25. package/bin/commands/check.mjs +1 -0
  26. package/bin/commands/check.test-support.mjs +1 -0
  27. package/bin/commands/clean.mjs +156 -0
  28. package/bin/commands/clean.test-support.mjs +48 -0
  29. package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -0
  30. package/bin/commands/commit-suggestion.test-support.mjs +1 -0
  31. package/bin/commands/iterate/check-instructions.mjs +19 -17
  32. package/bin/commands/iterate/classify.mjs +37 -9
  33. package/bin/commands/iterate/escalate.mjs +26 -28
  34. package/bin/commands/iterate/fix-code.mjs +28 -6
  35. package/bin/commands/iterate/index.mjs +2 -0
  36. package/bin/commands/iterate/render.mjs +51 -40
  37. package/bin/commands/iterate-test-support.mjs +1 -0
  38. package/bin/commands/poll.mjs +32 -0
  39. package/bin/commands/poll.test-support.mjs +77 -0
  40. package/bin/commands/resolve-instructions.mjs +2 -5
  41. package/bin/comments/resolve.mjs +70 -11
  42. package/bin/github/batch-parser-helpers.mjs +38 -0
  43. package/bin/github/batch-parsers.mjs +12 -38
  44. package/bin/github/client.mjs +10 -1
  45. package/bin/github/gql/batch-pr.gql +9 -0
  46. package/bin/state/base.mjs +2 -1
  47. package/bin/state/seen-comments.mjs +29 -15
  48. package/package.json +1 -1
  49. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  50. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +16 -5
  51. package/bin/agent-runtime.mjs +0 -7
@@ -1,9 +1,48 @@
1
+ /* eslint-disable max-lines */
1
2
  import { graphqlWithRateLimit } from "../github/client.mjs";
2
3
  import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "./rate-limit.mjs";
3
4
  import { setPendingOps } from "./pending-ops.mjs";
4
5
  import { waitForSha } from "./sha-poll.mjs";
6
+ const COMMENTED_DISMISS_ERROR_PATTERNS = [
7
+ /can\s*not\s+dismiss[\s\S]*?commented pull request review/i,
8
+ ];
9
+ function dedupeIds(ids) {
10
+ const seen = new Set();
11
+ const out = [];
12
+ for (const id of ids) {
13
+ if (seen.has(id))
14
+ continue;
15
+ seen.add(id);
16
+ out.push(id);
17
+ }
18
+ return out;
19
+ }
20
+ function isCommentedDismissError(message) {
21
+ return COMMENTED_DISMISS_ERROR_PATTERNS.some((pattern) => pattern.test(message));
22
+ }
23
+ function dismissReviewNonDismissibleMessage(id) {
24
+ return `Not dismissed: ${id} is a COMMENTED review. Use --minimize-comment-ids instead; --dismiss-review-ids is only for CHANGES_REQUESTED reviews.`;
25
+ }
5
26
  export async function applyResolveOptions(pr, repo, opts) {
6
- if ((opts.dismissReviewIds?.length ?? 0) > 0 && !opts.dismissMessage) {
27
+ const resolveThreadIds = dedupeIds(opts.resolveThreadIds ?? []);
28
+ const minimizeCommentIds = opts.minimizeCommentIds ?? [];
29
+ const dismissReviewIds = dedupeIds(opts.dismissReviewIds ?? []);
30
+ const minimizeCommentIdSet = new Set(minimizeCommentIds);
31
+ const filteredDismissReviewIds = dismissReviewIds.filter((id) => !minimizeCommentIdSet.has(id));
32
+ const overlappingDismissIds = dismissReviewIds.filter((id) => minimizeCommentIdSet.has(id));
33
+ const result = {
34
+ resolvedThreads: [],
35
+ minimizedComments: [],
36
+ dismissedReviews: [],
37
+ errors: [],
38
+ };
39
+ if (overlappingDismissIds.length > 0) {
40
+ result.skippedDismissals = [];
41
+ for (const id of overlappingDismissIds) {
42
+ result.skippedDismissals.push(id);
43
+ }
44
+ }
45
+ if (filteredDismissReviewIds.length > 0 && !opts.dismissMessage) {
7
46
  throw new Error("--message is required when dismissing reviews");
8
47
  }
9
48
  if (opts.requireSha) {
@@ -11,13 +50,7 @@ export async function applyResolveOptions(pr, repo, opts) {
11
50
  // before reviewers see the fix.
12
51
  await waitForSha(pr, repo, opts.requireSha);
13
52
  }
14
- const result = {
15
- resolvedThreads: [],
16
- minimizedComments: [],
17
- dismissedReviews: [],
18
- errors: [],
19
- };
20
- await bulkApply(opts.resolveThreadIds ?? [], opts.minimizeCommentIds ?? [], opts.dismissReviewIds ?? [], opts.dismissMessage ?? "", result);
53
+ await bulkApply(resolveThreadIds, minimizeCommentIds, filteredDismissReviewIds, opts.dismissMessage ?? "", result);
21
54
  return result;
22
55
  }
23
56
  export async function autoResolveOutdated(threadIds) {
@@ -65,13 +98,15 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
65
98
  if (resolveIds.length === 0 && minimizeIds.length === 0 && dismissIds.length === 0)
66
99
  return false;
67
100
  const doc = buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage);
68
- let data;
101
+ let data = {};
102
+ let graphQlErrors = [];
69
103
  let rateLimitStop;
70
104
  let suppressCurrentChunkErrors = false;
71
105
  try {
72
106
  const resp = await graphqlWithRateLimit(doc, {});
73
107
  data = resp.data;
74
- const graphQlErrorMessages = resp.errors?.map((e) => e.message) ?? [];
108
+ graphQlErrors = (resp.errors ?? []);
109
+ const graphQlErrorMessages = graphQlErrors.map((e) => e.message);
75
110
  suppressCurrentChunkErrors = graphQlErrorMessages.some(isRateLimitMessage);
76
111
  rateLimitStop = rateLimitFromGraphQlResult(graphQlErrorMessages, {
77
112
  rateLimit: resp.rateLimit,
@@ -109,12 +144,27 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
109
144
  else if (!suppressCurrentChunkErrors)
110
145
  result.errors.push(`${minimizeIds[i]}: minimize returned null or comment not minimized`);
111
146
  }
147
+ const singleDismiss = dismissIds.length === 1;
148
+ const commentedDismissErrorIndexes = new Set();
149
+ let hasUnmappedCommentedDismissError = false;
150
+ for (const error of graphQlErrors) {
151
+ if (!isCommentedDismissError(error.message))
152
+ continue;
153
+ const alias = dismissErrorAliasIndex(error);
154
+ if (alias === undefined) {
155
+ hasUnmappedCommentedDismissError = true;
156
+ continue;
157
+ }
158
+ commentedDismissErrorIndexes.add(alias);
159
+ }
112
160
  for (let i = 0; i < dismissIds.length; i++) {
113
161
  const d = data[`d${i}`];
114
162
  if (d?.pullRequestReview != null)
115
163
  result.dismissedReviews.push(dismissIds[i]);
116
164
  else if (!suppressCurrentChunkErrors)
117
- result.errors.push(`${dismissIds[i]}: dismiss returned null`);
165
+ result.errors.push(commentedDismissErrorIndexes.has(i) || (singleDismiss && hasUnmappedCommentedDismissError)
166
+ ? dismissReviewNonDismissibleMessage(dismissIds[i])
167
+ : `${dismissIds[i]}: dismiss returned null`);
118
168
  }
119
169
  if (rateLimitStop) {
120
170
  result.errors.push(`rate limit: ${rateLimitStop.message}`);
@@ -123,3 +173,12 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
123
173
  }
124
174
  return false;
125
175
  }
176
+ function dismissErrorAliasIndex(error) {
177
+ if (!Array.isArray(error.path))
178
+ return undefined;
179
+ const alias = error.path.find((part) => typeof part === "string" && /^d\d+$/.test(part));
180
+ if (typeof alias !== "string")
181
+ return undefined;
182
+ const parsed = Number.parseInt(alias.slice(1), 10);
183
+ return Number.isNaN(parsed) ? undefined : parsed;
184
+ }
@@ -0,0 +1,38 @@
1
+ export function mapAuthorType(typeName) {
2
+ if (typeName === "User" || typeName === "Bot")
3
+ return typeName;
4
+ return "Unknown";
5
+ }
6
+ export function parseCreatedAt(iso) {
7
+ const ms = new Date(iso).getTime();
8
+ return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
9
+ }
10
+ export function extractRunId(url) {
11
+ if (!url)
12
+ return null;
13
+ const m = /\/runs\/(\d+)/.exec(url);
14
+ return m ? (m[1] ?? null) : null;
15
+ }
16
+ export function extractCheckRunSummary(title, summary) {
17
+ const t = title?.trim();
18
+ if (t)
19
+ return t;
20
+ const firstLine = summary
21
+ ?.split("\n")
22
+ ?.find((l) => l.trim() !== "")
23
+ ?.trim();
24
+ return firstLine || undefined;
25
+ }
26
+ export function mapStatusContextState(state) {
27
+ switch (state) {
28
+ case "SUCCESS":
29
+ return { status: "COMPLETED", conclusion: "SUCCESS" };
30
+ case "FAILURE":
31
+ case "ERROR":
32
+ return { status: "COMPLETED", conclusion: "FAILURE" };
33
+ case "PENDING":
34
+ case "EXPECTED":
35
+ default:
36
+ return { status: "IN_PROGRESS", conclusion: null };
37
+ }
38
+ }
@@ -1,3 +1,4 @@
1
+ import { mapAuthorType, parseCreatedAt, extractRunId, extractCheckRunSummary, mapStatusContextState, } from "./batch-parser-helpers.mjs";
1
2
  export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawApprovedReviewNodes, rawCheckNodes) {
2
3
  const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
3
4
  const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
@@ -92,6 +93,16 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
92
93
  }
93
94
  return [];
94
95
  });
96
+ const rawProtection = raw.baseRef?.branchProtectionRule ?? null;
97
+ const branchProtection = rawProtection
98
+ ? {
99
+ requiresApprovingReviews: rawProtection.requiresApprovingReviews,
100
+ requiredApprovingReviewCount: rawProtection.requiredApprovingReviewCount,
101
+ requiresConversationResolution: rawProtection.requiresConversationResolution,
102
+ requiresStatusChecks: rawProtection.requiresStatusChecks,
103
+ requiredStatusCheckContexts: rawProtection.requiredStatusCheckContexts ?? [],
104
+ }
105
+ : null;
95
106
  return {
96
107
  nodeId: raw.id,
97
108
  number: raw.number,
@@ -112,43 +123,6 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
112
123
  reviewSummaries,
113
124
  approvedReviews,
114
125
  checks,
126
+ branchProtection,
115
127
  };
116
128
  }
117
- function mapAuthorType(typeName) {
118
- if (typeName === "User" || typeName === "Bot")
119
- return typeName;
120
- return "Unknown";
121
- }
122
- function parseCreatedAt(iso) {
123
- const ms = new Date(iso).getTime();
124
- return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
125
- }
126
- function extractRunId(url) {
127
- if (!url)
128
- return null;
129
- const m = /\/runs\/(\d+)/.exec(url);
130
- return m ? (m[1] ?? null) : null;
131
- }
132
- function extractCheckRunSummary(title, summary) {
133
- const t = title?.trim();
134
- if (t)
135
- return t;
136
- const firstLine = summary
137
- ?.split("\n")
138
- ?.find((l) => l.trim() !== "")
139
- ?.trim();
140
- return firstLine || undefined;
141
- }
142
- function mapStatusContextState(state) {
143
- switch (state) {
144
- case "SUCCESS":
145
- return { status: "COMPLETED", conclusion: "SUCCESS" };
146
- case "FAILURE":
147
- case "ERROR":
148
- return { status: "COMPLETED", conclusion: "FAILURE" };
149
- case "PENDING":
150
- case "EXPECTED":
151
- default:
152
- return { status: "IN_PROGRESS", conclusion: null };
153
- }
154
- }
@@ -34,7 +34,16 @@ export async function getCurrentPrNumber() {
34
34
  if (branch === "HEAD")
35
35
  return null;
36
36
  const repo = await getRepoInfo();
37
- const result = await httpGraphql(PR_NUMBER_BY_BRANCH_QUERY, { owner: repo.owner, repo: repo.name, branch });
37
+ return getPrNumberForBranch(branch, repo.owner, repo.name);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /** Returns the PR number for a given branch, or null if no open PR is found. */
44
+ export async function getPrNumberForBranch(branch, owner, repo) {
45
+ try {
46
+ const result = await httpGraphql(PR_NUMBER_BY_BRANCH_QUERY, { owner, repo, branch });
38
47
  return result.data.repository?.pullRequests.nodes[0]?.number ?? null;
39
48
  }
40
49
  catch {
@@ -24,6 +24,15 @@ query BatchPr(
24
24
  nameWithOwner
25
25
  }
26
26
  baseRefName
27
+ baseRef {
28
+ branchProtectionRule {
29
+ requiresApprovingReviews
30
+ requiredApprovingReviewCount
31
+ requiresConversationResolution
32
+ requiresStatusChecks
33
+ requiredStatusCheckContexts
34
+ }
35
+ }
27
36
  # Not paginated — capped at 50; PRs with more pending reviewers truncate silently.
28
37
  reviewRequests(last: 50) {
29
38
  nodes {
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import { tmpdir } from "node:os";
3
3
  export function resolveStateBase() {
4
- return process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
4
+ const envDir = process.env["PR_SHEPHERD_STATE_DIR"];
5
+ return envDir ? envDir : join(tmpdir(), "pr-shepherd-state");
5
6
  }
@@ -33,30 +33,35 @@ export function classifyItem(id, body, map) {
33
33
  * Returns an empty Set if the directory does not yet exist.
34
34
  */
35
35
  export async function loadSeenSet(key) {
36
- try {
37
- const dir = resolveDir(key);
38
- const entries = await readdir(dir);
39
- return new Set(entries.filter((e) => e.endsWith(".json")).map((e) => e.slice(0, -5)));
40
- }
41
- catch {
42
- return new Set();
43
- }
36
+ const map = await loadSeenMap(key);
37
+ return new Set(map.keys());
44
38
  }
45
39
  /**
46
40
  * Read the seen/ directory and return a Map from ID to SeenMarker.
47
41
  * Used when the caller needs the stored bodyHash to detect in-place edits.
48
42
  * Returns an empty Map if the directory does not yet exist.
43
+ *
44
+ * Map keys are the stored `id` field when present (guarding against
45
+ * case-insensitive filesystem collisions), falling back to the filename for
46
+ * legacy markers that predate this field.
49
47
  */
50
48
  export async function loadSeenMap(key) {
51
49
  const map = new Map();
52
50
  try {
53
51
  const dir = resolveDir(key);
54
52
  const entries = await readdir(dir);
55
- const ids = entries.filter((e) => e.endsWith(".json")).map((e) => e.slice(0, -5));
56
- for (const id of ids) {
53
+ for (const entry of entries.filter((e) => e.endsWith(".json"))) {
57
54
  try {
58
- const raw = await readFile(join(dir, `${id}.json`), "utf8");
59
- map.set(id, JSON.parse(raw));
55
+ const raw = await readFile(join(dir, entry), "utf8");
56
+ const marker = JSON.parse(raw);
57
+ // Prefer the stored id field; fall back to filename stem for legacy markers.
58
+ const mapKey = typeof marker.id === "string" ? marker.id : entry.slice(0, -5);
59
+ // Hash-based markers (those with an id field) take priority over legacy
60
+ // filename-based markers so that a stale legacy file cannot overwrite a
61
+ // newer hash-based entry that maps to the same key.
62
+ if (!map.has(mapKey) || typeof marker.id === "string") {
63
+ map.set(mapKey, marker);
64
+ }
60
65
  }
61
66
  catch {
62
67
  // unreadable or malformed — skip
@@ -82,7 +87,7 @@ export async function hasSeen(key, id) {
82
87
  * Write (or update) a "seen" marker for this id, storing the body hash so
83
88
  * in-place edits can be detected on future fetches.
84
89
  *
85
- * - First call (no existing marker): creates `{ seenAt: now, bodyHash }`.
90
+ * - First call (no existing marker): creates `{ seenAt: now, bodyHash, id }`.
86
91
  * - Subsequent call, hash unchanged: no-op (skips the write).
87
92
  * - Subsequent call, hash changed: updates `bodyHash`, preserves original `seenAt`.
88
93
  *
@@ -106,7 +111,10 @@ export async function markSeen(key, id, body) {
106
111
  return;
107
112
  const seenAt = existing?.seenAt ?? Date.now();
108
113
  tmp = `${path}.${randomUUID()}.tmp`;
109
- await writeFile(tmp, JSON.stringify({ seenAt, bodyHash: newHash }), "utf8");
114
+ // Store `id` in the payload so loadSeenMap can key by the original ID
115
+ // rather than the filename, guarding against case-insensitive filesystems
116
+ // (e.g. macOS APFS) where IDs differing only in case would collide.
117
+ await writeFile(tmp, JSON.stringify({ seenAt, bodyHash: newHash, id }), "utf8");
110
118
  await rename(tmp, path);
111
119
  tmp = undefined;
112
120
  }
@@ -153,5 +161,11 @@ function resolvePath(key, id) {
153
161
  if (!SAFE_SEGMENT.test(id)) {
154
162
  throw new Error(`Invalid state key segment "id": ${id}`);
155
163
  }
156
- return join(resolveDir(key), `${id}.json`);
164
+ // Hash the ID to produce a case-insensitive filename. On case-insensitive
165
+ // filesystems (macOS APFS), IDs that differ only in case (e.g. base64 IDs
166
+ // from GitHub like `ChG7F` vs `ChG7f`) would otherwise share the same file,
167
+ // causing seen-markers to overwrite each other and items to re-surface every
168
+ // tick. SHA-256 is case-sensitive so distinct IDs get distinct files.
169
+ const hash = createHash("sha256").update(id, "utf8").digest("hex");
170
+ return join(resolveDir(key), `${hash}.json`);
157
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.16.4",
3
+ "version": "0.18.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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.16.4",
3
+ "version": "0.18.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -29,12 +29,12 @@ One-tick dispatcher for iterating a PR to completion.
29
29
  If `MERGED` or `CLOSED`, output: `PR #N is already merged/closed. Nothing to do.` and stop.
30
30
 
31
31
  3. **Select the package runner** from the target repository root:
32
- - Prefer `package.json` `packageManager`: `pnpm@...` → `pnpm exec`, `yarn@...` → `yarn run`, `npm@...` → `npx`.
33
- - If `packageManager` is absent, use lockfiles: `pnpm-lock.yaml` → `pnpm exec`, `yarn.lock` → `yarn run`, `package-lock.json` or no signal → `npx`.
32
+ - Prefer `package.json` `packageManager`: `pnpm@...` → `pnpm exec`, `yarn@...` → `yarn run`, `bun@...` → `bunx`, `npm@...` → `npx`.
33
+ - If `packageManager` is absent, use lockfiles: `pnpm-lock.yaml` → `pnpm exec`, `yarn.lock` → `yarn run`, `bun.lock` / `bun.lockb` → `bunx`, `package-lock.json` or no signal → `npx`.
34
34
 
35
35
  4. **Run one iterate tick:**
36
36
 
37
- If the package is missing in the target repository, 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`.
37
+ If the package is missing in the target repository, tell the user to install pr-shepherd with the matching dev-dependency command: `pnpm add -D pr-shepherd`, `yarn add -D pr-shepherd`, `bun add -d pr-shepherd`, or `npm install --save-dev pr-shepherd`.
38
38
 
39
39
  ```bash
40
40
  <runner> pr-shepherd <N>
@@ -42,7 +42,18 @@ One-tick dispatcher for iterating a PR to completion.
42
42
 
43
43
  Print the full output. Follow the `## Instructions` section exactly.
44
44
 
45
- 5. **Stop conditions:**
45
+ 5. **Persistence:** Do not stop polling/iterating unless directed by the human or until the CLI returns a terminal state (`[CANCEL]` or `[ESCALATE]`). Every other action — `[WAIT]`, `[MARK_READY]`, `[FIX_CODE]` — is non-terminal and must be followed by another tick via one of the strategies below.
46
+
47
+ 6. **Stop conditions (terminal states):**
46
48
  - Stop when the CLI emits `[CANCEL]` (ready-delay completed, or PR merged/closed).
47
49
  - Stop when the CLI emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures.
48
- - All other actions (`[WAIT]`, `[MARK_READY]`, `[FIX_CODE]`) are non-terminal: follow the `## Instructions`. For Claude, schedule exactly one next session-only iteration and end the turn; do not sleep inline and do not create a recurring cron.
50
+ - **Do NOT merge the pull request** unless the human has explicitly requested or allowed it.
51
+
52
+ 7. **Non-terminal actions** (`[WAIT]`, `[MARK_READY]`, `[FIX_CODE]`) — follow the `## Instructions` in the output. Pick one iteration strategy:
53
+ - **Blocking poll** — rerun as `<runner> pr-shepherd poll <N> [--interval <duration>] [--timeout <duration>]` (defaults: interval 30s, timeout 5m). Holds the agent turn until the action is non-WAIT or the timeout fires. Simplest when the agent cannot reliably schedule its own follow-up.
54
+ - **Scheduled wakeup + one tick** — schedule a single session-only follow-up task to rerun `<runner> pr-shepherd <N>` after a fresh 30s–4m delay, then end the turn.
55
+ - **Inline sleep + rerun** — sleep inline for a fresh 30s–4m delay, then rerun.
56
+
57
+ **Never write a custom polling loop** (shell `while`/`until` loops, script files that loop over pr-shepherd output, etc.). Custom loops poll only for terminal states and silently skip `[FIX_CODE]` handling — actionable review threads, failing checks, and resolve commands get missed. Use `pr-shepherd poll` for WAIT-state waiting; it exits on any non-WAIT action so the caller handles `[FIX_CODE]` and other actionable outputs normally.
58
+
59
+ Do not combine strategies (e.g., poll AND schedule a wakeup). Remember step 5 — every non-terminal output requires a follow-up tick.
@@ -1,7 +0,0 @@
1
- export function detectAgentRuntime(env = process.env) {
2
- if (env.AGENT?.trim().toLowerCase() === "codex")
3
- return "codex";
4
- if (env.CODEX_CI === "1")
5
- return "codex";
6
- return "claude";
7
- }