pr-shepherd 0.45.0 → 0.46.1

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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +20 -9
  3. package/bin/api.d.mts +2 -2
  4. package/bin/checks/conclusions.d.mts +0 -16
  5. package/bin/checks/conclusions.mjs +0 -27
  6. package/bin/cli/fix-formatter.mjs +10 -4
  7. package/bin/cli/help-command-pages.d.mts +16 -5
  8. package/bin/cli/help-command-pages.mjs +16 -5
  9. package/bin/cli/help-top-page.d.mts +1 -1
  10. package/bin/cli/help-top-page.mjs +3 -1
  11. package/bin/cli/help.d.mts +17 -6
  12. package/bin/cli/help.mjs +2 -0
  13. package/bin/cli/iterate-checks-formatter.d.mts +2 -0
  14. package/bin/cli/iterate-checks-formatter.mjs +55 -0
  15. package/bin/cli/iterate-formatter.mjs +7 -2
  16. package/bin/cli/iterate-lean.mjs +4 -0
  17. package/bin/cli/journal-extract-handler.d.mts +2 -0
  18. package/bin/cli/journal-extract-handler.mjs +48 -0
  19. package/bin/cli/mark-files-as-viewed-formatter.mjs +7 -6
  20. package/bin/cli/safe-body-file.d.mts +14 -0
  21. package/bin/cli/safe-body-file.mjs +42 -0
  22. package/bin/cli-parser.mjs +7 -0
  23. package/bin/commands/check-status.mjs +4 -4
  24. package/bin/commands/check.mjs +25 -5
  25. package/bin/commands/iterate/api-usage.mjs +1 -8
  26. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  27. package/bin/commands/iterate/check-instructions.mjs +8 -32
  28. package/bin/commands/iterate/classify.mjs +2 -1
  29. package/bin/commands/iterate/escalate.mjs +63 -9
  30. package/bin/commands/iterate/fix-code.mjs +83 -80
  31. package/bin/commands/iterate/index.mjs +2 -2
  32. package/bin/commands/iterate/merge-state.mjs +8 -37
  33. package/bin/commands/iterate/merge.mjs +1 -0
  34. package/bin/commands/iterate/render.d.mts +1 -1
  35. package/bin/commands/iterate/render.mjs +16 -13
  36. package/bin/commands/journal/index.d.mts +1 -0
  37. package/bin/commands/journal/index.mjs +1 -10
  38. package/bin/commands/mark-files-as-viewed.d.mts +2 -1
  39. package/bin/commands/mark-files-as-viewed.mjs +116 -4
  40. package/bin/commands/poll.mjs +1 -4
  41. package/bin/commands/resolve-mutate.mjs +17 -40
  42. package/bin/comments/resolve.d.mts +4 -0
  43. package/bin/comments/resolve.mjs +10 -4
  44. package/bin/comments/review-visibility.d.mts +1 -1
  45. package/bin/comments/review-visibility.mjs +3 -2
  46. package/bin/comments/thread-visibility.d.mts +1 -1
  47. package/bin/comments/thread-visibility.mjs +9 -3
  48. package/bin/github/client.d.mts +1 -2
  49. package/bin/github/client.mjs +1 -4
  50. package/bin/github/gql/get-pr-body.gql +0 -1
  51. package/bin/mcp/server.mjs +2 -2
  52. package/bin/pr-reference.d.mts +1 -1
  53. package/bin/pr-reference.mjs +1 -1
  54. package/bin/types/escalate.d.mts +5 -3
  55. package/bin/types/report.d.mts +1 -1
  56. package/package.json +2 -2
  57. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  58. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  59. package/plugins/pr-shepherd/.mcp.json +1 -1
  60. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +2 -2
  61. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +21 -15
@@ -0,0 +1,42 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
4
+ const DEFAULT_DEPENDENCIES = {
5
+ platform: process.platform,
6
+ noFollow: constants.O_NOFOLLOW,
7
+ nonBlock: constants.O_NONBLOCK,
8
+ open,
9
+ };
10
+ /**
11
+ * Reads a regular file after rejecting unsafe final path entries without exposing its contents.
12
+ * Callers must trust parent directories: O_NOFOLLOW only protects the final path entry.
13
+ */
14
+ export async function readSafeBodyFile(filePath, dependencies = DEFAULT_DEPENDENCIES) {
15
+ const flags = safeOpenFlags(dependencies);
16
+ if (flags === null)
17
+ throw noInput();
18
+ let handle;
19
+ try {
20
+ handle = await dependencies.open(filePath, flags);
21
+ if (!(await handle.stat()).isFile())
22
+ throw new Error("not a regular file");
23
+ return await handle.readFile({ encoding: "utf8" });
24
+ }
25
+ catch {
26
+ throw noInput();
27
+ }
28
+ finally {
29
+ await handle?.close().catch(() => undefined);
30
+ }
31
+ }
32
+ function safeOpenFlags(dependencies) {
33
+ if (dependencies.platform === "win32" ||
34
+ dependencies.noFollow === undefined ||
35
+ dependencies.nonBlock === undefined) {
36
+ return null;
37
+ }
38
+ return constants.O_RDONLY | dependencies.noFollow | dependencies.nonBlock;
39
+ }
40
+ function noInput() {
41
+ return new ShepherdError("body file could not be read safely", EXIT.NOINPUT);
42
+ }
@@ -10,6 +10,7 @@ import { USAGE, helpKeyForArgs, maybePrintHelp } from "./cli/help.mjs";
10
10
  import { formatMutateResult } from "./cli/formatters.mjs";
11
11
  import { handleClean, handleCommitSuggestion, handleSuggestionPatches, handleIterate, handleMarkFilesAsViewed, } from "./cli/handlers.mjs";
12
12
  import { handleJournal } from "./cli/journal-handler.mjs";
13
+ import { handleJournalExtract } from "./cli/journal-extract-handler.mjs";
13
14
  import { handlePoll } from "./cli/poll-handler.mjs";
14
15
  import { warnPrrcThreadIds, validateRequireSha, rejectPrrcMinimizeIds, } from "./cli/resolve-validators.mjs";
15
16
  import { setupLog } from "./log/setup.mjs";
@@ -32,6 +33,12 @@ export async function main(argv) {
32
33
  await handleLogFile(args.slice(1));
33
34
  return;
34
35
  }
36
+ // Extraction deliberately precedes legacy warnings and logging: it is a local,
37
+ // GitHub/config/log-free read path for automation that already has a PR body.
38
+ if (subcommand === "journal" && args[1] === "extract") {
39
+ await handleJournalExtract(args.slice(2));
40
+ return;
41
+ }
35
42
  // The public command groups keep admin diagnostics out of the normal
36
43
  // command namespace. Keep this before setupLog because log-file reads the
37
44
  // log path and must not initialize/tee the log it is reporting.
@@ -9,15 +9,15 @@ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, me
9
9
  return "FAILING";
10
10
  if (verdict.anyInProgress)
11
11
  return "IN_PROGRESS";
12
- // BLOCKED with no remaining shepherd work — hand off via ready-delay regardless of why GitHub
12
+ // BLOCKED with no remaining shepherd work — enter ready-delay regardless of why GitHub
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
16
  // Exception: UNSTABLE with ignored checks — UNSTABLE means only non-required checks are
17
- // pending/failing, and if those are all ignored the handoff is safe even with no other checks.
17
+ // pending/failing, and if those are all ignored the ready state is safe even with no other checks.
18
18
  // BLOCKED is excluded from the ignoredNames extension: BLOCKED can mean required checks haven't
19
- // started, and handing off prematurely there risks a broken merge attempt.
20
- // blockingBotReviewInProgress is still excluded — a bot review is shepherd's problem, not a hand-off.
19
+ // started, and entering READY prematurely there risks a broken merge attempt.
20
+ // blockingBotReviewInProgress is still excluded — a bot review is Shepherd work, not a READY state.
21
21
  const hasRelevantPassingChecks = verdict.hasChecks || (mergeStatus.status === "UNSTABLE" && verdict.ignoredNames.length > 0);
22
22
  if (verdict.allPassed &&
23
23
  hasRelevantPassingChecks &&
@@ -16,7 +16,8 @@ import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
16
16
  import { classifyReviewsForDisplay, classifyChangesRequestedReviewsForDisplay, } from "../comments/review-visibility.mjs";
17
17
  import { autoMinimizeComments, autoResolveThreads } from "../comments/resolve.mjs";
18
18
  import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
19
- import { normalizeBotUsernames } from "../comments/authors.mjs";
19
+ import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "../comments/authors.mjs";
20
+ import { buildThreadMutationRouting } from "./iterate/thread-mutation-routing.mjs";
20
21
  import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
21
22
  import { buildClassifyIndex, partitionBatch } from "../classify/apply.mjs";
22
23
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
@@ -80,7 +81,17 @@ export async function runCheck(opts) {
80
81
  const deniedRuleAutoResolveCommentIds = new Set(partition.ruleAutoResolveCommentIds.filter((id) => batchData.comments.find((comment) => comment.id === id)?.viewerCanMinimize !== true));
81
82
  const visibleCommentClassification = classifyVisibleComments(batchData.comments.filter((c) => !partition.suppressedCommentIds.has(c.id) || deniedRuleAutoResolveCommentIds.has(c.id)), seenMap, config.iterate.minimizeComments, botUsernames);
82
83
  const deniedRuleAutoResolveThreadIds = new Set(partition.ruleAutoResolveThreadIds.filter((id) => batchData.reviewThreads.find((thread) => thread.id === id)?.viewerCanResolve !== true));
83
- const threadVisibility = classifyThreadVisibility(batchData.reviewThreads.filter((t) => !partition.suppressedThreadIds.has(t.id) || deniedRuleAutoResolveThreadIds.has(t.id)), seenMap, botUsernames);
84
+ const visibleThreadCandidates = batchData.reviewThreads.filter((t) => !partition.suppressedThreadIds.has(t.id) || deniedRuleAutoResolveThreadIds.has(t.id));
85
+ const threadMutationRouting = buildThreadMutationRouting(visibleThreadCandidates, botUsernames, partition.ruleAutoResolveThreadIds);
86
+ const replyThreadIds = new Set(threadMutationRouting.replyThreadIds);
87
+ const resolveThreadIds = new Set(threadMutationRouting.resolveThreadIds);
88
+ const repeatableThreadIds = new Set(visibleThreadCandidates
89
+ .filter((thread) => thread.path !== null &&
90
+ thread.line !== null &&
91
+ (!replyThreadIds.has(thread.id) || thread.viewerCanReply === true) &&
92
+ (!resolveThreadIds.has(thread.id) || thread.viewerCanResolve === true))
93
+ .map((thread) => thread.id));
94
+ const threadVisibility = classifyThreadVisibility(visibleThreadCandidates, seenMap, botUsernames, repeatableThreadIds);
84
95
  const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
85
96
  const cls = classifyItem(c.id, c.body, seenMap);
86
97
  if (cls === "unchanged")
@@ -103,7 +114,7 @@ export async function runCheck(opts) {
103
114
  else
104
115
  seenSummaries.push(r);
105
116
  }
106
- const changesRequestedReviewVisibility = classifyChangesRequestedReviewsForDisplay(batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)), seenMap, botUsernames);
117
+ const changesRequestedReviewVisibility = classifyChangesRequestedReviewsForDisplay(batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)), seenMap, botUsernames, batchData.viewerAuthorization?.viewerCanAdminister === true);
107
118
  const approvedReviewVisibility = classifyReviewsForDisplay(batchData.approvedReviews, seenMap);
108
119
  if (opts.persistSeen !== false) {
109
120
  const successfulAnnotations = [
@@ -145,12 +156,21 @@ export async function runCheck(opts) {
145
156
  ruleAutoResolveReviewSummaryIds: partition.ruleAutoResolveReviewSummaryIds.filter((id) => batchData.reviewSummaries.find((review) => review.id === id)?.viewerCanMinimize === true),
146
157
  };
147
158
  const { threadIds: authorizedRuleAutoResolveThreadIds, commentIds: ruleAutoResolveCommentIds, reviewSummaryIds: ruleAutoResolveReviewSummaryIds, } = await remainingRuleAutoResolveIds(authorizedPartition, opts.autoMinimizeSuppressed);
159
+ const visibleMutationThreadIds = new Set([...threadVisibility.activeThreads, ...threadVisibility.resolutionOnlyThreads].map((thread) => thread.id));
148
160
  const ruleAutoResolveThreadIds = [
149
161
  ...authorizedRuleAutoResolveThreadIds,
150
- ...deniedRuleAutoResolveThreadIds,
162
+ ...[...deniedRuleAutoResolveThreadIds].filter((id) => visibleMutationThreadIds.has(id)),
151
163
  ];
152
164
  const changesRequestedReviews = changesRequestedReviewVisibility.visible;
153
- const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)).length;
165
+ const visibleChangesRequestedIds = new Set(changesRequestedReviews.map((review) => review.id));
166
+ const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((review) => {
167
+ if (partition.suppressedChangesRequestedIds.has(review.id))
168
+ return false;
169
+ const isBot = !isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames);
170
+ return (!isBot ||
171
+ batchData.viewerAuthorization?.viewerCanAdminister === true ||
172
+ visibleChangesRequestedIds.has(review.id));
173
+ }).length;
154
174
  const approvedReviews = approvedReviewVisibility.visible;
155
175
  let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount);
156
176
  if (status === "READY" && !didRefreshMergeability) {
@@ -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 automatic mark-ready operation. Ask a repository maintainer to mark the pull request ready before resuming Shepherd.";
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) {