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,5 +1,8 @@
1
1
  import { renderLineRange, renderSuggestionBlock } from "./suggestion-renderer.mjs";
2
2
  const BODY_PREVIEW_MAX = 100;
3
+ export function renderAuthor(author, authorType) {
4
+ return authorType ? `@${author} · ${authorType}` : `@${author}`;
5
+ }
3
6
  export function renderBodyPreview(body) {
4
7
  const normalizedBody = body.replace(/\r\n?/g, "\n");
5
8
  const firstLine = normalizedBody.split("\n")[0]?.trim() ?? "";
@@ -24,7 +27,7 @@ export function renderThreadBullet(t, opts = {}) {
24
27
  : "`(no location)`";
25
28
  const suggestionMarker = t.suggestion ? " [suggestion]" : "";
26
29
  const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
27
- const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (@${t.author})${suggestionMarker}${statusSuffix}: ${renderBodyPreview(t.body)}`;
30
+ const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${statusSuffix}: ${renderBodyPreview(t.body)}`;
28
31
  if (t.suggestion && opts.renderSuggestion) {
29
32
  return `${bulletLine}\n${renderSuggestionBlock(t.suggestion)}`;
30
33
  }
@@ -33,11 +36,11 @@ export function renderThreadBullet(t, opts = {}) {
33
36
  export function renderCommentBullet(c, opts = {}) {
34
37
  const link = c.url ? ` [↗](${c.url})` : "";
35
38
  const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
36
- return `- \`commentId=${c.id}\`${link} (@${c.author})${statusSuffix}: ${renderBodyPreview(c.body)}`;
39
+ return `- \`commentId=${c.id}\`${link} (${renderAuthor(c.author, c.authorType)})${statusSuffix}: ${renderBodyPreview(c.body)}`;
37
40
  }
38
41
  export function renderReviewBullet(r, opts = {}) {
39
42
  const bodySuffix = opts.includeBody && r.body != null && r.body !== "" ? `: ${renderBodyPreview(r.body)}` : "";
40
- return `- \`reviewId=${r.id}\` (@${r.author})${bodySuffix}`;
43
+ return `- \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType)})${bodySuffix}`;
41
44
  }
42
45
  export function renderReviewListSection(heading, items) {
43
46
  if (items.length === 0)
@@ -7,7 +7,7 @@ export function makeIterateResult(action = "wait") {
7
7
  mergeStateStatus: "BLOCKED",
8
8
  mergeStatus: "BLOCKED",
9
9
  reviewDecision: null,
10
- copilotReviewInProgress: false,
10
+ blockingBotReviewInProgress: false,
11
11
  isDraft: false,
12
12
  shouldCancel: false,
13
13
  remainingSeconds: 60,
@@ -15,8 +15,6 @@ export function makeIterateResult(action = "wait") {
15
15
  baseBranch: "main",
16
16
  checks: [],
17
17
  };
18
- if (action === "cooldown")
19
- return { ...base, action: "cooldown", log: "SKIP: CI still starting" };
20
18
  if (action === "wait")
21
19
  return { ...base, action: "wait", log: "WAIT: 0 passing, 1 in-progress" };
22
20
  if (action === "mark_ready")
@@ -26,7 +24,6 @@ export function makeIterateResult(action = "wait") {
26
24
  ...base,
27
25
  action: "fix_code",
28
26
  fix: {
29
- mode: "rebase-and-push",
30
27
  threads: [],
31
28
  resolutionOnlyThreads: [],
32
29
  actionableComments: [],
@@ -42,7 +39,7 @@ export function makeIterateResult(action = "wait") {
42
39
  requiresDismissMessage: false,
43
40
  hasMutations: false,
44
41
  },
45
- instructions: ["End this iteration."],
42
+ instructions: ["Stop this iteration before the next tick."],
46
43
  firstLookThreads: [],
47
44
  firstLookComments: [],
48
45
  inProgressRunIds: [],
@@ -55,7 +52,7 @@ export function makeIterateResult(action = "wait") {
55
52
  ...base,
56
53
  action: "cancel",
57
54
  reason: "ready-delay-elapsed",
58
- log: "CANCEL: PR #42 — stopping monitor",
55
+ log: "CANCEL: PR #42 — stopping",
59
56
  };
60
57
  if (action === "escalate") {
61
58
  return {
@@ -67,7 +64,7 @@ export function makeIterateResult(action = "wait") {
67
64
  ambiguousComments: [],
68
65
  changesRequestedReviews: [],
69
66
  suggestion: "check manually",
70
- humanMessage: "⚠️ /pr-shepherd:monitor paused — needs human direction",
67
+ humanMessage: "⚠️ /pr-shepherd:pr-shepherd paused — needs human direction",
71
68
  },
72
69
  };
73
70
  }
@@ -3,35 +3,26 @@
3
3
  *
4
4
  * Usage:
5
5
  * pr-shepherd --version
6
- * pr-shepherd [PR] [--format text|json] [--cooldown-seconds N] [--ready-delay Nm]
6
+ * pr-shepherd [PR] [--format text|json] [--ready-delay Nm]
7
7
  * [--stall-timeout <duration>] [--no-auto-mark-ready]
8
8
  * [--no-auto-cancel-actionable]
9
- * pr-shepherd check [PR] [--format text|json]
10
9
  * pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
11
10
  * [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
12
11
  * pr-shepherd commit-suggestion [PR] --thread-id ID [--message MSG] [--description DESC]
13
12
  * [--dry-run] [--format text|json]
14
13
  * (--message is required unless --dry-run is set)
15
- * pr-shepherd iterate [PR] [--format text|json] [--cooldown-seconds N] [--ready-delay Nm]
14
+ * pr-shepherd iterate [PR] [--format text|json] [--ready-delay Nm]
16
15
  * [--stall-timeout <duration>] [--no-auto-mark-ready]
17
16
  * [--no-auto-cancel-actionable]
18
- * pr-shepherd monitor [PR] [--format text|json]
19
- * pr-shepherd status PR1 [PR2 …]
20
17
  */
21
18
  import { readFileSync } from "node:fs";
22
- import { runCheck } from "./commands/check.mjs";
23
19
  import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
24
20
  import { runLogFile } from "./commands/log-file.mjs";
25
- import { formatJson } from "./reporters/json.mjs";
26
- import { formatText } from "./reporters/text.mjs";
27
21
  import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
28
22
  import { isDefaultIterateInvocation, validateDefaultIterateArgs } from "./cli/default-iterate.mjs";
29
- import { statusToExitCode } from "./cli/exit-codes.mjs";
30
23
  import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
31
- import { handleCommitSuggestion, handleIterate, handleMonitor, handleStatus, } from "./cli/handlers.mjs";
24
+ import { handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
32
25
  import { setupLog } from "./log/setup.mjs";
33
- import { detectAgentRuntime } from "./agent-runtime.mjs";
34
- import { loadConfig } from "./config/load.mjs";
35
26
  // ---------------------------------------------------------------------------
36
27
  // Entry
37
28
  // ---------------------------------------------------------------------------
@@ -56,9 +47,6 @@ export async function main(argv) {
56
47
  return;
57
48
  }
58
49
  switch (subcommand) {
59
- case "check":
60
- await handleCheck(args.slice(1));
61
- break;
62
50
  case "resolve":
63
51
  await handleResolve(args.slice(1));
64
52
  break;
@@ -68,15 +56,9 @@ export async function main(argv) {
68
56
  case "iterate":
69
57
  await handleIterate(args.slice(1));
70
58
  break;
71
- case "monitor":
72
- await handleMonitor(args.slice(1));
73
- break;
74
- case "status":
75
- await handleStatus(args.slice(1));
76
- break;
77
59
  default:
78
60
  process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
79
- process.stderr.write("Usage: pr-shepherd <check|resolve|commit-suggestion|iterate|monitor|status|log-file> [options]\n" +
61
+ process.stderr.write("Usage: pr-shepherd <resolve|commit-suggestion|iterate|log-file> [options]\n" +
80
62
  " pr-shepherd --version | -v\n");
81
63
  process.exitCode = 1;
82
64
  return;
@@ -90,17 +72,6 @@ function readVersion() {
90
72
  // ---------------------------------------------------------------------------
91
73
  // Subcommand handlers
92
74
  // ---------------------------------------------------------------------------
93
- async function handleCheck(args) {
94
- const { prNumber, global: globalOpts } = parseCommonArgs(args);
95
- const runtime = detectAgentRuntime();
96
- const cfg = loadConfig();
97
- const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
98
- const output = globalOpts.format === "json"
99
- ? formatJson(report, { runtime, runner: cfg.cli?.runner })
100
- : formatText(report, { runtime, runner: cfg.cli?.runner });
101
- process.stdout.write(`${output}\n`);
102
- process.exitCode = statusToExitCode(report.status);
103
- }
104
75
  async function handleLogFile(args) {
105
76
  const jsonOut = args.some((a) => a === "--format=json") ||
106
77
  (() => {
@@ -13,14 +13,14 @@ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, me
13
13
  // is BLOCKED (review pending, insufficient approvals, branch-protection rule, etc.).
14
14
  // Requires hasChecks so that a PR with zero relevant checks (CI never started, or all
15
15
  // filtered/skipped) doesn't prematurely trigger READY before any check has reported.
16
- // copilotReviewInProgress is still excluded — a bot review is shepherd's problem, not a hand-off.
16
+ // blockingBotReviewInProgress is still excluded — a bot review is shepherd's problem, not a hand-off.
17
17
  if (verdict.allPassed &&
18
18
  verdict.hasChecks &&
19
19
  unresolvedThreads === 0 &&
20
20
  unresolvedComments === 0 &&
21
21
  changesRequestedReviews === 0 &&
22
22
  mergeStatus.status === "BLOCKED" &&
23
- !mergeStatus.copilotReviewInProgress) {
23
+ !mergeStatus.blockingBotReviewInProgress) {
24
24
  return "READY";
25
25
  }
26
26
  if (mergeStatus.status === "BLOCKED" ||
@@ -7,6 +7,7 @@ import { getOutdatedThreads } from "../comments/outdated.mjs";
7
7
  import { autoResolveOutdated } from "../comments/resolve.mjs";
8
8
  import { deriveMergeStatus } from "../merge-status/derive.mjs";
9
9
  import { loadConfig } from "../config/load.mjs";
10
+ import { classifyVisibleComments } from "../comments/visible-comments.mjs";
10
11
  import { computeStatus } from "./check-status.mjs";
11
12
  import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
12
13
  export async function runCheck(opts) {
@@ -41,7 +42,6 @@ export async function runCheck(opts) {
41
42
  const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
42
43
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
43
44
  const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved);
44
- const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
45
45
  const outdated = getOutdatedThreads(unresolvedThreads);
46
46
  let autoResolved = [];
47
47
  let autoResolveErrors = [];
@@ -56,6 +56,7 @@ export async function runCheck(opts) {
56
56
  const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
57
57
  const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
58
58
  const seenMap = await loadSeenMap(stateKey);
59
+ const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments);
59
60
  const autoResolvedIds = new Set(autoResolved.map((t) => t.id));
60
61
  const firstLookThreads = [
61
62
  ...outdatedCandidates.flatMap((t) => {
@@ -106,17 +107,16 @@ export async function runCheck(opts) {
106
107
  await Promise.allSettled([
107
108
  ...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
108
109
  ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
110
+ ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
109
111
  ...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
110
112
  ]);
111
- const actionableThreads = activeThreads;
112
113
  const resolutionOnlyThreads = unresolvedThreads.filter((t) => !autoResolvedIds.has(t.id) && (t.isOutdated || t.isMinimized));
113
- const actionableComments = visibleComments;
114
114
  const mergeStatus = deriveMergeStatus(batchData);
115
115
  const blockedByFilteredCheck = mergeStatus.status === "BLOCKED" &&
116
116
  !verdict.anyFailing &&
117
117
  !verdict.anyInProgress &&
118
118
  verdict.filteredNames.length > 0;
119
- const status = computeStatus(verdict, actionableThreads.length + resolutionOnlyThreads.length, actionableComments.length, mergeStatus, batchData.changesRequestedReviews.length);
119
+ const status = computeStatus(verdict, activeThreads.length + resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, batchData.changesRequestedReviews.length);
120
120
  return {
121
121
  pr: prNumber,
122
122
  nodeId: batchData.nodeId,
@@ -134,14 +134,15 @@ export async function runCheck(opts) {
134
134
  blockedByFilteredCheck,
135
135
  },
136
136
  threads: {
137
- actionable: actionableThreads,
137
+ actionable: activeThreads,
138
138
  resolutionOnly: resolutionOnlyThreads,
139
139
  autoResolved,
140
140
  autoResolveErrors,
141
141
  firstLook: firstLookThreads,
142
142
  },
143
143
  comments: {
144
- actionable: actionableComments,
144
+ actionable: visibleCommentClassification.actionable,
145
+ minimizeIds: visibleCommentClassification.minimizeIds,
145
146
  firstLook: firstLookComments,
146
147
  },
147
148
  changesRequestedReviews: batchData.changesRequestedReviews,
@@ -0,0 +1,23 @@
1
+ import { buildPrShepherdCommand } from "../cli/runner.mjs";
2
+ /**
3
+ * Build the `commit-suggestion` instruction step for agent consumers.
4
+ * Shared between `fix_code` (iterate) and `resolve --fetch` paths.
5
+ * @param sectionName - The markdown section heading where suggestion threads appear
6
+ * (e.g. `"## Review threads"` for iterate, `"## Actionable Review Threads"` for resolve).
7
+ * @param includeDriftHint - Whether to add the trailing note about drift on failed apply.
8
+ */
9
+ export function buildCommitSuggestionInstruction(prNumber, sectionName, includeDriftHint, runner) {
10
+ const command = buildPrShepherdCommand([
11
+ "commit-suggestion",
12
+ String(prNumber),
13
+ "--thread-id",
14
+ "<id>",
15
+ "--message",
16
+ "<one-sentence headline>",
17
+ "--format=json",
18
+ ], { runner }).text;
19
+ const driftHint = includeDriftHint
20
+ ? " If the patch fails to apply (drift since the suggestion was written), fall through to the manual fix step."
21
+ : " If the patch fails to apply, fall through to the manual-edit step.";
22
+ return `For each thread marked \`[suggestion]\` under \`${sectionName}\`: run \`${command}\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the resolve command below (the thread is not auto-resolved).${driftHint} Do not retry the same command.`;
23
+ }
@@ -6,7 +6,7 @@ export function buildFailingCheckInstructions(checks) {
6
6
  const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
7
7
  const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
8
8
  if (failedRunIdChecks.length > 0) {
9
- instructions.push(`For each failing check under \`## Failing checks\` with a run ID and no \`[conclusion: CANCELLED]\` or \`[conclusion: STARTUP_FAILURE]\` tag: run \`gh run view <runId> --log-failed\` to fetch the failing job's log.`, `If the log shows a transient infrastructure failure (network timeout, runner setup crash, OOM kill): run \`gh run rerun <runId> --failed\`.`, `If the log shows a real test/build failure: apply a code fix.`);
9
+ instructions.push(`For each failing check under \`## Failing checks\` with a run ID and no \`[conclusion: CANCELLED]\` or \`[conclusion: STARTUP_FAILURE]\` tag: run \`gh run view <runId> --log-failed\` to fetch the failing job's log. If the log shows a transient infrastructure failure (network timeout, runner setup crash, OOM kill), run \`gh run rerun <runId> --failed\`. If the log shows a real test/build failure, apply a code fix.`);
10
10
  }
11
11
  if (cancelledRunIdChecks.length > 0) {
12
12
  instructions.push(`For each \`[conclusion: CANCELLED]\` bullet under \`## Failing checks\`: the run was cancelled outside Shepherd's control (manual cancel, newer push, concurrency-group eviction). Run \`gh run rerun <runId>\` only if the cancellation looks unintended; otherwise treat it as resolved by the superseding run. Do NOT confuse these with IDs under \`## Cancelled runs\` — those were cancelled by Shepherd itself.`);
@@ -1,12 +1,17 @@
1
1
  import { buildPrShepherdCommand } from "../../cli/runner.mjs";
2
- export function classifyReviewSummaries(summaries, approvals, minimizeApprovals) {
2
+ import { shouldMinimizeAuthor } from "../../comments/minimize-policy.mjs";
3
+ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all") {
3
4
  // First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
4
5
  // they are already minimized server-side (body changed after minimize was applied).
5
6
  // First-look bodies are rendered so the agent sees them before the minimize happens.
6
- const minimizeIds = [...summaries.firstLook, ...summaries.seen].map((r) => r.id);
7
+ const minimizeIds = [...summaries.firstLook, ...summaries.seen]
8
+ .filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments))
9
+ .map((r) => r.id);
7
10
  if (minimizeApprovals) {
8
- for (const r of approvals)
9
- minimizeIds.push(r.id);
11
+ for (const r of approvals) {
12
+ if (shouldMinimizeAuthor(r.authorType, minimizeComments))
13
+ minimizeIds.push(r.id);
14
+ }
10
15
  return {
11
16
  minimizeIds,
12
17
  firstLookSummaries: summaries.firstLook,
@@ -55,7 +55,7 @@ export function validateBaseBranch(raw) {
55
55
  }
56
56
  export function buildEscalateHumanMessage(escalate, pr) {
57
57
  const lines = [];
58
- lines.push("⚠️ /pr-shepherd:monitor paused — needs human direction");
58
+ lines.push("⚠️ /pr-shepherd:pr-shepherd paused — needs human direction");
59
59
  lines.push("");
60
60
  lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
61
61
  lines.push("");
@@ -80,18 +80,17 @@ export function buildEscalateHumanMessage(escalate, pr) {
80
80
  lines.push(`- comment \`${c.id}\` (@${c.author}): ${firstLine}`);
81
81
  }
82
82
  }
83
- if (escalate.attemptHistory && escalate.attemptHistory.length > 0) {
83
+ if (escalate.thrashHistory && escalate.thrashHistory.length > 0) {
84
84
  lines.push("");
85
85
  lines.push("## Fix attempts");
86
- for (const a of escalate.attemptHistory) {
86
+ for (const a of escalate.thrashHistory) {
87
87
  lines.push(`- thread \`${a.threadId}\` attempted ${a.attempts} times`);
88
88
  }
89
89
  }
90
90
  lines.push("");
91
91
  lines.push("---");
92
92
  lines.push("");
93
- lines.push(`Run \`/pr-shepherd:check ${pr}\` to see current state.`);
94
- lines.push(`After fixing manually, rerun \`/pr-shepherd:monitor ${pr}\` to resume.`);
93
+ lines.push(`After fixing manually, rerun \`/pr-shepherd:pr-shepherd ${pr}\` to resume.`);
95
94
  return lines.join("\n");
96
95
  }
97
96
  export function buildEscalateSuggestion(triggers, detail) {
@@ -104,7 +103,7 @@ export function buildEscalateSuggestion(triggers, detail) {
104
103
  return `Could not determine the PR's base branch${reason} — refusing to emit a rebase that could force-push onto the wrong base. Run the rebase manually against the PR's real target branch.`;
105
104
  }
106
105
  if (triggers.includes("fix-thrash")) {
107
- return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:monitor";
106
+ return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:pr-shepherd";
108
107
  }
109
108
  if (triggers.includes("pr-level-changes-requested")) {
110
109
  return "Reviewer requested changes but left no inline comments — read the review and act manually";
@@ -23,7 +23,7 @@ export async function handleFixCode(ctx) {
23
23
  unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
24
24
  ambiguousComments: report.comments.actionable.map(toAgentComment),
25
25
  changesRequestedReviews: report.changesRequestedReviews,
26
- attemptHistory: escalateTriggers.thrashHistory,
26
+ thrashHistory: escalateTriggers.thrashHistory,
27
27
  suggestion: buildEscalateSuggestion(escalateTriggers.triggers),
28
28
  };
29
29
  return {
@@ -56,7 +56,8 @@ export async function handleFixCode(ctx) {
56
56
  const inProgressRunIds = hasGuaranteedSupersedingPush
57
57
  ? buildInProgressRunIds(report, cancelledSet)
58
58
  : [];
59
- const allCommentIds = [...actionableComments.map((c) => c.id), ...reviewSummaryIds];
59
+ const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
60
+ const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
60
61
  const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, cliRunner);
61
62
  if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
62
63
  const fallbackEscalateBase = {
@@ -83,7 +84,6 @@ export async function handleFixCode(ctx) {
83
84
  baseBranch: baseLookup.branch,
84
85
  action: "fix_code",
85
86
  fix: {
86
- mode: "rebase-and-push",
87
87
  threads,
88
88
  resolutionOnlyThreads,
89
89
  actionableComments,
@@ -58,15 +58,6 @@ export function buildRelevantChecks(report) {
58
58
  });
59
59
  return [...passing, ...failing];
60
60
  }
61
- export async function getLastCommitTime() {
62
- try {
63
- const { stdout } = await execFile("git", ["log", "-1", "--format=%ct", "HEAD"]);
64
- return parseInt(stdout.trim(), 10);
65
- }
66
- catch {
67
- return null;
68
- }
69
- }
70
61
  // Best-effort: cancelling a completed run is a no-op, not an error.
71
62
  export async function tryCancelRun(runId, owner, repo) {
72
63
  try {
@@ -118,23 +109,3 @@ export function buildWaitLog(base) {
118
109
  }
119
110
  return parts.join(" — ");
120
111
  }
121
- export function buildCooldownResult(prNumber, readyDelaySeconds) {
122
- return {
123
- action: "cooldown",
124
- pr: prNumber,
125
- repo: "",
126
- status: "UNKNOWN",
127
- state: "UNKNOWN",
128
- mergeStateStatus: "UNKNOWN",
129
- mergeStatus: "UNKNOWN",
130
- reviewDecision: null,
131
- copilotReviewInProgress: false,
132
- isDraft: false,
133
- shouldCancel: false,
134
- remainingSeconds: readyDelaySeconds,
135
- summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0 },
136
- baseBranch: "",
137
- checks: [],
138
- log: "SKIP: CI still starting — waiting for first check to appear",
139
- };
140
- }
@@ -4,13 +4,13 @@ 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 { getLastCommitTime, getCurrentHeadSha, buildSummary, buildRelevantChecks, buildCooldownResult, buildWaitLog, } from "./helpers.mjs";
7
+ import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildWaitLog } from "./helpers.mjs";
8
8
  import { classifyReviewSummaries } from "./classify.mjs";
9
9
  import { applyStallGuard } from "./stall.mjs";
10
+ import { clearStallState } from "../../state/iterate-stall.mjs";
10
11
  import { handleFixCode } from "./fix-code.mjs";
11
12
  export async function runIterate(opts) {
12
13
  const config = loadConfig();
13
- const cooldownSeconds = opts.cooldownSeconds ?? config.iterate.cooldownSeconds;
14
14
  const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
15
15
  const stallTimeoutSeconds = opts.stallTimeoutSeconds ?? config.iterate.stallTimeoutMinutes * 60;
16
16
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
@@ -18,17 +18,18 @@ export async function runIterate(opts) {
18
18
  throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
19
19
  }
20
20
  const optsWithPr = { ...opts, prNumber };
21
- const lastCommitTime = await getLastCommitTime();
22
- const nowSeconds = Math.floor(Date.now() / 1000);
23
- if (lastCommitTime !== null && nowSeconds - lastCommitTime < cooldownSeconds) {
24
- return buildCooldownResult(prNumber, readyDelaySeconds);
25
- }
26
21
  const report = await runCheck({
27
22
  ...optsWithPr,
28
23
  autoResolve: config.actions.autoResolveOutdated,
29
24
  });
25
+ const [repoOwner, repoName] = report.repo.split("/");
26
+ if (!repoOwner || !repoName) {
27
+ throw new Error(`Unexpected repo format: "${report.repo}" (expected "owner/name")`);
28
+ }
29
+ const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
30
30
  if (report.mergeStatus.state !== "OPEN") {
31
31
  const state = report.mergeStatus.state.toLowerCase();
32
+ await clearStallState(stallKey);
32
33
  return {
33
34
  pr: report.pr,
34
35
  repo: report.repo,
@@ -36,7 +37,7 @@ export async function runIterate(opts) {
36
37
  mergeStateStatus: report.mergeStatus.mergeStateStatus,
37
38
  mergeStatus: report.mergeStatus.status,
38
39
  reviewDecision: report.mergeStatus.reviewDecision,
39
- copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
40
+ blockingBotReviewInProgress: report.mergeStatus.blockingBotReviewInProgress,
40
41
  isDraft: report.mergeStatus.isDraft,
41
42
  shouldCancel: true,
42
43
  remainingSeconds: 0,
@@ -46,13 +47,9 @@ export async function runIterate(opts) {
46
47
  checks: buildRelevantChecks(report),
47
48
  action: "cancel",
48
49
  reason: report.mergeStatus.state === "MERGED" ? "merged" : "closed",
49
- log: `CANCEL: PR #${report.pr} is ${state} — stopping monitor`,
50
+ log: `CANCEL: PR #${report.pr} is ${state} — stopping`,
50
51
  };
51
52
  }
52
- const [repoOwner, repoName] = report.repo.split("/");
53
- if (!repoOwner || !repoName) {
54
- throw new Error(`Unexpected repo format: "${report.repo}" (expected "owner/name")`);
55
- }
56
53
  const isReady = report.status === "READY";
57
54
  const readyState = await updateReadyDelay(report.pr, isReady, readyDelaySeconds, repoOwner, repoName);
58
55
  const base = {
@@ -63,7 +60,7 @@ export async function runIterate(opts) {
63
60
  mergeStateStatus: report.mergeStatus.mergeStateStatus,
64
61
  mergeStatus: report.mergeStatus.status,
65
62
  reviewDecision: report.mergeStatus.reviewDecision,
66
- copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
63
+ blockingBotReviewInProgress: report.mergeStatus.blockingBotReviewInProgress,
67
64
  isDraft: report.mergeStatus.isDraft,
68
65
  shouldCancel: readyState.shouldCancel,
69
66
  remainingSeconds: readyState.remainingSeconds,
@@ -72,6 +69,7 @@ export async function runIterate(opts) {
72
69
  checks: buildRelevantChecks(report),
73
70
  };
74
71
  if (readyState.shouldCancel) {
72
+ await clearStallState(stallKey);
75
73
  let cancelNote;
76
74
  if (base.mergeStatus !== "BLOCKED")
77
75
  cancelNote = "has been ready for review";
@@ -85,23 +83,24 @@ export async function runIterate(opts) {
85
83
  ...base,
86
84
  action: "cancel",
87
85
  reason: "ready-delay-elapsed",
88
- log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping monitor`,
86
+ log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
89
87
  };
90
88
  }
91
89
  const headSha = (await getCurrentHeadSha()) ?? "unknown";
92
- const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
93
90
  const { minimizeIds: reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = classifyReviewSummaries({
94
91
  firstLook: report.firstLookSummaries,
95
92
  seen: report.reviewSummaries,
96
93
  edited: report.editedSummaries,
97
- }, report.approvedReviews, config.iterate.minimizeApprovals);
94
+ }, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments);
98
95
  const hasActionableWork = report.threads.actionable.length > 0 ||
99
96
  report.threads.resolutionOnly.length > 0 ||
100
97
  report.comments.actionable.length > 0 ||
101
98
  report.changesRequestedReviews.length > 0 ||
102
99
  report.checks.failing.length > 0 ||
103
100
  report.mergeStatus.status === "CONFLICTS" ||
104
- reviewSummaryIds.length > 0;
101
+ reviewSummaryIds.length > 0 ||
102
+ firstLookSummaries.length > 0 ||
103
+ editedSummaries.length > 0;
105
104
  if (hasActionableWork) {
106
105
  return handleFixCode({
107
106
  base,
@@ -122,7 +121,7 @@ export async function runIterate(opts) {
122
121
  }
123
122
  const canMarkReady = report.status === "READY" &&
124
123
  report.mergeStatus.isDraft &&
125
- !report.mergeStatus.copilotReviewInProgress &&
124
+ !report.mergeStatus.blockingBotReviewInProgress &&
126
125
  !readyState.shouldCancel;
127
126
  if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
128
127
  await graphql(MARK_PR_READY_MUTATION, { pullRequestId: report.nodeId });
@@ -1,9 +1,9 @@
1
- import { buildPrShepherdCommand, renderShellCommand } from "../../cli/runner.mjs";
1
+ import { renderShellCommand } from "../../cli/runner.mjs";
2
2
  import { buildFailingCheckInstructions } from "./check-instructions.mjs";
3
3
  import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
4
+ import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
4
5
  export const FIX_INSTRUCTION_STOP_AFTER_PUSH = "Stop this iteration — CI needs time to run on the new push before the next tick.";
5
6
  export const FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK = "Stop this iteration before the next tick.";
6
- export const FIX_INSTRUCTION_END_ITERATION = "End this iteration.";
7
7
  /**
8
8
  * Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE`, `$HEAD_SHA`, and
9
9
  * whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is appended separately
@@ -19,20 +19,11 @@ export function renderResolveCommand(rc) {
19
19
  export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner) {
20
20
  const instructions = [];
21
21
  if (inProgressRunIds.length > 0) {
22
- instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id>\`. Do this before applying any code fixes — the push at the end of this iteration will supersede those runs anyway, so letting them continue burns CI minutes for results no one will read. If \`gh\` reports a run is already completed, ignore it and continue with the next ID.`);
22
+ instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id>\` before applying code fixes. If \`gh\` reports a run is already completed, ignore it and continue with the next ID.`);
23
23
  }
24
24
  const hasSuggestions = threads.some((t) => t.suggestion);
25
25
  if (hasSuggestions) {
26
- const commitSuggestionCommand = buildPrShepherdCommand([
27
- "commit-suggestion",
28
- String(prNumber),
29
- "--thread-id",
30
- "<id>",
31
- "--message",
32
- "<one-sentence headline>",
33
- "--format=json",
34
- ], { runner }).text;
35
- instructions.push(`For each thread marked \`[suggestion]\` under \`## Review threads\`: run \`${commitSuggestionCommand}\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the \`resolve:\` command below (the thread is not auto-resolved). If the patch fails to apply, fall through to the manual-edit step. Do not retry the same command.`);
26
+ instructions.push(buildCommitSuggestionInstruction(prNumber, "## Review threads", false, runner));
36
27
  }
37
28
  if (threads.length > 0 || actionableComments.length > 0) {
38
29
  const suggestionFallback = hasSuggestions
@@ -97,11 +88,8 @@ export function buildFixInstructions(threads, actionableComments, checks, review
97
88
  if (needsPush) {
98
89
  instructions.push(FIX_INSTRUCTION_STOP_AFTER_PUSH);
99
90
  }
100
- else if (resolveCommand.hasMutations) {
101
- instructions.push(FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK);
102
- }
103
91
  else {
104
- instructions.push(FIX_INSTRUCTION_END_ITERATION);
92
+ instructions.push(FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK);
105
93
  }
106
94
  return instructions;
107
95
  }
@@ -1,5 +1,6 @@
1
1
  import { buildPrShepherdCommand } from "../cli/runner.mjs";
2
2
  import { SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS, buildShepherdJournalInstruction, } from "./shepherd-journal.mjs";
3
+ import { buildCommitSuggestionInstruction } from "./commit-suggestion-instruction.mjs";
3
4
  /**
4
5
  * Build the numbered triage/fix/resolve instruction steps for the agent to follow.
5
6
  * Steps are conditionally emitted based on what the fetch returned (mirrors
@@ -32,16 +33,7 @@ export function buildFetchInstructions(prNumber, result, runner) {
32
33
  instructions.push(`First-look bullets tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body before deciding whether any matching \`## Review threads to resolve\` item should be resolved.`);
33
34
  }
34
35
  if (hasSuggestions) {
35
- const commitSuggestionCommand = buildPrShepherdCommand([
36
- "commit-suggestion",
37
- String(prNumber),
38
- "--thread-id",
39
- "<id>",
40
- "--message",
41
- "<one-sentence headline>",
42
- "--format=json",
43
- ], { runner }).text;
44
- instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`${commitSuggestionCommand}\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the resolve command below (the thread is not auto-resolved). If the patch fails to apply (drift since the suggestion was written), fall through to the manual fix step. Do not retry the same \`commit-suggestion\` invocation.`);
36
+ instructions.push(buildCommitSuggestionInstruction(prNumber, "## Actionable Review Threads", true, runner));
45
37
  }
46
38
  if (hasCodeItems) {
47
39
  instructions.push(`Read and edit each file referenced under \`## Actionable Review Threads\`, \`## Actionable PR Comments\`, and \`## Pending CHANGES_REQUESTED reviews\` above. Reclassify each fixed item as Fixed. If an item is too complex to address, leave it as Actionable for the final report.`);
@@ -0,0 +1,16 @@
1
+ import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
+ import { applyResolveOptions } from "../comments/resolve.mjs";
3
+ export async function runResolveMutate(opts) {
4
+ const repo = await getRepoInfo();
5
+ const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
6
+ if (prNumber === null) {
7
+ throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
8
+ }
9
+ return applyResolveOptions(prNumber, repo, {
10
+ resolveThreadIds: opts.resolveThreadIds,
11
+ minimizeCommentIds: opts.minimizeCommentIds,
12
+ dismissReviewIds: opts.dismissReviewIds,
13
+ dismissMessage: opts.dismissMessage,
14
+ requireSha: opts.requireSha,
15
+ });
16
+ }