pr-shepherd 0.55.2 → 0.56.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 (83) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -2
  3. package/bin/api.d.mts +5 -0
  4. package/bin/api.mjs +12 -1
  5. package/bin/checks/job-log.d.mts +9 -0
  6. package/bin/checks/job-log.mjs +30 -0
  7. package/bin/checks/jobs-types.d.mts +14 -0
  8. package/bin/checks/jobs-types.mjs +7 -0
  9. package/bin/checks/related-jobs.d.mts +11 -0
  10. package/bin/checks/related-jobs.mjs +32 -0
  11. package/bin/checks/triage-budget.d.mts +17 -0
  12. package/bin/checks/triage-budget.mjs +49 -0
  13. package/bin/checks/triage.d.mts +6 -3
  14. package/bin/checks/triage.mjs +89 -61
  15. package/bin/cli/api-usage-formatter.mjs +2 -2
  16. package/bin/cli/fix-formatter.mjs +2 -0
  17. package/bin/cli/iterate-checks-formatter.mjs +2 -0
  18. package/bin/cli/related-jobs-format.d.mts +3 -0
  19. package/bin/cli/related-jobs-format.mjs +16 -0
  20. package/bin/commands/check-execution-context.d.mts +12 -0
  21. package/bin/commands/check-execution-context.mjs +38 -0
  22. package/bin/commands/check-fingerprint.mjs +14 -8
  23. package/bin/commands/check-unreported.d.mts +3 -2
  24. package/bin/commands/check-unreported.mjs +4 -4
  25. package/bin/commands/check.d.mts +2 -1
  26. package/bin/commands/check.mjs +21 -6
  27. package/bin/commands/iterate/check-instructions.d.mts +6 -3
  28. package/bin/commands/iterate/check-instructions.mjs +6 -5
  29. package/bin/commands/iterate/escalate.mjs +3 -0
  30. package/bin/commands/iterate/fix-code.mjs +6 -2
  31. package/bin/commands/iterate/helpers.mjs +1 -0
  32. package/bin/commands/iterate/index.mjs +20 -8
  33. package/bin/commands/iterate/render.mjs +11 -3
  34. package/bin/commands/iterate/stale-ancestry.d.mts +2 -1
  35. package/bin/commands/iterate/stale-ancestry.mjs +3 -2
  36. package/bin/commands/poll-quota.d.mts +2 -0
  37. package/bin/commands/poll-quota.mjs +18 -25
  38. package/bin/commands/poll-rate-limit-wait.mjs +13 -8
  39. package/bin/commands/ready-delay.d.mts +2 -0
  40. package/bin/commands/ready-delay.mjs +18 -0
  41. package/bin/commands/resolve-mutate.mjs +8 -9
  42. package/bin/github/batch-raw-types.d.mts +2 -0
  43. package/bin/github/batch-receipt-evidence.d.mts +5 -0
  44. package/bin/github/batch-receipt-evidence.mjs +61 -0
  45. package/bin/github/batch.d.mts +4 -0
  46. package/bin/github/batch.mjs +42 -8
  47. package/bin/github/errors.d.mts +3 -0
  48. package/bin/github/errors.mjs +15 -6
  49. package/bin/github/gql/batch-pr-page.gql +1 -0
  50. package/bin/github/gql/batch-pr.gql +1 -0
  51. package/bin/github/gql/poll-summary-annotation-probe.gql +8 -0
  52. package/bin/github/gql/reply-thread-comments.gql +25 -0
  53. package/bin/github/gql/reply-thread-transcripts.gql +31 -0
  54. package/bin/github/merge-queue-checks.d.mts +2 -1
  55. package/bin/github/merge-queue-checks.mjs +18 -8
  56. package/bin/github/merge-target-rules.d.mts +2 -1
  57. package/bin/github/merge-target-rules.mjs +4 -4
  58. package/bin/github/poll-summary-annotation-probe.d.mts +2 -0
  59. package/bin/github/poll-summary-annotation-probe.mjs +7 -0
  60. package/bin/github/queries.d.mts +5 -0
  61. package/bin/github/queries.mjs +5 -0
  62. package/bin/github/rate-limit-kind.d.mts +13 -0
  63. package/bin/github/rate-limit-kind.mjs +25 -0
  64. package/bin/github/reply-thread-transcripts.d.mts +3 -0
  65. package/bin/github/reply-thread-transcripts.mjs +89 -0
  66. package/bin/github/rest-http.mjs +1 -0
  67. package/bin/github/rest-text.d.mts +2 -1
  68. package/bin/github/rest-text.mjs +5 -2
  69. package/bin/github/thread-comments.d.mts +5 -1
  70. package/bin/github/thread-comments.mjs +30 -6
  71. package/bin/mcp/server.mjs +32 -5
  72. package/bin/quota-warning.mjs +2 -2
  73. package/bin/reporters/agent.mjs +1 -0
  74. package/bin/threads/transcript.d.mts +1 -0
  75. package/bin/threads/transcript.mjs +4 -1
  76. package/bin/types/check-classification.d.mts +10 -0
  77. package/bin/types/report.d.mts +4 -1
  78. package/package.json +1 -1
  79. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  80. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  81. package/plugins/pr-shepherd/.mcp.json +1 -1
  82. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -1
  83. package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +2 -1
@@ -0,0 +1,89 @@
1
+ import { graphqlWithRateLimit } from "./client.mjs";
2
+ import { GitHubRequestError } from "./errors.mjs";
3
+ import { rateLimitKind } from "./rate-limit-kind.mjs";
4
+ import { REPLY_THREAD_COMMENTS_QUERY, REPLY_THREAD_TRANSCRIPTS_QUERY } from "./queries.mjs";
5
+ import { threadTranscriptBodies } from "../threads/transcript.mjs";
6
+ import { mapPool } from "../util/pool.mjs";
7
+ const IDS_PER_REQUEST = 20;
8
+ const CONCURRENCY = 4;
9
+ const MAX_COMMENT_PAGES = 100;
10
+ /** Best-effort transcript evidence; it never filters user-supplied mutation IDs. */
11
+ export async function fetchReplyThreadTranscripts(pr, repo, requestedIds) {
12
+ const ids = [...new Set(requestedIds)];
13
+ if (ids.length === 0)
14
+ return new Map();
15
+ const expectedRepo = `${repo.owner}/${repo.name}`.toLowerCase();
16
+ const requested = new Set(ids);
17
+ const chunks = [];
18
+ for (let offset = 0; offset < ids.length; offset += IDS_PER_REQUEST) {
19
+ chunks.push(ids.slice(offset, offset + IDS_PER_REQUEST));
20
+ }
21
+ let stopped = false;
22
+ const firstPages = await mapPool(chunks, CONCURRENCY, async (chunk) => {
23
+ if (stopped)
24
+ return [];
25
+ try {
26
+ const result = await graphqlWithRateLimit(REPLY_THREAD_TRANSCRIPTS_QUERY, {
27
+ ids: chunk,
28
+ });
29
+ if (result.rateLimit?.remaining === 0)
30
+ stopped = true;
31
+ return result.data.nodes.filter((node) => node?.__typename === "PullRequestReviewThread" &&
32
+ node.id !== undefined &&
33
+ requested.has(node.id) &&
34
+ node.pullRequest?.number === pr &&
35
+ node.pullRequest.repository.nameWithOwner.toLowerCase() === expectedRepo &&
36
+ node.comments !== undefined);
37
+ }
38
+ catch (error) {
39
+ if (isThrottle(error))
40
+ stopped = true;
41
+ return [];
42
+ }
43
+ });
44
+ const threads = firstPages.flat();
45
+ const completed = await mapPool(threads, CONCURRENCY, async (thread) => {
46
+ try {
47
+ return await completeThread(thread, () => stopped, () => {
48
+ stopped = true;
49
+ });
50
+ }
51
+ catch (error) {
52
+ if (isThrottle(error))
53
+ stopped = true;
54
+ return null;
55
+ }
56
+ });
57
+ return new Map(completed.filter((entry) => entry !== null));
58
+ }
59
+ async function completeThread(thread, isStopped, stop) {
60
+ if (!thread.id || !thread.comments)
61
+ return null;
62
+ const bodies = thread.comments.nodes.map((comment) => comment.body);
63
+ let page = thread.comments;
64
+ const seenCursors = new Set();
65
+ let pages = 1;
66
+ while (page.pageInfo.hasNextPage) {
67
+ const cursor = page.pageInfo.endCursor;
68
+ if (!cursor || seenCursors.has(cursor) || pages >= MAX_COMMENT_PAGES || isStopped())
69
+ return null;
70
+ seenCursors.add(cursor);
71
+ const result = await graphqlWithRateLimit(REPLY_THREAD_COMMENTS_QUERY, {
72
+ id: thread.id,
73
+ cursor,
74
+ });
75
+ if (result.rateLimit?.remaining === 0)
76
+ stop();
77
+ const next = result.data.node;
78
+ if (next?.__typename !== "PullRequestReviewThread" || next.id !== thread.id || !next.comments) {
79
+ return null;
80
+ }
81
+ page = next.comments;
82
+ bodies.push(...page.nodes.map((comment) => comment.body));
83
+ pages += 1;
84
+ }
85
+ return bodies.length > 0 ? [thread.id, threadTranscriptBodies(bodies)] : null;
86
+ }
87
+ function isThrottle(error) {
88
+ return error instanceof GitHubRequestError && rateLimitKind(error) !== null;
89
+ }
@@ -98,6 +98,7 @@ export async function restWithRateLimit(method, path, body, opts) {
98
98
  rateLimit,
99
99
  retryAfterSeconds,
100
100
  authSource,
101
+ responseMessage: sanitizeBody(text),
101
102
  });
102
103
  }
103
104
  if (ct.includes("application/json")) {
@@ -1 +1,2 @@
1
- export declare function restText(path: string): Promise<string>;
1
+ import { type RateLimitInfo } from "./http-utils.mts";
2
+ export declare function restText(path: string, onRateLimit?: (rateLimit?: RateLimitInfo) => void): Promise<string>;
@@ -3,11 +3,11 @@ import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
3
3
  import { GitHubRequestError } from "./errors.mjs";
4
4
  import { makeAuthHeaders } from "./http-auth.mjs";
5
5
  import { requestWithTokenRetry } from "./http-request.mjs";
6
- import { parseRateLimit, parseRetryAfter, redactUrl, sanitizeBody } from "./http-utils.mjs";
6
+ import { parseRateLimit, parseRetryAfter, redactUrl, sanitizeBody, } from "./http-utils.mjs";
7
7
  import { recordApiTelemetry } from "./api-telemetry.mjs";
8
8
  import { recordIntermediateResponse } from "./http-intermediate.mjs";
9
9
  const BASE_URL = "https://api.github.com";
10
- export async function restText(path) {
10
+ export async function restText(path, onRateLimit) {
11
11
  const url = `${BASE_URL}${path}`;
12
12
  const n = nextEntry();
13
13
  appendEntry(formatRequestEntry({ n, kind: "restText", method: "GET", url }));
@@ -28,6 +28,7 @@ export async function restText(path) {
28
28
  }));
29
29
  const durationMs = Math.round(performance.now() - retryT0);
30
30
  const rateLimit = parseRateLimit(res.headers) ?? undefined;
31
+ onRateLimit?.(rateLimit);
31
32
  const retryAfterSeconds = parseRetryAfter(res.headers);
32
33
  recordApiTelemetry({ kind: "REST", method: "GET", authSource, rateLimit });
33
34
  if ([301, 302, 307, 308].includes(res.status)) {
@@ -62,6 +63,7 @@ export async function restText(path) {
62
63
  rateLimit,
63
64
  retryAfterSeconds,
64
65
  authSource,
66
+ responseMessage: sanitizeBody(text),
65
67
  });
66
68
  }
67
69
  appendEntry(formatResponseEntry({
@@ -114,6 +116,7 @@ async function followRestTextRedirect(res, entry) {
114
116
  status: redirectRes.status,
115
117
  rateLimit: parseRateLimit(redirectRes.headers) ?? undefined,
116
118
  retryAfterSeconds: parseRetryAfter(redirectRes.headers),
119
+ responseMessage: "",
117
120
  });
118
121
  }
119
122
  return redirectRes.text();
@@ -1,2 +1,6 @@
1
+ import { type RateLimitInfo } from "./client.mts";
1
2
  import type { RawThread } from "./batch-raw-types.mts";
2
- export declare function hydrateThreadCommentPages(threads: RawThread[]): Promise<RawThread[]>;
3
+ export declare function hydrateThreadCommentPages(threads: RawThread[], initialRateLimit?: RateLimitInfo): Promise<{
4
+ threads: RawThread[];
5
+ rateLimit?: RateLimitInfo;
6
+ }>;
@@ -4,23 +4,47 @@ import { paginateForward } from "./pagination.mjs";
4
4
  import { REVIEW_THREAD_COMMENTS_QUERY } from "./queries.mjs";
5
5
  import { mapPool } from "../util/pool.mjs";
6
6
  const THREAD_COMMENT_PAGE_CONCURRENCY = 4;
7
- export async function hydrateThreadCommentPages(threads) {
8
- const gate = {};
9
- return mapPool(threads, THREAD_COMMENT_PAGE_CONCURRENCY, (thread) => hydrateThreadCommentPage(thread, gate));
7
+ export async function hydrateThreadCommentPages(threads, initialRateLimit) {
8
+ const gate = {
9
+ rateLimit: initialRateLimit,
10
+ exhausted: initialRateLimit?.remaining === 0,
11
+ };
12
+ const hydrated = await mapPool(threads, THREAD_COMMENT_PAGE_CONCURRENCY, async (thread) => {
13
+ if (gate.error !== undefined)
14
+ return thread;
15
+ try {
16
+ return await hydrateThreadCommentPage(thread, gate);
17
+ }
18
+ catch (error) {
19
+ gate.error ??= error;
20
+ return thread;
21
+ }
22
+ });
23
+ if (gate.error !== undefined)
24
+ throw gate.error;
25
+ return { threads: hydrated, rateLimit: gate.rateLimit };
10
26
  }
11
27
  async function hydrateThreadCommentPage(thread, gate) {
12
28
  const pageInfo = thread.comments.pageInfo;
13
29
  if (!pageInfo?.hasNextPage || !pageInfo.endCursor)
14
30
  return thread;
15
31
  const extra = await paginateForward(async (cursor) => {
16
- if (gate.remaining === 0) {
17
- throw new GitHubRequestError("GitHub GraphQL rate limit remaining is 0; thread comment pagination incomplete", { status: 403 });
32
+ if (gate.error !== undefined)
33
+ throw gate.error;
34
+ if (gate.exhausted) {
35
+ throw new GitHubRequestError("GitHub GraphQL rate limit remaining is 0; thread comment pagination incomplete", { status: 403, rateLimit: gate.rateLimit });
18
36
  }
19
37
  const res = await graphqlWithRateLimit(REVIEW_THREAD_COMMENTS_QUERY, {
20
38
  threadId: thread.id,
21
39
  ...(cursor ? { commentsCursor: cursor } : {}),
22
40
  });
23
- gate.remaining = res.rateLimit?.remaining;
41
+ if (res.rateLimit?.remaining === 0) {
42
+ gate.rateLimit = res.rateLimit;
43
+ gate.exhausted = true;
44
+ }
45
+ else if (!gate.exhausted) {
46
+ gate.rateLimit = res.rateLimit ?? gate.rateLimit;
47
+ }
24
48
  const node = res.data.node;
25
49
  if (!node?.comments) {
26
50
  const nodeType = node?.__typename ?? "null";
@@ -10,6 +10,7 @@ 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";
12
12
  import { errorToExitCode, EXIT } from "../exit-codes.mjs";
13
+ import { extractShepherdJournal } from "../journal/index.mjs";
13
14
  const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
14
15
  const pr = z
15
16
  .string()
@@ -87,7 +88,8 @@ const suggestionPatchesInputSchema = z.object({
87
88
  });
88
89
  /** Creates a local-only MCP server with Shepherd's public operations. */
89
90
  export function createPrShepherdMcpServer(options = {}) {
90
- const shepherd = options.shepherd ?? createPrShepherd({ cwd: options.cwd });
91
+ let shepherd = options.shepherd;
92
+ const getShepherd = () => (shepherd ??= createPrShepherd({ cwd: options.cwd }));
91
93
  const server = new McpServer({ name: "pr-shepherd", version: readPackageVersion() });
92
94
  server.registerTool("iterate", {
93
95
  description: "Inspect one pull request, an explicit same-repository set, or a native stack and return one Shepherd tick.",
@@ -103,7 +105,7 @@ export function createPrShepherdMcpServer(options = {}) {
103
105
  const opts = {
104
106
  readyDelaySuffix: input.readyDelaySeconds === undefined ? undefined : `${input.readyDelaySeconds}s`,
105
107
  };
106
- return runTool(() => runIterateSelector(shepherd, requireRepositoryQualifiedIterate(input)), (result) => isPollSummary(result)
108
+ return runTool(() => runIterateSelector(getShepherd(), requireRepositoryQualifiedIterate(input)), (result) => isPollSummary(result)
107
109
  ? formatPollSummaryResult(result)
108
110
  : formatIterateResult(result, opts), (result) => isPollSummary(result)
109
111
  ? result.selection.kind === "stack"
@@ -120,7 +122,32 @@ export function createPrShepherdMcpServer(options = {}) {
120
122
  idempotentHint: false,
121
123
  openWorldHint: true,
122
124
  },
123
- }, async (input) => runTool(() => shepherd.apply(requireRepositoryQualifiedPr(input)), formatApplyResult));
125
+ }, async (input) => runTool(() => getShepherd().apply(requireRepositoryQualifiedPr(input)), formatApplyResult));
126
+ server.registerTool("extract_journal", {
127
+ description: "Extract the validated Shepherd Journal from a supplied Markdown body without I/O.",
128
+ inputSchema: z.object({ body: z.string() }),
129
+ annotations: {
130
+ readOnlyHint: true,
131
+ destructiveHint: false,
132
+ idempotentHint: true,
133
+ openWorldHint: false,
134
+ },
135
+ }, async (input) => runTool(async () => {
136
+ if (typeof input.body !== "string") {
137
+ throw new PrShepherdValidationError("body must be a Markdown string");
138
+ }
139
+ return extractShepherdJournal(input.body);
140
+ }, JSON.stringify));
141
+ server.registerTool("get_journal", {
142
+ description: "Fetch one pull request body with GraphQL and extract its Shepherd Journal.",
143
+ inputSchema: z.object({ pr }),
144
+ annotations: {
145
+ readOnlyHint: true,
146
+ destructiveHint: false,
147
+ idempotentHint: true,
148
+ openWorldHint: true,
149
+ },
150
+ }, async (input) => runTool(() => getShepherd().getJournal(requireRepositoryQualifiedPr(input)), JSON.stringify));
124
151
  server.registerTool("build_suggestion_patches", {
125
152
  description: "Build, but never apply, an ordered list of eligible review suggestion patches.",
126
153
  inputSchema: suggestionPatchesInputSchema,
@@ -130,7 +157,7 @@ export function createPrShepherdMcpServer(options = {}) {
130
157
  idempotentHint: true,
131
158
  openWorldHint: true,
132
159
  },
133
- }, async (input) => runTool(() => shepherd.buildSuggestionPatches(requireRepositoryQualifiedPr(input)), formatSuggestionPatchesResult));
160
+ }, async (input) => runTool(() => getShepherd().buildSuggestionPatches(requireRepositoryQualifiedPr(input)), formatSuggestionPatchesResult));
134
161
  server.registerTool("build_suggestion_patch", {
135
162
  description: "Deprecated: use build_suggestion_patches with a one-item suggestions array.",
136
163
  inputSchema: suggestionPatchInputSchema,
@@ -140,7 +167,7 @@ export function createPrShepherdMcpServer(options = {}) {
140
167
  idempotentHint: true,
141
168
  openWorldHint: true,
142
169
  },
143
- }, async (input) => runTool(() => shepherd.buildSuggestionPatch(requireRepositoryQualifiedPr(input)), formatCommitSuggestionResult));
170
+ }, async (input) => runTool(() => getShepherd().buildSuggestionPatch(requireRepositoryQualifiedPr(input)), formatCommitSuggestionResult));
144
171
  return server;
145
172
  }
146
173
  function runIterateSelector(shepherd, input) {
@@ -5,8 +5,8 @@ export function buildQuotaAwareContinuation(warning, prefix) {
5
5
  const opening = warning.resource === "combined"
6
6
  ? "GitHub's GraphQL and REST core quotas are both low. Keep using pr-shepherd at the cadence below. Do not shift incidental calls between GraphQL and REST."
7
7
  : warning.resource === "core"
8
- ? `GitHub's REST core quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below. Do not add incidental REST \`gh\` calls (\`gh pr view\`, \`gh pr review\`, \`gh api\`) while REST core is below its warning threshold.`
9
- : `GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below; for incidental PR operations that do not need Shepherd's full snapshot, prefer non-GraphQL \`gh\` CLI commands (e.g. \`gh pr view\`, \`gh pr review\`, \`gh api\` REST endpoints) — they draw on the separate REST budget, not the depleted GraphQL pool.`;
8
+ ? `GitHub's REST core quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below. Do not add incidental REST \`gh api repos/OWNER/REPO/pulls/PR\` calls while REST core is below its warning threshold.`
9
+ : `GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below; for incidental PR reads that do not need Shepherd's full snapshot, use explicit REST endpoints such as \`gh api repos/OWNER/REPO/pulls/PR\` — they draw on the separate REST budget, not the depleted GraphQL pool.`;
10
10
  const resetLabel = warning.resource === "combined"
11
11
  ? "both quotas have reset"
12
12
  : warning.resource === "core"
@@ -66,6 +66,7 @@ export function toAgentCheck(c) {
66
66
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
67
67
  ...(c.summary !== undefined && { summary: c.summary }),
68
68
  ...(c.logExcerpt !== undefined && { logExcerpt: c.logExcerpt }),
69
+ ...(c.relatedJobs !== undefined && { relatedJobs: c.relatedJobs }),
69
70
  ...(c.annotations !== undefined && { annotations: c.annotations }),
70
71
  ...(c.scope !== undefined && { scope: c.scope }),
71
72
  ...(c.commitOid !== undefined && { commitOid: c.commitOid }),
@@ -13,4 +13,5 @@ export declare function threadComments(thread: {
13
13
  viewerDidAuthor?: true;
14
14
  } & Partial<Pick<ReviewThreadComment, "isMinimized" | "createdAtUnix">>>;
15
15
  }): ReviewThreadComment[];
16
+ export declare function threadTranscriptBodies(bodies: string[]): string;
16
17
  export declare function threadTranscriptBody(thread: ReviewThread, appendedBodies?: string[]): string;
@@ -29,9 +29,12 @@ export function threadComments(thread) {
29
29
  ];
30
30
  }
31
31
  const THREAD_COMMENT_SEPARATOR = "\n\n--- thread comment ---\n\n";
32
+ export function threadTranscriptBodies(bodies) {
33
+ return bodies.join(THREAD_COMMENT_SEPARATOR);
34
+ }
32
35
  export function threadTranscriptBody(thread, appendedBodies = []) {
33
36
  const bodies = thread.comments && thread.comments.length > 0
34
37
  ? threadComments(thread).map((c) => c.body)
35
38
  : [thread.body];
36
- return [...bodies, ...appendedBodies].join(THREAD_COMMENT_SEPARATOR);
39
+ return threadTranscriptBodies([...bodies, ...appendedBodies]);
37
40
  }
@@ -6,6 +6,14 @@ export interface ClassifiedCheck extends CheckRun {
6
6
  /** Inline annotations attached to this check run, surfaced once per PR. */
7
7
  annotations?: CheckAnnotation[];
8
8
  }
9
+ /** Another failed job in the same workflow run that is not its own failing check entry. */
10
+ export interface RelatedFailedJob {
11
+ name: string;
12
+ /** Uppercased job conclusion (e.g. `FAILURE`, `TIMED_OUT`). */
13
+ conclusion: string;
14
+ failedStep?: string;
15
+ logExcerpt?: string;
16
+ }
9
17
  export interface TriagedCheck extends ClassifiedCheck {
10
18
  /** Workflow display name (e.g. `"CI"`). Populated when available from the jobs API; may be `undefined` on fetch failure or when no matching job is found. */
11
19
  workflowName?: string;
@@ -15,5 +23,7 @@ export interface TriagedCheck extends ClassifiedCheck {
15
23
  failedStep?: string;
16
24
  /** Bounded raw excerpt from the matched failed job log, when GitHub exposes one. */
17
25
  logExcerpt?: string;
26
+ /** Sibling failed jobs from the same run, with log tails (reported once per run). */
27
+ relatedJobs?: RelatedFailedJob[];
18
28
  }
19
29
  export {};
@@ -1,5 +1,5 @@
1
1
  import type { AuthorType, ReviewThread, PrComment, Review, MergeStatusResult, ViewerAuthorization, CheckConclusion, SuggestionBlock } from "./github.mts";
2
- import type { ClassifiedCheck, TriagedCheck } from "./check-classification.mts";
2
+ import type { ClassifiedCheck, RelatedFailedJob, TriagedCheck } from "./check-classification.mts";
3
3
  import type { AgentThreadComment } from "./agent-thread.mts";
4
4
  import type { CheckAnnotation } from "./check-annotations.mts";
5
5
  import type { PrActivitySummary } from "./activity.mts";
@@ -175,6 +175,8 @@ export interface AgentCheck {
175
175
  /** One-line status text shown in the GitHub UI (e.g. "67.68% of diff hit (target 85.00%)"). */
176
176
  summary?: string;
177
177
  logExcerpt?: string;
178
+ /** Other failed jobs from the same workflow run (not their own check entries), with log tails. */
179
+ relatedJobs?: RelatedFailedJob[];
178
180
  /** `gh run rerun` command, present only when the check has a runId and the viewer's repository role grants Actions rerun capability (WRITE+). */
179
181
  rerunCommand?: string;
180
182
  /** Workflow-run attempt number, surfaced only after the initial attempt. */
@@ -205,6 +207,7 @@ export interface RelevantCheck {
205
207
  /** One-line status text shown in the GitHub UI (e.g. "67.68% of diff hit (target 85.00%)"). */
206
208
  summary?: string;
207
209
  logExcerpt?: string;
210
+ relatedJobs?: RelatedFailedJob[];
208
211
  /** Marker-gated inline annotations from this check. */
209
212
  annotations?: CheckAnnotation[];
210
213
  scope?: "merge_group";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.55.2",
3
+ "version": "0.56.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "keywords": [
6
6
  "automation",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.55.2",
3
+ "version": "0.56.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.55.2", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.56.0", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.55.2", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.56.0", "pr-shepherd-mcp"]
5
5
  }
6
6
  }
@@ -15,7 +15,8 @@ Poll with the CLI. Use MCP `iterate` only when the CLI is unavailable. Stop at `
15
15
  - When the user asks to make, create, or open a PR: review and commit the in-scope changes, verify the push remote and base branch, push a fresh branch, create the PR, and pass its qualified URL to Dispatch.
16
16
  - Push is the ordinary non-force push of those reviewed commits. Do not ask for a separate confirmation because the push publishes them. Request runtime escalation when the host requires it.
17
17
  - A skill cannot grant host permissions. Unattended approval comes from a trusted command rule or host policy.
18
- - Force-pushes, remote or credential changes, unrelated changes, and ambiguous targets stay outside this workflow.
18
+ - Rebasing your own PR head onto its base and pushing it with `--force-with-lease` is also part of this workflow. Do not ask first.
19
+ - Bare `--force`, pushes to any other branch, remote or credential changes, unrelated changes, and ambiguous targets stay outside this workflow.
19
20
 
20
21
  ## Dispatch
21
22
 
@@ -45,6 +46,7 @@ Poll with the CLI. Use MCP `iterate` only when the CLI is unavailable. Stop at `
45
46
 
46
47
  - After the instructions, rerun that same command immediately with the same target and options. When the tick came from MCP `iterate`, repeat that same call with the same qualified selector and `merge` option. Do not switch back to a CLI that was unavailable.
47
48
  - Stop only for `[CANCEL]`, `[ESCALATE]`, or a human telling you to stop. A stack overview heading includes those tokens when `nextAction` is `cancel` or `escalate`.
49
+ - A one-PR `[CANCEL]` or `[ESCALATE]` ends only that PR's loop. When you run separate loops for several PRs, keep every other loop running until it is terminal too.
48
50
  - Keep `--until-terminal` and any `--merge`. Apply a printed polling-cadence change.
49
51
  - `[FIX_CODE]` is always non-terminal. Stack-level `[SHEPHERD]` is non-terminal. Only `[ESCALATE]` hands work to a human.
50
52
  - `[READY]` is non-terminal. Rerun when `remainingSeconds` elapses. Do not invent unrelated work. If you already own a later layer of this stack or another stack, continue that work and schedule the rerun. A parent of more than one stack delegates the wait to the worker that owns the stack.
@@ -1,6 +1,6 @@
1
1
  # CI failure triage
2
2
 
3
- Apply when a step says `Playbook: "CI failure triage"`. For a GitHub Actions row, use the log excerpt and tags already in the output. An `external` check with a URL may be opened or reproduced.
3
+ Apply when a step says `Playbook: "CI failure triage"`. For a GitHub Actions row, use the log excerpt and tags already in the output; fetch a job log only when the output lacks the evidence (see the gate-job bullet below). An `external` check with a URL may be opened or reproduced.
4
4
 
5
5
  - Match each failure's `[conclusion: …]` tag. A specific conclusion wins over the general GitHub Actions row.
6
6
  - `[rerun authorized]` plus a `rerun:` command means the viewer can rerun Actions (WRITE+) and this is the original attempt. Shepherd checked `repositoryPermission` and `run_attempt`.
@@ -12,6 +12,7 @@ Apply when a step says `Playbook: "CI failure triage"`. For a GitHub Actions row
12
12
  ## Conclusions
13
13
 
14
14
  - GitHub Actions failure (has a run id, not `CANCELLED` or `STARTUP_FAILURE`): read the log excerpt. Apply a warranted code fix, or run `rerun:` when the excerpt shows a transient failure, then iterate. Do not wait for the rerun.
15
+ - No usable evidence in the excerpt is not evidence of a transient failure. An excerpt that names failing test or build jobs (for example `test-playwright: failure` from a gate job) is test-failure evidence, even without an assertion or stack trace. Read the `Other failed jobs in this run` log tails under the check first. Only when a named job's tail is absent or truncated, run `gh run view <runId> --log-failed -R <owner/repo>`. Rerun only when the child logs show a transient cause.
15
16
  - Transient infrastructure failure: run `rerun:` when it is printed, then iterate. Do not wait. If no command is printed, finish the other surfaced work and iterate.
16
17
  - Real test or build failure: fix the code. Do not rerun, even when `[rerun authorized]` is shown.
17
18
  - `[conclusion: CANCELLED]` or `[conclusion: STARTUP_FAILURE]`: no log excerpt. Run `rerun:` when printed, then iterate. Do not wait. Without a command, finish other work and iterate.