pr-shepherd 0.60.1 → 1.0.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 (197) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -5
  3. package/bin/api.d.mts +35 -24
  4. package/bin/api.mjs +68 -48
  5. package/bin/checks/classify.d.mts +1 -1
  6. package/bin/checks/classify.mjs +4 -4
  7. package/bin/checks/log-excerpt.mjs +1 -1
  8. package/bin/cli/arg-flags.mjs +0 -3
  9. package/bin/cli/clean-handler.d.mts +1 -1
  10. package/bin/cli/clean-handler.mjs +5 -15
  11. package/bin/cli/default-poll.d.mts +2 -0
  12. package/bin/cli/default-poll.mjs +9 -6
  13. package/bin/cli/formatters.d.mts +2 -3
  14. package/bin/cli/formatters.mjs +0 -26
  15. package/bin/cli/handlers.d.mts +3 -3
  16. package/bin/cli/handlers.mjs +45 -35
  17. package/bin/cli/help-command-pages.d.mts +13 -158
  18. package/bin/cli/help-command-pages.mjs +12 -157
  19. package/bin/cli/help-iterate-poll-pages.d.mts +4 -3
  20. package/bin/cli/help-iterate-poll-pages.mjs +76 -36
  21. package/bin/cli/help-log-file-page.d.mts +1 -1
  22. package/bin/cli/help-log-file-page.mjs +3 -5
  23. package/bin/cli/help-top-page.d.mts +1 -1
  24. package/bin/cli/help-top-page.mjs +19 -20
  25. package/bin/cli/help.d.mts +14 -159
  26. package/bin/cli/iterate-checks-formatter.mjs +1 -1
  27. package/bin/cli/iterate-flags.d.mts +2 -4
  28. package/bin/cli/iterate-flags.mjs +4 -20
  29. package/bin/cli/iterate-formatter.d.mts +1 -1
  30. package/bin/cli/iterate-formatter.mjs +6 -6
  31. package/bin/cli/iterate-instructions.d.mts +1 -1
  32. package/bin/cli/iterate-instructions.mjs +5 -6
  33. package/bin/cli/iterate-lean.mjs +32 -40
  34. package/bin/cli/iterate-verbose.mjs +1 -1
  35. package/bin/cli/journal-formatter.mjs +0 -3
  36. package/bin/cli/journal-handler.d.mts +1 -1
  37. package/bin/cli/journal-handler.mjs +2 -1
  38. package/bin/cli/mutate-formatter.mjs +0 -9
  39. package/bin/cli/next-check-format.d.mts +0 -4
  40. package/bin/cli/next-check-format.mjs +0 -4
  41. package/bin/cli/poll-handler.d.mts +3 -0
  42. package/bin/cli/poll-handler.mjs +88 -47
  43. package/bin/cli/poll-summary-emitter.mjs +2 -2
  44. package/bin/cli/poll-summary-formatter.mjs +2 -2
  45. package/bin/cli/poll-targets.d.mts +2 -2
  46. package/bin/cli/poll-targets.mjs +15 -35
  47. package/bin/cli/removed-flags.d.mts +3 -0
  48. package/bin/cli/removed-flags.mjs +24 -0
  49. package/bin/cli/runner.d.mts +1 -1
  50. package/bin/cli/runner.mjs +2 -2
  51. package/bin/cli/stack-overview.d.mts +0 -1
  52. package/bin/cli/stack-overview.mjs +1 -2
  53. package/bin/cli/{fix-formatter-extra.d.mts → triage-formatter-extra.d.mts} +1 -2
  54. package/bin/cli/{fix-formatter-extra.mjs → triage-formatter-extra.mjs} +0 -6
  55. package/bin/cli/triage-formatter.d.mts +4 -0
  56. package/bin/cli/{fix-formatter.mjs → triage-formatter.mjs} +31 -42
  57. package/bin/cli/unknown-flags.d.mts +6 -0
  58. package/bin/cli/unknown-flags.mjs +29 -0
  59. package/bin/cli-parser.mjs +41 -62
  60. package/bin/commands/check-annotations.d.mts +1 -1
  61. package/bin/commands/check-annotations.mjs +1 -1
  62. package/bin/commands/check-review-budget.d.mts +1 -1
  63. package/bin/commands/check-review-budget.mjs +1 -1
  64. package/bin/commands/check-wait-detectors.d.mts +1 -1
  65. package/bin/commands/check-wait-detectors.mjs +1 -1
  66. package/bin/commands/check.d.mts +1 -1
  67. package/bin/commands/check.mjs +7 -7
  68. package/bin/commands/commit-suggestion-instruction.d.mts +2 -2
  69. package/bin/commands/commit-suggestion-instruction.mjs +3 -3
  70. package/bin/commands/iterate/api-usage.mjs +8 -8
  71. package/bin/commands/iterate/check-instructions.d.mts +4 -4
  72. package/bin/commands/iterate/check-instructions.mjs +8 -8
  73. package/bin/commands/iterate/classify.mjs +2 -2
  74. package/bin/commands/iterate/escalate.mjs +6 -6
  75. package/bin/commands/iterate/index.mjs +20 -23
  76. package/bin/commands/iterate/merge-plan.mjs +3 -3
  77. package/bin/commands/iterate/merge-state.mjs +4 -7
  78. package/bin/commands/iterate/native-stack-rebase.mjs +1 -1
  79. package/bin/commands/iterate/render.d.mts +1 -1
  80. package/bin/commands/iterate/render.mjs +2 -2
  81. package/bin/commands/iterate/stall.mjs +11 -9
  82. package/bin/commands/iterate/{fix-loop-instruction.mjs → triage-loop-instruction.mjs} +7 -3
  83. package/bin/commands/iterate/{fix-code.d.mts → triage.d.mts} +2 -2
  84. package/bin/commands/iterate/{fix-code.mjs → triage.mjs} +14 -23
  85. package/bin/commands/iterate/unreported-required.mjs +2 -5
  86. package/bin/commands/journal/index.d.mts +1 -3
  87. package/bin/commands/journal/index.mjs +1 -1
  88. package/bin/commands/mark-files-as-viewed.d.mts +1 -3
  89. package/bin/commands/mark-files-as-viewed.mjs +1 -1
  90. package/bin/commands/next-check.d.mts +2 -2
  91. package/bin/commands/next-check.mjs +4 -2
  92. package/bin/commands/poll-progress.mjs +1 -1
  93. package/bin/commands/poll-summary-explicit-instructions.mjs +1 -1
  94. package/bin/commands/poll-summary-instructions.mjs +7 -7
  95. package/bin/commands/poll-summary.mjs +4 -4
  96. package/bin/commands/poll.d.mts +1 -1
  97. package/bin/commands/poll.mjs +4 -4
  98. package/bin/commands/resolve-mutate.d.mts +1 -1
  99. package/bin/commands/resolve-mutate.mjs +1 -1
  100. package/bin/commands/rest-stack-merge-readiness.mjs +1 -1
  101. package/bin/commands/single-tick.d.mts +5 -0
  102. package/bin/commands/single-tick.mjs +7 -0
  103. package/bin/commands/stack-drain.mjs +1 -1
  104. package/bin/commands/stack-merge-flag.mjs +1 -1
  105. package/bin/commands/stack-stall.d.mts +2 -2
  106. package/bin/commands/stack-stall.mjs +2 -2
  107. package/bin/commands/stack-work.d.mts +2 -2
  108. package/bin/commands/stack-work.mjs +2 -2
  109. package/bin/commands/suggestion-patch-item.d.mts +0 -1
  110. package/bin/commands/suggestion-patch-item.mjs +0 -12
  111. package/bin/commands/suggestion-patches.d.mts +2 -2
  112. package/bin/commands/suggestion-patches.mjs +1 -1
  113. package/bin/commands/tick-instructions.d.mts +9 -0
  114. package/bin/commands/tick-instructions.mjs +23 -0
  115. package/bin/commands/tick.d.mts +15 -0
  116. package/bin/commands/{event-mode.mjs → tick.mjs} +39 -55
  117. package/bin/comments/minimize-policy.mjs +1 -3
  118. package/bin/comments/resolve.d.mts +0 -23
  119. package/bin/comments/resolve.mjs +0 -4
  120. package/bin/comments/review-output-budget.d.mts +1 -1
  121. package/bin/comments/review-output-budget.mjs +1 -1
  122. package/bin/comments/sha-poll.mjs +3 -1
  123. package/bin/config/default-bot-usernames.d.mts +2 -0
  124. package/bin/config/default-bot-usernames.mjs +15 -0
  125. package/bin/config/load.d.mts +4 -138
  126. package/bin/config/load.mjs +88 -443
  127. package/bin/config/merge-command-args.mjs +2 -2
  128. package/bin/config/merge-method.d.mts +1 -2
  129. package/bin/config/merge-method.mjs +1 -8
  130. package/bin/config/renames.d.mts +8 -0
  131. package/bin/config/renames.mjs +87 -0
  132. package/bin/config/resolve.d.mts +31 -0
  133. package/bin/config/resolve.mjs +89 -0
  134. package/bin/config/schema.d.mts +161 -0
  135. package/bin/config/schema.mjs +168 -0
  136. package/bin/exit-codes.d.mts +1 -1
  137. package/bin/exit-codes.mjs +3 -3
  138. package/bin/github/poll-summary-check-blockers.mjs +1 -1
  139. package/bin/github/poll-summary-command.mjs +8 -6
  140. package/bin/github/poll-summary-projector.mjs +2 -2
  141. package/bin/github/poll-summary-review.mjs +1 -1
  142. package/bin/github/poll-summary-route.mjs +9 -9
  143. package/bin/github/poll-summary-unreported.mjs +1 -1
  144. package/bin/github/poll-summary.mjs +1 -1
  145. package/bin/github/queries.d.mts +1 -1
  146. package/bin/github/queries.mjs +1 -1
  147. package/bin/mcp/server.mjs +48 -54
  148. package/bin/merge-status/derive.mjs +1 -1
  149. package/bin/state/durable-state.d.mts +1 -3
  150. package/bin/state/durable-state.mjs +2 -8
  151. package/bin/state/fix-attempts.d.mts +2 -2
  152. package/bin/state/fix-attempts.mjs +1 -1
  153. package/bin/state/merge-request.mjs +1 -1
  154. package/bin/state/pr-fingerprint.mjs +4 -11
  155. package/bin/state/seen-comments.d.mts +4 -5
  156. package/bin/state/seen-comments.mjs +8 -14
  157. package/bin/state/stack-stall.d.mts +1 -1
  158. package/bin/state/stack-stall.mjs +1 -1
  159. package/bin/state/stall-state-store.d.mts +1 -1
  160. package/bin/state/stall-state-store.mjs +1 -1
  161. package/bin/types/escalate.d.mts +2 -2
  162. package/bin/types/iterate.d.mts +12 -26
  163. package/bin/types/merge-queue.d.mts +2 -2
  164. package/bin/types/next-check.d.mts +1 -1
  165. package/bin/types/poll-summary.d.mts +3 -6
  166. package/bin/types/report.d.mts +2 -2
  167. package/bin/types/suggestion-patch.d.mts +1 -8
  168. package/package.json +2 -1
  169. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  170. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  171. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  172. package/plugins/pr-shepherd/.mcp.json +1 -1
  173. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +6 -6
  174. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +1 -1
  175. package/plugins/pr-shepherd/skills/pr-shepherd/references/{cloud-event-loop.md → event-loop.md} +5 -5
  176. package/plugins/pr-shepherd/skills/pr-shepherd/references/mcp-fallback.md +3 -3
  177. package/plugins/pr-shepherd/skills/pr-shepherd/references/merge-queue-ejection.md +1 -1
  178. package/plugins/pr-shepherd/skills/pr-shepherd/references/suggestion-patches.md +1 -1
  179. package/plugins/pr-shepherd/skills/pr-shepherd/references/{fix-code-loop.md → triage-loop.md} +3 -3
  180. package/plugins/pr-shepherd/skills/reduce-pr-noise/SKILL.md +1 -1
  181. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +2 -2
  182. package/plugins/pr-shepherd/skills/reduce-pr-noise/references/settings.md +11 -11
  183. package/pr-shepherd.schema.json +361 -0
  184. package/bin/cli/event-state-scope.d.mts +0 -19
  185. package/bin/cli/event-state-scope.mjs +0 -60
  186. package/bin/cli/fix-formatter.d.mts +0 -4
  187. package/bin/commands/commit-suggestion.d.mts +0 -8
  188. package/bin/commands/commit-suggestion.mjs +0 -26
  189. package/bin/commands/event-instructions.d.mts +0 -11
  190. package/bin/commands/event-instructions.mjs +0 -27
  191. package/bin/commands/event-mode.d.mts +0 -14
  192. package/bin/commands/poll-mode.d.mts +0 -8
  193. package/bin/commands/poll-mode.mjs +0 -14
  194. package/bin/config.json +0 -68
  195. package/bin/types/protected-run.d.mts +0 -6
  196. package/bin/types/protected-run.mjs +0 -1
  197. /package/bin/commands/iterate/{fix-loop-instruction.d.mts → triage-loop-instruction.d.mts} +0 -0
@@ -3,26 +3,26 @@ import { isKnownMergeStateStatus, transportReadinessGaps } from "./transport-evi
3
3
  import { canGenerateGithubMutation } from "./mutation-policy.mjs";
4
4
  import { getGithubTransport, isCcrTransport } from "./transport.mjs";
5
5
  export function routePollSummary(raw, checks, review, opts) {
6
- const actions = loadConfig().actions;
6
+ const config = loadConfig();
7
7
  const state = normalizePollSummaryState(raw.state);
8
8
  if (state === "MERGED" || state === "CLOSED") {
9
9
  return { action: "cancel", reasons: [state.toLowerCase()] };
10
10
  }
11
11
  if (raw.mergeable === "CONFLICTING" || raw.mergeStateStatus === "DIRTY") {
12
- return { action: "fix_code", reasons: ["merge-conflicts"] };
12
+ return { action: "triage", reasons: ["merge-conflicts"] };
13
13
  }
14
14
  if ((checks.failing ?? 0) > 0)
15
- return { action: "fix_code", reasons: ["failing-checks"] };
15
+ return { action: "triage", reasons: ["failing-checks"] };
16
16
  if ((review.actionable ?? 0) > 0) {
17
- if (opts.merge && raw.isInMergeQueue && actions.workWhileQueued !== true) {
17
+ if (opts.merge && raw.isInMergeQueue && config.actions["work-while-queued"] !== true) {
18
18
  return { action: "wait", reasons: ["review-work-deferred-while-queued"] };
19
19
  }
20
- return { action: "fix_code", reasons: ["review-work"] };
20
+ return { action: "triage", reasons: ["review-work"] };
21
21
  }
22
22
  if ((checks.unreportedRequired?.length ?? 0) > 0 &&
23
23
  checks.actionsWorkflowInProgress !== true &&
24
24
  (checks.inProgress ?? 0) === 0) {
25
- return { action: "fix_code", reasons: ["unreported-required-checks"] };
25
+ return { action: "triage", reasons: ["unreported-required-checks"] };
26
26
  }
27
27
  if ((checks.inProgress ?? 0) > 0 ||
28
28
  !isKnownMergeStateStatus(raw.mergeStateStatus) ||
@@ -39,9 +39,9 @@ export function routePollSummary(raw, checks, review, opts) {
39
39
  return { action: "wait", reasons: ["pending-or-unknown"] };
40
40
  }
41
41
  if (raw.isDraft) {
42
- const autoMarkReadyDisabled = opts.noAutoMarkReady || actions.autoMarkReady === false;
42
+ const autoMarkReadyDisabled = opts.noAutoMarkReady || config["mark-ready"].auto === false;
43
43
  if (autoMarkReadyDisabled && (checks.incomplete || review.incomplete))
44
- return { action: "fix_code", reasons: ["draft-readiness-verification-required"] };
44
+ return { action: "triage", reasons: ["draft-readiness-verification-required"] };
45
45
  if (autoMarkReadyDisabled)
46
46
  return { action: "cancel", reasons: ["draft-auto-mark-ready-disabled"] };
47
47
  if (!canGenerateGithubMutation(raw.viewerCanUpdate, "ready")) {
@@ -59,7 +59,7 @@ export function routePollSummary(raw, checks, review, opts) {
59
59
  };
60
60
  }
61
61
  if (opts.merge && raw.stack && opts.stackPrNumber === undefined) {
62
- return { action: "fix_code", reasons: ["authoritative-poll-required"] };
62
+ return { action: "triage", reasons: ["authoritative-poll-required"] };
63
63
  }
64
64
  if (opts.merge && raw.stack) {
65
65
  return { action: "merge", reasons: ["appears-ready"] };
@@ -33,7 +33,7 @@ export function applyUnreportedRequiredChecks(raw, checks, required) {
33
33
  return;
34
34
  checks.unreportedRequired = missing;
35
35
  const suites = raw.commits.nodes[0]?.commit.checkSuites?.nodes ?? [];
36
- if (actionsWorkflowInProgress(suites, new Set(loadConfig().checks.ciTriggerEvents))) {
36
+ if (actionsWorkflowInProgress(suites, new Set(loadConfig().checks["ci-trigger-events"]))) {
37
37
  checks.actionsWorkflowInProgress = true;
38
38
  }
39
39
  }
@@ -20,7 +20,7 @@ async function fetchSelectedPollSummary(opts, repo) {
20
20
  return fetchStackSummary(opts, repo);
21
21
  const requested = [...new Set(opts.prNumbers ?? [])];
22
22
  if (requested.length === 0) {
23
- throw new ShepherdError("Aggregate poll requires at least two PRs or --stack <PR>", EXIT.USAGE);
23
+ throw new ShepherdError("Aggregate poll requires at least two PRs or a stack PR", EXIT.USAGE);
24
24
  }
25
25
  const raw = [];
26
26
  let viewerCanAdminister;
@@ -43,7 +43,7 @@ export declare const POLL_STACK_TOPOLOGY_QUERY: string;
43
43
  export declare const UPPER_LAYER_CONFLICT_TARGET_QUERY: string;
44
44
  /** Merged PRs whose head may be the stale base of a non-stack conflict. */
45
45
  export declare const MERGED_BASE_PULL_REQUESTS_QUERY: string;
46
- /** PR head fields plus a single review thread for `commit-suggestion`. */
46
+ /** PR head fields plus the requested review threads for `suggest`. */
47
47
  export declare const SUGGESTION_THREADS_QUERY: string;
48
48
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
49
49
  export declare const REVIEW_THREAD_COMMENTS_QUERY: string;
@@ -48,7 +48,7 @@ export const POLL_STACK_TOPOLOGY_QUERY = gql("poll-stack-topology.gql");
48
48
  export const UPPER_LAYER_CONFLICT_TARGET_QUERY = gql("upper-layer-conflict-target.gql");
49
49
  /** Merged PRs whose head may be the stale base of a non-stack conflict. */
50
50
  export const MERGED_BASE_PULL_REQUESTS_QUERY = gql("merged-base-pull-requests.gql");
51
- /** PR head fields plus a single review thread for `commit-suggestion`. */
51
+ /** PR head fields plus the requested review threads for `suggest`. */
52
52
  export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
53
53
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
54
54
  export const REVIEW_THREAD_COMMENTS_QUERY = gql("review-thread-comments.gql");
@@ -5,7 +5,7 @@ import { z } from "zod";
5
5
  import { createPrShepherd, PartialApplyError, PrShepherdValidationError, } from "../api.mjs";
6
6
  import { isRepositoryQualifiedPrReference, normalizeRepositoryIdentity, parsePrReference, } from "../pr-reference.mjs";
7
7
  import { formatJournalResult } from "../cli/journal-formatter.mjs";
8
- import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, projectIterateLean, } from "../cli/formatters.mjs";
8
+ import { formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, projectIterateLean, } from "../cli/formatters.mjs";
9
9
  import { formatPollSummaryResult } from "../cli/poll-summary-formatter.mjs";
10
10
  import { projectStackOverview } from "../cli/stack-overview.mjs";
11
11
  import { formatCliError, serializeGitHubRequestErrorDetails } from "../cli/error-format.mjs";
@@ -13,6 +13,7 @@ import { errorToExitCode, EXIT } from "../exit-codes.mjs";
13
13
  import { formatApplyMergeResult } from "../cli/apply-merge-handler.mjs";
14
14
  import { extractShepherdJournal } from "../journal/index.mjs";
15
15
  import { formatPlaybookResult, runPlaybook } from "../commands/playbook.mjs";
16
+ import { formatCheckBlockerResult } from "../commands/apply-check-blocker.mjs";
16
17
  const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
17
18
  const pr = z
18
19
  .string()
@@ -20,12 +21,8 @@ const pr = z
20
21
  .describe("GitHub pull-request URL or owner/repo#number; the explicit repository may differ from the server working directory");
21
22
  const ids = z.array(z.string().min(1)).optional();
22
23
  const transport = z.enum(["auto", "graphql", "rest"]).optional();
23
- const iterateInputSchema = z
24
- .object({
24
+ const tickOptionsSchema = {
25
25
  transport,
26
- pr: pr.optional(),
27
- prs: z.array(pr).min(1).optional(),
28
- stack: pr.optional(),
29
26
  readyDelaySeconds: z.number().nonnegative().optional(),
30
27
  stallTimeoutSeconds: z.number().nonnegative().optional(),
31
28
  noAutoMarkReady: z.boolean().optional(),
@@ -33,25 +30,20 @@ const iterateInputSchema = z
33
30
  instructions: z
34
31
  .enum(["playbook", "inline"])
35
32
  .optional()
36
- .describe("fix_code instruction style. inline (default) prints every step each tick; playbook folds commit and push into the Fix-code loop playbook."),
37
- pollMode: z
38
- .enum(["auto", "poll", "event"])
39
- .optional()
40
- .describe("Overrides poll.mode. event returns one tick with pollMode and nextCheck so the caller owns the wake-up; auto (default) selects event when CLAUDE_CODE_REMOTE=true."),
41
- noAutoCancelActionable: z
42
- .boolean()
43
- .optional()
44
- .describe("Deprecated no-op; Shepherd never cancels workflow runs."),
45
- neverCancelRuns: z
46
- .array(z.string())
47
- .optional()
48
- .describe("Deprecated per-call no-op retained for compatibility."),
33
+ .describe("triage instruction style. inline (default) prints every step each tick; playbook folds commit and push into the Triage loop playbook."),
34
+ };
35
+ const iterateInputSchema = z
36
+ .object({
37
+ ...tickOptionsSchema,
38
+ pr: pr.optional(),
39
+ prs: z.array(pr).min(1).optional(),
49
40
  })
50
- .refine((input) => [input.pr, input.prs, input.stack].filter((value) => value !== undefined).length === 1, {
51
- message: "exactly one of pr, prs, or stack is required",
41
+ .refine((input) => (input.pr === undefined) !== (input.prs === undefined), {
42
+ message: "exactly one of pr or prs is required",
52
43
  })
53
44
  .refine((input) => !input.prs ||
54
45
  new Set(input.prs.map((ref) => normalizeRepositoryIdentity(parsePrReference(ref)?.repository ?? ""))).size === 1, { message: "all prs must belong to the same repository" });
46
+ const stackInputSchema = z.object({ ...tickOptionsSchema, pr });
55
47
  const reviewMutationsOperationSchema = z.object({
56
48
  type: z.literal("review_mutations"),
57
49
  resolveThreadIds: ids,
@@ -78,6 +70,16 @@ const acknowledgeQueueRemovalOperationSchema = z.object({
78
70
  queueCommitOid: z.string().regex(/^[0-9a-f]{40}$/),
79
71
  removedAtUnix: z.number().int().positive().safe(),
80
72
  });
73
+ const checkBlockerOperationSchema = z.object({
74
+ type: z.literal("check_blocker"),
75
+ check: z.string().min(1).describe("Exact check name."),
76
+ blockedBy: z
77
+ .string()
78
+ .min(1)
79
+ .optional()
80
+ .describe("Blocking PR/issue URL, owner/repo#N, or issue:owner/repo#N."),
81
+ clear: z.boolean().optional().describe("Clear the recorded blocker instead of setting one."),
82
+ });
81
83
  const mergeOperationSchema = z.object({
82
84
  type: z.literal("merge"),
83
85
  requireSha: z.string().regex(/^[0-9a-f]{40}$/),
@@ -109,17 +111,11 @@ const applyInputSchema = z.object({
109
111
  markFilesViewedOperationSchema,
110
112
  appendJournalOperationSchema,
111
113
  acknowledgeQueueRemovalOperationSchema,
114
+ checkBlockerOperationSchema,
112
115
  ]))
113
116
  .min(1),
114
117
  });
115
- const suggestionPatchInputSchema = z.object({
116
- transport,
117
- pr,
118
- threadId: z.string().min(1),
119
- message: z.string().min(1),
120
- description: z.string().optional(),
121
- });
122
- const suggestionPatchesInputSchema = z.object({
118
+ const suggestInputSchema = z.object({
123
119
  transport,
124
120
  pr,
125
121
  suggestions: z
@@ -136,7 +132,7 @@ export function createPrShepherdMcpServer(options = {}) {
136
132
  const getShepherd = () => (shepherd ??= createPrShepherd({ cwd: options.cwd, transport: options.transport }));
137
133
  const server = new McpServer({ name: "pr-shepherd", version: readPackageVersion() });
138
134
  server.registerTool("iterate", {
139
- description: "Inspect one pull request, an explicit same-repository set, or a native stack and return one Shepherd tick.",
135
+ description: "Inspect one pull request (pr) or an explicit same-repository set (prs) and return one Shepherd tick.",
140
136
  inputSchema: iterateInputSchema,
141
137
  annotations: {
142
138
  readOnlyHint: false,
@@ -151,14 +147,20 @@ export function createPrShepherdMcpServer(options = {}) {
151
147
  };
152
148
  return runTool(() => runIterateSelector(getShepherd(), requireRepositoryQualifiedIterate(input)), (result) => isPollSummary(result)
153
149
  ? formatPollSummaryResult(result)
154
- : formatIterateResult(result, opts), (result) => isPollSummary(result)
155
- ? result.selection.kind === "stack"
156
- ? projectStackOverview(result)
157
- : result
158
- : projectIterateLean(result, opts));
150
+ : formatIterateResult(result, opts), (result) => isPollSummary(result) ? result : projectIterateLean(result, opts));
159
151
  });
152
+ server.registerTool("stack", {
153
+ description: "Inspect the native stack containing one pull request and return one aggregate Shepherd tick.",
154
+ inputSchema: stackInputSchema,
155
+ annotations: {
156
+ readOnlyHint: false,
157
+ destructiveHint: true,
158
+ idempotentHint: false,
159
+ openWorldHint: true,
160
+ },
161
+ }, async (input) => runTool(() => getShepherd().stack(requireRepositoryQualifiedPr(input)), formatPollSummaryResult, projectStackOverview));
160
162
  server.registerTool("apply", {
161
- description: "Apply ordered review, journal, and file-view operations after prevalidation; explicit requests rely on GitHub's mutation response.",
163
+ description: "Apply ordered merge, review, file-view, journal, check-blocker, and queue-removal operations after prevalidation; explicit requests rely on GitHub's mutation response.",
162
164
  inputSchema: applyInputSchema,
163
165
  annotations: {
164
166
  readOnlyHint: false,
@@ -202,38 +204,28 @@ export function createPrShepherdMcpServer(options = {}) {
202
204
  openWorldHint: false,
203
205
  },
204
206
  }, async (input) => runTool(async () => runPlaybook(input.name), formatPlaybookResult));
205
- server.registerTool("build_suggestion_patches", {
207
+ server.registerTool("suggest", {
206
208
  description: "Build, but never apply, an ordered list of eligible review suggestion patches.",
207
- inputSchema: suggestionPatchesInputSchema,
208
- annotations: {
209
- readOnlyHint: true,
210
- destructiveHint: false,
211
- idempotentHint: true,
212
- openWorldHint: true,
213
- },
214
- }, async (input) => runTool(() => getShepherd().buildSuggestionPatches(requireRepositoryQualifiedPr(input)), formatSuggestionPatchesResult));
215
- server.registerTool("build_suggestion_patch", {
216
- description: "Deprecated: use build_suggestion_patches with a one-item suggestions array.",
217
- inputSchema: suggestionPatchInputSchema,
209
+ inputSchema: suggestInputSchema,
218
210
  annotations: {
219
211
  readOnlyHint: true,
220
212
  destructiveHint: false,
221
213
  idempotentHint: true,
222
214
  openWorldHint: true,
223
215
  },
224
- }, async (input) => runTool(() => getShepherd().buildSuggestionPatch(requireRepositoryQualifiedPr(input)), formatCommitSuggestionResult));
216
+ }, async (input) => runTool(() => getShepherd().suggest(requireRepositoryQualifiedPr(input)), formatSuggestionPatchesResult));
225
217
  return server;
226
218
  }
227
219
  function runIterateSelector(shepherd, input) {
228
- return "prs" in input || "stack" in input
220
+ return "prs" in input && input.prs !== undefined
229
221
  ? shepherd.iterate(input)
230
222
  : shepherd.iterate(input);
231
223
  }
232
224
  function requireRepositoryQualifiedIterate(input) {
233
- if ([input.pr, input.prs, input.stack].filter((value) => value !== undefined).length !== 1) {
234
- throw new PrShepherdValidationError("exactly one of pr, prs, or stack is required");
225
+ if ((input.pr === undefined) === (input.prs === undefined)) {
226
+ throw new PrShepherdValidationError("exactly one of pr or prs is required");
235
227
  }
236
- const refs = Array.isArray(input.prs) ? input.prs : [input.pr ?? input.stack];
228
+ const refs = Array.isArray(input.prs) ? input.prs : [input.pr];
237
229
  if (refs.length === 0 || refs.some((ref) => !isRepositoryQualifiedPrReference(ref))) {
238
230
  throw new PrShepherdValidationError(QUALIFIED_PR_ERROR);
239
231
  }
@@ -264,7 +256,7 @@ function toolResult(structured, text) {
264
256
  }
265
257
  /**
266
258
  * `project` mirrors the CLI's `--format=json` treatment of the same result. Tools
267
- * whose CLI JSON is the raw result object (apply, build_suggestion_patch(es) — see
259
+ * whose CLI JSON is the raw result object (apply, suggest — see
268
260
  * handlers.mts and cli-parser.mts, which JSON.stringify the result directly) omit
269
261
  * `project` and return the result unchanged, matching their own CLI JSON output.
270
262
  */
@@ -290,6 +282,8 @@ function formatApplyResult(result) {
290
282
  return `${heading}\n\n${formatMarkFilesAsViewedResult(operation.result)}`;
291
283
  case "append_journal":
292
284
  return `${heading}\n\n${formatJournalResult(operation.result)}`;
285
+ case "check_blocker":
286
+ return `${heading}\n\n${formatCheckBlockerResult(operation.result)}`;
293
287
  case "acknowledge_queue_removal": {
294
288
  const result = operation.result;
295
289
  return [
@@ -70,7 +70,7 @@ function detectBlockingBotReview(pr) {
70
70
  // A blocking bot review is "in progress" when:
71
71
  // 1. Any reviewRequest has a login starting with one of the configured prefixes, OR
72
72
  // 2. Any latestReview has such a login AND state == "PENDING"
73
- const prefixes = loadConfig().mergeStatus.blockingReviewerLogins.map((l) => l.toLowerCase());
73
+ const prefixes = loadConfig()["mark-ready"]["blocking-reviewer-logins"].map((l) => l.toLowerCase());
74
74
  const isBlocking = (login) => prefixes.some((p) => login.toLowerCase().startsWith(p));
75
75
  const requested = pr.reviewRequests.some((r) => isBlocking(r.login));
76
76
  const pendingReview = pr.latestReviews.some((r) => isBlocking(r.login) && r.state === "PENDING");
@@ -1,6 +1,4 @@
1
- /** Run work whose state (seen markers, stall timers, reply records) must outlive a temp dir. */
2
- export declare function runWithDurableState<T>(work: () => T): T;
3
- /** Cloud sessions are always durable; other callers opt in through `runWithDurableState`. */
1
+ /** Cloud sessions keep state (seen markers, stall timers, reply records) beyond a temp dir. */
4
2
  export declare function durableStateRequested(): boolean;
5
3
  /** `<git-common-dir>/pr-shepherd-state`, or null when not requested or outside a repository. */
6
4
  export declare function resolveDurableStateBase(): string | null;
@@ -1,5 +1,4 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { AsyncLocalStorage } from "node:async_hooks";
3
2
  import { join, resolve } from "node:path";
4
3
  import { getEffectiveCwd } from "../execution-context.mjs";
5
4
  /**
@@ -7,15 +6,10 @@ import { getEffectiveCwd } from "../execution-context.mjs";
7
6
  * directory lives with the repository checkout, so state stored there survives whatever the
8
7
  * session does with `TMPDIR`. Only a read-only `git rev-parse` is ever invoked.
9
8
  */
10
- const durableScope = new AsyncLocalStorage();
11
9
  const commonDirByCwd = new Map();
12
- /** Run work whose state (seen markers, stall timers, reply records) must outlive a temp dir. */
13
- export function runWithDurableState(work) {
14
- return durableScope.run(true, work);
15
- }
16
- /** Cloud sessions are always durable; other callers opt in through `runWithDurableState`. */
10
+ /** Cloud sessions keep state (seen markers, stall timers, reply records) beyond a temp dir. */
17
11
  export function durableStateRequested() {
18
- return durableScope.getStore() === true || process.env["CLAUDE_CODE_REMOTE"] === "true";
12
+ return process.env["CLAUDE_CODE_REMOTE"] === "true";
19
13
  }
20
14
  /** `<git-common-dir>/pr-shepherd-state`, or null when not requested or outside a repository. */
21
15
  export function resolveDurableStateBase() {
@@ -2,14 +2,14 @@
2
2
  * Persistent attempt counter for the iterate escalation guard.
3
3
  *
4
4
  * Tracks how many caller-visible times each review thread has been dispatched to
5
- * the fix_code handler without being resolved. Body edits reset the count.
5
+ * the triage handler without being resolved. Body edits reset the count.
6
6
  *
7
7
  * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/fix-attempts.json`.
8
8
  */
9
9
  export interface FixAttemptsState {
10
10
  /** HEAD SHA at the time the counts were last written, retained for observability/compatibility. */
11
11
  headSha: string;
12
- /** Map from thread ID → number of fix_code dispatches that included this thread. */
12
+ /** Map from thread ID → number of triage dispatches that included this thread. */
13
13
  threadAttempts: Record<string, number>;
14
14
  /** Map from thread ID → body hash used for the associated attempt count. */
15
15
  threadBodyHashes?: Record<string, string>;
@@ -2,7 +2,7 @@
2
2
  * Persistent attempt counter for the iterate escalation guard.
3
3
  *
4
4
  * Tracks how many caller-visible times each review thread has been dispatched to
5
- * the fix_code handler without being resolved. Body edits reset the count.
5
+ * the triage handler without being resolved. Body edits reset the count.
6
6
  *
7
7
  * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/fix-attempts.json`.
8
8
  */
@@ -100,7 +100,7 @@ export async function replaceFailedMergeRequest(key, previous, next) {
100
100
  throw error;
101
101
  persisted = parseRecord(await readFile(`${stem}.json`, "utf8"));
102
102
  if (!persisted.replacementToken)
103
- throw new Error("Legacy merge successor has no recovery token; reconcile its outcome");
103
+ throw new Error("Merge successor has no recovery token; reconcile its outcome");
104
104
  if (persisted.options.requireSha !== next.options.requireSha ||
105
105
  persisted.options.mergeAction !== next.options.mergeAction ||
106
106
  persisted.options.mergeMethod !== next.options.mergeMethod ||
@@ -5,7 +5,7 @@ import { dirname } from "node:path";
5
5
  import { resolvePrStatePath } from "./base.mjs";
6
6
  import { discoverRuleFiles } from "../classify/loader.mjs";
7
7
  import { getEffectiveCwd } from "../execution-context.mjs";
8
- const VERSION = 4;
8
+ const VERSION = 5;
9
9
  let packageVersion;
10
10
  /** Bundled standard rules change with the package, so their digest input is its version. */
11
11
  function readPackageVersion() {
@@ -15,18 +15,11 @@ function readPackageVersion() {
15
15
  export function fingerprintInputDigest(config) {
16
16
  const hash = createHash("sha256");
17
17
  hash.update(JSON.stringify({
18
- ignoreChecks: config.ignoreChecks,
19
- botUsernames: config.botUsernames,
18
+ "bot-usernames": config["bot-usernames"],
20
19
  iterate: config.iterate,
21
- watch: { readyDelayMinutes: config.watch.readyDelayMinutes },
22
20
  checks: config.checks,
23
- mergeStatus: config.mergeStatus,
24
- actions: {
25
- autoMinimizeSuppressed: config.actions.autoMinimizeSuppressed,
26
- autoMarkReady: config.actions.autoMarkReady,
27
- neverCancelRuns: config.actions.neverCancelRuns,
28
- workWhileQueued: config.actions.workWhileQueued,
29
- },
21
+ "mark-ready": config["mark-ready"],
22
+ actions: config.actions,
30
23
  standardRules: (config.standardRules?.length ?? 0) > 0
31
24
  ? { names: config.standardRules, version: readPackageVersion() }
32
25
  : undefined,
@@ -16,8 +16,8 @@ export declare function hashBody(body: string): string;
16
16
  * - "new" — no marker exists; surface the body and write the marker.
17
17
  * - "edited" — marker exists but stored hash differs from the current body;
18
18
  * surface the updated body and update the marker hash.
19
- * - "unchanged" — marker exists and hash matches (or marker has no hash, which
20
- * is treated conservatively as unchanged).
19
+ * - "unchanged" — marker exists and hash matches (or marker has no hash and no
20
+ * inline thread IDs, which is treated conservatively as unchanged).
21
21
  */
22
22
  export declare function classifyItem(id: string, body: string, map: Map<string, SeenMarker>): "new" | "edited" | "unchanged";
23
23
  /**
@@ -31,9 +31,8 @@ export declare function loadSeenSet(key: StateKey): Promise<Set<string>>;
31
31
  * Used when the caller needs the stored bodyHash to detect in-place edits.
32
32
  * Returns an empty Map if the directory does not yet exist.
33
33
  *
34
- * Map keys are the stored `id` field when present (guarding against
35
- * case-insensitive filesystem collisions), falling back to the filename for
36
- * legacy markers that predate this field.
34
+ * Map keys are the stored `id` field (guarding against case-insensitive
35
+ * filesystem collisions); markers without one are skipped.
37
36
  */
38
37
  export declare function loadSeenMap(key: StateKey): Promise<Map<string, SeenMarker>>;
39
38
  /** Return true if a "seen" marker exists for this id. */
@@ -17,8 +17,8 @@ export function hashBody(body) {
17
17
  * - "new" — no marker exists; surface the body and write the marker.
18
18
  * - "edited" — marker exists but stored hash differs from the current body;
19
19
  * surface the updated body and update the marker hash.
20
- * - "unchanged" — marker exists and hash matches (or marker has no hash, which
21
- * is treated conservatively as unchanged).
20
+ * - "unchanged" — marker exists and hash matches (or marker has no hash and no
21
+ * inline thread IDs, which is treated conservatively as unchanged).
22
22
  */
23
23
  export function classifyItem(id, body, map) {
24
24
  const m = map.get(id);
@@ -48,9 +48,8 @@ export async function loadSeenSet(key) {
48
48
  * Used when the caller needs the stored bodyHash to detect in-place edits.
49
49
  * Returns an empty Map if the directory does not yet exist.
50
50
  *
51
- * Map keys are the stored `id` field when present (guarding against
52
- * case-insensitive filesystem collisions), falling back to the filename for
53
- * legacy markers that predate this field.
51
+ * Map keys are the stored `id` field (guarding against case-insensitive
52
+ * filesystem collisions); markers without one are skipped.
54
53
  */
55
54
  export async function loadSeenMap(key) {
56
55
  const map = new Map();
@@ -63,14 +62,8 @@ export async function loadSeenMap(key) {
63
62
  try {
64
63
  const raw = await readFile(join(dir, entry), "utf8");
65
64
  const marker = JSON.parse(raw);
66
- // Prefer the stored id field; fall back to filename stem for legacy markers.
67
- const mapKey = typeof marker.id === "string" ? marker.id : entry.slice(0, -5);
68
- // Hash-based markers (those with an id field) take priority over legacy
69
- // filename-based markers so that a stale legacy file cannot overwrite a
70
- // newer hash-based entry that maps to the same key.
71
- if (!map.has(mapKey) || typeof marker.id === "string") {
72
- map.set(mapKey, marker);
73
- }
65
+ if (typeof marker.id === "string")
66
+ map.set(marker.id, marker);
74
67
  }
75
68
  catch {
76
69
  // unreadable or malformed — skip
@@ -155,7 +148,8 @@ async function writeSeenMarker(key, id, markerFields) {
155
148
  // no existing marker — will create below
156
149
  }
157
150
  const unchanged = Object.entries(markerFields).every(([field, value]) => markerFieldEqual(existing?.[field], value));
158
- if (existing !== null && unchanged)
151
+ // A marker without its `id` is ignored by loadSeenMap, so rewrite it even when unchanged.
152
+ if (existing !== null && unchanged && existing.id === id)
159
153
  return;
160
154
  const seenAt = existing?.seenAt ?? Date.now();
161
155
  tmp = `${path}.${randomUUID()}.tmp`;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Persistent stall-detection state for an aggregate `--stack` selection whose open layers can
2
+ * Persistent stall-detection state for an aggregate `stack` selection whose open layers can
3
3
  * only wait. Keyed by native stack number because the anchor PR can merge or change.
4
4
  *
5
5
  * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/stack-stall.json`.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Persistent stall-detection state for an aggregate `--stack` selection whose open layers can
2
+ * Persistent stall-detection state for an aggregate `stack` selection whose open layers can
3
3
  * only wait. Keyed by native stack number because the anchor PR can merge or change.
4
4
  *
5
5
  * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/stack-stall.json`.
@@ -27,7 +27,7 @@ export interface StallStateStore<Key> {
27
27
  clear(key: Key): Promise<void>;
28
28
  }
29
29
  /**
30
- * `{fingerprint, firstSeenAt}` files behind the one-PR and `--stack` stall guards.
30
+ * `{fingerprint, firstSeenAt}` files behind the one-PR and `stack` stall guards.
31
31
  * A missing file is a miss. An unwritable directory or an unsafe key is a persistence failure
32
32
  * so the caller can hand off instead of treating every tick as the first sighting.
33
33
  * `ENOENT` is the only read error treated as a miss. Corrupt JSON is also a miss, so one
@@ -2,7 +2,7 @@ import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { dirname } from "node:path";
4
4
  /**
5
- * `{fingerprint, firstSeenAt}` files behind the one-PR and `--stack` stall guards.
5
+ * `{fingerprint, firstSeenAt}` files behind the one-PR and `stack` stall guards.
6
6
  * A missing file is a miss. An unwritable directory or an unsafe key is a persistence failure
7
7
  * so the caller can hand off instead of treating every tick as the first sighting.
8
8
  * `ENOENT` is the only read error treated as a miss. Corrupt JSON is also a miss, so one
@@ -2,7 +2,7 @@ import type { AgentCheck, AgentComment, AgentThread } from "./report.mts";
2
2
  import type { ResolveCommand } from "./iterate.mts";
3
3
  import type { CheckStatus, Review } from "./github.mts";
4
4
  import type { MergeQueueRemovalStatus } from "./merge-requirements.mts";
5
- export type EscalateTrigger = "transport-unsupported" | "fix-thrash" | "base-branch-unknown" | "stall-timeout" | "required-checks-unreported" | "stall-state-unavailable" | "check-follow-up-unavailable" | "authorization-required" | "merge-queue-removed" | "merge-method-unavailable";
5
+ export type EscalateTrigger = "transport-unsupported" | "triage-thrash" | "base-branch-unknown" | "stall-timeout" | "required-checks-unreported" | "stall-state-unavailable" | "check-follow-up-unavailable" | "authorization-required" | "merge-queue-removed" | "merge-method-unavailable";
6
6
  export interface AgentStalledCheck {
7
7
  name: string;
8
8
  status: CheckStatus;
@@ -32,7 +32,7 @@ export interface EscalateDetails {
32
32
  attempts: number;
33
33
  }>;
34
34
  /**
35
- * Set only when `iterate.reviewOutputMaxChars` held review items back on the tick that
35
+ * Set only when `iterate.review-output-max-chars` held review items back on the tick that
36
36
  * escalated: `shown` of `total` review items appear here. The held items stay unmarked, so the
37
37
  * next run shows them.
38
38
  */