pr-shepherd 0.45.0 → 0.46.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 (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -5
  3. package/bin/checks/conclusions.d.mts +0 -16
  4. package/bin/checks/conclusions.mjs +0 -27
  5. package/bin/cli/fix-formatter.mjs +10 -4
  6. package/bin/cli/help-command-pages.d.mts +13 -0
  7. package/bin/cli/help-command-pages.mjs +13 -0
  8. package/bin/cli/help-top-page.d.mts +1 -1
  9. package/bin/cli/help-top-page.mjs +2 -0
  10. package/bin/cli/help.d.mts +14 -1
  11. package/bin/cli/help.mjs +2 -0
  12. package/bin/cli/iterate-checks-formatter.d.mts +2 -0
  13. package/bin/cli/iterate-checks-formatter.mjs +55 -0
  14. package/bin/cli/iterate-formatter.mjs +7 -2
  15. package/bin/cli/iterate-lean.mjs +4 -0
  16. package/bin/cli/journal-extract-handler.d.mts +2 -0
  17. package/bin/cli/journal-extract-handler.mjs +48 -0
  18. package/bin/cli/safe-body-file.d.mts +14 -0
  19. package/bin/cli/safe-body-file.mjs +42 -0
  20. package/bin/cli-parser.mjs +7 -0
  21. package/bin/commands/check-status.mjs +4 -4
  22. package/bin/commands/check.mjs +25 -5
  23. package/bin/commands/iterate/api-usage.mjs +1 -8
  24. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  25. package/bin/commands/iterate/check-instructions.mjs +8 -32
  26. package/bin/commands/iterate/classify.mjs +2 -1
  27. package/bin/commands/iterate/escalate.mjs +63 -9
  28. package/bin/commands/iterate/fix-code.mjs +83 -80
  29. package/bin/commands/iterate/index.mjs +2 -2
  30. package/bin/commands/iterate/render.d.mts +1 -1
  31. package/bin/commands/iterate/render.mjs +16 -13
  32. package/bin/commands/poll.mjs +1 -4
  33. package/bin/comments/review-visibility.d.mts +1 -1
  34. package/bin/comments/review-visibility.mjs +3 -2
  35. package/bin/comments/thread-visibility.d.mts +1 -1
  36. package/bin/comments/thread-visibility.mjs +9 -3
  37. package/bin/github/client.d.mts +1 -1
  38. package/bin/github/client.mjs +1 -1
  39. package/bin/pr-reference.d.mts +1 -1
  40. package/bin/pr-reference.mjs +1 -1
  41. package/bin/types/escalate.d.mts +5 -3
  42. package/bin/types/report.d.mts +1 -1
  43. package/package.json +2 -2
  44. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  45. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  46. package/plugins/pr-shepherd/.mcp.json +1 -1
  47. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +21 -15
@@ -3,11 +3,7 @@ import { summarizeApiTelemetry } from "../../github/api-telemetry.mjs";
3
3
  import { evaluateWorktreeGraphqlQuotaWarning } from "../../state/graphql-quota-warnings.mjs";
4
4
  import { buildQuotaAwareContinuation } from "../../quota-warning.mjs";
5
5
  function shouldWarn(result) {
6
- if (["wait", "mark_ready", "merge"].includes(result.action))
7
- return true;
8
- if (result.action !== "fix_code")
9
- return false;
10
- return !result.fix.instructions.some((instruction) => /stop polling|human direction/i.test(instruction));
6
+ return ["wait", "mark_ready", "merge", "fix_code"].includes(result.action);
11
7
  }
12
8
  export async function attachApiUsage(result, persistWarning, preservePersistedWarning = false) {
13
9
  const apiUsage = summarizeApiTelemetry();
@@ -34,9 +30,6 @@ export async function attachApiUsage(result, persistWarning, preservePersistedWa
34
30
  if (/\[FIX_CODE\].*non-terminal/i.test(completion)) {
35
31
  instructions[instructions.length - 1] = buildQuotaAwareContinuation(quotaWarning, "`[FIX_CODE]` is non-terminal. After completing these steps,");
36
32
  }
37
- else if (/\[FIX_CODE\].*is conditional:.*if you did not change code,/i.test(completion)) {
38
- instructions[instructions.length - 1] = completion.replace(/if you did not change code,(.*?)\s+and iterate again with the same options\./i, (_, before) => `if you did not change code,${before.trimEnd()}. ${buildQuotaAwareContinuation(quotaWarning, "").trimStart()}`);
39
- }
40
33
  }
41
34
  return { ...withWarning, fix: { ...withWarning.fix, instructions } };
42
35
  }
@@ -27,7 +27,7 @@ export declare function buildBehindBaseHintInstruction(baseBranch: string, hint:
27
27
  * command run unmodified is already correct for them. The pointer below is load-bearing:
28
28
  * without it, nothing in CLI output tells the agent that playbook exists.
29
29
  */
30
- export declare function buildResolveCommandInstruction(resolveCommand: ResolveCommand, pushAuthorized?: boolean): string[];
30
+ export declare function buildResolveCommandInstruction(resolveCommand: ResolveCommand): string[];
31
31
  /** Build the CI-triage pointer; the skill limits follow-up actions to included evidence. */
32
32
  export declare function buildFailingCheckInstructions(checks: AgentCheck[]): string[];
33
- export declare function buildFixCompletionInstruction(checks: AgentCheck[], requiresRemoteUpdateAuthorization?: boolean, hasShaGatedReviewMutations?: boolean, pushAuthorized?: boolean): string;
33
+ export declare function buildFixCompletionInstruction(checks: AgentCheck[], hasConflicts?: boolean, hasShaGatedReviewMutations?: boolean): string;
@@ -36,7 +36,7 @@ export function buildBehindBaseHintInstruction(baseBranch, hint, isBehind) {
36
36
  * command run unmodified is already correct for them. The pointer below is load-bearing:
37
37
  * without it, nothing in CLI output tells the agent that playbook exists.
38
38
  */
39
- export function buildResolveCommandInstruction(resolveCommand, pushAuthorized = false) {
39
+ export function buildResolveCommandInstruction(resolveCommand) {
40
40
  if (!resolveCommand.hasMutations)
41
41
  return [];
42
42
  const instructions = [];
@@ -44,9 +44,7 @@ export function buildResolveCommandInstruction(resolveCommand, pushAuthorized =
44
44
  instructions.push("Run the generated thread IDs unchanged. A latest comment beginning `<!-- pr-shepherd -->` is an established Shepherd reply; a marked viewer-authored human thread is emitted resolve-only, not for another reply.");
45
45
  }
46
46
  if (resolveCommand.requiresHeadSha) {
47
- instructions.push(pushAuthorized
48
- ? "If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)`, which must equal the current remote PR head. If you changed code, commit and push to the PR head branch first, then replace `$HEAD_SHA` with the pushed commit SHA."
49
- : "If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)`, which must equal the current remote PR head. If you changed code, do not run this command until an authorized push updates the remote PR head; then replace `$HEAD_SHA` with that pushed commit SHA.");
47
+ instructions.push("If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)`, which must equal the current remote PR head. If you changed code, commit and push to the PR head branch first, then replace `$HEAD_SHA` with the pushed commit SHA.");
50
48
  }
51
49
  if (resolveCommand.requiresDismissMessage) {
52
50
  instructions.push("Replace `$DISMISS_MESSAGE` with one sentence describing what changed.");
@@ -69,39 +67,17 @@ export function buildFailingCheckInstructions(checks) {
69
67
  instructions.push('A `[rerun authorized]` check includes a `rerun:` command. See "CI failure triage" in the pr-shepherd skill for which conclusions warrant a rerun versus a code fix.');
70
68
  }
71
69
  if (hasBare) {
72
- instructions.push("For each `(no runId)` failure, escalate to a human because no log or URL is available.");
70
+ instructions.push("For each `(no runId)` failure, preserve the displayed metadata; Shepherd will escalate when no other autonomous work remains.");
73
71
  }
74
72
  return instructions;
75
73
  }
76
- export function buildFixCompletionInstruction(checks, requiresRemoteUpdateAuthorization = false, hasShaGatedReviewMutations = false, pushAuthorized = false) {
77
- if (requiresRemoteUpdateAuthorization) {
78
- if (pushAuthorized) {
79
- return "`[FIX_CODE]` is non-terminal: resolve the conflicts, commit, push to the PR head branch, then iterate again with the same options.";
80
- }
81
- return "`[FIX_CODE]` requires a human handoff for an authorized push after conflict resolution. Shepherd cannot verify the Git credential's push authorization. Stop polling after committing, and resume only after the remote PR head changes.";
82
- }
83
- const hasUninspectableFailure = checks.some((check) => !check.runId && !check.detailsUrl);
84
- if (hasUninspectableFailure) {
85
- return "`[FIX_CODE]` requires a human handoff for an uninspectable failing check. Stop polling after escalating, and resume only after human direction.";
86
- }
87
- // Checks that would otherwise need a workflow-run mutation (rerun) to move forward.
88
- // `rerunCommand` is only populated when the viewer's repository role grants Actions
89
- // rerun capability (see canRerunWorkflows) — unauthorized checks stay a terminal handoff.
90
- const ciHandoffChecks = checks.filter((check) => check.conclusion === "CANCELLED" ||
91
- check.conclusion === "STARTUP_FAILURE" ||
92
- (check.runId === null && Boolean(check.detailsUrl)) ||
93
- (check.runId !== null && !check.logExcerpt?.trim()));
94
- const hasUnauthorizedCiHandoff = ciHandoffChecks.some((check) => !check.rerunCommand);
95
- if (hasUnauthorizedCiHandoff) {
96
- return "`[FIX_CODE]` requires a human handoff for a failing check with no authorized follow-up action. Stop polling after escalating, and resume only after human direction.";
97
- }
74
+ export function buildFixCompletionInstruction(checks, hasConflicts = false, hasShaGatedReviewMutations = false) {
75
+ if (hasConflicts)
76
+ return "`[FIX_CODE]` is non-terminal: resolve the conflicts, commit, push to the PR head branch, then iterate again with the same options.";
98
77
  if (hasShaGatedReviewMutations) {
99
- if (pushAuthorized) {
100
- return "`[FIX_CODE]` is non-terminal: if you changed code, commit and push to the PR head branch, then run the review mutations using the pushed commit SHA and iterate again with the same options; if you did not change code, complete the authorized review mutations and iterate again with the same options.";
101
- }
102
- return "`[FIX_CODE]` is conditional: if you changed code, stop after committing and resume only after an authorized push changes the remote PR head; if you did not change code, complete the authorized review mutations and iterate again with the same options.";
78
+ return "`[FIX_CODE]` is non-terminal: if you changed code, commit and push to the PR head branch, then run the review mutations using the pushed commit SHA and iterate again with the same options; if you did not change code, complete the authorized review mutations and iterate again with the same options.";
103
79
  }
104
- if (ciHandoffChecks.some((check) => check.rerunCommand)) {
80
+ if (checks.some((check) => check.rerunCommand)) {
105
81
  return "`[FIX_CODE]` is non-terminal. Run any warranted reruns for `[rerun authorized]` checks (or apply code fixes for real failures), then iterate again with the same options to continue.";
106
82
  }
107
83
  return "`[FIX_CODE]` is non-terminal. After completing these steps, iterate again with the same options to continue.";
@@ -67,7 +67,8 @@ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentId
67
67
  const canResolve = new Set(authorizationThreads
68
68
  .filter((thread) => thread.viewerCanResolve === true)
69
69
  .map((thread) => thread.id));
70
- const replyThreadIds = routed.replyThreadIds.filter((id) => canReply.has(id));
70
+ const pairedResolveIds = new Set(routed.pairedResolveThreadIds);
71
+ const replyThreadIds = routed.replyThreadIds.filter((id) => canReply.has(id) && (!pairedResolveIds.has(id) || canResolve.has(id)));
71
72
  // Viewer-authored human resolves stay paired with an authorized reply. Marker-ended
72
73
  // viewer-authored retries and bot/non-human resolves need only resolve authorization.
73
74
  const pairedResolveThreadIds = routed.pairedResolveThreadIds.filter((id) => canReply.has(id) && canResolve.has(id));
@@ -2,6 +2,61 @@ import { loadConfig } from "../../config/load.mjs";
2
2
  function renderEscalateAuthor(item) {
3
3
  return [`@${item.author}`, item.authorType, item.authorAssociation].filter(Boolean).join(" · ");
4
4
  }
5
+ function renderCheckTarget(check) {
6
+ const parts = [];
7
+ if (check.runId)
8
+ parts.push(`run \`${check.runId}\``);
9
+ if (check.detailsUrl)
10
+ parts.push(`URL \`${check.detailsUrl}\``);
11
+ return parts.length > 0 ? parts.join(", ") : "no run ID or URL";
12
+ }
13
+ function renderCheckScope(check) {
14
+ if (!check.scope)
15
+ return "";
16
+ const commit = check.commitOid ? ` at \`${check.commitOid}\`` : "";
17
+ return `, scope \`${check.scope}\`${commit}`;
18
+ }
19
+ function renderBlockquoteLines(value, indent) {
20
+ return value.split("\n").map((line) => `${indent}> ${line}`);
21
+ }
22
+ function renderEscalateAnnotation(annotation) {
23
+ const start = annotation.startLine ?? annotation.endLine ?? "?";
24
+ const end = annotation.endLine ?? annotation.startLine ?? "?";
25
+ const range = start === end ? String(start) : `${start}-${end}`;
26
+ const columns = annotation.startColumn == null && annotation.endColumn == null
27
+ ? ""
28
+ : `, columns ${annotation.startColumn ?? "?"}-${annotation.endColumn ?? "?"}`;
29
+ const title = annotation.title ? ` — ${annotation.title}` : "";
30
+ const link = annotation.blobUrl ? ` [source](${annotation.blobUrl})` : "";
31
+ const lines = [
32
+ ` - annotation \`${annotation.id}\`${link} — \`${annotation.path}:${range}\` [${annotation.level}${columns}]${title}`,
33
+ ...renderBlockquoteLines(annotation.message, " "),
34
+ ];
35
+ if (annotation.rawDetails) {
36
+ lines.push(...renderBlockquoteLines(annotation.rawDetails, " "));
37
+ }
38
+ return lines;
39
+ }
40
+ function renderEscalateCheck(check) {
41
+ const workflowPrefix = check.workflowName ? `${check.workflowName} › ` : "";
42
+ const jobLabel = check.jobName ?? check.name;
43
+ const conclusion = check.conclusion ?? "UNKNOWN";
44
+ const lines = [
45
+ `- ${renderCheckTarget(check)} — \`${workflowPrefix}${jobLabel}\` [conclusion: ${conclusion}]${renderCheckScope(check)}`,
46
+ ];
47
+ if (check.failedStep)
48
+ lines.push(` > failed step: ${check.failedStep}`);
49
+ if (check.summary)
50
+ lines.push(` > ${check.summary}`);
51
+ if (check.logExcerpt)
52
+ lines.push(...renderBlockquoteLines(check.logExcerpt, " "));
53
+ if (check.rerunCommand)
54
+ lines.push(` rerun: \`${check.rerunCommand}\``);
55
+ for (const annotation of check.annotations ?? []) {
56
+ lines.push(...renderEscalateAnnotation(annotation));
57
+ }
58
+ return lines;
59
+ }
5
60
  export function checkEscalateTriggers(actionableThreads, threadAttempts) {
6
61
  const triggers = [];
7
62
  const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
@@ -10,11 +65,6 @@ export function checkEscalateTriggers(actionableThreads, threadAttempts) {
10
65
  if (thrashThreads.length > 0) {
11
66
  triggers.push("fix-thrash");
12
67
  }
13
- // Trigger 2: actionable thread has no file/line — cannot locate code to edit.
14
- const unlocatable = actionableThreads.filter((t) => t.path === null || t.line === null);
15
- if (unlocatable.length > 0) {
16
- triggers.push("thread-missing-location");
17
- }
18
68
  return {
19
69
  triggers,
20
70
  thrashHistory: thrashThreads.length > 0
@@ -69,6 +119,7 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
69
119
  const hasItems = escalate.unresolvedThreads.length > 0 ||
70
120
  escalate.changesRequestedReviews.length > 0 ||
71
121
  escalate.ambiguousComments.length > 0 ||
122
+ (escalate.checks?.length ?? 0) > 0 ||
72
123
  (escalate.stalledChecks?.length ?? 0) > 0;
73
124
  if (hasItems) {
74
125
  lines.push("");
@@ -86,6 +137,9 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
86
137
  }
87
138
  if ((escalate.stalledChecks?.length ?? 0) > 0)
88
139
  lines.push("");
140
+ for (const check of escalate.checks ?? []) {
141
+ lines.push(...renderEscalateCheck(check), "");
142
+ }
89
143
  for (const t of escalate.unresolvedThreads) {
90
144
  const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
91
145
  lines.push(`- thread \`${t.id}\` — ${loc} (${renderEscalateAuthor(t)}):`);
@@ -144,8 +198,11 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
144
198
  return lines.join("\n");
145
199
  }
146
200
  export function buildEscalateSuggestion(triggers, detail) {
201
+ if (triggers.includes("check-follow-up-unavailable")) {
202
+ return "One or more failing checks have no autonomous follow-up available. Use the displayed conclusion, run or URL, and included evidence to handle them manually.";
203
+ }
147
204
  if (triggers.includes("authorization-required")) {
148
- return "GitHub did not confirm that the current viewer may perform one or more required actions. Ask a repository maintainer to handle the listed items; Shepherd will not recommend commands that would be denied.";
205
+ return "GitHub did not confirm that the current viewer may perform the requested mark-ready or merge/enqueue operation. Ask a repository maintainer to perform that state change; Shepherd will not recommend a command that would be denied.";
149
206
  }
150
207
  if (triggers.includes("merge-queue-removed")) {
151
208
  const reason = detail ? ` GitHub reason: ${detail}.` : "";
@@ -162,9 +219,6 @@ export function buildEscalateSuggestion(triggers, detail) {
162
219
  if (triggers.includes("fix-thrash")) {
163
220
  return "Same thread(s) reached the automated attempt limit — treat this as a manual handoff. Apply the fix by hand.";
164
221
  }
165
- if (triggers.includes("thread-missing-location")) {
166
- return "Review thread has no file/line reference — automated location routing failed and manual handling is required.";
167
- }
168
222
  if (triggers.includes("bot-cr-not-dismissed")) {
169
223
  const ids = detail ? ` (review IDs: ${detail})` : "";
170
224
  return `Bot CHANGES_REQUESTED review(s) remained undismissed past the stall window${ids}. The agent likely dropped \`--dismiss-review-ids\` from a prior apply command. Dismiss the review(s) manually (or re-run \`pr-shepherd apply review\` with the IDs) to unblock the PR.`;
@@ -11,9 +11,23 @@ import { applyStallGuard } from "./stall.mjs";
11
11
  import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
12
12
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
13
13
  import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
14
- import { canRerunWorkflows, canPushToHead } from "../../checks/conclusions.mjs";
14
+ import { canRerunWorkflows } from "../../checks/conclusions.mjs";
15
15
  import { loadConfig } from "../../config/load.mjs";
16
16
  import { formatPrUrl } from "../../pr-reference.mjs";
17
+ function checkRequiresHumanFollowUp(check) {
18
+ if (check.rerunCommand)
19
+ return false;
20
+ if (check.conclusion === "ACTION_REQUIRED" ||
21
+ check.conclusion === "CANCELLED" ||
22
+ check.conclusion === "STARTUP_FAILURE")
23
+ return true;
24
+ // An external check's direct URL is actionable evidence: the agent can inspect the
25
+ // provider and/or reproduce the reported failure locally. Only a truly bare check
26
+ // has no autonomous investigation path.
27
+ if (check.runId === null)
28
+ return !check.detailsUrl?.trim();
29
+ return !check.logExcerpt?.trim();
30
+ }
17
31
  function nextFixAttempts(stored, headSha, threads) {
18
32
  const threadAttempts = stored ? { ...stored.threadAttempts } : {};
19
33
  const threadBodyHashes = stored?.threadBodyHashes
@@ -41,66 +55,17 @@ export async function handleFixCode(ctx) {
41
55
  ]);
42
56
  const replyIdSet = new Set(routedThreadMutations.replyThreadIds);
43
57
  const resolveIdSet = new Set(routedThreadMutations.resolveThreadIds);
44
- const unauthorizedReplies = allThreads.filter((thread) => report.viewerAuthorization !== undefined &&
45
- replyIdSet.has(thread.id) &&
46
- thread.viewerCanReply !== true);
47
- const unauthorizedResolves = allThreads.filter((thread) => report.viewerAuthorization !== undefined &&
48
- resolveIdSet.has(thread.id) &&
49
- thread.viewerCanResolve !== true);
50
- const unauthorizedDismissals = report.changesRequestedReviews.filter((review) => report.viewerAuthorization !== undefined &&
51
- (!isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames)) &&
58
+ const unauthorizedReplies = allThreads.filter((thread) => replyIdSet.has(thread.id) && thread.viewerCanReply !== true);
59
+ const unauthorizedResolves = allThreads.filter((thread) => resolveIdSet.has(thread.id) && thread.viewerCanResolve !== true);
60
+ const unauthorizedDismissals = report.changesRequestedReviews.filter((review) => (!isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames)) &&
52
61
  report.viewerAuthorization?.viewerCanAdminister !== true);
53
- const authorization = [
54
- ...(unauthorizedReplies.length > 0
55
- ? [
56
- {
57
- action: "reply-thread",
58
- targetIds: unauthorizedReplies.map((thread) => thread.id),
59
- reason: "denied-or-unverifiable",
60
- },
61
- ]
62
- : []),
63
- ...(unauthorizedResolves.length > 0
64
- ? [
65
- {
66
- action: "resolve-thread",
67
- targetIds: unauthorizedResolves.map((thread) => thread.id),
68
- reason: "denied-or-unverifiable",
69
- },
70
- ]
71
- : []),
72
- ...(unauthorizedDismissals.length > 0
73
- ? [
74
- {
75
- action: "dismiss-review",
76
- targetIds: unauthorizedDismissals.map((review) => review.id),
77
- reason: "denied-or-unverifiable",
78
- },
79
- ]
80
- : []),
81
- ];
82
- if (authorization.length > 0) {
83
- const authorizationEscalateBase = {
84
- triggers: ["authorization-required"],
85
- unresolvedThreads: allThreads.map(toAgentThread),
86
- ambiguousComments: report.comments.actionable.map(toAgentComment),
87
- changesRequestedReviews: report.changesRequestedReviews,
88
- authorization,
89
- suggestion: buildEscalateSuggestion(["authorization-required"]),
90
- };
91
- return {
92
- ...base,
93
- action: "escalate",
94
- escalate: {
95
- ...authorizationEscalateBase,
96
- humanMessage: buildEscalateHumanMessage(authorizationEscalateBase, prReference),
97
- },
98
- };
99
- }
62
+ const skippedThreadIds = new Set([...unauthorizedReplies, ...unauthorizedResolves].map((thread) => thread.id));
63
+ const retryableActionableThreads = report.threads.actionable.filter((thread) => !skippedThreadIds.has(thread.id) && thread.path !== null && thread.line !== null);
100
64
  const protectedRuns = [];
101
65
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
102
- const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
103
- const botCrReviews = report.changesRequestedReviews.filter((r) => !isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames));
66
+ const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, retryableActionableThreads);
67
+ const botCrReviews = report.changesRequestedReviews.filter((r) => (!isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames)) &&
68
+ report.viewerAuthorization?.viewerCanAdminister === true);
104
69
  const botCrStateKey = { owner: repoOwner, repo: repoName, pr: prNumber };
105
70
  const previousBotCrState = await readBotCrSeenState(botCrStateKey);
106
71
  const nowSeconds = Math.floor(Date.now() / 1000);
@@ -121,11 +86,13 @@ export async function handleFixCode(ctx) {
121
86
  action: "escalate",
122
87
  escalate: {
123
88
  ...escalateBase,
124
- humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
89
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference, {
90
+ merge: opts.merge,
91
+ }),
125
92
  },
126
93
  };
127
94
  }
128
- const escalateTriggers = checkEscalateTriggers(report.threads.actionable, threadAttempts);
95
+ const escalateTriggers = checkEscalateTriggers(retryableActionableThreads, threadAttempts);
129
96
  if (escalateTriggers.triggers.length > 0) {
130
97
  const escalateBase = {
131
98
  triggers: escalateTriggers.triggers,
@@ -140,7 +107,9 @@ export async function handleFixCode(ctx) {
140
107
  action: "escalate",
141
108
  escalate: {
142
109
  ...escalateBase,
143
- humanMessage: buildEscalateHumanMessage(escalateBase, prReference),
110
+ humanMessage: buildEscalateHumanMessage(escalateBase, prReference, {
111
+ merge: opts.merge,
112
+ }),
144
113
  },
145
114
  };
146
115
  }
@@ -183,40 +152,72 @@ export async function handleFixCode(ctx) {
183
152
  ...toAgentChecks(annotatedExtra).map((c) => ({ ...c, annotationOnly: true })),
184
153
  ];
185
154
  const { changesRequestedReviews } = report;
155
+ const actionableChangesRequestedReviews = changesRequestedReviews.filter((review) => review.staleReview !== true ||
156
+ !isHumanAuthor(review) ||
157
+ isConfiguredBotAuthor(review, botUsernames));
158
+ const skippedDismissalIds = new Set(unauthorizedDismissals.map((review) => review.id));
159
+ const changesRequestedReviewsForWork = actionableChangesRequestedReviews.filter((review) => !skippedDismissalIds.has(review.id));
160
+ const resolutionOnlyThreadsForWork = resolutionOnlyThreads.filter((thread) => !skippedThreadIds.has(thread.id) && thread.path !== null && thread.line !== null);
186
161
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
187
162
  const isBehind = report.mergeStatus.status === "BEHIND";
188
163
  const { behindBaseHint } = loadConfig().iterate;
189
- // Whether GitHub reports the viewer can push to the PR head branch (own-repo write
190
- // access, or a fork with viewerCanEditFiles/head-repo write for a fork PR). Drives
191
- // whether the fix instructions push autonomously or hand off for an authorized push —
192
- // see canPushToHead.
193
- const pushAuthorized = canPushToHead(report.viewerAuthorization);
194
164
  // Only surface in-progress runs when a push is plausible — resolution-only and
195
165
  // summary-only iterations have no path to a push, so listing runs would prompt
196
166
  // unnecessary cancellation.
197
167
  const inProgressRunIds = [];
198
168
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
199
169
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
200
- // Conflict resolution necessarily changes the branch head. When the viewer cannot push,
201
- // Shepherd cannot verify authorization for the local Git credential that would publish
202
- // it, so review mutations are deferred until an authorized push updates the PR and a
203
- // fresh iteration can rebuild SHA-safe commands. When the viewer can push, the agent
204
- // pushes as part of this fix_code tick, so mutations can be built normally (SHA-gated
205
- // like any other push-requiring tick).
206
- const { resolveCommand, resolveOnlyCommand } = hasConflicts && !pushAuthorized
207
- ? buildResolveCommand([], [], [], [], [], prReference, botUsernames, [], undefined, [])
208
- : buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, failingAgentChecks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads);
170
+ const belongsToActiveWorkflowRun = (check) => check.runId !== null && inProgressWorkflowRunIds.has(check.runId);
171
+ const manualFollowUpChecks = failingAgentChecks.filter((check) => !belongsToActiveWorkflowRun(check) && checkRequiresHumanFollowUp(check));
172
+ const hasAutonomousWork = hasConflicts ||
173
+ threads.length > 0 ||
174
+ resolutionOnlyThreads.length > 0 ||
175
+ actionableComments.length > 0 ||
176
+ commentMinimizeIds.length > 0 ||
177
+ actionableChangesRequestedReviews.length > 0 ||
178
+ reviewSummaryIds.length > 0 ||
179
+ firstLookSummaries.length > 0 ||
180
+ editedSummaries.length > 0 ||
181
+ report.threads.firstLook.length > 0 ||
182
+ report.comments.firstLook.length > 0 ||
183
+ checks.some((check) => (check.annotations?.length ?? 0) > 0) ||
184
+ failingAgentChecks.some((check) => belongsToActiveWorkflowRun(check) || !checkRequiresHumanFollowUp(check));
185
+ if (manualFollowUpChecks.length > 0 && !hasAutonomousWork) {
186
+ const checkEscalateBase = {
187
+ triggers: ["check-follow-up-unavailable"],
188
+ unresolvedThreads: [],
189
+ ambiguousComments: [],
190
+ changesRequestedReviews,
191
+ checks: manualFollowUpChecks,
192
+ suggestion: buildEscalateSuggestion(["check-follow-up-unavailable"]),
193
+ };
194
+ return {
195
+ ...base,
196
+ action: "escalate",
197
+ escalate: {
198
+ ...checkEscalateBase,
199
+ humanMessage: buildEscalateHumanMessage(checkEscalateBase, prReference, {
200
+ merge: opts.merge,
201
+ }),
202
+ },
203
+ };
204
+ }
205
+ // Push access to the PR head branch is a usage precondition. Build review mutations for
206
+ // conflict ticks normally so the caller can push and complete the same fix_code cycle.
207
+ const retryableActionableIds = new Set(retryableActionableThreads.map((thread) => thread.id));
208
+ const retryableAgentThreads = threads.filter((thread) => retryableActionableIds.has(thread.id));
209
+ const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(retryableAgentThreads, resolutionOnlyThreadsForWork, allCommentIds, changesRequestedReviewsForWork, failingAgentChecks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads);
209
210
  // Safety: if the base branch is unknown, escalate when a push is plausible — the agent
210
211
  // would need the correct base to rebase safely. This is a conservative guard, not a
211
212
  // prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
212
213
  // resolution-only threads also need a known base in case the agent does push.
213
- const pushIsPlausible = threads.length > 0 ||
214
+ const pushIsPlausible = retryableActionableThreads.length > 0 ||
214
215
  failingAgentChecks.length > 0 ||
215
216
  annotatedExtra.length > 0 ||
216
217
  hasConflicts ||
217
- changesRequestedReviews.length > 0 ||
218
+ changesRequestedReviewsForWork.length > 0 ||
218
219
  actionableComments.length > 0 ||
219
- resolutionOnlyThreads.length > 0;
220
+ resolutionOnlyThreadsForWork.length > 0;
220
221
  if (baseLookup.isFallback && pushIsPlausible) {
221
222
  const fallbackEscalateBase = {
222
223
  triggers: ["base-branch-unknown"],
@@ -230,13 +231,15 @@ export async function handleFixCode(ctx) {
230
231
  action: "escalate",
231
232
  escalate: {
232
233
  ...fallbackEscalateBase,
233
- humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prReference),
234
+ humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prReference, {
235
+ merge: opts.merge,
236
+ }),
234
237
  },
235
238
  };
236
239
  }
237
240
  const firstLookThreads = report.threads.firstLook;
238
241
  const firstLookComments = report.comments.firstLook;
239
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, pushAuthorized);
242
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true);
240
243
  const prospectiveResult = {
241
244
  ...base,
242
245
  baseBranch: baseLookup.branch,
@@ -81,8 +81,8 @@ async function runIterateCore(opts) {
81
81
  editedSummaries.length > 0 ||
82
82
  (config.iterate.minimizeApprovals && surfacedApprovals.length > 0);
83
83
  const activeMerge = Boolean(opts.merge && (report.mergeQueue?.inQueue || report.mergeQueue?.autoMergeRequest));
84
- const isCleanReadyHandoff = report.status === "READY" && !hasActionableWork && !activeMerge;
85
- const readyState = await updateReadyDelay(report.pr, isCleanReadyHandoff, readyDelaySeconds, repoOwner, repoName);
84
+ const isCleanReadyState = report.status === "READY" && !hasActionableWork && !activeMerge;
85
+ const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName);
86
86
  const base = buildIterateBase(report, readyState);
87
87
  const headSha = (await getCurrentHeadSha()) ?? "unknown";
88
88
  if (hasActionableWork) {
@@ -2,4 +2,4 @@ import type { AgentThread, AgentComment, AgentCheck, Review, ResolveCommand, Fir
2
2
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
3
3
  export declare function renderResolveCommand(rc: ResolveCommand): string;
4
4
  export declare function buildFixInstructions(threads: AgentThread[], actionableComments: AgentComment[], checks: AgentCheck[], changesRequestedReviews: Review[], baseBranch: string, resolveCommand: ResolveCommand, hasConflicts: boolean, prReference: string | number, cancelledCount: number, firstLookThreads?: FirstLookThread[], firstLookComments?: FirstLookComment[], firstLookSummaries?: Review[], editedSummaries?: Review[], inProgressRunIds?: string[], resolutionOnlyThreads?: ReviewThread[], resolveOnlyCommand?: ResolveCommand, behindBaseHint?: string, // iterate.behindBaseHint — see buildBehindBaseHintInstruction
5
- isBehind?: boolean, viewerCanUpdate?: boolean, pushAuthorized?: boolean): string[];
5
+ isBehind?: boolean, viewerCanUpdate?: boolean): string[];
@@ -11,8 +11,10 @@ export function renderResolveCommand(rc) {
11
11
  return renderShellCommand(parts);
12
12
  }
13
13
  export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prReference, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
14
- isBehind = false, viewerCanUpdate = false, pushAuthorized = false) {
14
+ isBehind = false, viewerCanUpdate = false) {
15
15
  const instructions = [];
16
+ const locatedThreads = threads.filter((thread) => thread.path !== null && thread.line !== null);
17
+ const unlocatedThreads = threads.filter((thread) => thread.path === null || thread.line === null);
16
18
  const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
17
19
  const hasAnnotations = checks.some((c) => (c.annotations?.length ?? 0) > 0);
18
20
  const hasNonConflictHints = threads.length > 0 ||
@@ -23,8 +25,10 @@ isBehind = false, viewerCanUpdate = false, pushAuthorized = false) {
23
25
  // Start with interpretation. The agent decides what raw feedback warrants a code change.
24
26
  if (hasNonConflictHints) {
25
27
  const actionableSections = [];
26
- if (threads.length > 0)
28
+ if (locatedThreads.length > 0)
27
29
  actionableSections.push("`## Review threads`");
30
+ if (unlocatedThreads.length > 0)
31
+ actionableSections.push("`## Unlocated review threads (logged once — no mutation)`");
28
32
  if (actionableComments.length > 0)
29
33
  actionableSections.push("`## Actionable comments`");
30
34
  if (failingChecks.length > 0)
@@ -53,17 +57,20 @@ isBehind = false, viewerCanUpdate = false, pushAuthorized = false) {
53
57
  if (editedTotal > 0) {
54
58
  instructions.push("Read every item marked `[edited since first look]`, including edited summaries and edited first-look bullets, before deciding whether to resolve a matching thread.");
55
59
  }
60
+ if (unlocatedThreads.length > 0) {
61
+ instructions.push("Acknowledge each item under `## Unlocated review threads (logged once — no mutation)`. Shepherd cannot route a code fix or review mutation without a path and line; the unchanged item will be skipped on later ticks.");
62
+ }
56
63
  // GitHub exposes no exact viewer capability for workflow-run cancellation, so the
57
64
  // informational run lists never produce a cancellation recommendation.
58
65
  void inProgressRunIds;
59
66
  void cancelledCount;
60
- const hasSuggestions = threads.some((t) => t.suggestion);
67
+ const hasSuggestions = locatedThreads.some((t) => t.suggestion);
61
68
  if (hasSuggestions)
62
69
  instructions.push(buildCommitSuggestionInstruction(prReference, "## Review threads"));
63
- if (threads.length > 0 || actionableComments.length > 0) {
70
+ if (locatedThreads.length > 0 || actionableComments.length > 0) {
64
71
  // Actionable comments carry no file/line location (unlike threads), so "referenced above"
65
72
  // is only accurate when threads are present.
66
- const filesRef = threads.length > 0 ? "each file referenced above" : "the relevant files";
73
+ const filesRef = locatedThreads.length > 0 ? "each file referenced above" : "the relevant files";
67
74
  instructions.push(`Apply every warranted review fix in ${filesRef}.`);
68
75
  }
69
76
  if (resolutionOnlyThreads.length > 0) {
@@ -81,14 +88,10 @@ isBehind = false, viewerCanUpdate = false, pushAuthorized = false) {
81
88
  const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
82
89
  const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
83
90
  if (hasConflicts) {
84
- instructions.push(pushAuthorized
85
- ? `Commit any remaining conflict-resolution changes and push to the PR head branch${mutationSuffix}.`
86
- : `Commit any remaining conflict-resolution changes${mutationSuffix}.`);
91
+ instructions.push(`Commit any remaining conflict-resolution changes and push to the PR head branch${mutationSuffix}.`);
87
92
  }
88
93
  else if (hasNonConflictHints) {
89
- instructions.push(pushAuthorized
90
- ? "If you changed code, commit any remaining changes and push to the PR head branch, then run the remaining review mutations using the pushed commit SHA and iterate again with the same options. If you did not change code, do not commit and continue with the remaining steps."
91
- : "If you changed code, commit any remaining changes, then stop and hand off for a push whose authorization is established outside Shepherd; do not run the remaining review mutations or iterate until the remote PR head changes. Shepherd cannot verify the Git credential's push authorization. If you did not change code, do not commit and continue with the remaining steps.");
94
+ instructions.push("If you changed code, commit any remaining changes and push to the PR head branch, then run the remaining review mutations using the pushed commit SHA and iterate again with the same options. If you did not change code, do not commit and continue with the remaining steps.");
92
95
  }
93
96
  if (viewerCanUpdate &&
94
97
  (hasReviewMutations ||
@@ -100,7 +103,7 @@ isBehind = false, viewerCanUpdate = false, pushAuthorized = false) {
100
103
  }
101
104
  if (resolveOnlyCommand?.hasMutations)
102
105
  instructions.push("Run the `resolve-only:` command shown above.");
103
- instructions.push(...buildResolveCommandInstruction(resolveCommand, pushAuthorized));
104
- instructions.push(buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha, pushAuthorized));
106
+ instructions.push(...buildResolveCommandInstruction(resolveCommand));
107
+ instructions.push(buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha));
105
108
  return instructions;
106
109
  }
@@ -99,10 +99,7 @@ async function runPollCore(opts) {
99
99
  pendingQuotaWarning !== undefined &&
100
100
  !(lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) &&
101
101
  !(debounceUntil !== null && !pastDebounce)) {
102
- const terminalOrHandoff = ["cancel", "escalate"].includes(lastResult.action) ||
103
- (lastResult.action === "fix_code" &&
104
- lastResult.fix.instructions.some((instruction) => /stop polling|human direction/i.test(instruction)));
105
- if (terminalOrHandoff) {
102
+ if (["cancel", "escalate"].includes(lastResult.action)) {
106
103
  const { quotaWarning: _quotaWarning, ...withoutQuotaWarning } = lastResult;
107
104
  lastResult = withoutQuotaWarning;
108
105
  }
@@ -24,5 +24,5 @@ export declare function classifyReviewsForDisplay(reviews: Review[], seenMap: Ma
24
24
  *
25
25
  * Human-authored CR reviews continue to flow through the standard seen-gate.
26
26
  */
27
- export declare function classifyChangesRequestedReviewsForDisplay(reviews: Review[], seenMap: Map<string, SeenMarker>, botUsernames: NormalizedBotUsernames): ReviewVisibility;
27
+ export declare function classifyChangesRequestedReviewsForDisplay(reviews: Review[], seenMap: Map<string, SeenMarker>, botUsernames: NormalizedBotUsernames, repeatBotReviews?: boolean): ReviewVisibility;
28
28
  export {};
@@ -31,7 +31,7 @@ export function classifyReviewsForDisplay(reviews, seenMap) {
31
31
  *
32
32
  * Human-authored CR reviews continue to flow through the standard seen-gate.
33
33
  */
34
- export function classifyChangesRequestedReviewsForDisplay(reviews, seenMap, botUsernames) {
34
+ export function classifyChangesRequestedReviewsForDisplay(reviews, seenMap, botUsernames, repeatBotReviews = true) {
35
35
  const visible = [];
36
36
  const toMarkSeen = [];
37
37
  for (const review of reviews) {
@@ -39,7 +39,8 @@ export function classifyChangesRequestedReviewsForDisplay(reviews, seenMap, botU
39
39
  const cls = classifyItem(review.id, review.body, seenMap);
40
40
  if (isBot) {
41
41
  if (cls === "unchanged") {
42
- visible.push({ ...review, staleBotCr: true });
42
+ if (repeatBotReviews)
43
+ visible.push({ ...review, staleBotCr: true });
43
44
  }
44
45
  else if (cls === "edited") {
45
46
  visible.push({ ...review, edited: true });
@@ -7,5 +7,5 @@ interface ThreadVisibility {
7
7
  firstLookThreads: FirstLookThread[];
8
8
  toMarkSeen: ReviewThread[];
9
9
  }
10
- export declare function classifyThreadVisibility(threads: ReviewThread[], seenMap: Map<string, SeenMarker>, botUsernames?: NormalizedBotUsernames): ThreadVisibility;
10
+ export declare function classifyThreadVisibility(threads: ReviewThread[], seenMap: Map<string, SeenMarker>, botUsernames?: NormalizedBotUsernames, repeatableThreadIds?: ReadonlySet<string>): ThreadVisibility;
11
11
  export {};