pr-shepherd 0.46.5 → 0.46.7

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 (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -3
  3. package/bin/api.d.mts +1 -1
  4. package/bin/checks/triage.mjs +12 -1
  5. package/bin/cli/body-truncate.d.mts +12 -0
  6. package/bin/cli/body-truncate.mjs +123 -0
  7. package/bin/cli/fix-formatter-extra.d.mts +2 -1
  8. package/bin/cli/fix-formatter-extra.mjs +18 -3
  9. package/bin/cli/fix-formatter.d.mts +3 -1
  10. package/bin/cli/fix-formatter.mjs +42 -16
  11. package/bin/cli/help-top-page.d.mts +1 -1
  12. package/bin/cli/help-top-page.mjs +2 -0
  13. package/bin/cli/help.d.mts +1 -1
  14. package/bin/cli/iterate-checks-formatter.mjs +8 -6
  15. package/bin/cli/iterate-formatter.mjs +10 -9
  16. package/bin/cli/iterate-lean.mjs +5 -0
  17. package/bin/cli/iterate-merge-formatter.d.mts +3 -1
  18. package/bin/cli/iterate-merge-formatter.mjs +15 -0
  19. package/bin/cli/list-formatters.d.mts +4 -3
  20. package/bin/cli/list-formatters.mjs +22 -14
  21. package/bin/commands/check-annotations.d.mts +15 -0
  22. package/bin/commands/check-annotations.mjs +23 -1
  23. package/bin/commands/check.d.mts +1 -0
  24. package/bin/commands/check.mjs +73 -40
  25. package/bin/commands/iterate/check-instructions.mjs +1 -1
  26. package/bin/commands/iterate/classify.d.mts +2 -2
  27. package/bin/commands/iterate/classify.mjs +2 -2
  28. package/bin/commands/iterate/escalate.mjs +9 -0
  29. package/bin/commands/iterate/fix-code.mjs +11 -9
  30. package/bin/commands/iterate/fix-instruction-threads.d.mts +14 -0
  31. package/bin/commands/iterate/fix-instruction-threads.mjs +32 -0
  32. package/bin/commands/iterate/index.mjs +20 -7
  33. package/bin/commands/iterate/merge-state.d.mts +7 -2
  34. package/bin/commands/iterate/merge-state.mjs +68 -2
  35. package/bin/commands/iterate/render.d.mts +1 -1
  36. package/bin/commands/iterate/render.mjs +12 -23
  37. package/bin/commands/iterate/thread-mutation-routing.d.mts +10 -2
  38. package/bin/commands/iterate/thread-mutation-routing.mjs +26 -14
  39. package/bin/commands/resolve-mutate.mjs +18 -7
  40. package/bin/comments/thread-resolve-policy.d.mts +6 -0
  41. package/bin/comments/thread-resolve-policy.mjs +9 -0
  42. package/bin/comments/thread-visibility.d.mts +2 -1
  43. package/bin/comments/thread-visibility.mjs +12 -5
  44. package/bin/config/load.d.mts +18 -0
  45. package/bin/config/load.mjs +27 -1
  46. package/bin/config.json +6 -3
  47. package/bin/mcp/server.mjs +18 -6
  48. package/bin/types/escalate.d.mts +3 -2
  49. package/bin/types/iterate.d.mts +10 -0
  50. package/bin/types/merge-queue.d.mts +16 -0
  51. package/package.json +9 -6
  52. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  53. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  54. package/plugins/pr-shepherd/.mcp.json +1 -1
  55. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +17 -4
@@ -2,9 +2,34 @@ import { clearStallState } from "../../state/iterate-stall.mjs";
2
2
  import { buildEscalateHumanMessage, buildEscalateSuggestion } from "./escalate.mjs";
3
3
  import { buildMergeCommandPlan } from "./merge.mjs";
4
4
  import { formatPrUrl } from "../../pr-reference.mjs";
5
- export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
5
+ /** A stacked PR's merge is human-only: `gh pr merge` targets the PR's own base, which for a
6
+ * mid-stack layer is an unmerged parent branch, and auto-merge is unsupported on stacks. */
7
+ function buildStackedEscalateResult(base, report, stack) {
8
+ const escalateBase = {
9
+ triggers: ["stacked-pr"],
10
+ unresolvedThreads: [],
11
+ ambiguousComments: [],
12
+ changesRequestedReviews: [],
13
+ stack,
14
+ suggestion: buildEscalateSuggestion(["stacked-pr"], String(report.pr)),
15
+ };
16
+ return {
17
+ ...base,
18
+ action: "escalate",
19
+ escalate: {
20
+ ...escalateBase,
21
+ humanMessage: buildEscalateHumanMessage(escalateBase, formatPrUrl(report.repo, report.pr), {
22
+ merge: true,
23
+ }),
24
+ },
25
+ };
26
+ }
27
+ export function buildReadyMergeOutcome(enabled, readyElapsed, base, report) {
6
28
  if (!enabled || !readyElapsed || report.mergeStatus.isDraft)
7
29
  return null;
30
+ const stack = report.mergeStatus.mergeRequirements?.stack;
31
+ if (stack)
32
+ return buildStackedEscalateResult(base, report, stack);
8
33
  const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
9
34
  report.mergeStatus.mergeRequirements?.mergeQueue?.enabled);
10
35
  return {
@@ -19,14 +44,55 @@ export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
19
44
  }),
20
45
  };
21
46
  }
47
+ /** Raw counts of non-CI actionable work held back for one queued-PR wait tick. Omitted (all zero) when empty. */
48
+ function buildDeferredWork(input) {
49
+ const { report, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, minimizeApprovals, } = input;
50
+ // These buckets are not disjoint (e.g. an unresolved outdated thread is both
51
+ // `resolutionOnly` and `firstLook`; an eligible-to-minimize comment/summary is both
52
+ // `actionable`/`firstLook` and queued in `minimizeIds`/`reviewSummaryIds`) — dedupe by ID.
53
+ const threadIds = new Set([
54
+ ...report.threads.actionable.map((t) => t.id),
55
+ ...report.threads.resolutionOnly.map((t) => t.id),
56
+ ...report.threads.firstLook.map((t) => t.id),
57
+ ...(report.threads.ruleAutoResolveIds ?? []),
58
+ ]);
59
+ const commentIds = new Set([
60
+ ...report.comments.actionable.map((c) => c.id),
61
+ ...(report.comments.minimizeIds ?? []),
62
+ ...report.comments.firstLook.map((c) => c.id),
63
+ ]);
64
+ const reviewSummaryIdSet = new Set([
65
+ ...reviewSummaryIds,
66
+ ...firstLookSummaries.map((r) => r.id),
67
+ ...editedSummaries.map((r) => r.id),
68
+ ...(minimizeApprovals ? surfacedApprovals.map((r) => r.id) : []),
69
+ ]);
70
+ const deferredWork = {
71
+ threads: threadIds.size,
72
+ comments: commentIds.size,
73
+ changesRequestedReviews: report.changesRequestedReviews.length,
74
+ reviewSummaries: reviewSummaryIdSet.size,
75
+ };
76
+ const total = deferredWork.threads +
77
+ deferredWork.comments +
78
+ deferredWork.changesRequestedReviews +
79
+ deferredWork.reviewSummaries;
80
+ return total > 0 ? deferredWork : undefined;
81
+ }
22
82
  export async function handleActiveMergeState(input) {
23
83
  const { enabled, active, base, report, stallKey } = input;
84
+ const inQueue = report.mergeQueue?.inQueue === true;
24
85
  if (enabled && active) {
25
86
  await clearStallState(stallKey);
87
+ // Only the queued case ever holds back non-CI actionable work (see the `deferWhileQueued`
88
+ // gate in index.mts, which requires `inQueue === true`); an ordinary active auto-merge
89
+ // request with no queue membership never defers anything, so it never carries counts here.
90
+ const deferredWork = inQueue ? buildDeferredWork(input) : undefined;
26
91
  return {
27
92
  ...base,
28
93
  action: "wait",
29
- log: report.mergeQueue?.inQueue
94
+ ...(deferredWork && { deferredWork }),
95
+ log: inQueue
30
96
  ? `WAIT: PR #${report.pr} is in the merge queue`
31
97
  : `WAIT: PR #${report.pr} has auto-merge enabled`,
32
98
  };
@@ -1,5 +1,5 @@
1
1
  import type { AgentThread, AgentComment, AgentCheck, Review, ResolveCommand, FirstLookThread, FirstLookComment, ReviewThread } from "../../types.mts";
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
- 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
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
5
  isBehind?: boolean, viewerCanUpdate?: boolean, hasExhaustedWorkflowRerun?: boolean): string[];
@@ -3,6 +3,7 @@ import { buildFailingCheckInstructions, buildCrStaleClause, buildBehindBaseHintI
3
3
  import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
4
4
  import { isFailingAgentCheck } from "../../checks/conclusions.mjs";
5
5
  import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
6
+ import { partitionFixThreads, reviewSectionRefs } from "./fix-instruction-threads.mjs";
6
7
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
7
8
  export function renderResolveCommand(rc) {
8
9
  const parts = [...rc.argv];
@@ -10,11 +11,10 @@ export function renderResolveCommand(rc) {
10
11
  parts.push("--require-sha", "$HEAD_SHA");
11
12
  return renderShellCommand(parts);
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
+ 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
15
  isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
15
16
  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);
17
+ const { locatedThreads, unlocatedMutatedThreads, unlocatedThreads } = partitionFixThreads(threads, resolveCommand, resolveOnlyCommand);
18
18
  const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
19
19
  const repeatedWorkflowBranchRecoveryInstructions = buildRepeatedWorkflowBranchRecoveryInstructions(baseBranch, hasExhaustedWorkflowRerun, {
20
20
  isBehind,
@@ -29,20 +29,14 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
29
29
  actionableComments.length > 0;
30
30
  // Start with interpretation. The agent decides what raw feedback warrants a code change.
31
31
  if (hasNonConflictHints) {
32
- const actionableSections = [];
33
- if (locatedThreads.length > 0)
34
- actionableSections.push("`## Review threads`");
35
- if (unlocatedThreads.length > 0)
36
- actionableSections.push("`## Unlocated review threads (logged once — no mutation)`");
37
- if (actionableComments.length > 0)
38
- actionableSections.push("`## Actionable comments`");
39
- if (failingChecks.length > 0)
40
- actionableSections.push("`## Failing checks`");
41
- if (hasAnnotations) {
42
- actionableSections.push("`## Check annotations`");
43
- }
44
- if (changesRequestedReviews.length > 0)
45
- actionableSections.push("`## Changes-requested reviews`");
32
+ const actionableSections = reviewSectionRefs({
33
+ hasReviewThreads: locatedThreads.length > 0 || unlocatedMutatedThreads.length > 0,
34
+ hasUnlocatedSkipThreads: unlocatedThreads.length > 0,
35
+ hasActionableComments: actionableComments.length > 0,
36
+ hasFailingChecks: failingChecks.length > 0,
37
+ hasAnnotations,
38
+ hasChangesRequested: changesRequestedReviews.length > 0,
39
+ });
46
40
  const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
47
41
  instructions.push(`Review each item ${sectionRef} and decide whether it needs a code change.`);
48
42
  }
@@ -65,10 +59,6 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
65
59
  if (unlocatedThreads.length > 0) {
66
60
  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.");
67
61
  }
68
- // GitHub exposes no exact viewer capability for workflow-run cancellation, so the
69
- // informational run lists never produce a cancellation recommendation.
70
- void inProgressRunIds;
71
- void cancelledCount;
72
62
  const hasSuggestions = locatedThreads.some((t) => t.suggestion);
73
63
  if (hasSuggestions)
74
64
  instructions.push(buildCommitSuggestionInstruction(prReference, "## Review threads"));
@@ -111,7 +101,6 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
111
101
  }
112
102
  if (resolveOnlyCommand?.hasMutations)
113
103
  instructions.push("Run the `resolve-only:` command shown above.");
114
- instructions.push(...buildResolveCommandInstruction(resolveCommand));
115
- instructions.push(buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha));
104
+ instructions.push(...buildResolveCommandInstruction(resolveCommand), buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha));
116
105
  return instructions;
117
106
  }
@@ -1,10 +1,18 @@
1
1
  import { type NormalizedBotUsernames } from "../../comments/authors.mts";
2
+ import { shouldResolveOtherHumanThread } from "../../comments/thread-resolve-policy.mts";
3
+ import type { ResolveOtherHumanThreads } from "../../config/load.mts";
2
4
  import type { AgentThread, ReviewThread } from "../../types.mts";
5
+ export { shouldResolveOtherHumanThread };
6
+ export type RoutableThread = AgentThread | ReviewThread;
3
7
  export interface ThreadMutationRouting {
4
8
  replyThreadIds: string[];
5
9
  pairedResolveThreadIds: string[];
6
10
  standaloneResolveThreadIds: string[];
7
11
  resolveThreadIds: string[];
8
12
  }
9
- export declare function canResolveOutdatedBotWithoutLocation(thread: ReviewThread, botUsernames: NormalizedBotUsernames): boolean;
10
- export declare function buildThreadMutationRouting(threads: Array<AgentThread | ReviewThread>, botUsernames: NormalizedBotUsernames, ruleAutoResolveThreadIds: string[]): ThreadMutationRouting;
13
+ export declare function threadHasAuthorizedMutation(thread: {
14
+ id: string;
15
+ viewerCanReply?: boolean;
16
+ viewerCanResolve?: boolean;
17
+ }, replyThreadIds: ReadonlySet<string>, resolveThreadIds: ReadonlySet<string>): boolean;
18
+ export declare function buildThreadMutationRouting(threads: RoutableThread[], botUsernames: NormalizedBotUsernames, ruleAutoResolveThreadIds: string[], policy?: ResolveOtherHumanThreads): ThreadMutationRouting;
@@ -1,29 +1,41 @@
1
1
  import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, } from "../../comments/authors.mjs";
2
2
  import { threadEndedByShepherd } from "../../comments/marker.mjs";
3
+ import { shouldResolveOtherHumanThread } from "../../comments/thread-resolve-policy.mjs";
4
+ export { shouldResolveOtherHumanThread };
3
5
  function dedupeIds(ids) {
4
6
  return [...new Set(ids)];
5
7
  }
6
- export function canResolveOutdatedBotWithoutLocation(thread, botUsernames) {
7
- return (!thread.isResolved &&
8
- thread.isOutdated &&
9
- (thread.path === null || thread.line === null) &&
10
- isConfiguredBotAuthor(thread, botUsernames) &&
11
- thread.viewerCanResolve === true);
8
+ function isOrdinaryHuman(thread, botUsernames) {
9
+ return isHumanAuthor(thread) && !isConfiguredBotAuthor(thread, botUsernames);
12
10
  }
13
- export function buildThreadMutationRouting(threads, botUsernames, ruleAutoResolveThreadIds) {
14
- const isOrdinaryHuman = (thread) => isHumanAuthor(thread) && !isConfiguredBotAuthor(thread, botUsernames);
15
- const replyThreadIds = dedupeIds(threads
16
- .filter((thread) => isOrdinaryHuman(thread) && !threadEndedByShepherd(thread))
17
- .map((thread) => thread.id));
11
+ function shouldPairResolve(thread, botUsernames, policy = "none") {
12
+ if (!isOrdinaryHuman(thread, botUsernames))
13
+ return true;
14
+ if (isViewerAuthoredHuman(thread, botUsernames))
15
+ return true;
16
+ return shouldResolveOtherHumanThread(thread, policy);
17
+ }
18
+ export function threadHasAuthorizedMutation(thread, replyThreadIds, resolveThreadIds) {
19
+ const inReply = replyThreadIds.has(thread.id);
20
+ const inResolve = resolveThreadIds.has(thread.id);
21
+ if (!inReply && !inResolve)
22
+ return false;
23
+ if (inReply && thread.viewerCanReply !== true)
24
+ return false;
25
+ if (inResolve && thread.viewerCanResolve !== true)
26
+ return false;
27
+ return true;
28
+ }
29
+ export function buildThreadMutationRouting(threads, botUsernames, ruleAutoResolveThreadIds, policy = "none") {
30
+ const replyThreadIds = dedupeIds(threads.filter((thread) => !threadEndedByShepherd(thread)).map((thread) => thread.id));
18
31
  const pairedResolveThreadIds = dedupeIds(threads
19
- .filter((thread) => isViewerAuthoredHuman(thread, botUsernames) && !threadEndedByShepherd(thread))
32
+ .filter((thread) => shouldPairResolve(thread, botUsernames, policy) && !threadEndedByShepherd(thread))
20
33
  .map((thread) => thread.id));
21
34
  const pairedResolveIdSet = new Set(pairedResolveThreadIds);
22
35
  // Rule-matched threads bypass author routing; resolve-mutate retains the human-author guard.
23
36
  const standaloneResolveThreadIds = dedupeIds([
24
37
  ...threads
25
- .filter((thread) => !isOrdinaryHuman(thread) ||
26
- (isViewerAuthoredHuman(thread, botUsernames) && threadEndedByShepherd(thread)))
38
+ .filter((thread) => shouldPairResolve(thread, botUsernames, policy) && threadEndedByShepherd(thread))
27
39
  .map((thread) => thread.id),
28
40
  ...ruleAutoResolveThreadIds,
29
41
  ]).filter((id) => !pairedResolveIdSet.has(id));
@@ -3,6 +3,7 @@ import { applyResolveOptions } from "../comments/resolve.mjs";
3
3
  import { fetchPrBatch } from "../github/batch.mjs";
4
4
  import { loadConfig } from "../config/load.mjs";
5
5
  import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, normalizeBotUsernames, } from "../comments/authors.mjs";
6
+ import { shouldResolveOtherHumanThread } from "./iterate/thread-mutation-routing.mjs";
6
7
  import { markReplySeen } from "../state/seen-comments.mjs";
7
8
  import { threadTranscriptBody } from "../threads/transcript.mjs";
8
9
  import { addPrShepherdMarker, threadEndedByShepherd } from "../comments/marker.mjs";
@@ -31,14 +32,24 @@ export async function runResolveMutate(opts) {
31
32
  // print. Once a caller explicitly runs apply, GitHub's mutation response is
32
33
  // authoritative and this path must not second-guess that intent.
33
34
  const requestedReplyIds = new Set(opts.replyThreadIds ?? []);
34
- const allowedViewerHumanResolveIds = new Set(data.reviewThreads
35
- .filter((thread) => isViewerAuthoredHuman(thread, botUsernames) &&
36
- (requestedReplyIds.has(thread.id) || threadEndedByShepherd(thread)))
35
+ const policy = config.iterate?.resolveOtherHumanThreads ?? "none";
36
+ const allowedHumanResolveIds = new Set(data.reviewThreads
37
+ .filter((thread) => {
38
+ if (!humanThreadIds.has(thread.id))
39
+ return false;
40
+ const paired = requestedReplyIds.has(thread.id) || threadEndedByShepherd(thread);
41
+ if (!paired)
42
+ return false;
43
+ if (isViewerAuthoredHuman(thread, botUsernames))
44
+ return true;
45
+ return shouldResolveOtherHumanThread(thread, policy);
46
+ })
37
47
  .map((thread) => thread.id));
38
- const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id) || allowedViewerHumanResolveIds.has(id));
39
- const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id) && !allowedViewerHumanResolveIds.has(id));
40
- const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(id));
41
- const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
48
+ const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id) || allowedHumanResolveIds.has(id));
49
+ const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id) && !allowedHumanResolveIds.has(id));
50
+ const knownThreadIds = new Set(data.reviewThreads.map((thread) => thread.id));
51
+ const replyThreadIds = opts.replyThreadIds?.filter((id) => knownThreadIds.has(id));
52
+ const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !knownThreadIds.has(id));
42
53
  const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id));
43
54
  const skippedHumanMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => humanCommentIds.has(id) || humanReviewIds.has(id));
44
55
  const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id) && data.changesRequestedReviews.some((review) => review.id === id));
@@ -0,0 +1,6 @@
1
+ import type { ResolveOtherHumanThreads } from "../config/load.mts";
2
+ import type { AgentThread, ReviewThread } from "../types.mts";
3
+ type OutdatableThread = AgentThread | ReviewThread;
4
+ /** Other-human threads are resolved only when the iterate enum allows it. */
5
+ export declare function shouldResolveOtherHumanThread(thread: OutdatableThread, policy: ResolveOtherHumanThreads): boolean;
6
+ export {};
@@ -0,0 +1,9 @@
1
+ function threadIsOutdated(thread) {
2
+ return "isOutdated" in thread && thread.isOutdated === true;
3
+ }
4
+ /** Other-human threads are resolved only when the iterate enum allows it. */
5
+ export function shouldResolveOtherHumanThread(thread, policy) {
6
+ if (policy === "always")
7
+ return true;
8
+ return policy === "outdated" && threadIsOutdated(thread);
9
+ }
@@ -1,5 +1,6 @@
1
1
  import { type SeenMarker } from "../state/seen-comments.mts";
2
2
  import { type NormalizedBotUsernames } from "./authors.mts";
3
+ import type { ResolveOtherHumanThreads } from "../config/load.mts";
3
4
  import type { FirstLookThread, ReviewThread } from "../types.mts";
4
5
  interface ThreadVisibility {
5
6
  activeThreads: ReviewThread[];
@@ -7,5 +8,5 @@ interface ThreadVisibility {
7
8
  firstLookThreads: FirstLookThread[];
8
9
  toMarkSeen: ReviewThread[];
9
10
  }
10
- export declare function classifyThreadVisibility(threads: ReviewThread[], seenMap: Map<string, SeenMarker>, botUsernames?: NormalizedBotUsernames, repeatableThreadIds?: ReadonlySet<string>): ThreadVisibility;
11
+ export declare function classifyThreadVisibility(threads: ReviewThread[], seenMap: Map<string, SeenMarker>, botUsernames?: NormalizedBotUsernames, repeatableThreadIds?: ReadonlySet<string>, resolveOtherHumanThreads?: ResolveOtherHumanThreads): ThreadVisibility;
11
12
  export {};
@@ -2,6 +2,7 @@ import { classifyItem } from "../state/seen-comments.mjs";
2
2
  import { threadTranscriptBody } from "../threads/transcript.mjs";
3
3
  import { isConfiguredBotAuthor, isHumanAuthor, isViewerAuthoredHuman, } from "./authors.mjs";
4
4
  import { threadEndedByShepherd } from "./marker.mjs";
5
+ import { shouldResolveOtherHumanThread } from "./thread-resolve-policy.mjs";
5
6
  function withEdited(thread, edited) {
6
7
  return edited ? { ...thread, edited: true } : thread;
7
8
  }
@@ -19,15 +20,19 @@ function classifyFirstLookThread(thread, seenMap, firstLookStatus) {
19
20
  return null;
20
21
  return { ...visible, firstLookStatus };
21
22
  }
22
- export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set(), repeatableThreadIds) {
23
+ export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set(), repeatableThreadIds, resolveOtherHumanThreads = "none") {
23
24
  const shouldRepeat = (thread) => repeatableThreadIds?.has(thread.id) ?? true;
25
+ const isOrdinaryHuman = (thread) => isHumanAuthor(thread) && !isConfiguredBotAuthor(thread, botUsernames);
24
26
  const unresolvedThreads = threads.filter((t) => !t.isResolved);
25
27
  const activeThreads = unresolvedThreads
26
28
  .filter((t) => !t.isOutdated && !t.isMinimized)
27
29
  .flatMap((t) => {
28
30
  if (threadEndedByShepherd(t))
29
31
  return [];
30
- if (isConfiguredBotAuthor(t, botUsernames) && shouldRepeat(t))
32
+ const repeatAuthor = isConfiguredBotAuthor(t, botUsernames) ||
33
+ isViewerAuthoredHuman(t, botUsernames) ||
34
+ (isOrdinaryHuman(t) && resolveOtherHumanThreads === "always");
35
+ if (repeatAuthor && shouldRepeat(t))
31
36
  return [t];
32
37
  const visible = classifyVisibleThread(t, seenMap);
33
38
  return visible ? [visible] : [];
@@ -35,9 +40,11 @@ export function classifyThreadVisibility(threads, seenMap, botUsernames = new Se
35
40
  const resolutionOnlyThreads = unresolvedThreads
36
41
  .filter((t) => {
37
42
  const endedByShepherd = threadEndedByShepherd(t);
38
- const ordinaryHuman = isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames);
39
- if (endedByShepherd && ordinaryHuman) {
40
- return isViewerAuthoredHuman(t, botUsernames);
43
+ if (endedByShepherd) {
44
+ if (!isOrdinaryHuman(t))
45
+ return true;
46
+ return (isViewerAuthoredHuman(t, botUsernames) ||
47
+ shouldResolveOtherHumanThread(t, resolveOtherHumanThreads));
41
48
  }
42
49
  return t.isOutdated || t.isMinimized;
43
50
  })
@@ -1,5 +1,7 @@
1
1
  declare const MINIMIZE_COMMENTS_POLICIES: readonly ["all", "bots", "users", "none"];
2
2
  export type MinimizeCommentsPolicy = (typeof MINIMIZE_COMMENTS_POLICIES)[number];
3
+ declare const RESOLVE_OTHER_HUMAN_THREADS: readonly ["none", "outdated", "always"];
4
+ export type ResolveOtherHumanThreads = (typeof RESOLVE_OTHER_HUMAN_THREADS)[number];
3
5
  export interface GraphqlQuotaWarningBand {
4
6
  remainingPercent: number;
5
7
  pollIntervalMinutes: number;
@@ -31,6 +33,12 @@ export interface PrShepherdConfig {
31
33
  * (default) omits the hint entirely; the CLI never prescribes rebase/merge mechanics itself.
32
34
  */
33
35
  behindBaseHint: string;
36
+ /**
37
+ * When to resolve other-human inline threads after Shepherd replies. Own and bot threads
38
+ * always reply-and-resolve. `none` (default) keeps other humans reply-only; `outdated`
39
+ * also resolves when GitHub reports `isOutdated`; `always` pairs reply-and-resolve.
40
+ */
41
+ resolveOtherHumanThreads: ResolveOtherHumanThreads;
34
42
  };
35
43
  watch: {
36
44
  readyDelayMinutes: number;
@@ -44,6 +52,8 @@ export interface PrShepherdConfig {
44
52
  };
45
53
  checks: {
46
54
  ciTriggerEvents: string[];
55
+ /** Regex patterns matched against each raw log line; matching lines are dropped from log excerpts. Empty by default — no lines are stripped unless configured. */
56
+ ignoreLogLines: string[];
47
57
  };
48
58
  mergeStatus: {
49
59
  blockingReviewerLogins: string[];
@@ -57,6 +67,14 @@ export interface PrShepherdConfig {
57
67
  autoMarkReady: boolean;
58
68
  /** Legacy-named patterns that keep matching Actions checks visible despite ignoreChecks. */
59
69
  neverCancelRuns: string[];
70
+ /**
71
+ * When `false` (default), `iterate --merge` defers non-CI actionable work
72
+ * (review threads, comments, changes-requested reviews, review summaries)
73
+ * while the PR sits in the merge queue, since a Shepherd-initiated push
74
+ * would eject it. When `true`, restores pre-existing behavior: actionable
75
+ * work is handled immediately regardless of queue membership.
76
+ */
77
+ workWhileQueued: boolean;
60
78
  /** @deprecated Accepted for compatibility, ignored by the loader. */
61
79
  autoResolveOutdated?: boolean;
62
80
  /** @deprecated Accepted for compatibility, ignored by the loader. */
@@ -7,6 +7,7 @@ import builtins from "../config.json" with { type: "json" };
7
7
  import { getEffectiveCwd } from "../execution-context.mjs";
8
8
  import { findMergeStrategies } from "./merge-command-args.mjs";
9
9
  const MINIMIZE_COMMENTS_POLICIES = ["all", "bots", "users", "none"];
10
+ const RESOLVE_OTHER_HUMAN_THREADS = ["none", "outdated", "always"];
10
11
  const RC_FILENAME = ".pr-shepherdrc.yml";
11
12
  /**
12
13
  * Collect `.pr-shepherdrc.yml` files from `startDir` toward `$HOME` (closest first).
@@ -68,6 +69,13 @@ function parseMinimizeCommentsPolicy(value) {
68
69
  return value;
69
70
  throw new Error(`Invalid config: iterate.minimizeComments must be one of "all", "bots", "users", or "none", got ${JSON.stringify(value)}`);
70
71
  }
72
+ function parseResolveOtherHumanThreads(value) {
73
+ if (typeof value === "string" &&
74
+ RESOLVE_OTHER_HUMAN_THREADS.includes(value)) {
75
+ return value;
76
+ }
77
+ throw new Error(`Invalid config: iterate.resolveOtherHumanThreads must be one of "none", "outdated", or "always", got ${JSON.stringify(value)}`);
78
+ }
71
79
  function parseBotUsernames(value) {
72
80
  if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
73
81
  throw new Error(`Invalid config: botUsernames must be an array of strings`);
@@ -86,6 +94,20 @@ function parseNeverCancelRuns(value) {
86
94
  }
87
95
  return value;
88
96
  }
97
+ function parseIgnoreLogLines(value) {
98
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
99
+ throw new Error(`Invalid config: checks.ignoreLogLines must be an array of strings`);
100
+ }
101
+ for (const pattern of value) {
102
+ try {
103
+ new RegExp(pattern);
104
+ }
105
+ catch {
106
+ throw new Error(`Invalid config: checks.ignoreLogLines contains an invalid regular expression: ${pattern}`);
107
+ }
108
+ }
109
+ return value;
110
+ }
89
111
  const SHEPHERD_OWNED_MERGE_FLAGS = [
90
112
  "--repo",
91
113
  "-R",
@@ -165,16 +187,18 @@ const KNOWN_NESTED_KEYS = {
165
187
  "minimizeApprovals",
166
188
  "minimizeComments",
167
189
  "behindBaseHint",
190
+ "resolveOtherHumanThreads",
168
191
  ]),
169
192
  watch: new Set(["readyDelayMinutes", "graphqlQuotaWarnings"]),
170
193
  resolve: new Set(["shaPoll"]),
171
- checks: new Set(["ciTriggerEvents"]),
194
+ checks: new Set(["ciTriggerEvents", "ignoreLogLines"]),
172
195
  mergeStatus: new Set(["blockingReviewerLogins"]),
173
196
  merge: new Set(["commandArgs"]),
174
197
  actions: new Set([
175
198
  "autoMinimizeSuppressed",
176
199
  "autoMarkReady",
177
200
  "neverCancelRuns",
201
+ "workWhileQueued",
178
202
  "autoResolveOutdated",
179
203
  "commitSuggestions",
180
204
  ]),
@@ -261,6 +285,8 @@ export function loadConfig() {
261
285
  config.merge.commandArgs = parseMergeCommandArgs(config.merge.commandArgs);
262
286
  config.watch.graphqlQuotaWarnings = parseGraphqlQuotaWarnings(config.watch.graphqlQuotaWarnings);
263
287
  config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
288
+ config.iterate.resolveOtherHumanThreads = parseResolveOtherHumanThreads(config.iterate.resolveOtherHumanThreads);
289
+ config.checks.ignoreLogLines = parseIgnoreLogLines(config.checks.ignoreLogLines);
264
290
  configCache.set(cwd, config);
265
291
  return config;
266
292
  }
package/bin/config.json CHANGED
@@ -19,7 +19,8 @@
19
19
  "stallTimeoutMinutes": 60,
20
20
  "minimizeApprovals": false,
21
21
  "minimizeComments": "all",
22
- "behindBaseHint": ""
22
+ "behindBaseHint": "",
23
+ "resolveOtherHumanThreads": "none"
23
24
  },
24
25
  "watch": {
25
26
  "readyDelayMinutes": 10,
@@ -36,7 +37,8 @@
36
37
  }
37
38
  },
38
39
  "checks": {
39
- "ciTriggerEvents": ["pull_request", "pull_request_target"]
40
+ "ciTriggerEvents": ["pull_request", "pull_request_target"],
41
+ "ignoreLogLines": []
40
42
  },
41
43
  "mergeStatus": {
42
44
  "blockingReviewerLogins": ["copilot"]
@@ -47,6 +49,7 @@
47
49
  "actions": {
48
50
  "autoMinimizeSuppressed": true,
49
51
  "autoMarkReady": true,
50
- "neverCancelRuns": []
52
+ "neverCancelRuns": [],
53
+ "workWhileQueued": false
51
54
  }
52
55
  }
@@ -5,7 +5,7 @@ import { z } from "zod";
5
5
  import { createPrShepherd, PartialApplyError, PrShepherdValidationError, } from "../api.mjs";
6
6
  import { isRepositoryQualifiedPrReference } from "../pr-reference.mjs";
7
7
  import { formatJournalResult } from "../cli/journal-formatter.mjs";
8
- import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, } from "../cli/formatters.mjs";
8
+ import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, projectIterateLean, } from "../cli/formatters.mjs";
9
9
  import { formatCliError, serializeGitHubRequestErrorDetails } from "../cli/error-format.mjs";
10
10
  import { errorToExitCode, EXIT } from "../exit-codes.mjs";
11
11
  const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
@@ -88,7 +88,13 @@ export function createPrShepherdMcpServer(options = {}) {
88
88
  idempotentHint: false,
89
89
  openWorldHint: true,
90
90
  },
91
- }, async (input) => runTool(() => shepherd.iterate(requireRepositoryQualifiedPr(input)), formatIterateResult));
91
+ }, async (input) => {
92
+ // One options object feeds both channels so JSON and Markdown cannot drift.
93
+ const opts = {
94
+ readyDelaySuffix: input.readyDelaySeconds === undefined ? undefined : `${input.readyDelaySeconds}s`,
95
+ };
96
+ return runTool(() => shepherd.iterate(requireRepositoryQualifiedPr(input)), (result) => formatIterateResult(result, opts), (result) => projectIterateLean(result, opts));
97
+ });
92
98
  server.registerTool("apply", {
93
99
  description: "Apply ordered review, journal, and file-view operations after prevalidation; explicit requests rely on GitHub's mutation response.",
94
100
  inputSchema: applyInputSchema,
@@ -131,16 +137,22 @@ function readPackageVersion() {
131
137
  const packageJson = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
132
138
  return packageJson.version;
133
139
  }
134
- function toolResult(result, text) {
140
+ function toolResult(structured, text) {
135
141
  return {
136
142
  content: [{ type: "text", text }],
137
- structuredContent: result,
143
+ structuredContent: structured,
138
144
  };
139
145
  }
140
- async function runTool(work, format) {
146
+ /**
147
+ * `project` mirrors the CLI's `--format=json` treatment of the same result. Tools
148
+ * whose CLI JSON is the raw result object (apply, build_suggestion_patch(es) — see
149
+ * handlers.mts and cli-parser.mts, which JSON.stringify the result directly) omit
150
+ * `project` and return the result unchanged, matching their own CLI JSON output.
151
+ */
152
+ async function runTool(work, format, project) {
141
153
  try {
142
154
  const result = await work();
143
- return toolResult(result, format(result));
155
+ return toolResult(project ? project(result) : result, format(result));
144
156
  }
145
157
  catch (error) {
146
158
  return toolError(error);
@@ -1,7 +1,7 @@
1
1
  import type { AgentCheck, AgentComment, AgentThread } from "./report.mts";
2
2
  import type { CheckStatus, Review } from "./github.mts";
3
- import type { MergeQueueRemovalStatus } from "./merge-requirements.mts";
4
- export type EscalateTrigger = "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "check-follow-up-unavailable" | "authorization-required" | "bot-cr-not-dismissed" | "merge-queue-removed";
3
+ import type { MergeQueueRemovalStatus, StackStatus } from "./merge-requirements.mts";
4
+ export type EscalateTrigger = "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "check-follow-up-unavailable" | "authorization-required" | "bot-cr-not-dismissed" | "merge-queue-removed" | "stacked-pr";
5
5
  export interface AgentStalledCheck {
6
6
  name: string;
7
7
  status: CheckStatus;
@@ -29,6 +29,7 @@ export interface EscalateDetails {
29
29
  suggestion: string;
30
30
  humanMessage: string;
31
31
  mergeQueueRemoval?: MergeQueueRemovalStatus;
32
+ stack?: StackStatus;
32
33
  authorization?: Array<{
33
34
  action: "mark-ready" | "merge-or-enqueue";
34
35
  targetIds: string[];
@@ -48,6 +48,7 @@ export interface IterateResultBase {
48
48
  interface IterateResultWait extends IterateResultBase {
49
49
  action: "wait";
50
50
  log: string;
51
+ deferredWork?: import("./merge-queue.mts").IterateDeferredWork;
51
52
  }
52
53
  export type CancelReason = "merged" | "closed" | "ready-delay-elapsed";
53
54
  interface IterateResultCancel extends IterateResultBase {
@@ -135,9 +136,18 @@ export interface IterateCommandOptions extends GlobalOptions {
135
136
  stallTimeoutSeconds?: number;
136
137
  /** Legacy per-invocation no-op retained for API compatibility. */
137
138
  neverCancelRuns?: string[];
139
+ /**
140
+ * Internal. `false` skips seen-marker writes (poll's discarded debounce ticks). Set
141
+ * only by `runPollCore`; excluded from the public `IterateInput` in api.mts.
142
+ */
138
143
  persistSeen?: boolean;
139
144
  /** Shepherd through readiness and emit the exact merge/queue command when ready. */
140
145
  merge?: boolean;
146
+ /**
147
+ * Internal. Defers attaching a quota warning until an until-terminal poll actually
148
+ * breaks. Set only by `runPollCore`; excluded from the public `IterateInput` in
149
+ * api.mts.
150
+ */
141
151
  deferQuotaWarning?: boolean;
142
152
  }
143
153
  export {};
@@ -11,3 +11,19 @@ export interface MergeQueueReport {
11
11
  /** The current PR head is not a parent of the removed synthetic queue commit. */
12
12
  headUpdatedAfterRemoval?: true;
13
13
  }
14
+ /**
15
+ * Raw counts of actionable work held back while the PR sits in the merge queue
16
+ * (`actions.workWhileQueued` is `false`, the default) — a Shepherd-initiated push
17
+ * or mutation right now would eject the PR. Omitted entirely once every count is
18
+ * zero. Not emitted for checks/annotations/conflicts: those always surface via
19
+ * `fix_code` immediately regardless of queue membership.
20
+ */
21
+ export interface IterateDeferredWork {
22
+ /** Unique review threads across actionable, resolution-only, first-look, and rule-auto-resolve. */
23
+ threads: number;
24
+ /** Unique PR comments across actionable, minimize-queued, and first-look. */
25
+ comments: number;
26
+ changesRequestedReviews: number;
27
+ /** Unique review summaries across the minimize queue, first-look, edited, and (if opted in) surfaced approvals. */
28
+ reviewSummaries: number;
29
+ }