pr-shepherd 0.51.0 → 0.52.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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +25 -17
  3. package/bin/checks/log-excerpt.d.mts +1 -0
  4. package/bin/checks/log-excerpt.mjs +192 -0
  5. package/bin/checks/triage.mjs +2 -121
  6. package/bin/cli/iterate-lean.mjs +0 -3
  7. package/bin/cli/poll-summary-emitter.mjs +2 -3
  8. package/bin/cli/poll-summary-formatter.mjs +9 -2
  9. package/bin/commands/check.mjs +1 -0
  10. package/bin/commands/iterate/escalate.mjs +0 -13
  11. package/bin/commands/iterate/fix-code.d.mts +2 -0
  12. package/bin/commands/iterate/fix-code.mjs +14 -42
  13. package/bin/commands/iterate/index.mjs +140 -3
  14. package/bin/commands/iterate/merge-state.mjs +33 -18
  15. package/bin/commands/iterate/parent-first.d.mts +9 -0
  16. package/bin/commands/iterate/parent-first.mjs +57 -0
  17. package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
  18. package/bin/commands/iterate/stale-ancestry.mjs +37 -0
  19. package/bin/commands/poll-summary-instructions.mjs +179 -99
  20. package/bin/commands/poll-summary.mjs +28 -4
  21. package/bin/exit-codes.d.mts +2 -0
  22. package/bin/exit-codes.mjs +2 -0
  23. package/bin/github/batch-parsers.mjs +1 -0
  24. package/bin/github/batch-raw-rules.d.mts +3 -0
  25. package/bin/github/gql/poll-summary-fragment.gql +54 -0
  26. package/bin/github/gql/pr-merge-policy.gql +3 -0
  27. package/bin/github/poll-summary-fingerprint.d.mts +8 -0
  28. package/bin/github/poll-summary-fingerprint.mjs +28 -0
  29. package/bin/github/poll-summary-projector.mjs +67 -14
  30. package/bin/github/poll-summary-queue-removal.d.mts +5 -0
  31. package/bin/github/poll-summary-queue-removal.mjs +16 -0
  32. package/bin/github/poll-summary-raw.d.mts +33 -0
  33. package/bin/github/poll-summary-readiness.d.mts +6 -0
  34. package/bin/github/poll-summary-readiness.mjs +25 -0
  35. package/bin/github/poll-summary.d.mts +3 -0
  36. package/bin/github/poll-summary.mjs +5 -0
  37. package/bin/state/ready-receipts.d.mts +45 -0
  38. package/bin/state/ready-receipts.mjs +86 -0
  39. package/bin/types/escalate.d.mts +2 -3
  40. package/bin/types/github.d.mts +2 -0
  41. package/bin/types/poll-summary.d.mts +12 -2
  42. package/bin/types/report.d.mts +2 -0
  43. package/package.json +4 -4
  44. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  45. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  46. package/plugins/pr-shepherd/.mcp.json +1 -1
  47. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
  48. package/bin/state/bot-cr-seen.d.mts +0 -51
  49. package/bin/state/bot-cr-seen.mjs +0 -100
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.51.0",
4
+ "version": "0.52.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -36,7 +36,10 @@ Each tick returns exactly one action:
36
36
  - `FIX_CODE` — agent work is required; complete it, push when needed, then continue polling. Push access to the PR head branch is a usage precondition.
37
37
  - `MERGE` — run the emitted head-pinned auto-merge or queue command. Ordinary merges include a plain-merge fallback; queue merges include a GraphQL enqueue fallback. GitHub is authoritative for the result and reports any authorization failure.
38
38
  - `CANCEL` — stop polling because the PR merged, closed, or completed its ready-delay.
39
- - `ESCALATE` — stop polling until a human provides direction.
39
+ - `ESCALATE` — stop polling until a human provides direction. Native stacks reach this only after their autonomous one-PR sessions are exhausted.
40
+
41
+ Native-stack summaries additionally use stack-level `SHEPHERD`: run the listed one-PR sessions,
42
+ then recheck the stack. It is not a per-PR `FIX_CODE` action.
40
43
 
41
44
  Example shape:
42
45
 
@@ -79,7 +82,7 @@ Conversations Resolved: No [Not Required]
79
82
  9. `[FIX_CODE]` is non-terminal: if you changed code, commit and push to the PR head branch, then run review mutations using the pushed commit SHA and iterate immediately with the same options; without code changes, complete the authorized review mutations and iterate immediately.
80
83
  ```
81
84
 
82
- See [docs/actions.md](docs/actions.md) for the complete output contract and [docs/escalations.md](docs/escalations.md) for the exact finite human-handoff boundary. Iterate/poll PR outcomes use exit codes `0` and `10`–`15`; command and GitHub failures use `sysexits.h` codes — [docs/exit-codes.md](docs/exit-codes.md).
85
+ See [docs/actions.md](docs/actions.md) for the complete output contract and [docs/escalations.md](docs/escalations.md) for the exact finite human-handoff boundary. Iterate/poll PR outcomes use exit codes `0` and `10`–`16`; command and GitHub failures use `sysexits.h` codes — [docs/exit-codes.md](docs/exit-codes.md).
83
86
 
84
87
  ## Workflow Assumptions
85
88
 
@@ -129,7 +132,7 @@ pr-shepherd 42 # poll until non-WAIT or timeout
129
132
  pr-shepherd 42 --interval 60s --timeout 270s
130
133
  pr-shepherd 42 --quiet-status # print only changed WAIT status snapshots
131
134
  pr-shepherd 42 --until-terminal # continue through WAIT/MARK_READY until work or terminal state
132
- pr-shepherd 42 --debounce 5m # wait 5m after first FIX_CODE, then return one batched tick
135
+ pr-shepherd 42 --debounce 5m # wait 5m after first FIX_CODE or stack SHEPHERD, then return one batched tick
133
136
  pr-shepherd 42 --ready-delay 15m
134
137
  pr-shepherd 42 --merge # request head-pinned auto-merge/queue; GitHub reports the result
135
138
  pr-shepherd iterate 42 # single tick
@@ -139,20 +142,25 @@ pr-shepherd 42 43 44 # summarize an explicit same-repository s
139
142
  pr-shepherd --stack 43 # summarize every PR in a native GitHub stack
140
143
  ```
141
144
 
142
- Multi-PR and `--stack` polling use compact, read-only GraphQL summaries. They return when any row
143
- needs agent work, all rows are terminal, the bounded timeout expires, or `--until-terminal` crosses
144
- a configured GraphQL quota-warning band. Check counts use the same ignored, protected-run,
145
- superseded-run, and event rules as singular iteration and include active merge-queue commit checks.
146
- Bounded review/check overflow remains visible without permanently forcing work, and clean rows use
147
- the configured ready-delay before becoming terminal. Explicit PR sets give each actionable row an
148
- exact single-PR `pollCommand`, so independent rows can proceed before the next aggregate poll.
149
- Stack rows are ordered bottom-to-top and follow the one ordered stack instruction block instead.
150
- The summary also checks that every open child was based on
151
- its direct parent's current head. A stale child/parent OID pair is actionable even when GitHub
152
- reports both PRs clean: without `--merge`, rebase the upstack branches from their parent and push
153
- them with the emitted `gh stack` commands; with `--merge`, finish the contiguous ready lower
154
- layers with the emitted `gh stack merge --squash` command, recheck, then repair the child. API and
155
- MCP aggregate calls perform one summary tick and leave recurrence to the caller.
145
+ Multi-PR and `--stack` polling use compact, read-only GraphQL summaries. They return when work is
146
+ needed, every selected PR is complete, the bounded timeout expires, or `--until-terminal` crosses a
147
+ configured GraphQL quota-warning band. Explicit PR sets give each actionable row an exact single-PR
148
+ `pollCommand`, so independent rows can proceed before the next aggregate poll.
149
+
150
+ Native-stack rows are ordered bottom-to-top. `--stack` never performs a mutation itself; only
151
+ `--stack --merge` can emit a complete-stack merge command for the agent. An unready layer (draft, missing a READY
152
+ receipt, conflicting, failing, or stale) returns stack-level `SHEPHERD` with one-PR Shepherd instructions for
153
+ the affected layers. A draft or other unready lower layer marks every higher open layer with
154
+ `blockedByPr`; review and CI sessions on independent layers may proceed concurrently, but an upper
155
+ draft cannot transition to ready until every lower layer has its READY receipt. A queued stack
156
+ returns `WAIT`. A terminal READY or fully merged stack returns `CANCEL`. Closed or unverified
157
+ topology returns `ESCALATE` for human direction after any other shepherdable PRs are handled;
158
+ until then, `SHEPHERD` remains the immediate action and lists the human blockers too.
159
+
160
+ With `--stack --merge`, a fully reconciled and READY stack returns `MERGE` with a `gh stack merge`
161
+ command for the agent to run, checking and installing the optional `github/gh-stack` extension first
162
+ when necessary. It then rechecks until every layer merges and returns `CANCEL`. API and MCP
163
+ aggregate calls perform one summary tick and leave recurrence to the caller.
156
164
 
157
165
  Polling defaults can be set under `poll` in `.pr-shepherdrc.yml`: `intervalSeconds`, `timeoutSeconds`, `debounceSeconds`, and `quietStatus`. Explicit flags override configuration, including `--no-quiet-status` when a shared config enables quiet output. Quiet status remains off by default.
158
166
 
@@ -0,0 +1 @@
1
+ export declare function buildLogExcerpt(raw: string): string | undefined;
@@ -0,0 +1,192 @@
1
+ import { loadConfig } from "../config/load.mjs";
2
+ const LOG_EXCERPT_CONTEXT_LINES = 16;
3
+ const LOG_EXCERPT_TAIL_LINES = 28;
4
+ const LOG_EXCERPT_MAX_CHARS = 4_000;
5
+ const TRUNCATED_MARK = "[truncated]";
6
+ const ANSI_SGR_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
7
+ const STEP_GROUP_RE = /^##\[group\](?:Run |Post )/;
8
+ const GROUP_RE = /^##\[group\]/;
9
+ const ENDGROUP_RE = /^##\[endgroup\]$/;
10
+ const POST_JOB_RE = /^(?:Post job cleanup\.?|Cleaning up orphan processes)$/;
11
+ export function buildLogExcerpt(raw) {
12
+ const ignorePatterns = compileIgnoreLogLinePatterns();
13
+ const prepared = raw
14
+ .split(/\r?\n/)
15
+ .map(cleanLogLine)
16
+ .filter((line) => line.trim() !== "");
17
+ if (prepared.length === 0)
18
+ return undefined;
19
+ const isolated = isolateFailedStep(prepared);
20
+ const lines = isolated.lines
21
+ .map(stripGroupMarkers)
22
+ .filter((line) => line.trim() !== "" && !isNoiseLine(line, ignorePatterns));
23
+ if (lines.length === 0)
24
+ return undefined;
25
+ const aggregateExcerpt = buildAggregateJobResultsExcerpt(lines);
26
+ if (aggregateExcerpt !== undefined)
27
+ return aggregateExcerpt;
28
+ if (isolated.isolated) {
29
+ const errorIndex = findLogExcerptAnchor(lines);
30
+ return truncateTail(lines.join("\n"), errorIndex === -1 ? undefined : lines[errorIndex]);
31
+ }
32
+ return boundFallbackExcerpt(lines);
33
+ }
34
+ function isolateFailedStep(lines) {
35
+ const postJob = lines.findIndex((line) => POST_JOB_RE.test(line));
36
+ const capped = postJob === -1 ? lines : lines.slice(0, postJob);
37
+ const anchor = findLogExcerptAnchor(capped);
38
+ const groupStart = findPrecedingStepGroup(capped, anchor === -1 ? capped.length - 1 : anchor);
39
+ if (groupStart === -1)
40
+ return { lines: capped, isolated: false };
41
+ const endgroup = findMatchingEndgroup(capped, groupStart);
42
+ const after = endgroup === -1 ? groupStart + 1 : endgroup + 1;
43
+ const next = findNextStepGroup(capped, after);
44
+ return { lines: capped.slice(after, next === -1 ? capped.length : next), isolated: true };
45
+ }
46
+ function findPrecedingStepGroup(lines, from) {
47
+ for (let i = Math.min(from, lines.length - 1); i >= 0; i--) {
48
+ if (STEP_GROUP_RE.test(lines[i] ?? ""))
49
+ return i;
50
+ }
51
+ return -1;
52
+ }
53
+ function findMatchingEndgroup(lines, groupStart) {
54
+ let depth = 0;
55
+ for (let i = groupStart; i < lines.length; i++) {
56
+ const line = lines[i] ?? "";
57
+ if (GROUP_RE.test(line))
58
+ depth++;
59
+ else if (ENDGROUP_RE.test(line) && --depth === 0)
60
+ return i;
61
+ }
62
+ return -1;
63
+ }
64
+ function findNextStepGroup(lines, from) {
65
+ let depth = 0;
66
+ for (let i = from; i < lines.length; i++) {
67
+ const line = lines[i] ?? "";
68
+ if (GROUP_RE.test(line)) {
69
+ if (depth === 0 && STEP_GROUP_RE.test(line))
70
+ return i;
71
+ depth++;
72
+ }
73
+ else if (ENDGROUP_RE.test(line) && depth > 0)
74
+ depth--;
75
+ }
76
+ return -1;
77
+ }
78
+ function boundFallbackExcerpt(lines) {
79
+ const errorIndex = findLogExcerptAnchor(lines);
80
+ if (errorIndex === -1) {
81
+ return truncateLogExcerpt(lines.slice(-LOG_EXCERPT_TAIL_LINES).join("\n"));
82
+ }
83
+ const start = Math.max(0, errorIndex - LOG_EXCERPT_CONTEXT_LINES);
84
+ const excerpt = lines.slice(start, Math.min(lines.length, errorIndex + LOG_EXCERPT_CONTEXT_LINES + 1));
85
+ return truncateAnchoredExcerpt(excerpt, errorIndex - start);
86
+ }
87
+ function findLogExcerptAnchor(lines) {
88
+ const explicitError = lines.findIndex((line) => line.includes("##[error]"));
89
+ if (explicitError !== -1)
90
+ return explicitError;
91
+ return lines.findIndex((line) => /\b(error|failed|cancelled)\b/i.test(line));
92
+ }
93
+ function buildAggregateJobResultsExcerpt(lines) {
94
+ const jobResults = extractJobResults(lines);
95
+ if (jobResults === undefined)
96
+ return undefined;
97
+ const failed = Object.entries(jobResults)
98
+ .map(([name, value]) => ({ name, result: extractJobResult(value) }))
99
+ .filter((entry) => entry.result !== undefined && !["success", "skipped"].includes(entry.result));
100
+ if (failed.length === 0)
101
+ return undefined;
102
+ return truncateLogExcerpt([
103
+ ...lines.filter((line) => /required jobs failed|exit code \d+/i.test(line)),
104
+ "Job results (non-success):",
105
+ ...failed.map((entry) => `${entry.name}: ${entry.result}`),
106
+ ].join("\n"));
107
+ }
108
+ function extractJobResults(lines) {
109
+ const startIndex = lines.findIndex((line) => line.includes("Job results:"));
110
+ if (startIndex === -1)
111
+ return undefined;
112
+ const block = collectJsonBlock(lines, startIndex);
113
+ if (block === undefined)
114
+ return undefined;
115
+ try {
116
+ const parsed = JSON.parse(block);
117
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
118
+ ? parsed
119
+ : undefined;
120
+ }
121
+ catch {
122
+ return undefined;
123
+ }
124
+ }
125
+ function collectJsonBlock(lines, startIndex) {
126
+ const startLine = lines[startIndex] ?? "";
127
+ const objectStart = startLine.indexOf("{");
128
+ if (objectStart === -1)
129
+ return undefined;
130
+ const collected = [startLine.slice(objectStart)];
131
+ let depth = braceDepth(collected[0]);
132
+ for (let i = startIndex + 1; i < lines.length && depth > 0; i++) {
133
+ const line = lines[i] ?? "";
134
+ collected.push(line);
135
+ depth += braceDepth(line);
136
+ }
137
+ return depth === 0 ? collected.join("\n") : undefined;
138
+ }
139
+ function braceDepth(line) {
140
+ return [...line].reduce((depth, ch) => {
141
+ if (ch === "{")
142
+ return depth + 1;
143
+ if (ch === "}")
144
+ return depth - 1;
145
+ return depth;
146
+ }, 0);
147
+ }
148
+ function extractJobResult(value) {
149
+ if (value === null || typeof value !== "object" || Array.isArray(value))
150
+ return undefined;
151
+ const result = value.result;
152
+ return typeof result === "string" ? result : undefined;
153
+ }
154
+ function truncateLogExcerpt(text) {
155
+ if (text.length <= LOG_EXCERPT_MAX_CHARS)
156
+ return text;
157
+ return `${text.slice(0, LOG_EXCERPT_MAX_CHARS - `\n${TRUNCATED_MARK}`.length).trimEnd()}\n${TRUNCATED_MARK}`;
158
+ }
159
+ function truncateAnchoredExcerpt(lines, anchorIndex) {
160
+ const text = lines.join("\n");
161
+ if (text.length <= LOG_EXCERPT_MAX_CHARS)
162
+ return text;
163
+ return truncateLogExcerpt(`${TRUNCATED_MARK}\n${lines.slice(anchorIndex).join("\n")}`);
164
+ }
165
+ function truncateTail(text, keep) {
166
+ if (text.length <= LOG_EXCERPT_MAX_CHARS)
167
+ return text;
168
+ const head = `${TRUNCATED_MARK}\n`;
169
+ const slice = text.slice(-(LOG_EXCERPT_MAX_CHARS - head.length));
170
+ const nl = slice.indexOf("\n");
171
+ const tail = `${head}${nl === -1 ? slice : slice.slice(nl + 1)}`;
172
+ if (!keep || tail.includes(keep))
173
+ return tail;
174
+ const from = text.lastIndexOf(keep);
175
+ return from === -1 ? tail : truncateLogExcerpt(`${head}${text.slice(from)}`);
176
+ }
177
+ function compileIgnoreLogLinePatterns() {
178
+ return loadConfig().checks.ignoreLogLines.map((pattern) => new RegExp(pattern));
179
+ }
180
+ function isNoiseLine(line, patterns) {
181
+ return patterns.some((re) => re.test(line));
182
+ }
183
+ function cleanLogLine(line) {
184
+ return line
185
+ .replace(/^\uFEFF/, "")
186
+ .replace(ANSI_SGR_RE, "")
187
+ .replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s*/, "")
188
+ .trimEnd();
189
+ }
190
+ function stripGroupMarkers(line) {
191
+ return line.replace(/##\[(?:group|endgroup)\]/g, "");
192
+ }
@@ -1,13 +1,8 @@
1
1
  /* eslint-disable max-lines */
2
2
  import { restWithRateLimit, restText } from "../github/http.mjs";
3
3
  import { loadDerived, storeDerived } from "../state/rest-cache.mjs";
4
- import { loadConfig } from "../config/load.mjs";
4
+ import { buildLogExcerpt } from "./log-excerpt.mjs";
5
5
  const STARTUP_FAILURE_STATUS = "startup_failure";
6
- const LOG_EXCERPT_CONTEXT_LINES = 16;
7
- const LOG_EXCERPT_TAIL_LINES = 28;
8
- const LOG_EXCERPT_MAX_CHARS = 4_000;
9
- const TRUNCATED_SUFFIX = "\n[truncated]";
10
- const ANSI_SGR_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
11
6
  export function triageFailingChecks(failingChecks, repo, stateKey) {
12
7
  const jobsCache = new Map();
13
8
  return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache, stateKey)));
@@ -163,7 +158,7 @@ function pickJobInfo(jobs, checkName) {
163
158
  * never gets frozen into the cache.
164
159
  */
165
160
  async function fetchJobLogExcerpt(jobId, repo, stateKey, cacheable = false) {
166
- const cacheName = `joblog-${jobId}`;
161
+ const cacheName = `joblog-v2-${jobId}`;
167
162
  if (stateKey && cacheable) {
168
163
  const cached = await loadDerived(stateKey, cacheName);
169
164
  if (cached)
@@ -181,117 +176,3 @@ async function fetchJobLogExcerpt(jobId, repo, stateKey, cacheable = false) {
181
176
  return undefined;
182
177
  }
183
178
  }
184
- function buildLogExcerpt(raw) {
185
- const ignorePatterns = compileIgnoreLogLinePatterns();
186
- const lines = raw
187
- .split(/\r?\n/)
188
- .map(cleanLogLine)
189
- .filter((line) => line.trim() !== "" && !isNoiseLine(line, ignorePatterns));
190
- if (lines.length === 0)
191
- return undefined;
192
- const aggregateExcerpt = buildAggregateJobResultsExcerpt(lines);
193
- if (aggregateExcerpt !== undefined)
194
- return aggregateExcerpt;
195
- const errorIndex = findLogExcerptAnchor(lines);
196
- if (errorIndex === -1)
197
- return truncateLogExcerpt(lines.slice(-LOG_EXCERPT_TAIL_LINES).join("\n"));
198
- const start = Math.max(0, errorIndex - LOG_EXCERPT_CONTEXT_LINES);
199
- const excerpt = lines.slice(start, Math.min(lines.length, errorIndex + LOG_EXCERPT_CONTEXT_LINES + 1));
200
- return truncateAnchoredExcerpt(excerpt, errorIndex - start);
201
- }
202
- function findLogExcerptAnchor(lines) {
203
- const explicitError = lines.findIndex((line) => line.includes("##[error]"));
204
- if (explicitError !== -1)
205
- return explicitError;
206
- return lines.findIndex((line) => /\b(error|failed|cancelled)\b/i.test(line));
207
- }
208
- function buildAggregateJobResultsExcerpt(lines) {
209
- const jobResults = extractJobResults(lines);
210
- if (jobResults === undefined)
211
- return undefined;
212
- const failed = Object.entries(jobResults)
213
- .map(([name, value]) => ({ name, result: extractJobResult(value) }))
214
- .filter((entry) => entry.result !== undefined && !["success", "skipped"].includes(entry.result));
215
- if (failed.length === 0)
216
- return undefined;
217
- const output = [
218
- ...lines.filter((line) => /required jobs failed|exit code \d+/i.test(line)),
219
- "Job results (non-success):",
220
- ...failed.map((entry) => `${entry.name}: ${entry.result}`),
221
- ];
222
- return truncateLogExcerpt(output.join("\n"));
223
- }
224
- function extractJobResults(lines) {
225
- const startIndex = lines.findIndex((line) => line.includes("Job results:"));
226
- if (startIndex === -1)
227
- return undefined;
228
- const block = collectJsonBlock(lines, startIndex);
229
- if (block === undefined)
230
- return undefined;
231
- try {
232
- const parsed = JSON.parse(block);
233
- return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
234
- ? parsed
235
- : undefined;
236
- }
237
- catch {
238
- return undefined;
239
- }
240
- }
241
- function collectJsonBlock(lines, startIndex) {
242
- const startLine = lines[startIndex] ?? "";
243
- const objectStart = startLine.indexOf("{");
244
- if (objectStart === -1)
245
- return undefined;
246
- const collected = [startLine.slice(objectStart)];
247
- let depth = braceDepth(collected[0]);
248
- for (let i = startIndex + 1; i < lines.length && depth > 0; i++) {
249
- const line = lines[i] ?? "";
250
- collected.push(line);
251
- depth += braceDepth(line);
252
- }
253
- return depth === 0 ? collected.join("\n") : undefined;
254
- }
255
- function braceDepth(line) {
256
- return [...line].reduce((depth, ch) => {
257
- if (ch === "{")
258
- return depth + 1;
259
- if (ch === "}")
260
- return depth - 1;
261
- return depth;
262
- }, 0);
263
- }
264
- function extractJobResult(value) {
265
- if (value === null || typeof value !== "object" || Array.isArray(value))
266
- return undefined;
267
- const result = value.result;
268
- return typeof result === "string" ? result : undefined;
269
- }
270
- function truncateLogExcerpt(text) {
271
- if (text.length <= LOG_EXCERPT_MAX_CHARS)
272
- return text;
273
- return `${text.slice(0, LOG_EXCERPT_MAX_CHARS - TRUNCATED_SUFFIX.length).trimEnd()}${TRUNCATED_SUFFIX}`;
274
- }
275
- function truncateAnchoredExcerpt(lines, anchorIndex) {
276
- const text = lines.join("\n");
277
- if (text.length <= LOG_EXCERPT_MAX_CHARS)
278
- return text;
279
- return truncateLogExcerpt(`${TRUNCATED_SUFFIX.trim()}\n${lines.slice(anchorIndex).join("\n")}`);
280
- }
281
- // User-configured via `checks.ignoreLogLines` (regex source strings) — empty by
282
- // default. What counts as noise varies by CI toolchain, so Shepherd ships no
283
- // built-in patterns; a project opts in via `.pr-shepherdrc.yml`.
284
- function compileIgnoreLogLinePatterns() {
285
- return loadConfig().checks.ignoreLogLines.map((pattern) => new RegExp(pattern));
286
- }
287
- function isNoiseLine(line, patterns) {
288
- return patterns.some((re) => re.test(line));
289
- }
290
- function cleanLogLine(line) {
291
- return line
292
- .replace(/^\uFEFF/, "")
293
- .replace(ANSI_SGR_RE, "")
294
- .replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s*/, "")
295
- .replace(/##\[(?:group|endgroup)\]/g, "")
296
- .trimEnd();
297
- }
@@ -183,9 +183,6 @@ export function projectIterateLean(result, opts) {
183
183
  ...(result.escalate.mergeQueueRemoval && {
184
184
  mergeQueueRemoval: result.escalate.mergeQueueRemoval,
185
185
  }),
186
- ...(result.escalate.stack && {
187
- stack: result.escalate.stack,
188
- }),
189
186
  ...(result.escalate.authorization &&
190
187
  result.escalate.authorization.length > 0 && {
191
188
  authorization: result.escalate.authorization,
@@ -12,13 +12,12 @@ function pollSummaryExitCode(result) {
12
12
  if (result.nextAction) {
13
13
  const stackExitCode = {
14
14
  escalate: EXIT.ESCALATE,
15
- fix_code: EXIT.FIX_CODE,
15
+ shepherd: EXIT.SHEPHERD,
16
16
  merge: EXIT.MERGE,
17
- mark_ready: EXIT.MARK_READY,
18
17
  wait: EXIT.WAIT,
19
18
  cancel: EXIT.OK,
20
19
  };
21
- return stackExitCode[result.nextAction] ?? EXIT.OK;
20
+ return stackExitCode[result.nextAction];
22
21
  }
23
22
  const actions = new Set(result.prs.map((item) => item.action));
24
23
  if (actions.has("escalate"))
@@ -7,7 +7,7 @@ export function formatPollSummaryResult(result) {
7
7
  const lines = [
8
8
  `# Poll summary [${result.reason.toUpperCase()}]`,
9
9
  "",
10
- `**repo** \`${result.repo}\` · **selection** ${selection} · **mode** \`${result.mode}\`${result.nextAction ? ` · **next action** \`${result.nextAction}\`` : ""}`,
10
+ `**repo** \`${result.repo}\` · **selection** ${selection} · **mode** \`${result.mode}\`${result.stackMergeable !== undefined ? ` · **stack mergeable** \`${result.stackMergeable}\`` : ""}${result.nextAction ? ` · **next action** \`${result.nextAction}\`` : ""}`,
11
11
  "",
12
12
  "## Pull requests",
13
13
  "",
@@ -42,11 +42,13 @@ function formatItem(item) {
42
42
  ? " · blocking reviewer `in progress`"
43
43
  : "";
44
44
  const readyDelay = item.remainingSeconds !== undefined ? ` · ready delay \`${item.remainingSeconds}s\`` : "";
45
+ const readyReceipt = item.readyReceipt ? " · Shepherd READY completion `verified`" : "";
46
+ const blockedBy = item.blockedByPr ? ` · stack blocked by PR #${item.blockedByPr}` : "";
45
47
  const checks = item.checks;
46
48
  const review = item.review;
47
49
  return [
48
50
  `- [PR #${item.pr}: ${escapeMarkdownText(item.title)}](${item.url}) [${item.action.toUpperCase()}]`,
49
- ` - state \`${item.state}\` · mergeable \`${item.mergeable}\` · merge \`${item.mergeStateStatus}\`${reviewDecision}${stateFlags}${blockingReviewer}${readyDelay}${stack}`,
51
+ ` - state \`${item.state}\` · mergeable \`${item.mergeable}\` · merge \`${item.mergeStateStatus}\`${reviewDecision}${stateFlags}${blockingReviewer}${readyDelay}${readyReceipt}${blockedBy}${stack}`,
50
52
  ` - head \`${item.headRefName}\` at \`${item.headRefOid}\` · base \`${item.baseRefName}\``,
51
53
  ...(checks
52
54
  ? [` - checks: ${formatCounts(checks, checks.incomplete ? ", incomplete" : "")}`]
@@ -54,6 +56,11 @@ function formatItem(item) {
54
56
  ...(review
55
57
  ? [` - review: ${formatCounts(review, review.incomplete ? ", incomplete" : "")}`]
56
58
  : []),
59
+ ...(item.queueRemoval
60
+ ? [
61
+ ` - queue removal: reason \`${item.queueRemoval.reason ?? "UNKNOWN"}\` · at \`${item.queueRemoval.createdAtUnix}\`${item.queueRemoval.actor ? ` · actor \`@${item.queueRemoval.actor}\`` : ""}${item.queueRemoval.beforeCommitOid ? ` · commit \`${item.queueRemoval.beforeCommitOid}\`` : ""}${item.queueRemoval.beforeCommitParentOids?.length ? ` · parents \`${item.queueRemoval.beforeCommitParentOids.join(",")}\`` : ""}`,
62
+ ]
63
+ : []),
57
64
  ` - reasons: ${item.reasons.map((reason) => `\`${reason}\``).join(", ")}`,
58
65
  ...(item.pollCommand ? [` - pollCommand: \`${item.pollCommand}\``] : []),
59
66
  ].join("\n");
@@ -249,6 +249,7 @@ export async function runCheck(opts) {
249
249
  ...(batchData.viewerAuthorization && { viewerAuthorization: batchData.viewerAuthorization }),
250
250
  status,
251
251
  baseBranch: batchData.baseRefName,
252
+ ...(batchData.baseRefOid && { baseRefOid: batchData.baseRefOid }),
252
253
  mergeStatus,
253
254
  checks: {
254
255
  passing: merged.passing,
@@ -1,5 +1,4 @@
1
1
  import { loadConfig } from "../../config/load.mjs";
2
- import { inlineCode } from "../../util/markdown.mjs";
3
2
  import { renderResolveCommand } from "./render.mjs";
4
3
  function renderEscalateAuthor(item) {
5
4
  return [`@${item.author}`, item.authorType, item.authorAssociation].filter(Boolean).join(" · ");
@@ -194,10 +193,6 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
194
193
  if (removal.beforeCommitOid)
195
194
  lines.push(`- queue commit: \`${removal.beforeCommitOid}\``);
196
195
  }
197
- if (escalate.stack) {
198
- const s = escalate.stack;
199
- lines.push("", "## GitHub stack", "", `- layer: \`${s.position}\` of \`${s.size}\` in stack \`${s.number}\``, `- stack base: ${inlineCode(s.baseRefName)}`);
200
- }
201
196
  if (escalate.authorization && escalate.authorization.length > 0) {
202
197
  lines.push("");
203
198
  lines.push("## Authorization");
@@ -231,10 +226,6 @@ export function buildEscalateHumanMessage(escalate, pr, opts) {
231
226
  return lines.join("\n");
232
227
  }
233
228
  export function buildEscalateSuggestion(triggers, detail) {
234
- if (triggers.includes("stacked-pr")) {
235
- const selector = detail ?? "<pr>";
236
- return `This PR belongs to a GitHub stack, so Shepherd will not emit a merge command. \`gh pr merge\` targets the PR's own base branch — for a mid-stack layer that is the unmerged parent branch, not the stack's base — and auto-merge is unsupported on stacked PRs. Merge from the GitHub stack UI, or run \`gh stack merge --squash ${selector}\` (requires the \`github/gh-stack\` extension — run \`gh extension install github/gh-stack\` first if it's not installed), which lands this PR and every unmerged layer below it.`;
237
- }
238
229
  if (triggers.includes("check-follow-up-unavailable")) {
239
230
  return "One or more failing checks have no autonomous follow-up available. Use the displayed conclusion, run or URL, and included evidence to handle them manually.";
240
231
  }
@@ -257,9 +248,5 @@ export function buildEscalateSuggestion(triggers, detail) {
257
248
  const attempts = loadConfig().iterate.fixAttemptsPerThread;
258
249
  return `The same thread(s) remain unresolved after their pending review commands were returned for ${attempts} FIX_CODE ticks. Automated iteration is paused for a manual decision.`;
259
250
  }
260
- if (triggers.includes("bot-cr-not-dismissed")) {
261
- const ids = detail ? ` (review IDs: ${detail})` : "";
262
- return `Bot CHANGES_REQUESTED review(s) remained undismissed past the stall window${ids}. The agent likely dropped \`--dismiss-review-ids\` from a prior apply command. Dismiss the review(s) manually (or re-run \`pr-shepherd apply review\` with the IDs) to unblock the PR.`;
263
- }
264
251
  return "Ambiguous state — automated handling cannot proceed safely. Inspect the PR and act manually.";
265
252
  }
@@ -20,6 +20,8 @@ interface HandleFixCodeContext {
20
20
  surfacedApprovals: Review[];
21
21
  botUsernames: NormalizedBotUsernames;
22
22
  ruleAutoResolveThreadIds?: string[];
23
+ /** Verified stack-repair guidance, when ancestry is stale. */
24
+ repairInstructions?: string[];
23
25
  }
24
26
  export declare function handleFixCode(ctx: HandleFixCodeContext): Promise<IterateResult>;
25
27
  export {};
@@ -1,6 +1,5 @@
1
1
  /* eslint-disable max-lines */
2
2
  import { readFixAttempts, writeFixAttempts, } from "../../state/fix-attempts.mjs";
3
- import { readBotCrSeenState, writeBotCrSeenState, updateBotCrSeenState, } from "../../state/bot-cr-seen.mjs";
4
3
  import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
5
4
  import { hashBody, markSeen } from "../../state/seen-comments.mjs";
6
5
  import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
@@ -17,15 +16,15 @@ import { formatPrUrl } from "../../pr-reference.mjs";
17
16
  function checkRequiresHumanFollowUp(check) {
18
17
  if (check.rerunCommand)
19
18
  return false;
20
- // Once GitHub advances beyond the original attempt, Shepherd's one autonomous rerun has
21
- // already been consumed. Hand the repeated failure off even when logs are available; the
22
- // human still receives that evidence in the escalation payload.
23
- if (check.runAttempt !== undefined && check.runAttempt > 1)
24
- return true;
25
19
  if (check.conclusion === "ACTION_REQUIRED" ||
26
20
  check.conclusion === "CANCELLED" ||
27
21
  check.conclusion === "STARTUP_FAILURE")
28
22
  return true;
23
+ // A later attempt cannot be rerun automatically, but its included log can still
24
+ // identify a code or configuration fix for the agent. Only a later failure with
25
+ // no actionable evidence needs a human handoff.
26
+ if (check.runAttempt !== undefined && check.runAttempt > 1)
27
+ return !check.logExcerpt?.trim();
29
28
  // An external check's direct URL is actionable evidence: the agent can inspect the
30
29
  // provider and/or reproduce the reported failure locally. Only a truly bare check
31
30
  // has no autonomous investigation path.
@@ -68,7 +67,7 @@ function pendingReviewCommands(resolveCommand, resolveOnlyCommand) {
68
67
  return Object.keys(pending).length > 0 ? pending : undefined;
69
68
  }
70
69
  export async function handleFixCode(ctx) {
71
- const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, } = ctx;
70
+ const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
72
71
  const prReference = formatPrUrl(report.repo, prNumber);
73
72
  const failingChecks = report.checks.failing;
74
73
  const annotatedExtra = checksWithActionableAnnotations(report).filter((c) => c.category !== "failing");
@@ -105,37 +104,6 @@ export async function handleFixCode(ctx) {
105
104
  ...(report.comments.minimizeIds ?? report.comments.actionable.map((comment) => comment.id)),
106
105
  ...reviewSummaryIds,
107
106
  ], changesRequestedReviewsForWork, checks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads, resolveOtherHumanThreads);
108
- const botCrReviews = report.changesRequestedReviews.filter((r) => (!isHumanAuthor(r) || isConfiguredBotAuthor(r, botUsernames)) &&
109
- report.viewerAuthorization?.viewerCanAdminister === true);
110
- const botCrStateKey = { owner: repoOwner, repo: repoName, pr: prNumber };
111
- const previousBotCrState = await readBotCrSeenState(botCrStateKey);
112
- const nowSeconds = Math.floor(Date.now() / 1000);
113
- const { next: nextBotCrState, staleIds: staleBotCrIds } = updateBotCrSeenState(previousBotCrState, botCrReviews, nowSeconds, stallTimeoutSeconds);
114
- await writeBotCrSeenState(botCrStateKey, nextBotCrState);
115
- if (staleBotCrIds.length > 0) {
116
- const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(toAgentChecks(failingChecks));
117
- const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
118
- const escalateBase = {
119
- triggers: ["bot-cr-not-dismissed"],
120
- unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
121
- ambiguousComments: report.comments.actionable.map(toAgentComment),
122
- changesRequestedReviews: report.changesRequestedReviews,
123
- ...(firstLookSummaries.length > 0 && { firstLookSummaries }),
124
- ...(editedSummaries.length > 0 && { editedSummaries }),
125
- ...(pending && { pendingReviewCommands: pending }),
126
- suggestion: buildEscalateSuggestion(["bot-cr-not-dismissed"], staleBotCrIds.join(", ")),
127
- };
128
- return {
129
- ...base,
130
- action: "escalate",
131
- escalate: {
132
- ...escalateBase,
133
- humanMessage: buildEscalateHumanMessage(escalateBase, prReference, {
134
- merge: opts.merge,
135
- }),
136
- },
137
- };
138
- }
139
107
  const escalateTriggers = countFixCodeAttempt
140
108
  ? checkEscalateTriggers(retryableActionableThreads, priorThreadAttempts)
141
109
  : { triggers: [], thrashHistory: undefined };
@@ -216,8 +184,9 @@ export async function handleFixCode(ctx) {
216
184
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
217
185
  const belongsToActiveWorkflowRun = (check) => check.runId !== null && inProgressWorkflowRunIds.has(check.runId);
218
186
  const manualFollowUpChecks = failingAgentChecks.filter((check) => !belongsToActiveWorkflowRun(check) && checkRequiresHumanFollowUp(check));
219
- const exhaustedAttempts = manualFollowUpChecks.filter((check) => check.runAttempt !== undefined && check.runAttempt > 1);
220
- const hasBehindBaseRecovery = isBehind && exhaustedAttempts.length > 0;
187
+ const exhaustedAttempts = failingAgentChecks.filter((check) => check.runAttempt !== undefined && check.runAttempt > 1);
188
+ const manualExhaustedAttempts = exhaustedAttempts.filter((check) => manualFollowUpChecks.includes(check));
189
+ const hasBehindBaseRecovery = isBehind && manualExhaustedAttempts.length > 0;
221
190
  const hasAutonomousWork = hasConflicts ||
222
191
  hasBehindBaseRecovery ||
223
192
  threads.length > 0 ||
@@ -235,8 +204,8 @@ export async function handleFixCode(ctx) {
235
204
  if (manualFollowUpChecks.length > 0 && !hasAutonomousWork) {
236
205
  const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(failingAgentChecks);
237
206
  const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
238
- const checkSuggestion = exhaustedAttempts.length > 0
239
- ? `GitHub reports a later workflow attempt (${exhaustedAttempts
207
+ const checkSuggestion = manualExhaustedAttempts.length > 0
208
+ ? `GitHub reports a later workflow attempt (${manualExhaustedAttempts
240
209
  .map((check) => `${check.runId ?? check.name}: attempt ${check.runAttempt}`)
241
210
  .join(", ")}), so Shepherd's single rerun allowance is exhausted. Use the included evidence to handle the repeated failure manually before resuming.`
242
211
  : buildEscalateSuggestion(["check-follow-up-unavailable"]);
@@ -303,6 +272,9 @@ export async function handleFixCode(ctx) {
303
272
  const firstLookThreads = report.threads.firstLook;
304
273
  const firstLookComments = report.comments.firstLook;
305
274
  const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, exhaustedAttempts.length > 0);
275
+ if (repairInstructions && repairInstructions.length > 0) {
276
+ instructions.unshift(...repairInstructions);
277
+ }
306
278
  const prospectiveResult = {
307
279
  ...base,
308
280
  baseBranch: baseLookup.branch,