pr-shepherd 0.15.2 → 0.16.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 (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +23 -89
  4. package/bin/cli/args.mjs +0 -1
  5. package/bin/cli/default-iterate.mjs +2 -7
  6. package/bin/cli/exit-codes.mjs +0 -4
  7. package/bin/cli/fix-formatter.mjs +8 -8
  8. package/bin/cli/handlers.mjs +3 -72
  9. package/bin/cli/iterate-formatter.mjs +8 -17
  10. package/bin/cli/iterate-instructions.mjs +13 -47
  11. package/bin/cli/iterate-lean.mjs +4 -11
  12. package/bin/cli/list-formatters.mjs +6 -3
  13. package/bin/cli-parser.iterate-fixtures.mjs +4 -7
  14. package/bin/cli-parser.mjs +4 -33
  15. package/bin/commands/check-status.mjs +2 -2
  16. package/bin/commands/check.mjs +7 -6
  17. package/bin/commands/commit-suggestion-instruction.mjs +23 -0
  18. package/bin/commands/iterate/check-instructions.mjs +1 -1
  19. package/bin/commands/iterate/classify.mjs +9 -4
  20. package/bin/commands/iterate/escalate.mjs +5 -6
  21. package/bin/commands/iterate/fix-code.mjs +3 -3
  22. package/bin/commands/iterate/helpers.mjs +0 -29
  23. package/bin/commands/iterate/index.mjs +18 -19
  24. package/bin/commands/iterate/render.mjs +5 -17
  25. package/bin/commands/resolve-instructions.mjs +2 -10
  26. package/bin/commands/resolve-mutate.mjs +16 -0
  27. package/bin/commands/resolve.mjs +6 -17
  28. package/bin/commands/shepherd-journal.mjs +1 -1
  29. package/bin/comments/minimize-policy.mjs +15 -0
  30. package/bin/comments/visible-comments.mjs +20 -0
  31. package/bin/config/load.mjs +10 -3
  32. package/bin/config.json +2 -3
  33. package/bin/github/batch-parsers.mjs +10 -0
  34. package/bin/github/gql/batch-pr.gql +6 -0
  35. package/bin/github/queries.mjs +0 -2
  36. package/bin/index.mjs +0 -2
  37. package/bin/merge-status/derive.mjs +6 -6
  38. package/bin/reporters/agent.mjs +10 -3
  39. package/bin/state/iterate-stall.mjs +9 -0
  40. package/package.json +5 -6
  41. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  42. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +31 -26
  43. package/bin/commands/iterate.mjs +0 -2
  44. package/bin/commands/monitor.mjs +0 -139
  45. package/bin/commands/status.mjs +0 -126
  46. package/bin/github/gql/multi-pr-status-paged.gql +0 -31
  47. package/bin/reporters/check-instructions.mjs +0 -69
  48. package/bin/reporters/json.mjs +0 -10
  49. package/bin/reporters/text.mjs +0 -156
  50. package/plugin/skills/check/SKILL.md +0 -37
  51. package/plugin/skills/monitor/SKILL.md +0 -40
  52. package/plugin/skills/resolve/SKILL.md +0 -38
@@ -1,11 +1,13 @@
1
1
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { fetchPrBatch } from "../github/batch.mjs";
3
3
  import { getOutdatedThreads } from "../comments/outdated.mjs";
4
- import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mjs";
4
+ import { autoResolveOutdated } from "../comments/resolve.mjs";
5
5
  import { loadConfig } from "../config/load.mjs";
6
+ import { classifyVisibleComments } from "../comments/visible-comments.mjs";
6
7
  import { extractSuggestion } from "../suggestions/extract.mjs";
7
8
  import { buildFetchInstructions } from "./resolve-instructions.mjs";
8
9
  import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
10
+ export { runResolveMutate } from "./resolve-mutate.mjs";
9
11
  export async function runResolveFetch(opts) {
10
12
  const repo = await getRepoInfo();
11
13
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
@@ -15,7 +17,6 @@ export async function runResolveFetch(opts) {
15
17
  const { data } = await fetchPrBatch(prNumber, repo);
16
18
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
17
19
  const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved);
18
- const visibleComments = data.comments.filter((c) => !c.isMinimized);
19
20
  const outdatedCandidates = data.reviewThreads.filter((t) => t.isOutdated);
20
21
  const resolvedCandidates = data.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
21
22
  const minimizedThreadCandidates = data.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
@@ -70,6 +71,7 @@ export async function runResolveFetch(opts) {
70
71
  const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated && !t.isMinimized);
71
72
  const resolutionOnlyThreads = unresolvedThreads.filter((t) => !autoResolvedIds.has(t.id) && (t.isOutdated || t.isMinimized));
72
73
  const cfg = loadConfig();
74
+ const visibleCommentClassification = classifyVisibleComments(data.comments, seenMap, cfg.iterate?.minimizeComments);
73
75
  const actionableThreads = activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => {
74
76
  const thread = rest;
75
77
  const suggestion = extractSuggestion(rest);
@@ -114,13 +116,14 @@ export async function runResolveFetch(opts) {
114
116
  await Promise.allSettled([
115
117
  ...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
116
118
  ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
119
+ ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
117
120
  ]);
118
121
  const result = {
119
122
  prNumber,
120
123
  actionableThreads,
121
124
  resolutionOnlyThreads,
122
125
  firstLookThreads,
123
- actionableComments: visibleComments,
126
+ actionableComments: visibleCommentClassification.actionable,
124
127
  firstLookComments,
125
128
  changesRequestedReviews: data.changesRequestedReviews,
126
129
  reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
@@ -128,17 +131,3 @@ export async function runResolveFetch(opts) {
128
131
  };
129
132
  return { ...result, instructions: buildFetchInstructions(prNumber, result, cfg.cli?.runner) };
130
133
  }
131
- export async function runResolveMutate(opts) {
132
- const repo = await getRepoInfo();
133
- const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
134
- if (prNumber === null) {
135
- throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
136
- }
137
- return applyResolveOptions(prNumber, repo, {
138
- resolveThreadIds: opts.resolveThreadIds,
139
- minimizeCommentIds: opts.minimizeCommentIds,
140
- dismissReviewIds: opts.dismissReviewIds,
141
- dismissMessage: opts.dismissMessage,
142
- requireSha: opts.requireSha,
143
- });
144
- }
@@ -1,7 +1,7 @@
1
1
  export const SHEPHERD_JOURNAL_SECTION = "## Shepherd Journal";
2
2
  export const SHEPHERD_JOURNAL_SECTION_PATTERN = /^##\s+Shepherd\s+Journal$/;
3
3
  export const SHEPHERD_JOURNAL_APPEND_HINT = "If this section already exists, append your entries under it instead of creating a duplicate heading.";
4
- export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review the bodies shown under `## Review summaries (first look — to be minimized)` — you are seeing these for the first time. Their IDs are already included in the resolve command's `--minimize-comment-ids`; if any warrants a Shepherd Journal note, append it before running resolve.";
4
+ export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review the bodies shown under `## Review summaries (first look)` — you are seeing these for the first time. Any IDs eligible for minimization are already included in the resolve command's `--minimize-comment-ids`; if any warrants a Shepherd Journal note, append it before running resolve.";
5
5
  export function buildShepherdJournalInstruction(prNumber, itemReferenceGuidance) {
6
6
  return [
7
7
  `For any large decisions or rejections you made this iteration, add or update a \`${SHEPHERD_JOURNAL_SECTION}\` section in the PR description (\`gh pr edit ${prNumber} --body …\`) summarizing each decision.`,
@@ -0,0 +1,15 @@
1
+ export function shouldMinimizeAuthor(authorType, policy) {
2
+ switch (policy) {
3
+ case undefined:
4
+ case "all":
5
+ return true;
6
+ case "bots":
7
+ return authorType === "Bot";
8
+ case "users":
9
+ return authorType === "User";
10
+ case "none":
11
+ return false;
12
+ default:
13
+ throw new Error(`Invalid minimizeComments policy: ${String(policy)}`);
14
+ }
15
+ }
@@ -0,0 +1,20 @@
1
+ import { shouldMinimizeAuthor } from "./minimize-policy.mjs";
2
+ import { classifyItem } from "../state/seen-comments.mjs";
3
+ export function classifyVisibleComments(comments, seenMap, minimizeComments) {
4
+ const actionable = [];
5
+ const minimizeIds = [];
6
+ const toMarkSeen = [];
7
+ for (const c of comments.filter((comment) => !comment.isMinimized)) {
8
+ if (shouldMinimizeAuthor(c.authorType, minimizeComments)) {
9
+ actionable.push(c);
10
+ minimizeIds.push(c.id);
11
+ continue;
12
+ }
13
+ const cls = classifyItem(c.id, c.body, seenMap);
14
+ if (cls === "unchanged")
15
+ continue;
16
+ actionable.push(c);
17
+ toMarkSeen.push(c);
18
+ }
19
+ return { actionable, minimizeIds, toMarkSeen };
20
+ }
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { parse } from "yaml";
5
5
  import builtins from "../config.json" with { type: "json" };
6
6
  import { parseCliRunner } from "../cli/runner.mjs";
7
+ const MINIMIZE_COMMENTS_POLICIES = ["all", "bots", "users", "none"];
7
8
  const RC_FILENAME = ".pr-shepherdrc.yml";
8
9
  function findRcFile(startDir) {
9
10
  const home = homedir();
@@ -37,6 +38,14 @@ function deepMerge(base, override) {
37
38
  }
38
39
  return result;
39
40
  }
41
+ function isMinimizeCommentsPolicy(value) {
42
+ return MINIMIZE_COMMENTS_POLICIES.some((policy) => policy === value);
43
+ }
44
+ function parseMinimizeCommentsPolicy(value) {
45
+ if (isMinimizeCommentsPolicy(value))
46
+ return value;
47
+ throw new Error(`Invalid config: iterate.minimizeComments must be one of "all", "bots", "users", or "none", got ${JSON.stringify(value)}`);
48
+ }
40
49
  const defaults = builtins;
41
50
  const configCache = new Map();
42
51
  export function loadConfig() {
@@ -58,9 +67,7 @@ export function loadConfig() {
58
67
  throw new Error(`Invalid config: cli must be a plain object, got ${JSON.stringify(config.cli)}`);
59
68
  }
60
69
  config.cli.runner = parseCliRunner(config.cli.runner);
61
- if (typeof config.watch?.interval !== "string" || !/^\d+[smhd]$/.test(config.watch.interval)) {
62
- throw new Error(`Invalid config: watch.interval must be a duration string like "4m" or "1h", got ${JSON.stringify(config.watch?.interval)}`);
63
- }
70
+ config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
64
71
  configCache.set(cwd, config);
65
72
  return config;
66
73
  }
package/bin/config.json CHANGED
@@ -3,13 +3,12 @@
3
3
  "runner": "auto"
4
4
  },
5
5
  "iterate": {
6
- "cooldownSeconds": 30,
7
6
  "fixAttemptsPerThread": 3,
8
7
  "stallTimeoutMinutes": 30,
9
- "minimizeApprovals": false
8
+ "minimizeApprovals": false,
9
+ "minimizeComments": "all"
10
10
  },
11
11
  "watch": {
12
- "interval": "4m",
13
12
  "readyDelayMinutes": 10
14
13
  },
15
14
  "resolve": {
@@ -18,6 +18,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
18
18
  line: comment?.line ?? null,
19
19
  startLine: comment?.startLine ?? null,
20
20
  author: comment?.author?.login ?? "unknown",
21
+ authorType: mapAuthorType(comment?.author?.__typename),
21
22
  body: comment?.body ?? "",
22
23
  url: comment?.url ?? "",
23
24
  createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
@@ -27,6 +28,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
27
28
  id: c.id,
28
29
  isMinimized: c.isMinimized,
29
30
  author: c.author?.login ?? "unknown",
31
+ authorType: mapAuthorType(c.author?.__typename),
30
32
  body: c.body,
31
33
  url: c.url,
32
34
  createdAtUnix: parseCreatedAt(c.createdAt),
@@ -34,6 +36,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
34
36
  const changesRequestedReviews = rawReviewNodes.map((r) => ({
35
37
  id: r.id,
36
38
  author: r.author?.login ?? "unknown",
39
+ authorType: mapAuthorType(r.author?.__typename),
37
40
  body: r.body,
38
41
  }));
39
42
  const reviewSummaries = rawReviewSummaryNodes
@@ -41,6 +44,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
41
44
  .map((r) => ({
42
45
  id: r.id,
43
46
  author: r.author?.login ?? "unknown",
47
+ authorType: mapAuthorType(r.author?.__typename),
44
48
  body: r.body,
45
49
  }));
46
50
  // APPROVED reviews often have empty bodies (clicking "Approve" without a comment), so
@@ -51,6 +55,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
51
55
  .map((r) => ({
52
56
  id: r.id,
53
57
  author: r.author?.login ?? "unknown",
58
+ authorType: mapAuthorType(r.author?.__typename),
54
59
  body: r.body,
55
60
  }));
56
61
  const checks = rawCheckNodes.flatMap((node) => {
@@ -109,6 +114,11 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
109
114
  checks,
110
115
  };
111
116
  }
117
+ function mapAuthorType(typeName) {
118
+ if (typeName === "User" || typeName === "Bot")
119
+ return typeName;
120
+ return "Unknown";
121
+ }
112
122
  function parseCreatedAt(iso) {
113
123
  const ms = new Date(iso).getTime();
114
124
  return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
@@ -43,6 +43,7 @@ query BatchPr(
43
43
  latestReviews(last: 100) {
44
44
  nodes {
45
45
  author {
46
+ __typename
46
47
  login
47
48
  }
48
49
  state
@@ -64,6 +65,7 @@ query BatchPr(
64
65
  isMinimized
65
66
  url
66
67
  author {
68
+ __typename
67
69
  login
68
70
  }
69
71
  body
@@ -85,6 +87,7 @@ query BatchPr(
85
87
  isMinimized
86
88
  url
87
89
  author {
90
+ __typename
88
91
  login
89
92
  }
90
93
  body
@@ -103,6 +106,7 @@ query BatchPr(
103
106
  nodes {
104
107
  id
105
108
  author {
109
+ __typename
106
110
  login
107
111
  }
108
112
  body
@@ -117,6 +121,7 @@ query BatchPr(
117
121
  id
118
122
  isMinimized
119
123
  author {
124
+ __typename
120
125
  login
121
126
  }
122
127
  body
@@ -131,6 +136,7 @@ query BatchPr(
131
136
  id
132
137
  isMinimized
133
138
  author {
139
+ __typename
134
140
  login
135
141
  }
136
142
  body
@@ -13,8 +13,6 @@ const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8
13
13
  export const BATCH_PR_QUERY = gql("batch-pr.gql");
14
14
  /** Returns the current head commit SHA for a PR. Used by waitForSha polling. */
15
15
  export const GET_PR_HEAD_SHA_QUERY = gql("get-pr-head-sha.gql");
16
- /** Paginated follow-up for `shepherd status` — used when reviewThreads is truncated (totalCount > 100). */
17
- export const MULTI_PR_STATUS_QUERY_WITH_CURSOR = gql("multi-pr-status-paged.gql");
18
16
  /** Look up PR number by branch name (for getCurrentPrNumber). */
19
17
  export const PR_NUMBER_BY_BRANCH_QUERY = gql("pr-number-by-branch.gql");
20
18
  /** Convert a draft PR to ready for review. */
package/bin/index.mjs CHANGED
@@ -4,10 +4,8 @@
4
4
  *
5
5
  * Usage:
6
6
  * pr-shepherd [PR]
7
- * pr-shepherd check [PR]
8
7
  * pr-shepherd resolve [PR]
9
8
  * pr-shepherd iterate [PR]
10
- * pr-shepherd status PR1 [PR2 …]
11
9
  */
12
10
  import { main } from "./cli-parser.mjs";
13
11
  function formatCause(cause, seen = new Set(), depth = 0) {
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Interpretation order for `status` — first match wins:
8
8
  * 1. mergeable == CONFLICTING → CONFLICTS (hard conflict even for drafts)
9
- * 2. copilotReviewInProgress → BLOCKED
9
+ * 2. blockingBotReviewInProgress → BLOCKED
10
10
  * 3. mergeStateStatus DIRTY → CONFLICTS (GitHub merge conflicts, even for drafts)
11
11
  * 4. isDraft → DRAFT
12
12
  * 5. mergeStateStatus BEHIND → BEHIND
@@ -17,12 +17,12 @@
17
17
  */
18
18
  import { loadConfig } from "../config/load.mjs";
19
19
  export function deriveMergeStatus(pr) {
20
- const copilotReviewInProgress = detectCopilotReview(pr);
20
+ const blockingBotReviewInProgress = detectBlockingBotReview(pr);
21
21
  let status;
22
22
  if (pr.mergeable === "CONFLICTING") {
23
23
  status = "CONFLICTS";
24
24
  }
25
- else if (copilotReviewInProgress) {
25
+ else if (blockingBotReviewInProgress) {
26
26
  status = "BLOCKED";
27
27
  }
28
28
  else if (pr.mergeStateStatus === "DIRTY") {
@@ -53,14 +53,14 @@ export function deriveMergeStatus(pr) {
53
53
  isDraft: pr.isDraft,
54
54
  mergeable: pr.mergeable,
55
55
  reviewDecision: pr.reviewDecision,
56
- copilotReviewInProgress,
56
+ blockingBotReviewInProgress,
57
57
  mergeStateStatus: pr.mergeStateStatus,
58
58
  };
59
59
  }
60
60
  // ---------------------------------------------------------------------------
61
- // Copilot review detection
61
+ // Blocking bot review detection
62
62
  // ---------------------------------------------------------------------------
63
- function detectCopilotReview(pr) {
63
+ function detectBlockingBotReview(pr) {
64
64
  // A blocking bot review is "in progress" when:
65
65
  // 1. Any reviewRequest has a login starting with one of the configured prefixes, OR
66
66
  // 2. Any latestReview has such a login AND state == "PENDING"
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * These strip fields that are always-false by the time items reach iterate
5
5
  * (isResolved, isOutdated, isMinimized, createdAtUnix) and check metadata the
6
- * monitor prompt never reads (event, status, category).
6
+ * agent/iterate prompt never reads (event, status, category).
7
7
  * conclusion is preserved on AgentCheck so the formatter can branch on run-level conclusions.
8
8
  * detailsUrl is preserved in AgentCheck as a fallback for external status checks.
9
- * The original domain types are preserved for check command output.
9
+ * The original domain types are preserved as internal snapshot types.
10
10
  */
11
11
  import { extractSuggestion } from "../suggestions/extract.mjs";
12
12
  export function toAgentThread(t) {
@@ -19,13 +19,20 @@ export function toAgentThread(t) {
19
19
  t.startLine !== null &&
20
20
  t.startLine !== t.line && { startLine: t.startLine }),
21
21
  author: t.author,
22
+ ...(t.authorType !== undefined && { authorType: t.authorType }),
22
23
  body: t.body,
23
24
  url: t.url,
24
25
  ...(suggestion !== undefined && { suggestion }),
25
26
  };
26
27
  }
27
28
  export function toAgentComment(c) {
28
- return { id: c.id, author: c.author, body: c.body, url: c.url };
29
+ return {
30
+ id: c.id,
31
+ author: c.author,
32
+ ...(c.authorType !== undefined && { authorType: c.authorType }),
33
+ body: c.body,
34
+ url: c.url,
35
+ };
29
36
  }
30
37
  export function toAgentCheck(c) {
31
38
  if (c.conclusion === "SKIPPED" || c.conclusion === "NEUTRAL") {
@@ -32,6 +32,15 @@ export async function readStallState(key) {
32
32
  return null;
33
33
  }
34
34
  }
35
+ /** Clear stall state so the next invocation starts a fresh timer (fire-and-forget — never throws). */
36
+ export async function clearStallState(key) {
37
+ try {
38
+ await unlink(resolvePath(key));
39
+ }
40
+ catch {
41
+ // Best-effort — file may not exist.
42
+ }
43
+ }
35
44
  /** Write stall state (fire-and-forget — never throws). */
36
45
  export async function writeStallState(key, state) {
37
46
  let tmp;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.15.2",
3
+ "version": "0.16.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",
@@ -10,7 +10,6 @@
10
10
  },
11
11
  "files": [
12
12
  "bin/**",
13
- "plugin/**",
14
13
  "plugins/**",
15
14
  "plugins/**/.codex-plugin/**",
16
15
  ".claude-plugin/**",
@@ -36,12 +35,12 @@
36
35
  },
37
36
  "scripts": {
38
37
  "build": "node scripts/build.mjs",
39
- "prepare": "node scripts/install-husky.mjs && node scripts/install-plugin-symlink.mjs && npm run build",
38
+ "prepare": "node scripts/install-husky.mjs && npm run build",
40
39
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
41
40
  "typecheck": "tsc --noEmit",
42
- "lint": "oxlint src/ plugin/skills/ plugins/ .agents/plugins/",
43
- "format": "oxfmt src/ plugin/skills/ plugins/ .agents/plugins/ docs/ README.md",
44
- "format:check": "oxfmt --check src/ plugin/skills/ plugins/ .agents/plugins/ docs/ README.md",
41
+ "lint": "oxlint src/ plugins/ .agents/plugins/",
42
+ "format": "oxfmt src/ plugins/ .agents/plugins/ docs/ README.md",
43
+ "format:check": "oxfmt --check src/ plugins/ .agents/plugins/ docs/ README.md",
45
44
  "test": "vitest run",
46
45
  "test:coverage": "vitest run --coverage",
47
46
  "test:watch": "vitest"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -1,43 +1,48 @@
1
1
  ---
2
2
  name: pr-shepherd
3
- description: 'Codex-only skill for iterating a GitHub pull request to completion with pr-shepherd. Use for requests like "use pr-shepherd", "iterate PR #123", or "run pr-shepherd until this PR is ready".'
3
+ description: 'Iterate a GitHub pull request to completion with pr-shepherd. Use for requests like "use pr-shepherd", "iterate PR #123", or "run pr-shepherd until this PR is ready".'
4
+ user-invocable: true
5
+ argument-hint: "[PR number or URL]"
6
+ allowed-tools: ["Bash", "Read", "Grep", "Edit", "Write", "Glob", "Skill"]
4
7
  ---
5
8
 
6
9
  # pr-shepherd
7
10
 
8
- Codex-only workflow for getting actionable PR updates from `pr-shepherd`.
11
+ One-tick dispatcher for iterating a PR to completion.
9
12
 
10
- ## Workflow
13
+ ## Arguments: $ARGUMENTS
11
14
 
12
- 1. Resolve the PR number.
13
- - If the user provides a PR number, use it.
14
- - If the user provides a GitHub PR URL, extract the PR number.
15
- - If no PR is provided, infer it from the current branch with:
16
- `gh pr view --json number --jq .number`
17
- - If no PR is found, report that and stop.
15
+ ## Steps
18
16
 
19
- 2. Use this objective for the whole goal:
20
- - `Run pr-shepherd PR_NUMBER cycles through the target repo package runner, picking a fresh sleep/timeout between 1 and 4 minutes before each rerun, until Shepherd emits [CANCEL] for ready-delay completion or PR #PR_NUMBER is merged/closed, or pr-shepherd escalates, including repeated unchanged CI failures.`
17
+ 1. **Resolve PR number:**
18
+ - If `$ARGUMENTS` contains a PR number, use it.
19
+ - If `$ARGUMENTS` contains a GitHub PR URL, extract the number.
20
+ - Otherwise, infer: `gh pr view --json number --jq .number`
21
+ - If no PR found, report an error and stop.
21
22
 
22
- 3. Select the package runner from the target repository root.
23
- - Prefer `package.json` `packageManager`: `pnpm@...` -> `pnpm exec`, `yarn@...` -> `yarn run`, `npm@...` -> `npx --no-install`.
24
- - If `packageManager` is absent, use lockfiles: `pnpm-lock.yaml` -> `pnpm exec`, `yarn.lock` -> `yarn run`, `package-lock.json` or no signal -> `npx --no-install`.
25
- - Example: in `~/filaments`, use `pnpm exec pr-shepherd ...` because the root package declares `packageManager: "pnpm@..."` and has `pnpm-lock.yaml`.
23
+ 2. **Short-circuit if merged or closed:**
26
24
 
27
- 4. Verify the CLI is available.
28
- - Only when the target repository itself is the pr-shepherd source checkout, verify `bin/` and `node_modules/` exist before any local CLI invocation. If either is missing, run the source checkout's package-manager install command. This repository currently uses npm, so run:
29
- `npm install`
30
- - In other repositories, run through the selected package runner so Codex does not install packages implicitly. If the package is missing, tell the user to install `pr-shepherd` with the matching dev-dependency command: `pnpm add -D pr-shepherd`, `yarn add -D pr-shepherd`, or `npm install --save-dev pr-shepherd`.
25
+ ```bash
26
+ gh pr view <N> --json state --jq '.state'
27
+ ```
31
28
 
32
- 5. Run the appropriate command from the repository root.
33
- - `<runner> pr-shepherd PR_NUMBER`
29
+ If `MERGED` or `CLOSED`, output: `PR #N is already merged/closed. Nothing to do.` and stop.
34
30
 
35
- 6. Print or summarize the important status, then follow the output's `## Instructions` exactly.
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`.
36
34
 
37
- 7. If the output indicates continuation, pick a fresh sleep/timeout between 1 and 4 minutes, wait that long, and run another explicit `<runner> pr-shepherd PR_NUMBER` cycle through the same runner.
35
+ 4. **Run one iterate tick:**
38
36
 
39
- 8. Do not stop on `[WAIT]`, `[COOLDOWN]`, `[MARK_READY]`, or post-fix CI wait states. These are nonterminal Codex recurrence states.
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`.
40
38
 
41
- 9. Stop only when Shepherd emits `[CANCEL]` for ready-delay completion or PR #PR_NUMBER is merged/closed, or when it emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures.
39
+ ```bash
40
+ <runner> pr-shepherd <N>
41
+ ```
42
42
 
43
- 10. If the output includes fixes, pushes, rebases, or resolve commands, perform only the instructed scoped actions. Do not resolve, minimize, or dismiss comments until the CLI-provided post-push and `--require-sha` instructions are satisfied.
43
+ Print the full output. Follow the `## Instructions` section exactly.
44
+
45
+ 5. **Stop conditions:**
46
+ - Stop when the CLI emits `[CANCEL]` (ready-delay completed, or PR merged/closed).
47
+ - 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` to sleep/wait and rerun.
@@ -1,2 +0,0 @@
1
- export { runIterate } from "./iterate/index.mjs";
2
- export { FIX_INSTRUCTION_END_ITERATION, FIX_INSTRUCTION_STOP_AFTER_PUSH, FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK, renderResolveCommand, } from "./iterate/render.mjs";
@@ -1,139 +0,0 @@
1
- import { getCurrentPrNumber } from "../github/client.mjs";
2
- import { loadConfig } from "../config/load.mjs";
3
- import { joinSections } from "../util/markdown.mjs";
4
- import { buildPrShepherdCommand } from "../cli/runner.mjs";
5
- export async function runMonitor(opts) {
6
- const config = loadConfig();
7
- const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
8
- if (prNumber === null) {
9
- throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
10
- }
11
- const { interval } = config.watch;
12
- if (typeof interval !== "string" || !/^\d+[smhd]$/.test(interval)) {
13
- throw new Error(`Invalid config: watch.interval must be a duration string like "4m" or "1h", got ${JSON.stringify(interval)}`);
14
- }
15
- // No space after `#` — `# text` is a CommonMark ATX heading; `#text` is not.
16
- // Trailing `:` prevents substring false positives: without it, the dedup grep
17
- // for pr=1 would match a cron prompt for pr=135. Both the CronList check in
18
- // step 1 of formatMonitorResult's ## Instructions and the in-prompt Self-dedup
19
- // block depend on this exact string — don't change the format.
20
- const loopTag = `#pr-shepherd-loop:pr=${prNumber}:`;
21
- const loopArgs = interval;
22
- const reusableCommand = buildIterateCommand(prNumber, opts.readyDelaySuffix, config.cli?.runner);
23
- const loopPrompt = buildLoopPrompt(prNumber, loopTag, reusableCommand, opts.runtime ?? "claude", config.cli?.runner);
24
- return {
25
- prNumber,
26
- loopTag,
27
- loopArgs,
28
- loopPrompt,
29
- reusableCommand,
30
- };
31
- }
32
- // ---------------------------------------------------------------------------
33
- // Formatters
34
- // ---------------------------------------------------------------------------
35
- export function formatMonitorResult(result, opts) {
36
- const { prNumber, loopTag, loopArgs, loopPrompt } = result;
37
- const runtime = opts?.runtime ?? "claude";
38
- const sections = [
39
- [
40
- `# PR #${prNumber} [MONITOR]`,
41
- "",
42
- `Loop tag: \`${loopTag}\``,
43
- runtime === "codex" ? null : `Loop args: \`${loopArgs}\``,
44
- ]
45
- .filter((line) => line !== null)
46
- .join("\n"),
47
- runtime === "codex" ? `Reusable command: \`${result.reusableCommand}\`` : null,
48
- "## Loop prompt",
49
- loopPrompt,
50
- "## Instructions",
51
- buildMonitorInstructions(result, runtime)
52
- .map((inst, i) => `${i + 1}. ${inst}`)
53
- .join("\n"),
54
- ];
55
- return joinSections(sections);
56
- }
57
- export function formatMonitorJson(result, opts) {
58
- const runtime = opts?.runtime ?? "claude";
59
- if (runtime === "codex") {
60
- return {
61
- prNumber: result.prNumber,
62
- loopTag: result.loopTag,
63
- loopPrompt: result.loopPrompt,
64
- reusableCommand: result.reusableCommand,
65
- instructions: buildMonitorInstructions(result, runtime),
66
- };
67
- }
68
- return {
69
- prNumber: result.prNumber,
70
- loopTag: result.loopTag,
71
- loopArgs: result.loopArgs,
72
- loopPrompt: result.loopPrompt,
73
- instructions: buildMonitorInstructions(result, runtime),
74
- };
75
- }
76
- // ---------------------------------------------------------------------------
77
- // Internal
78
- // ---------------------------------------------------------------------------
79
- function validateReadyDelaySuffix(readyDelaySuffix) {
80
- if (readyDelaySuffix === undefined)
81
- return undefined;
82
- const trimmed = readyDelaySuffix.trim();
83
- if (!/^\d+(?:m|min|minutes?|h|hours?)$/.test(trimmed)) {
84
- throw new Error(`Invalid --ready-delay: ${readyDelaySuffix}. Expected a duration like 5m, 2h, 10m, or 1h.`);
85
- }
86
- return trimmed;
87
- }
88
- function buildIterateCommand(prNumber, readyDelaySuffix, runner) {
89
- const validatedDelay = validateReadyDelaySuffix(readyDelaySuffix);
90
- return buildPrShepherdCommand([String(prNumber), ...(validatedDelay ? ["--ready-delay", validatedDelay] : [])], { runner }).text;
91
- }
92
- function buildLoopPrompt(prNumber, loopTag, iterateCmd, runtime = "claude", runner) {
93
- if (runtime === "codex") {
94
- return [
95
- loopTag,
96
- "",
97
- "**IMPORTANT — Codex recurrence rules:**",
98
- "- Run the command below once and follow its `## Instructions` exactly.",
99
- "- If the output tells you to continue the active Codex goal, pick a fresh sleep/timeout between 1 and 4 minutes, wait that long, and rerun the reusable command from the monitor output.",
100
- "- Stop only when Shepherd emits `[CANCEL]` because the ready-delay completed or the PR was merged/closed, or when Shepherd emits `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures).",
101
- `- Do not call \`/loop\`, \`ScheduleWakeup\`, \`CronCreate\`, or \`${buildPrShepherdCommand(["monitor", String(prNumber)], { runner }).text}\`; Codex recurrence is explicit \`iterate\` command cycles.`,
102
- "",
103
- "Run in a single Bash call:",
104
- ` ${iterateCmd}`,
105
- "",
106
- `Exit codes 0–3 are all valid. If the command crashes (non-zero exit, no markdown output starting with \`# PR #${prNumber} [\`), report the first line of stderr and stop so the user can retry.`,
107
- "",
108
- "The output is Markdown. The first line is an H1 heading of the form `# PR #<N> [<ACTION>]`. Every output ends with a `## Instructions` section — follow those numbered steps exactly.",
109
- ].join("\n");
110
- }
111
- return [
112
- loopTag,
113
- "",
114
- "**IMPORTANT — recurrence rules:**",
115
- "- **Do NOT call `ScheduleWakeup` or `/loop`.** This session is fired by a recurring cron job. Either call creates a duplicate runner, causing concurrent git operations and `.git/index.lock` collisions.",
116
- "- End the turn cleanly after completing the actions below. The cron job handles the next fire.",
117
- "",
118
- `**Self-dedup:** Run \`CronList\`. If more than one job contains \`${loopTag}\`, keep the lowest job ID and \`CronDelete\` the rest (ignore errors — a concurrent runner may have already deleted them).`,
119
- "",
120
- "Run in a single Bash call:",
121
- ` ${iterateCmd}`,
122
- "",
123
- `Exit codes 0–3 are all valid. If the command crashes (non-zero exit, no markdown output starting with \`# PR #${prNumber} [\`), log the first line of stderr and continue — do not cancel the loop. The next cron fire will retry.`,
124
- "",
125
- "The output is Markdown. The first line is an H1 heading of the form `# PR #<N> [<ACTION>]`. Every output ends with a `## Instructions` section — follow those numbered steps exactly.",
126
- ].join("\n");
127
- }
128
- function buildMonitorInstructions(result, runtime) {
129
- if (runtime === "codex") {
130
- return [
131
- "Run the `## Loop prompt` body once inline now.",
132
- `For an active Codex goal, keep cycling with \`${result.reusableCommand}\` by picking a fresh sleep/timeout between 1 and 4 minutes before each rerun until a terminal condition is reached. Codex does not create a \`/loop\` monitor.`,
133
- ];
134
- }
135
- return [
136
- `Run \`CronList\`. If any job's prompt contains \`${result.loopTag}\`, run the \`## Loop prompt\` body once inline (as if it were a cron tick) then stop — do not create a duplicate loop.`,
137
- "Otherwise, invoke the `/loop` skill via the Skill tool. Build the `args` parameter as: only the value inside the backticks on the `Loop args` line above (the interval — not the `Loop args:` label), then a blank line, then the full `## Loop prompt` body.",
138
- ];
139
- }