pr-shepherd 0.8.1 → 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 (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +123 -21
  3. package/bin/checks/classify.mjs +2 -1
  4. package/bin/checks/triage.mjs +63 -61
  5. package/bin/cli/args.mjs +9 -3
  6. package/bin/cli/fence.mjs +4 -0
  7. package/bin/cli/fix-formatter.mjs +45 -19
  8. package/bin/cli/formatters.mjs +61 -44
  9. package/bin/cli/handlers.mjs +5 -4
  10. package/bin/cli/iterate-formatter.mjs +51 -45
  11. package/bin/cli/iterate-lean.mjs +95 -0
  12. package/bin/cli/list-formatters.mjs +32 -0
  13. package/bin/cli/suggestion-renderer.mjs +31 -0
  14. package/bin/cli-parser.iterate-fixtures.mjs +6 -5
  15. package/bin/cli-parser.mjs +27 -3
  16. package/bin/commands/check-status.mjs +7 -4
  17. package/bin/commands/check.mjs +39 -3
  18. package/bin/commands/commit-suggestion.mjs +12 -10
  19. package/bin/commands/iterate/classify.mjs +10 -54
  20. package/bin/commands/iterate/escalate.mjs +5 -14
  21. package/bin/commands/iterate/fix-code.mjs +15 -16
  22. package/bin/commands/iterate/helpers.mjs +8 -5
  23. package/bin/commands/iterate/index.mjs +18 -12
  24. package/bin/commands/iterate/render.mjs +28 -12
  25. package/bin/commands/iterate/stall.mjs +1 -1
  26. package/bin/commands/log-file.mjs +7 -0
  27. package/bin/commands/monitor.mjs +7 -4
  28. package/bin/commands/ready-delay.mjs +2 -2
  29. package/bin/commands/resolve-instructions.mjs +14 -5
  30. package/bin/commands/resolve.mjs +38 -37
  31. package/bin/commands/status.mjs +41 -29
  32. package/bin/comments/resolve.mjs +75 -65
  33. package/bin/config.json +4 -7
  34. package/bin/github/batch-parsers.mjs +4 -0
  35. package/bin/github/client.mjs +11 -32
  36. package/bin/github/gql/batch-pr.gql +6 -0
  37. package/bin/github/gql/get-pr-head-sha.gql +7 -0
  38. package/bin/github/http.mjs +166 -11
  39. package/bin/github/queries.mjs +7 -11
  40. package/bin/log/log-file.mjs +88 -0
  41. package/bin/log/session.mjs +100 -0
  42. package/bin/log/setup.mjs +54 -0
  43. package/bin/reporters/agent.mjs +21 -13
  44. package/bin/reporters/check-instructions.mjs +9 -14
  45. package/bin/reporters/text.mjs +93 -105
  46. package/bin/state/base.mjs +5 -0
  47. package/bin/state/fix-attempts.mjs +2 -2
  48. package/bin/state/iterate-stall.mjs +2 -2
  49. package/bin/state/seen-comments.mjs +76 -0
  50. package/bin/suggestions/extract.mjs +15 -0
  51. package/bin/suggestions/parse.mjs +1 -26
  52. package/bin/types/report.mjs +0 -1
  53. package/bin/util/markdown.mjs +7 -0
  54. package/bin/util/worktree.mjs +23 -0
  55. package/package.json +2 -2
  56. package/bin/commands/iterate/steps.mjs +0 -31
  57. package/bin/github/gql/dismiss-review.gql +0 -7
  58. package/bin/github/gql/minimize-comment.gql +0 -7
  59. package/bin/github/gql/multi-pr-status.gql +0 -32
  60. package/bin/github/gql/resolve-thread.gql +0 -7
@@ -1,49 +1,79 @@
1
1
  export { formatIterateResult } from "./iterate-formatter.mjs";
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";
2
6
  export function formatFetchResult(result) {
3
- const total = result.actionableThreads.length +
7
+ const activeTotal = result.actionableThreads.length +
4
8
  result.actionableComments.length +
5
9
  result.changesRequestedReviews.length +
6
10
  result.reviewSummaries.length;
11
+ const firstLookTotal = result.firstLookThreads.length + result.firstLookComments.length;
12
+ const total = activeTotal + firstLookTotal;
13
+ const headingParts = [];
14
+ if (activeTotal > 0)
15
+ headingParts.push(`${activeTotal} actionable`);
16
+ if (firstLookTotal > 0)
17
+ headingParts.push(`${firstLookTotal} first-look`);
18
+ const headingSuffix = headingParts.length > 0 ? headingParts.join(", ") : "0 actionable";
7
19
  const sections = [];
8
- sections.push(`# PR #${result.prNumber} — Resolve fetch (${total === 0 ? "0 actionable" : `${total} actionable`})`);
20
+ sections.push(`# PR #${result.prNumber} — Resolve fetch (${headingSuffix})`);
9
21
  if (result.actionableThreads.length > 0) {
10
22
  sections.push(`## Actionable Review Threads (${result.actionableThreads.length})` +
11
23
  (result.commitSuggestionsEnabled ? " [commit-suggestions: enabled]" : ""));
12
24
  sections.push(result.actionableThreads
13
- .map((t) => {
14
- const suggestionMarker = t.suggestion ? " [suggestion]" : "";
15
- return `- \`threadId=${t.id}\` \`${t.path ?? ""}:${t.line ?? "?"}\` (@${t.author})${suggestionMarker}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`;
16
- })
17
- .join("\n"));
25
+ .map((t) => renderThreadBullet(t, { renderSuggestion: true }))
26
+ .join("\n\n"));
18
27
  }
19
28
  if (result.actionableComments.length > 0) {
20
29
  sections.push(`## Actionable PR Comments (${result.actionableComments.length})`);
21
- sections.push(result.actionableComments
22
- .map((c) => `- \`commentId=${c.id}\` (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`)
23
- .join("\n"));
30
+ sections.push(result.actionableComments.map((c) => renderCommentBullet(c)).join("\n"));
24
31
  }
25
32
  if (result.changesRequestedReviews.length > 0) {
26
33
  sections.push(`## Pending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length})`);
27
- sections.push(result.changesRequestedReviews.map((r) => `- \`reviewId=${r.id}\` (@${r.author})`).join("\n"));
34
+ sections.push(result.changesRequestedReviews.map((r) => renderReviewBullet(r)).join("\n"));
28
35
  }
29
36
  if (result.reviewSummaries.length > 0) {
30
37
  sections.push(`## Review summaries (${result.reviewSummaries.length})`);
31
- sections.push(result.reviewSummaries
32
- .map((r) => `- \`reviewId=${r.id}\` (@${r.author}): ${r.body.split("\n")[0].slice(0, 100)}`)
33
- .join("\n"));
38
+ sections.push(result.reviewSummaries.map((r) => renderReviewBullet(r, { includeBody: true })).join("\n"));
39
+ }
40
+ if (firstLookTotal > 0) {
41
+ sections.push(`## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`);
42
+ const bullets = [];
43
+ for (const t of result.firstLookThreads) {
44
+ bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
45
+ }
46
+ for (const c of result.firstLookComments) {
47
+ bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
48
+ }
49
+ sections.push(bullets.join("\n"));
34
50
  }
35
51
  sections.push("## Summary");
36
- sections.push(total === 0 ? "0 actionable — all threads resolved/minimized" : `${total} actionable item(s)`);
52
+ sections.push(total === 0
53
+ ? "0 actionable, 0 first-look — all items seen"
54
+ : [
55
+ activeTotal > 0 ? `${activeTotal} actionable` : null,
56
+ firstLookTotal > 0 ? `${firstLookTotal} first-look` : null,
57
+ ]
58
+ .filter(Boolean)
59
+ .join(", "));
37
60
  sections.push("## Instructions");
38
61
  sections.push(result.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
39
- return `${sections.join("\n\n")}\n`;
62
+ return joinSections(sections);
40
63
  }
41
64
  export function formatCommitSuggestionResult(result) {
42
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
+ }
43
76
  if (result.dryRun) {
44
- const range = result.startLine === result.endLine
45
- ? `line ${result.startLine}`
46
- : `lines ${result.startLine}-${result.endLine}`;
47
77
  if (result.valid) {
48
78
  lines.push(`Dry-run: would apply suggestion from @${result.author}:`);
49
79
  lines.push(` ${result.path} (${range})`);
@@ -54,45 +84,32 @@ export function formatCommitSuggestionResult(result) {
54
84
  lines.push(`- author: @${result.author}`);
55
85
  lines.push(`- reason: ${result.reason ?? "unknown"}`);
56
86
  }
57
- if (result.patch) {
58
- lines.push("");
59
- lines.push("```diff");
60
- lines.push(result.patch.trimEnd());
61
- lines.push("```");
62
- }
87
+ if (result.patch)
88
+ pushPatch(result.patch);
63
89
  }
64
90
  else if (result.applied) {
65
91
  lines.push(`Applied suggestion from @${result.author}:`);
66
- const range = result.startLine === result.endLine
67
- ? `line ${result.startLine}`
68
- : `lines ${result.startLine}-${result.endLine}`;
69
92
  lines.push(` ${result.path} (${range})`);
70
93
  if (result.commitSha)
71
94
  lines.push(`Commit: ${result.commitSha}`);
72
- if (result.patch) {
73
- lines.push("");
74
- lines.push("```diff");
75
- lines.push(result.patch.trimEnd());
76
- lines.push("```");
77
- }
95
+ if (result.patch)
96
+ pushPatch(result.patch);
78
97
  }
79
98
  else {
80
99
  lines.push(`Failed to apply suggestion ${result.threadId}:`);
81
- lines.push(`- path: ${result.path} (lines ${result.startLine}–${result.endLine})`);
100
+ lines.push(`- path: ${result.path} (${range})`);
82
101
  lines.push(`- author: @${result.author}`);
83
102
  lines.push(`- reason: ${result.reason ?? "unknown"}`);
84
- if (result.patch) {
85
- lines.push("");
86
- lines.push("```diff");
87
- lines.push(result.patch.trimEnd());
88
- lines.push("```");
89
- }
103
+ if (result.patch)
104
+ pushPatch(result.patch);
90
105
  }
91
106
  if (result.postActionInstruction) {
92
107
  lines.push("");
93
- lines.push(result.postActionInstruction);
108
+ lines.push("## Instructions");
109
+ lines.push("");
110
+ lines.push(`1. ${result.postActionInstruction}`);
94
111
  }
95
- return `${lines.join("\n")}\n`;
112
+ return lines.join("\n");
96
113
  }
97
114
  export function formatMutateResult(result) {
98
115
  const lines = [];
@@ -104,5 +121,5 @@ export function formatMutateResult(result) {
104
121
  lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
105
122
  if (result.errors.length)
106
123
  lines.push(`Errors:\n ${result.errors.join("\n ")}`);
107
- return `${lines.join("\n")}\n`;
124
+ return lines.join("\n");
108
125
  }
@@ -6,7 +6,7 @@ import { getRepoInfo } from "../github/client.mjs";
6
6
  import { loadConfig } from "../config/load.mjs";
7
7
  import { parseCommonArgs, getFlag, hasFlag, parseStatusPrNumbers, parseIntStrict, } from "./args.mjs";
8
8
  import { parseDurationToMinutes, iterateActionToExitCode, deriveSimpleReady, } from "./exit-codes.mjs";
9
- import { formatCommitSuggestionResult, formatIterateResult } from "./formatters.mjs";
9
+ import { formatCommitSuggestionResult, formatIterateResult, projectIterateLean, } from "./formatters.mjs";
10
10
  export async function handleCommitSuggestion(args) {
11
11
  const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
12
12
  const threadId = getFlag(extra, "--thread-id");
@@ -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
  }
@@ -67,10 +67,11 @@ export async function handleIterate(args) {
67
67
  noAutoCancelActionable,
68
68
  });
69
69
  if (globalOpts.format === "json") {
70
- process.stdout.write(`${JSON.stringify(result)}\n`);
70
+ const output = globalOpts.verbose ? result : projectIterateLean(result);
71
+ process.stdout.write(`${JSON.stringify(output)}\n`);
71
72
  }
72
73
  else {
73
- process.stdout.write(`${formatIterateResult(result)}\n`);
74
+ process.stdout.write(`${formatIterateResult(result, { verbose: globalOpts.verbose })}\n`);
74
75
  }
75
76
  process.exitCode = iterateActionToExitCode(result.action);
76
77
  }
@@ -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
  *
@@ -14,64 +15,69 @@ import { formatFixCodeResult } from "./fix-formatter.mjs";
14
15
  * unconditional: every action, every variant, always emits at least one step.
15
16
  * The SKILL simply follows those steps; it does not need its own dispatch table.
16
17
  */
17
- export function formatIterateResult(result) {
18
+ export function formatIterateResult(result, opts) {
19
+ const verbose = opts?.verbose ?? false;
18
20
  const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
19
- const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\` · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
20
- const summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress · **remainingSeconds** ${result.remainingSeconds} · **copilotReviewInProgress** ${result.copilotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}`;
21
+ const reviewDecisionSeg = result.mergeStatus === "BLOCKED" && result.reviewDecision
22
+ ? ` · **reviewDecision** \`${result.reviewDecision}\``
23
+ : "";
24
+ const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\`${reviewDecisionSeg} · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
25
+ let summaryLine;
26
+ if (verbose) {
27
+ summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress · **remainingSeconds** ${result.remainingSeconds} · **copilotReviewInProgress** ${result.copilotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}`;
28
+ }
29
+ else {
30
+ const counts = [`${result.summary.passing} passing`];
31
+ if (result.summary.skipped > 0)
32
+ counts.push(`${result.summary.skipped} skipped`);
33
+ if (result.summary.filtered > 0)
34
+ counts.push(`${result.summary.filtered} filtered`);
35
+ if (result.summary.inProgress > 0)
36
+ counts.push(`${result.summary.inProgress} inProgress`);
37
+ const segs = [`**summary** ${counts.join(", ")}`];
38
+ if (result.status === "READY" && result.remainingSeconds > 0) {
39
+ segs.push(`**remainingSeconds** ${result.remainingSeconds}`);
40
+ }
41
+ if (result.copilotReviewInProgress)
42
+ segs.push(`**copilotReviewInProgress**`);
43
+ if (result.isDraft)
44
+ segs.push(`**isDraft**`);
45
+ summaryLine = segs.join(" · ");
46
+ }
21
47
  const header = [heading, "", baseLine, summaryLine].join("\n");
22
48
  switch (result.action) {
23
49
  case "cooldown":
24
- return [
25
- header,
26
- "",
50
+ // In default mode: suppress base/summary lines — cooldown carries UNKNOWN/empty
51
+ // placeholders that add no value. Emit only heading + log + Instructions.
52
+ return joinSections([
53
+ verbose ? header : heading,
27
54
  result.log,
28
- "",
29
- "## Instructions",
30
- "",
31
- "1. End this iteration — the next cron fire will recheck once CI starts reporting.",
32
- ].join("\n");
33
- case "wait": {
34
- 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([
35
59
  header,
36
60
  result.log,
37
- "## Instructions",
38
- "1. End this iteration — the next cron fire will recheck.",
39
- ];
40
- return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
41
- }
42
- case "rerun_ci": {
43
- const rerunInstructions = result.reran.map((r, i) => `${i + 1}. Run: \`gh run rerun ${r.runId} --failed\``);
44
- rerunInstructions.push(`${rerunInstructions.length + 1}. End this iteration — wait for CI to report results after the re-run.`);
45
- const parts = [header, result.log, "## Instructions", rerunInstructions.join("\n")];
46
- return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
47
- }
48
- case "mark_ready": {
49
- const parts = [
61
+ "## Instructions\n\n1. End this iteration — the next cron fire will recheck.",
62
+ ]);
63
+ case "mark_ready":
64
+ return joinSections([
50
65
  header,
51
66
  result.log,
52
- "## Instructions",
53
- "1. The CLI already marked the PR ready for review — end this iteration.",
54
- ];
55
- return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
56
- }
57
- case "cancel": {
58
- 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([
59
71
  [`${heading} — ${result.reason}`, "", baseLine, summaryLine].join("\n"),
60
72
  result.log,
61
- "## Instructions",
62
- "1. Invoke `/loop cancel` via the Skill tool.\n2. Stop.",
63
- ];
64
- return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
65
- }
66
- case "escalate": {
67
- const parts = [
73
+ "## Instructions\n\n1. Invoke `/loop cancel` via the Skill tool.\n2. Stop.",
74
+ ]);
75
+ case "escalate":
76
+ return joinSections([
68
77
  header,
69
78
  result.escalate.humanMessage,
70
- "## Instructions",
71
- "1. Invoke `/loop cancel` via the Skill tool.\n2. Stop — the PR needs human direction before monitoring can resume.",
72
- ];
73
- return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
74
- }
79
+ "## Instructions\n\n1. Invoke `/loop cancel` via the Skill tool.\n2. Stop — the PR needs human direction before monitoring can resume.",
80
+ ]);
75
81
  case "fix_code":
76
82
  return formatFixCodeResult(header, result);
77
83
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Project an IterateResult to a lean JSON shape for the default (non-verbose) output.
3
+ * Omits fields that are the trivial default (false, 0, empty) or state-gated fields
4
+ * outside the state where they are meaningful.
5
+ */
6
+ export function projectIterateLean(result) {
7
+ const base = {
8
+ action: result.action,
9
+ pr: result.pr,
10
+ repo: result.repo || undefined,
11
+ status: result.status,
12
+ state: result.state,
13
+ mergeStateStatus: result.mergeStateStatus,
14
+ ...(result.mergeStatus === "BLOCKED" &&
15
+ result.reviewDecision !== null && { reviewDecision: result.reviewDecision }),
16
+ ...(result.copilotReviewInProgress && { copilotReviewInProgress: true }),
17
+ ...(result.isDraft && { isDraft: true }),
18
+ summary: {
19
+ passing: result.summary.passing,
20
+ ...(result.summary.skipped > 0 && { skipped: result.summary.skipped }),
21
+ ...(result.summary.filtered > 0 && { filtered: result.summary.filtered }),
22
+ ...(result.summary.inProgress > 0 && { inProgress: result.summary.inProgress }),
23
+ },
24
+ // remainingSeconds: only when the ready-delay timer is actively counting down
25
+ ...(result.status === "READY" &&
26
+ result.remainingSeconds > 0 && {
27
+ remainingSeconds: result.remainingSeconds,
28
+ }),
29
+ ...(result.baseBranch && { baseBranch: result.baseBranch }),
30
+ };
31
+ switch (result.action) {
32
+ case "cooldown":
33
+ return { ...base, log: result.log };
34
+ case "wait":
35
+ return { ...base, log: result.log };
36
+ case "cancel":
37
+ return { ...base, reason: result.reason, log: result.log };
38
+ case "mark_ready":
39
+ // drop markedReady — always true, redundant with action discriminator
40
+ return { ...base, log: result.log };
41
+ case "fix_code":
42
+ return {
43
+ ...base,
44
+ ...(result.checks.length > 0 && { checks: result.checks }),
45
+ ...(result.cancelled.length > 0 && { cancelled: result.cancelled }),
46
+ fix: {
47
+ mode: result.fix.mode,
48
+ ...(result.fix.threads.length > 0 && { threads: result.fix.threads }),
49
+ ...(result.fix.actionableComments.length > 0 && {
50
+ actionableComments: result.fix.actionableComments,
51
+ }),
52
+ ...(result.fix.reviewSummaryIds.length > 0 && {
53
+ reviewSummaryIds: result.fix.reviewSummaryIds,
54
+ }),
55
+ ...(result.fix.surfacedApprovals.length > 0 && {
56
+ surfacedApprovals: result.fix.surfacedApprovals,
57
+ }),
58
+ ...(result.fix.firstLookThreads.length > 0 && {
59
+ firstLookThreads: result.fix.firstLookThreads,
60
+ }),
61
+ ...(result.fix.firstLookComments.length > 0 && {
62
+ firstLookComments: result.fix.firstLookComments,
63
+ }),
64
+ ...(result.fix.checks.length > 0 && { checks: result.fix.checks }),
65
+ ...(result.fix.changesRequestedReviews.length > 0 && {
66
+ changesRequestedReviews: result.fix.changesRequestedReviews,
67
+ }),
68
+ resolveCommand: result.fix.resolveCommand,
69
+ ...(result.fix.instructions.length > 0 && { instructions: result.fix.instructions }),
70
+ },
71
+ };
72
+ case "escalate":
73
+ return {
74
+ ...base,
75
+ escalate: {
76
+ ...(result.escalate.triggers.length > 0 && { triggers: result.escalate.triggers }),
77
+ ...(result.escalate.unresolvedThreads.length > 0 && {
78
+ unresolvedThreads: result.escalate.unresolvedThreads,
79
+ }),
80
+ ...(result.escalate.ambiguousComments.length > 0 && {
81
+ ambiguousComments: result.escalate.ambiguousComments,
82
+ }),
83
+ ...(result.escalate.changesRequestedReviews.length > 0 && {
84
+ changesRequestedReviews: result.escalate.changesRequestedReviews,
85
+ }),
86
+ ...(result.escalate.attemptHistory &&
87
+ result.escalate.attemptHistory.length > 0 && {
88
+ attemptHistory: result.escalate.attemptHistory,
89
+ }),
90
+ suggestion: result.escalate.suggestion,
91
+ humanMessage: result.escalate.humanMessage,
92
+ },
93
+ };
94
+ }
95
+ }
@@ -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
+ }
@@ -5,6 +5,8 @@ export function makeIterateResult(action = "wait") {
5
5
  status: "IN_PROGRESS",
6
6
  state: "OPEN",
7
7
  mergeStateStatus: "BLOCKED",
8
+ mergeStatus: "BLOCKED",
9
+ reviewDecision: null,
8
10
  copilotReviewInProgress: false,
9
11
  isDraft: false,
10
12
  shouldCancel: false,
@@ -17,8 +19,6 @@ export function makeIterateResult(action = "wait") {
17
19
  return { ...base, action: "cooldown", log: "SKIP: CI still starting" };
18
20
  if (action === "wait")
19
21
  return { ...base, action: "wait", log: "WAIT: 0 passing, 1 in-progress" };
20
- if (action === "rerun_ci")
21
- return { ...base, action: "rerun_ci", log: "RERAN: run-99 (typecheck — transient)", reran: [] };
22
22
  if (action === "mark_ready")
23
23
  return { ...base, action: "mark_ready", markedReady: true, log: "MARKED READY: PR 42" };
24
24
  if (action === "fix_code") {
@@ -29,9 +29,8 @@ export function makeIterateResult(action = "wait") {
29
29
  mode: "rebase-and-push",
30
30
  threads: [],
31
31
  actionableComments: [],
32
- noiseCommentIds: [],
33
32
  reviewSummaryIds: [],
34
- surfacedSummaries: [],
33
+ surfacedApprovals: [],
35
34
  checks: [],
36
35
  changesRequestedReviews: [],
37
36
  resolveCommand: {
@@ -41,6 +40,8 @@ export function makeIterateResult(action = "wait") {
41
40
  hasMutations: false,
42
41
  },
43
42
  instructions: ["End this iteration."],
43
+ firstLookThreads: [],
44
+ firstLookComments: [],
44
45
  },
45
46
  cancelled: [],
46
47
  };
@@ -62,7 +63,7 @@ export function makeIterateResult(action = "wait") {
62
63
  ambiguousComments: [],
63
64
  changesRequestedReviews: [],
64
65
  suggestion: "check manually",
65
- humanMessage: "⚠️ /pr-shepherd:monitor paused — needs human direction",
66
+ humanMessage: "⚠️ /pr-shepherd:monitor paused — needs human direction",
66
67
  },
67
68
  };
68
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
  }
@@ -9,15 +9,18 @@ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, me
9
9
  return "FAILING";
10
10
  if (verdict.anyInProgress)
11
11
  return "IN_PROGRESS";
12
- // BLOCKED solely because a human reviewer hasn't approved yet — shepherd is done, hand off.
13
- // copilotReviewInProgress means a bot still owes a review, which is not this case.
12
+ // BLOCKED with no remaining shepherd work — hand off via ready-delay regardless of why GitHub
13
+ // is BLOCKED (review pending, insufficient approvals, branch-protection rule, etc.).
14
+ // Requires hasChecks so that a PR with zero relevant checks (CI never started, or all
15
+ // filtered/skipped) doesn't prematurely trigger READY before any check has reported.
16
+ // copilotReviewInProgress is still excluded — a bot review is shepherd's problem, not a hand-off.
14
17
  if (verdict.allPassed &&
18
+ verdict.hasChecks &&
15
19
  unresolvedThreads === 0 &&
16
20
  unresolvedComments === 0 &&
17
21
  changesRequestedReviews === 0 &&
18
22
  mergeStatus.status === "BLOCKED" &&
19
- !mergeStatus.copilotReviewInProgress &&
20
- mergeStatus.reviewDecision === "REVIEW_REQUIRED") {
23
+ !mergeStatus.copilotReviewInProgress) {
21
24
  return "READY";
22
25
  }
23
26
  if (mergeStatus.status === "BLOCKED" ||