pr-shepherd 0.9.0 → 0.10.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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +2 -2
  3. package/bin/checks/triage.mjs +32 -40
  4. package/bin/cli/fence.mjs +4 -0
  5. package/bin/cli/fix-formatter.mjs +21 -24
  6. package/bin/cli/formatters.mjs +33 -51
  7. package/bin/cli/handlers.mjs +1 -1
  8. package/bin/cli/iterate-formatter.mjs +20 -35
  9. package/bin/cli/iterate-lean.mjs +0 -3
  10. package/bin/cli/list-formatters.mjs +32 -0
  11. package/bin/cli/suggestion-renderer.mjs +31 -0
  12. package/bin/cli-parser.iterate-fixtures.mjs +1 -2
  13. package/bin/cli-parser.mjs +27 -3
  14. package/bin/commands/check.mjs +1 -2
  15. package/bin/commands/commit-suggestion.mjs +12 -10
  16. package/bin/commands/iterate/classify.mjs +5 -29
  17. package/bin/commands/iterate/escalate.mjs +3 -12
  18. package/bin/commands/iterate/fix-code.mjs +4 -9
  19. package/bin/commands/iterate/render.mjs +9 -2
  20. package/bin/commands/log-file.mjs +7 -0
  21. package/bin/commands/monitor.mjs +7 -4
  22. package/bin/commands/ready-delay.mjs +2 -2
  23. package/bin/commands/resolve-instructions.mjs +5 -2
  24. package/bin/commands/resolve.mjs +1 -20
  25. package/bin/commands/status.mjs +40 -28
  26. package/bin/comments/resolve.mjs +75 -65
  27. package/bin/config.json +2 -2
  28. package/bin/github/batch-parsers.mjs +2 -0
  29. package/bin/github/client.mjs +11 -32
  30. package/bin/github/gql/batch-pr.gql +4 -0
  31. package/bin/github/gql/get-pr-head-sha.gql +7 -0
  32. package/bin/github/http.mjs +166 -11
  33. package/bin/github/queries.mjs +7 -11
  34. package/bin/log/log-file.mjs +88 -0
  35. package/bin/log/session.mjs +100 -0
  36. package/bin/log/setup.mjs +54 -0
  37. package/bin/reporters/agent.mjs +14 -1
  38. package/bin/reporters/text.mjs +81 -102
  39. package/bin/state/base.mjs +5 -0
  40. package/bin/state/fix-attempts.mjs +2 -2
  41. package/bin/state/iterate-stall.mjs +2 -2
  42. package/bin/state/seen-comments.mjs +2 -2
  43. package/bin/suggestions/extract.mjs +15 -0
  44. package/bin/suggestions/parse.mjs +1 -26
  45. package/bin/util/markdown.mjs +7 -0
  46. package/bin/util/worktree.mjs +23 -0
  47. package/package.json +2 -2
  48. package/bin/github/gql/dismiss-review.gql +0 -7
  49. package/bin/github/gql/minimize-comment.gql +0 -7
  50. package/bin/github/gql/multi-pr-status.gql +0 -32
  51. package/bin/github/gql/resolve-thread.gql +0 -7
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
4
- "version": "0.9.0",
4
+ "version": "0.10.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -40,7 +40,7 @@ Loop args: `4m --max-turns 50 --expires 8h`
40
40
  after completing the actions below. The cron job handles the next fire.
41
41
 
42
42
  Run in a single Bash call:
43
- npx pr-shepherd iterate 123 --no-cache
43
+ npx pr-shepherd iterate 123
44
44
 
45
45
  …(self-dedup guidance, error-handling instructions)…
46
46
 
@@ -231,7 +231,7 @@ actions:
231
231
  autoMarkReady: false # disable to stay draft until you manually promote
232
232
  ```
233
233
 
234
- Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`), `PR_SHEPHERD_STATE_DIR` (override loop-state base dir).
234
+ Environment variables: `GH_TOKEN` / `GITHUB_TOKEN` (auth; falls back to `gh auth token`), `PR_SHEPHERD_STATE_DIR` (override loop-state and log base dir), `PR_SHEPHERD_LOG_DISABLED=1` (disable the per-worktree debug log).
235
235
 
236
236
  See [docs/configuration.md](docs/configuration.md) for full semantics and deprecated-key migration.
237
237
 
@@ -1,44 +1,16 @@
1
- /**
2
- * Triage failing check runs: call the jobs API once per runId to fetch
3
- * workflow name, job name, and the first failed step name. Then fetch the
4
- * last N lines of the failing job's log so the agent can diagnose failures
5
- * without a separate tool call.
6
- *
7
- * No heuristic classification is applied — the raw GitHub `conclusion` field
8
- * is preserved as-is. The agent decides whether a failure is transient or
9
- * real based on the log tail and other context.
10
- *
11
- * For checks with runId === null (external StatusContexts) no API calls are
12
- * made; workflowName, jobName, failedStep, and logTail are all absent.
13
- *
14
- * Jobs responses are cached by runId so checks that share a run (e.g. matrix
15
- * builds or multiple required steps in one workflow) make only one API call.
16
- * Log tails are fetched per job ID, not per runId.
17
- */
18
1
  import { rest, restText } from "../github/http.mjs";
19
- // ---------------------------------------------------------------------------
20
- // Public API
21
- // ---------------------------------------------------------------------------
22
- /**
23
- * Triage each failing check: call the jobs API for each check with a non-null
24
- * runId to fetch workflow name, job name, and the first failed step name, then
25
- * fetch the last `logTailLines` lines of the failing job's log.
26
- */
27
- export function triageFailingChecks(failingChecks, repo, logTailLines) {
2
+ export function triageFailingChecks(failingChecks, repo, logTailLines, logTailChars = 200) {
28
3
  const jobsCache = new Map();
29
- return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache, logTailLines)));
4
+ return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache, logTailLines, logTailChars)));
30
5
  }
31
- // ---------------------------------------------------------------------------
32
- // Internal
33
- // ---------------------------------------------------------------------------
34
- async function triageCheck(check, repo, jobsCache, logTailLines) {
6
+ async function triageCheck(check, repo, jobsCache, logTailLines, logTailChars) {
35
7
  if (check.runId === null) {
36
8
  return { ...check };
37
9
  }
38
10
  const jobs = await fetchJobs(check.runId, repo, jobsCache);
39
11
  const jobInfo = jobs ? pickJobInfo(jobs, check.name) : undefined;
40
12
  const logTail = jobInfo?.jobId !== undefined && logTailLines > 0
41
- ? await fetchLogTail(jobInfo.jobId, repo, logTailLines)
13
+ ? await fetchLogTail(jobInfo.jobId, repo, logTailLines, logTailChars, jobInfo.failedStep)
42
14
  : undefined;
43
15
  return {
44
16
  ...check,
@@ -80,9 +52,6 @@ async function fetchJobsUncached(runId, repo) {
80
52
  return allJobs;
81
53
  }
82
54
  function pickJobInfo(jobs, checkName) {
83
- // Match by name. For matrix jobs sharing a check name, prefer a failing one.
84
- // Fall back to prefix matching for matrix jobs whose workflow-API name includes
85
- // a suffix like "(ubuntu)" while checkName is just the base name.
86
55
  const exactMatches = jobs.filter((j) => j.name === checkName);
87
56
  const matchedJobs = exactMatches.length > 0 ? exactMatches : jobs.filter((j) => j.name.startsWith(checkName));
88
57
  const job = matchedJobs.find((j) => j.conclusion === "failure") ??
@@ -101,16 +70,39 @@ function pickJobInfo(jobs, checkName) {
101
70
  jobId: job.id,
102
71
  };
103
72
  }
104
- async function fetchLogTail(jobId, repo, logTailLines) {
73
+ async function fetchLogTail(jobId, repo, logTailLines, logTailChars, failedStepName) {
105
74
  const { owner, name } = repo;
106
75
  try {
107
76
  const text = await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`);
108
- const lines = text.split("\n");
109
- if (lines.length <= logTailLines)
110
- return text;
111
- return lines.slice(-logTailLines).join("\n");
77
+ const allLines = text.split("\n");
78
+ const stepLines = failedStepName ? extractStepLines(allLines, failedStepName) : null;
79
+ const lines = stepLines ?? allLines;
80
+ const tail = lines.length <= logTailLines ? lines.join("\n") : lines.slice(-logTailLines).join("\n");
81
+ return tail.length <= logTailChars ? tail : tail.slice(-logTailChars);
112
82
  }
113
83
  catch {
114
84
  return undefined;
115
85
  }
116
86
  }
87
+ // Extract the lines inside the ##[group]..##[endgroup] section for the named step.
88
+ // Returns null when no matching group is found so the caller falls back to the full log.
89
+ function extractStepLines(lines, stepName) {
90
+ const lowerStep = stepName.toLowerCase();
91
+ let inStep = false;
92
+ const result = [];
93
+ for (const line of lines) {
94
+ const content = line.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/, "");
95
+ if (!inStep) {
96
+ if (content.startsWith("##[group]") && content.slice(9).toLowerCase().includes(lowerStep)) {
97
+ inStep = true;
98
+ }
99
+ }
100
+ else if (content.startsWith("##[endgroup]")) {
101
+ inStep = false;
102
+ }
103
+ else {
104
+ result.push(line);
105
+ }
106
+ }
107
+ return result.length > 0 ? result : null;
108
+ }
@@ -0,0 +1,4 @@
1
+ export function safeFence(content) {
2
+ const maxRun = Math.max(0, ...Array.from(content.matchAll(/`+/g), (m) => m[0].length));
3
+ return "`".repeat(Math.max(3, maxRun + 1));
4
+ }
@@ -1,23 +1,28 @@
1
1
  import { renderResolveCommand } from "../commands/iterate.mjs";
2
- function safeFence(content) {
3
- const maxRun = Math.max(0, ...Array.from(content.matchAll(/`+/g), (m) => m[0].length));
4
- return "`".repeat(Math.max(3, maxRun + 1));
5
- }
2
+ import { safeFence } from "./fence.mjs";
3
+ import { joinSections } from "../util/markdown.mjs";
4
+ import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mjs";
5
+ import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, } from "./list-formatters.mjs";
6
6
  export function formatFixCodeResult(header, result) {
7
7
  const sections = [header];
8
8
  if (result.fix.threads.length > 0) {
9
9
  sections.push("## Review threads");
10
10
  for (const t of result.fix.threads) {
11
- const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
12
- const heading = t.url ? `[${t.id}](${t.url})` : `\`${t.id}\``;
13
- sections.push(`### ${heading} ${loc} (@${t.author})`);
11
+ const lineLabel = renderLineRange(t.startLine, t.line);
12
+ const loc = t.path ? `\`${t.path}:${lineLabel}\`` : "(no location)";
13
+ const heading = t.url ? `[threadId=${t.id}](${t.url})` : `\`threadId=${t.id}\``;
14
+ const suggestionMarker = t.suggestion ? " [suggestion]" : "";
15
+ sections.push(`### ${heading} — ${loc} (@${t.author})${suggestionMarker}`);
14
16
  sections.push(blockquote(t.body));
17
+ if (t.suggestion) {
18
+ sections.push(renderSuggestionBlock(t.suggestion, ""));
19
+ }
15
20
  }
16
21
  }
17
22
  if (result.fix.actionableComments.length > 0) {
18
23
  sections.push("## Actionable comments");
19
24
  for (const c of result.fix.actionableComments) {
20
- const heading = c.url ? `[${c.id}](${c.url})` : `\`${c.id}\``;
25
+ const heading = c.url ? `[commentId=${c.id}](${c.url})` : `\`commentId=${c.id}\``;
21
26
  sections.push(`### ${heading} (@${c.author})`);
22
27
  sections.push(blockquote(c.body));
23
28
  }
@@ -49,20 +54,16 @@ export function formatFixCodeResult(header, result) {
49
54
  }
50
55
  if (result.fix.changesRequestedReviews.length > 0) {
51
56
  sections.push("## Changes-requested reviews");
52
- sections.push(result.fix.changesRequestedReviews.map((r) => `- \`${r.id}\` (@${r.author})`).join("\n"));
53
- }
54
- if (result.fix.noiseCommentIds.length > 0) {
55
- sections.push("## Noise (minimize only)");
56
- sections.push(result.fix.noiseCommentIds.map((id) => `\`${id}\``).join(", "));
57
+ sections.push(result.fix.changesRequestedReviews.map((r) => renderReviewBullet(r)).join("\n"));
57
58
  }
58
59
  if (result.fix.reviewSummaryIds.length > 0) {
59
60
  sections.push("## Review summaries (minimize only)");
60
- sections.push(result.fix.reviewSummaryIds.map((id) => `\`${id}\``).join(", "));
61
+ sections.push(result.fix.reviewSummaryIds.map((id) => `- \`${id}\``).join("\n"));
61
62
  }
62
63
  if (result.fix.surfacedApprovals.length > 0) {
63
64
  sections.push("## Approvals (surfaced — not minimized)");
64
65
  for (const r of result.fix.surfacedApprovals) {
65
- sections.push(`### \`${r.id}\` (@${r.author})`);
66
+ sections.push(`### \`reviewId=${r.id}\` (@${r.author})`);
66
67
  sections.push(r.body.trim() === "" ? "(no review body)" : blockquote(r.body));
67
68
  }
68
69
  }
@@ -71,20 +72,16 @@ export function formatFixCodeResult(header, result) {
71
72
  sections.push(`## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`);
72
73
  const bullets = [];
73
74
  for (const t of result.fix.firstLookThreads) {
74
- const statusTag = t.autoResolved
75
- ? `[status: outdated, auto-resolved]`
76
- : `[status: ${t.firstLookStatus}]`;
77
- const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
78
- bullets.push(`- \`${t.id}\` ${loc} (@${t.author}) ${statusTag}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
75
+ bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
79
76
  }
80
77
  for (const c of result.fix.firstLookComments) {
81
- bullets.push(`- \`${c.id}\` (@${c.author}) [status: minimized]: ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
78
+ bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
82
79
  }
83
80
  sections.push(bullets.join("\n"));
84
81
  }
85
82
  if (result.cancelled.length > 0) {
86
83
  sections.push("## Cancelled runs");
87
- sections.push(result.cancelled.map((id) => `\`${id}\``).join(", "));
84
+ sections.push(result.cancelled.map((id) => `- \`${id}\``).join("\n"));
88
85
  }
89
86
  sections.push("## Post-fix push");
90
87
  const postFixLines = [`- base: \`${result.baseBranch}\``];
@@ -94,9 +91,9 @@ export function formatFixCodeResult(header, result) {
94
91
  sections.push(postFixLines.join("\n"));
95
92
  sections.push("## Instructions");
96
93
  sections.push(result.fix.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
97
- return sections.join("\n\n");
94
+ return joinSections(sections);
98
95
  }
99
- export function blockquote(body) {
96
+ function blockquote(body) {
100
97
  return body
101
98
  .replace(/\r\n/g, "\n")
102
99
  .split("\n")
@@ -1,5 +1,8 @@
1
1
  export { formatIterateResult } from "./iterate-formatter.mjs";
2
2
  export { projectIterateLean } from "./iterate-lean.mjs";
3
+ import { safeFence } from "./fence.mjs";
4
+ import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, } from "./list-formatters.mjs";
5
+ import { joinSections } from "../util/markdown.mjs";
3
6
  export function formatFetchResult(result) {
4
7
  const activeTotal = result.actionableThreads.length +
5
8
  result.actionableComments.length +
@@ -19,44 +22,29 @@ export function formatFetchResult(result) {
19
22
  sections.push(`## Actionable Review Threads (${result.actionableThreads.length})` +
20
23
  (result.commitSuggestionsEnabled ? " [commit-suggestions: enabled]" : ""));
21
24
  sections.push(result.actionableThreads
22
- .map((t) => {
23
- const suggestionMarker = t.suggestion ? " [suggestion]" : "";
24
- const link = t.url ? ` [↗](${t.url})` : "";
25
- return `- \`threadId=${t.id}\`${link} \`${t.path ?? ""}:${t.line ?? "?"}\` (@${t.author})${suggestionMarker}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`;
26
- })
27
- .join("\n"));
25
+ .map((t) => renderThreadBullet(t, { renderSuggestion: true }))
26
+ .join("\n\n"));
28
27
  }
29
28
  if (result.actionableComments.length > 0) {
30
29
  sections.push(`## Actionable PR Comments (${result.actionableComments.length})`);
31
- sections.push(result.actionableComments
32
- .map((c) => {
33
- const link = c.url ? ` [↗](${c.url})` : "";
34
- return `- \`commentId=${c.id}\`${link} (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`;
35
- })
36
- .join("\n"));
30
+ sections.push(result.actionableComments.map((c) => renderCommentBullet(c)).join("\n"));
37
31
  }
38
32
  if (result.changesRequestedReviews.length > 0) {
39
33
  sections.push(`## Pending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length})`);
40
- sections.push(result.changesRequestedReviews.map((r) => `- \`reviewId=${r.id}\` (@${r.author})`).join("\n"));
34
+ sections.push(result.changesRequestedReviews.map((r) => renderReviewBullet(r)).join("\n"));
41
35
  }
42
36
  if (result.reviewSummaries.length > 0) {
43
37
  sections.push(`## Review summaries (${result.reviewSummaries.length})`);
44
- sections.push(result.reviewSummaries
45
- .map((r) => `- \`reviewId=${r.id}\` (@${r.author}): ${r.body.split("\n")[0].slice(0, 100)}`)
46
- .join("\n"));
38
+ sections.push(result.reviewSummaries.map((r) => renderReviewBullet(r, { includeBody: true })).join("\n"));
47
39
  }
48
40
  if (firstLookTotal > 0) {
49
41
  sections.push(`## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`);
50
42
  const bullets = [];
51
43
  for (const t of result.firstLookThreads) {
52
- const statusTag = t.autoResolved
53
- ? `[status: outdated, auto-resolved]`
54
- : `[status: ${t.firstLookStatus}]`;
55
- const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
56
- bullets.push(`- \`threadId=${t.id}\` ${loc} (@${t.author}) ${statusTag}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
44
+ bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
57
45
  }
58
46
  for (const c of result.firstLookComments) {
59
- bullets.push(`- \`commentId=${c.id}\` (@${c.author}) [status: minimized]: ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
47
+ bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
60
48
  }
61
49
  sections.push(bullets.join("\n"));
62
50
  }
@@ -71,14 +59,21 @@ export function formatFetchResult(result) {
71
59
  .join(", "));
72
60
  sections.push("## Instructions");
73
61
  sections.push(result.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
74
- return `${sections.join("\n\n")}\n`;
62
+ return joinSections(sections);
75
63
  }
76
64
  export function formatCommitSuggestionResult(result) {
77
65
  const lines = [];
66
+ const range = result.startLine === result.endLine
67
+ ? `line ${result.startLine}`
68
+ : `lines ${result.startLine}–${result.endLine}`;
69
+ function pushPatch(patch) {
70
+ const fence = safeFence(patch);
71
+ lines.push("");
72
+ lines.push(`${fence}diff`);
73
+ lines.push(patch.trimEnd());
74
+ lines.push(fence);
75
+ }
78
76
  if (result.dryRun) {
79
- const range = result.startLine === result.endLine
80
- ? `line ${result.startLine}`
81
- : `lines ${result.startLine}-${result.endLine}`;
82
77
  if (result.valid) {
83
78
  lines.push(`Dry-run: would apply suggestion from @${result.author}:`);
84
79
  lines.push(` ${result.path} (${range})`);
@@ -89,45 +84,32 @@ export function formatCommitSuggestionResult(result) {
89
84
  lines.push(`- author: @${result.author}`);
90
85
  lines.push(`- reason: ${result.reason ?? "unknown"}`);
91
86
  }
92
- if (result.patch) {
93
- lines.push("");
94
- lines.push("```diff");
95
- lines.push(result.patch.trimEnd());
96
- lines.push("```");
97
- }
87
+ if (result.patch)
88
+ pushPatch(result.patch);
98
89
  }
99
90
  else if (result.applied) {
100
91
  lines.push(`Applied suggestion from @${result.author}:`);
101
- const range = result.startLine === result.endLine
102
- ? `line ${result.startLine}`
103
- : `lines ${result.startLine}-${result.endLine}`;
104
92
  lines.push(` ${result.path} (${range})`);
105
93
  if (result.commitSha)
106
94
  lines.push(`Commit: ${result.commitSha}`);
107
- if (result.patch) {
108
- lines.push("");
109
- lines.push("```diff");
110
- lines.push(result.patch.trimEnd());
111
- lines.push("```");
112
- }
95
+ if (result.patch)
96
+ pushPatch(result.patch);
113
97
  }
114
98
  else {
115
99
  lines.push(`Failed to apply suggestion ${result.threadId}:`);
116
- lines.push(`- path: ${result.path} (lines ${result.startLine}–${result.endLine})`);
100
+ lines.push(`- path: ${result.path} (${range})`);
117
101
  lines.push(`- author: @${result.author}`);
118
102
  lines.push(`- reason: ${result.reason ?? "unknown"}`);
119
- if (result.patch) {
120
- lines.push("");
121
- lines.push("```diff");
122
- lines.push(result.patch.trimEnd());
123
- lines.push("```");
124
- }
103
+ if (result.patch)
104
+ pushPatch(result.patch);
125
105
  }
126
106
  if (result.postActionInstruction) {
127
107
  lines.push("");
128
- lines.push(result.postActionInstruction);
108
+ lines.push("## Instructions");
109
+ lines.push("");
110
+ lines.push(`1. ${result.postActionInstruction}`);
129
111
  }
130
- return `${lines.join("\n")}\n`;
112
+ return lines.join("\n");
131
113
  }
132
114
  export function formatMutateResult(result) {
133
115
  const lines = [];
@@ -139,5 +121,5 @@ export function formatMutateResult(result) {
139
121
  lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
140
122
  if (result.errors.length)
141
123
  lines.push(`Errors:\n ${result.errors.join("\n ")}`);
142
- return `${lines.join("\n")}\n`;
124
+ return lines.join("\n");
143
125
  }
@@ -34,7 +34,7 @@ export async function handleCommitSuggestion(args) {
34
34
  });
35
35
  process.stdout.write(globalOpts.format === "json"
36
36
  ? `${JSON.stringify(result, null, 2)}\n`
37
- : formatCommitSuggestionResult(result));
37
+ : `${formatCommitSuggestionResult(result)}\n`);
38
38
  if (result.dryRun) {
39
39
  process.exitCode = result.valid ? 0 : 1;
40
40
  }
@@ -1,4 +1,5 @@
1
1
  import { formatFixCodeResult } from "./fix-formatter.mjs";
2
+ import { joinSections } from "../util/markdown.mjs";
2
3
  /**
3
4
  * Format an IterateResult as human-readable Markdown.
4
5
  *
@@ -48,51 +49,35 @@ export function formatIterateResult(result, opts) {
48
49
  case "cooldown":
49
50
  // In default mode: suppress base/summary lines — cooldown carries UNKNOWN/empty
50
51
  // placeholders that add no value. Emit only heading + log + Instructions.
51
- return [
52
+ return joinSections([
52
53
  verbose ? header : heading,
53
- "",
54
54
  result.log,
55
- "",
56
- "## Instructions",
57
- "",
58
- "1. End this iteration — the next cron fire will recheck once CI starts reporting.",
59
- ].join("\n");
60
- case "wait": {
61
- const parts = [
55
+ "## Instructions\n\n1. End this iteration — the next cron fire will recheck once CI starts reporting.",
56
+ ]);
57
+ case "wait":
58
+ return joinSections([
62
59
  header,
63
60
  result.log,
64
- "## Instructions",
65
- "1. End this iteration — the next cron fire will recheck.",
66
- ];
67
- return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
68
- }
69
- case "mark_ready": {
70
- const parts = [
61
+ "## Instructions\n\n1. End this iteration — the next cron fire will recheck.",
62
+ ]);
63
+ case "mark_ready":
64
+ return joinSections([
71
65
  header,
72
66
  result.log,
73
- "## Instructions",
74
- "1. The CLI already marked the PR ready for review — end this iteration.",
75
- ];
76
- return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
77
- }
78
- case "cancel": {
79
- const parts = [
67
+ "## Instructions\n\n1. The CLI already marked the PR ready for review — end this iteration.",
68
+ ]);
69
+ case "cancel":
70
+ return joinSections([
80
71
  [`${heading} — ${result.reason}`, "", baseLine, summaryLine].join("\n"),
81
72
  result.log,
82
- "## Instructions",
83
- "1. Invoke `/loop cancel` via the Skill tool.\n2. Stop.",
84
- ];
85
- return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
86
- }
87
- case "escalate": {
88
- const parts = [
73
+ "## Instructions\n\n1. Invoke `/loop cancel` via the Skill tool.\n2. Stop.",
74
+ ]);
75
+ case "escalate":
76
+ return joinSections([
89
77
  header,
90
78
  result.escalate.humanMessage,
91
- "## Instructions",
92
- "1. Invoke `/loop cancel` via the Skill tool.\n2. Stop — the PR needs human direction before monitoring can resume.",
93
- ];
94
- return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
95
- }
79
+ "## Instructions\n\n1. Invoke `/loop cancel` via the Skill tool.\n2. Stop — the PR needs human direction before monitoring can resume.",
80
+ ]);
96
81
  case "fix_code":
97
82
  return formatFixCodeResult(header, result);
98
83
  }
@@ -49,9 +49,6 @@ export function projectIterateLean(result) {
49
49
  ...(result.fix.actionableComments.length > 0 && {
50
50
  actionableComments: result.fix.actionableComments,
51
51
  }),
52
- ...(result.fix.noiseCommentIds.length > 0 && {
53
- noiseCommentIds: result.fix.noiseCommentIds,
54
- }),
55
52
  ...(result.fix.reviewSummaryIds.length > 0 && {
56
53
  reviewSummaryIds: result.fix.reviewSummaryIds,
57
54
  }),
@@ -0,0 +1,32 @@
1
+ import { renderLineRange, renderSuggestionBlock } from "./suggestion-renderer.mjs";
2
+ const BODY_PREVIEW_MAX = 100;
3
+ export function renderBodyPreview(body) {
4
+ const normalizedBody = body.replace(/\r\n?/g, "\n");
5
+ const firstLine = normalizedBody.split("\n")[0]?.trim() ?? "";
6
+ return firstLine.slice(0, BODY_PREVIEW_MAX);
7
+ }
8
+ export function renderFirstLookStatusTag(t) {
9
+ return t.autoResolved ? "[status: outdated, auto-resolved]" : `[status: ${t.firstLookStatus}]`;
10
+ }
11
+ export function renderThreadBullet(t, opts = {}) {
12
+ const link = t.url ? ` [↗](${t.url})` : "";
13
+ const loc = t.path
14
+ ? `\`${t.path}:${renderLineRange(t.startLine ?? undefined, t.line ?? null)}\``
15
+ : "`(no location)`";
16
+ const suggestionMarker = t.suggestion ? " [suggestion]" : "";
17
+ const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
18
+ const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (@${t.author})${suggestionMarker}${statusSuffix}: ${renderBodyPreview(t.body)}`;
19
+ if (t.suggestion && opts.renderSuggestion) {
20
+ return `${bulletLine}\n${renderSuggestionBlock(t.suggestion)}`;
21
+ }
22
+ return bulletLine;
23
+ }
24
+ export function renderCommentBullet(c, opts = {}) {
25
+ const link = c.url ? ` [↗](${c.url})` : "";
26
+ const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
27
+ return `- \`commentId=${c.id}\`${link} (@${c.author})${statusSuffix}: ${renderBodyPreview(c.body)}`;
28
+ }
29
+ export function renderReviewBullet(r, opts = {}) {
30
+ const bodySuffix = opts.includeBody && r.body != null && r.body !== "" ? `: ${renderBodyPreview(r.body)}` : "";
31
+ return `- \`reviewId=${r.id}\` (@${r.author})${bodySuffix}`;
32
+ }
@@ -0,0 +1,31 @@
1
+ import { safeFence } from "./fence.mjs";
2
+ export function renderLineRange(startLine, endLine) {
3
+ if (endLine === null)
4
+ return "?";
5
+ if (startLine !== undefined && startLine !== endLine)
6
+ return `${startLine}-${endLine}`;
7
+ return String(endLine);
8
+ }
9
+ export function renderSuggestionBlock(s, indent = " ") {
10
+ const rangeLabel = s.startLine !== s.endLine ? `lines ${s.startLine}–${s.endLine}` : `line ${s.startLine}`;
11
+ const content = s.lines.join("\n");
12
+ const fence = safeFence(content);
13
+ const label = s.lines.length === 0
14
+ ? `Replaces ${rangeLabel} with nothing:`
15
+ : s.lines.length === 1 && s.lines[0] === ""
16
+ ? `Replaces ${rangeLabel} with a blank line:`
17
+ : `Replaces ${rangeLabel}:`;
18
+ return [
19
+ `${indent}${label}`,
20
+ `${indent}${fence}`,
21
+ ...(content === ""
22
+ ? []
23
+ : [
24
+ content
25
+ .split("\n")
26
+ .map((l) => `${indent}${l}`)
27
+ .join("\n"),
28
+ ]),
29
+ `${indent}${fence}`,
30
+ ].join("\n");
31
+ }
@@ -29,7 +29,6 @@ export function makeIterateResult(action = "wait") {
29
29
  mode: "rebase-and-push",
30
30
  threads: [],
31
31
  actionableComments: [],
32
- noiseCommentIds: [],
33
32
  reviewSummaryIds: [],
34
33
  surfacedApprovals: [],
35
34
  checks: [],
@@ -64,7 +63,7 @@ export function makeIterateResult(action = "wait") {
64
63
  ambiguousComments: [],
65
64
  changesRequestedReviews: [],
66
65
  suggestion: "check manually",
67
- humanMessage: "⚠️ /pr-shepherd:monitor paused — needs human direction",
66
+ humanMessage: "⚠️ /pr-shepherd:monitor paused — needs human direction",
68
67
  },
69
68
  };
70
69
  }
@@ -18,12 +18,14 @@
18
18
  import { readFileSync } from "node:fs";
19
19
  import { runCheck } from "./commands/check.mjs";
20
20
  import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
21
+ import { runLogFile } from "./commands/log-file.mjs";
21
22
  import { formatJson } from "./reporters/json.mjs";
22
23
  import { formatText } from "./reporters/text.mjs";
23
24
  import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
24
25
  import { statusToExitCode } from "./cli/exit-codes.mjs";
25
26
  import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
26
27
  import { handleCommitSuggestion, handleIterate, handleMonitor, handleStatus, } from "./cli/handlers.mjs";
28
+ import { setupLog } from "./log/setup.mjs";
27
29
  // ---------------------------------------------------------------------------
28
30
  // Entry
29
31
  // ---------------------------------------------------------------------------
@@ -34,6 +36,13 @@ export async function main(argv) {
34
36
  process.stdout.write(`${readVersion()}\n`);
35
37
  return;
36
38
  }
39
+ // log-file must run before the stdout tee and log init to avoid recursion.
40
+ if (subcommand === "log-file") {
41
+ await handleLogFile(args.slice(1));
42
+ return;
43
+ }
44
+ // Initialize the per-worktree log and install a stdout tee.
45
+ await setupLog(argv);
37
46
  switch (subcommand) {
38
47
  case "check":
39
48
  await handleCheck(args.slice(1));
@@ -55,7 +64,7 @@ export async function main(argv) {
55
64
  break;
56
65
  default:
57
66
  process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
58
- process.stderr.write("Usage: pr-shepherd <check|resolve|commit-suggestion|iterate|monitor|status> [options]\n" +
67
+ process.stderr.write("Usage: pr-shepherd <check|resolve|commit-suggestion|iterate|monitor|status|log-file> [options]\n" +
59
68
  " pr-shepherd --version | -v\n");
60
69
  process.exitCode = 1;
61
70
  return;
@@ -76,6 +85,21 @@ async function handleCheck(args) {
76
85
  process.stdout.write(`${output}\n`);
77
86
  process.exitCode = statusToExitCode(report.status);
78
87
  }
88
+ async function handleLogFile(args) {
89
+ const jsonOut = args.some((a) => a === "--format=json") ||
90
+ (() => {
91
+ const idx = args.indexOf("--format");
92
+ return idx !== -1 && args[idx + 1] === "json";
93
+ })();
94
+ try {
95
+ const result = await runLogFile();
96
+ process.stdout.write(jsonOut ? `${JSON.stringify(result, null, 2)}\n` : `${result.path}\n`);
97
+ }
98
+ catch (e) {
99
+ process.stderr.write(`pr-shepherd: log-file: ${String(e)}\n`);
100
+ process.exitCode = 1;
101
+ }
102
+ }
79
103
  async function handleResolve(args) {
80
104
  const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
81
105
  const resolveThreadIds = parseList(getFlag(extra, "--resolve-thread-ids"));
@@ -91,7 +115,7 @@ async function handleResolve(args) {
91
115
  const result = await runResolveFetch({ ...globalOpts, prNumber });
92
116
  process.stdout.write(globalOpts.format === "json"
93
117
  ? `${JSON.stringify(result, null, 2)}\n`
94
- : formatFetchResult(result));
118
+ : `${formatFetchResult(result)}\n`);
95
119
  }
96
120
  else {
97
121
  const result = await runResolveMutate({
@@ -105,6 +129,6 @@ async function handleResolve(args) {
105
129
  });
106
130
  process.stdout.write(globalOpts.format === "json"
107
131
  ? `${JSON.stringify(result, null, 2)}\n`
108
- : formatMutateResult(result));
132
+ : `${formatMutateResult(result)}\n`);
109
133
  }
110
134
  }
@@ -54,9 +54,8 @@ export async function runCheck(opts) {
54
54
  const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
55
55
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
56
56
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
57
- // Triage failures (fetch job info + log tails) — skipped when caller short-circuits early.
58
57
  const triaged = failing.length > 0 && !opts.skipTriage
59
- ? await triageFailingChecks(failing, repo, config.checks.logTailLines)
58
+ ? await triageFailingChecks(failing, repo, config.checks.logTailLines, config.checks.logTailChars)
60
59
  : failing;
61
60
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
62
61
  // Resolve threads and comments.