pr-shepherd 0.9.0 → 0.10.1

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 (52) 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 +31 -25
  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 +3 -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 +2 -2
  13. package/bin/cli-parser.mjs +27 -3
  14. package/bin/commands/check.mjs +7 -5
  15. package/bin/commands/commit-suggestion.mjs +12 -10
  16. package/bin/commands/iterate/classify.mjs +10 -32
  17. package/bin/commands/iterate/escalate.mjs +3 -12
  18. package/bin/commands/iterate/fix-code.mjs +7 -11
  19. package/bin/commands/iterate/index.mjs +2 -1
  20. package/bin/commands/iterate/render.mjs +30 -21
  21. package/bin/commands/log-file.mjs +7 -0
  22. package/bin/commands/monitor.mjs +7 -4
  23. package/bin/commands/ready-delay.mjs +2 -2
  24. package/bin/commands/resolve-instructions.mjs +5 -2
  25. package/bin/commands/resolve.mjs +1 -20
  26. package/bin/commands/status.mjs +40 -28
  27. package/bin/comments/resolve.mjs +75 -65
  28. package/bin/config.json +2 -2
  29. package/bin/github/batch-parsers.mjs +2 -0
  30. package/bin/github/client.mjs +11 -32
  31. package/bin/github/gql/batch-pr.gql +4 -0
  32. package/bin/github/gql/get-pr-head-sha.gql +7 -0
  33. package/bin/github/http.mjs +166 -11
  34. package/bin/github/queries.mjs +7 -11
  35. package/bin/log/log-file.mjs +88 -0
  36. package/bin/log/session.mjs +100 -0
  37. package/bin/log/setup.mjs +54 -0
  38. package/bin/reporters/agent.mjs +14 -1
  39. package/bin/reporters/text.mjs +82 -102
  40. package/bin/state/base.mjs +5 -0
  41. package/bin/state/fix-attempts.mjs +2 -2
  42. package/bin/state/iterate-stall.mjs +2 -2
  43. package/bin/state/seen-comments.mjs +2 -2
  44. package/bin/suggestions/extract.mjs +15 -0
  45. package/bin/suggestions/parse.mjs +1 -26
  46. package/bin/util/markdown.mjs +7 -0
  47. package/bin/util/worktree.mjs +23 -0
  48. package/package.json +6 -5
  49. package/bin/github/gql/dismiss-review.gql +0 -7
  50. package/bin/github/gql/minimize-comment.gql +0 -7
  51. package/bin/github/gql/multi-pr-status.gql +0 -32
  52. package/bin/github/gql/resolve-thread.gql +0 -7
@@ -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
  }
@@ -46,7 +46,6 @@ export async function runCheck(opts) {
46
46
  mergeStateStatus: restState.mergeStateStatus ?? batchData.mergeStateStatus,
47
47
  };
48
48
  }
49
- // Classify checks.
50
49
  const classifiedChecks = classifyChecks(batchData.checks);
51
50
  const verdict = getCiVerdict(classifiedChecks);
52
51
  const passing = classifiedChecks.filter((c) => c.category === "passed");
@@ -54,12 +53,10 @@ export async function runCheck(opts) {
54
53
  const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
55
54
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
56
55
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
57
- // Triage failures (fetch job info + log tails) — skipped when caller short-circuits early.
58
56
  const triaged = failing.length > 0 && !opts.skipTriage
59
- ? await triageFailingChecks(failing, repo, config.checks.logTailLines)
57
+ ? await triageFailingChecks(failing, repo, config.checks.logTailLines, config.checks.logTailChars)
60
58
  : failing;
61
59
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
62
- // Resolve threads and comments.
63
60
  const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
64
61
  const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
65
62
  // Auto-resolve outdated threads.
@@ -97,10 +94,14 @@ export async function runCheck(opts) {
97
94
  const firstLookComments = minimizedCommentCandidates
98
95
  .filter((c) => !seenSet.has(c.id))
99
96
  .map((c) => ({ ...c, firstLookStatus: "minimized" }));
97
+ // Split review summaries into first-look (unseen — surface body) vs seen (minimize silently).
98
+ const firstLookSummaries = batchData.reviewSummaries.filter((r) => !seenSet.has(r.id));
99
+ const seenSummaries = batchData.reviewSummaries.filter((r) => seenSet.has(r.id));
100
100
  // Mark first-look items as seen (best-effort — markSeen never throws).
101
101
  await Promise.allSettled([
102
102
  ...firstLookThreads.map((t) => markSeen(stateKey, t.id)),
103
103
  ...firstLookComments.map((c) => markSeen(stateKey, c.id)),
104
+ ...firstLookSummaries.map((r) => markSeen(stateKey, r.id)),
104
105
  ]);
105
106
  // Actionable: all active threads and all visible comments (no classification — LLM handles triage).
106
107
  const actionableThreads = activeThreads;
@@ -141,7 +142,8 @@ export async function runCheck(opts) {
141
142
  firstLook: firstLookComments,
142
143
  },
143
144
  changesRequestedReviews: batchData.changesRequestedReviews,
144
- reviewSummaries: batchData.reviewSummaries,
145
+ reviewSummaries: seenSummaries,
146
+ firstLookSummaries,
145
147
  approvedReviews: batchData.approvedReviews,
146
148
  };
147
149
  }
@@ -3,7 +3,7 @@ import { readFile, writeFile, unlink } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { promisify } from "node:util";
6
- import { getRepoInfo, getCurrentPrNumber, getPrHead, getCurrentBranch } from "../github/client.mjs";
6
+ import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/client.mjs";
7
7
  import { fetchPrBatch } from "../github/batch.mjs";
8
8
  import { applyResolveOptions } from "../comments/resolve.mjs";
9
9
  import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
@@ -25,19 +25,21 @@ export async function runCommitSuggestion(opts) {
25
25
  if (prNumber === null) {
26
26
  throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
27
27
  }
28
- const head = await getPrHead(prNumber, repo.owner, repo.name);
29
28
  const currentBranch = await getCurrentBranch();
30
- if (currentBranch !== head.ref) {
31
- throw new Error(`Current branch "${currentBranch}" does not match PR head branch "${head.ref}". ` +
32
- `Check out "${head.ref}" before applying suggestions.`);
33
- }
34
29
  const { stdout: localHeadOut } = await execFile("git", ["rev-parse", "HEAD"]);
35
30
  const localHeadSha = localHeadOut.trim();
36
- if (localHeadSha !== head.sha) {
37
- throw new Error(`Local HEAD ${localHeadSha} does not match PR head ${head.sha}. ` +
38
- `Pull/rebase "${head.ref}" to the latest PR head and try again.`);
39
- }
40
31
  const { data } = await fetchPrBatch(prNumber, repo);
32
+ if (!data.headRepoWithOwner) {
33
+ throw new Error(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`);
34
+ }
35
+ if (currentBranch !== data.headRefName) {
36
+ throw new Error(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
37
+ `Check out "${data.headRefName}" before applying suggestions.`);
38
+ }
39
+ if (localHeadSha !== data.headRefOid) {
40
+ throw new Error(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
41
+ `Pull/rebase "${data.headRefName}" to the latest PR head and try again.`);
42
+ }
41
43
  const thread = data.reviewThreads.find((t) => t.id === opts.threadId);
42
44
  if (!thread) {
43
45
  throw new Error(`Thread ${opts.threadId} not found on PR #${prNumber}.`);
@@ -1,38 +1,15 @@
1
1
  export function classifyReviewSummaries(summaries, approvals, minimizeApprovals) {
2
- const minimizeIds = summaries.map((r) => r.id);
2
+ // Both first-look and seen summaries go into the minimize mutation; first-look bodies are
3
+ // rendered in the output so the agent sees them before the minimize happens.
4
+ const minimizeIds = [...summaries.firstLook, ...summaries.seen].map((r) => r.id);
3
5
  if (minimizeApprovals) {
4
6
  for (const r of approvals)
5
7
  minimizeIds.push(r.id);
6
- return { minimizeIds, surfacedApprovals: [] };
8
+ return { minimizeIds, firstLookSummaries: summaries.firstLook, surfacedApprovals: [] };
7
9
  }
8
- return { minimizeIds, surfacedApprovals: approvals };
10
+ return { minimizeIds, firstLookSummaries: summaries.firstLook, surfacedApprovals: approvals };
9
11
  }
10
- // Patterns that indicate a comment is bot-generated noise rather than actionable feedback.
11
- // Conservative: only match explicit known patterns to avoid accidentally suppressing real reviews.
12
- const NOISE_PATTERNS = [
13
- /you have reached your daily quota/i,
14
- /please wait up to \d+ hours?/i,
15
- /rate[\s-]?limit(?:ed)?\s*[-—:]\s*try again/i,
16
- /resuming (monitoring|watch|checking)/i,
17
- /restarting (monitoring|watch)/i,
18
- ];
19
- function isNoiseComment(comment) {
20
- return NOISE_PATTERNS.some((p) => p.test(comment.body));
21
- }
22
- export function classifyComments(comments) {
23
- const actionable = [];
24
- const noiseIds = [];
25
- for (const c of comments) {
26
- if (isNoiseComment(c)) {
27
- noiseIds.push(c.id);
28
- }
29
- else {
30
- actionable.push(c);
31
- }
32
- }
33
- return { actionable, noiseIds };
34
- }
35
- export function buildResolveCommand(threads, actionableComments, allCommentIds, reviews, checks, prNumber) {
12
+ export function buildResolveCommand(threads, allCommentIds, reviews, checks, prNumber) {
36
13
  const argv = ["npx", "pr-shepherd", "resolve", String(prNumber)];
37
14
  if (threads.length > 0) {
38
15
  argv.push("--resolve-thread-ids", threads.map((t) => t.id).join(","));
@@ -45,9 +22,10 @@ export function buildResolveCommand(threads, actionableComments, allCommentIds,
45
22
  argv.push("--dismiss-review-ids", reviews.map((r) => r.id).join(","));
46
23
  argv.push("--message", "$DISMISS_MESSAGE");
47
24
  }
48
- // A push happens when there is code to change — threads, actionable comments, CI checks, or reviews.
49
- // Noise-only comment minimization skips commit/push, so requiresHeadSha must be false.
50
- const requiresHeadSha = threads.length > 0 || actionableComments.length > 0 || checks.length > 0 || reviews.length > 0;
25
+ // A push is required when threads, CI failures, or changes-requested reviews are present — the
26
+ // CLI knows those imply code edits. Comments are surfaced for the agent to evaluate; the CLI
27
+ // cannot know whether a given comment will require a push, so comments are excluded here.
28
+ const requiresHeadSha = threads.length > 0 || checks.length > 0 || reviews.length > 0;
51
29
  // hasMutations = we appended at least one of --resolve-thread-ids,
52
30
  // --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
53
31
  // (rather than derived from argv.length) so callers don't couple to the
@@ -31,8 +31,8 @@ export function checkEscalateTriggers(actionableThreads, actionableComments, cha
31
31
  /**
32
32
  * Validate the base branch name from the GraphQL batch (`report.baseBranch`)
33
33
  * and fall back safely if it's missing/unsafe. The branch is interpolated into
34
- * shell commands by `buildRebaseShellScript` and `buildFixInstructions`, so we
35
- * reject anything outside `[A-Za-z0-9._/-]` to prevent shell injection.
34
+ * shell commands by `buildFixInstructions`, so we reject anything outside
35
+ * `[A-Za-z0-9._/-]` to prevent shell injection.
36
36
  */
37
37
  export function validateBaseBranch(raw) {
38
38
  const trimmed = raw.trim();
@@ -52,18 +52,9 @@ export function validateBaseBranch(raw) {
52
52
  }
53
53
  return { branch: trimmed, isFallback: false };
54
54
  }
55
- export function buildRebaseShellScript(baseBranch) {
56
- return [
57
- `if ! git diff --quiet || ! git diff --cached --quiet; then`,
58
- ` echo "SKIP rebase: dirty worktree (uncommitted changes present)"`,
59
- ` exit 1`,
60
- `fi`,
61
- `git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease`,
62
- ].join("\n");
63
- }
64
55
  export function buildEscalateHumanMessage(escalate, pr) {
65
56
  const lines = [];
66
- lines.push("⚠️ /pr-shepherd:monitor paused — needs human direction");
57
+ lines.push("⚠️ /pr-shepherd:monitor paused — needs human direction");
67
58
  lines.push("");
68
59
  lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
69
60
  lines.push("");
@@ -1,12 +1,12 @@
1
1
  import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
2
2
  import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
3
3
  import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
4
- import { classifyComments, buildResolveCommand } from "./classify.mjs";
4
+ import { buildResolveCommand } from "./classify.mjs";
5
5
  import { buildFixInstructions } from "./render.mjs";
6
6
  import { applyStallGuard } from "./stall.mjs";
7
7
  import { tryCancelRun } from "./helpers.mjs";
8
8
  export async function handleFixCode(ctx) {
9
- const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, surfacedApprovals, } = ctx;
9
+ const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, surfacedApprovals, } = ctx;
10
10
  const failingChecks = report.checks.failing;
11
11
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
12
12
  const isNewSha = stored?.headSha !== headSha;
@@ -48,16 +48,12 @@ export async function handleFixCode(ctx) {
48
48
  }
49
49
  const baseLookup = validateBaseBranch(report.baseBranch);
50
50
  const threads = report.threads.actionable.map(toAgentThread);
51
- const { actionable: actionableComments, noiseIds: noiseCommentIds } = classifyComments(report.comments.actionable.map(toAgentComment));
51
+ const actionableComments = report.comments.actionable.map(toAgentComment);
52
52
  const checks = toAgentChecks(failingChecks);
53
53
  const { changesRequestedReviews } = report;
54
54
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
55
- const allCommentIds = [
56
- ...actionableComments.map((c) => c.id),
57
- ...noiseCommentIds,
58
- ...reviewSummaryIds,
59
- ];
60
- const resolveCommand = buildResolveCommand(threads, actionableComments, allCommentIds, changesRequestedReviews, checks, prNumber);
55
+ const allCommentIds = [...actionableComments.map((c) => c.id), ...reviewSummaryIds];
56
+ const resolveCommand = buildResolveCommand(threads, allCommentIds, changesRequestedReviews, checks, prNumber);
61
57
  if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
62
58
  const fallbackEscalateBase = {
63
59
  triggers: ["base-branch-unknown"],
@@ -77,7 +73,7 @@ export async function handleFixCode(ctx) {
77
73
  }
78
74
  const firstLookThreads = report.threads.firstLook;
79
75
  const firstLookComments = report.comments.firstLook;
80
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments);
76
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries);
81
77
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
82
78
  ...base,
83
79
  baseBranch: baseLookup.branch,
@@ -86,8 +82,8 @@ export async function handleFixCode(ctx) {
86
82
  mode: "rebase-and-push",
87
83
  threads,
88
84
  actionableComments,
89
- noiseCommentIds,
90
85
  reviewSummaryIds,
86
+ firstLookSummaries,
91
87
  surfacedApprovals,
92
88
  checks,
93
89
  changesRequestedReviews,
@@ -91,7 +91,7 @@ export async function runIterate(opts) {
91
91
  }
92
92
  const headSha = (await getCurrentHeadSha()) ?? "unknown";
93
93
  const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
94
- const { minimizeIds: reviewSummaryIds, surfacedApprovals } = classifyReviewSummaries(report.reviewSummaries, report.approvedReviews, config.iterate.minimizeApprovals);
94
+ const { minimizeIds: reviewSummaryIds, firstLookSummaries, surfacedApprovals, } = classifyReviewSummaries({ firstLook: report.firstLookSummaries, seen: report.reviewSummaries }, report.approvedReviews, config.iterate.minimizeApprovals);
95
95
  const hasActionableWork = report.threads.actionable.length > 0 ||
96
96
  report.comments.actionable.length > 0 ||
97
97
  report.changesRequestedReviews.length > 0 ||
@@ -110,6 +110,7 @@ export async function runIterate(opts) {
110
110
  repoOwner,
111
111
  repoName,
112
112
  reviewSummaryIds,
113
+ firstLookSummaries,
113
114
  surfacedApprovals,
114
115
  });
115
116
  }
@@ -1,22 +1,23 @@
1
1
  /**
2
- * Render a ResolveCommand as a single-line command string for the monitor loop
3
- * to print or execute. This is NOT a general-purpose POSIX escaper — it wraps
4
- * the two known placeholders ($DISMISS_MESSAGE, $HEAD_SHA) and any whitespace-
5
- * bearing arg in double quotes so multi-word values don't split across flags.
2
+ * Render a resolve command as a shell snippet for the narrow placeholder-based
3
+ * invocation used by iterate.
6
4
  *
7
- * Contract for callers substituting placeholders: replace the entire quoted
8
- * token (including the surrounding `"`) with a properly shell-quoted literal.
9
- * Do not splice raw text inside the existing quotes — the output would then
10
- * re-expand `$…` / `$(…)` / embedded `"` and break.
5
+ * This is not a general-purpose shell escaper. It only wraps
6
+ * `$DISMISS_MESSAGE` and whitespace-bearing `rc.argv` entries in double quotes
7
+ * so the surrounding command template can later substitute placeholder values.
8
+ *
9
+ * Callers must preserve that contract:
10
+ * - placeholder substitution must replace the entire quoted token (for example,
11
+ * replace `"$DISMISS_MESSAGE"` as a whole, not text inside the quotes);
12
+ * - `rc.argv` must not contain `"`, `$`, `` ` ``, or `\`, because this helper
13
+ * does not escape them and will throw if they are present;
14
+ * - `$HEAD_SHA` must not appear in `rc.argv`; when `requiresHeadSha` is set it
15
+ * is appended separately below as the already-quoted token `"$HEAD_SHA"`.
11
16
  */
12
17
  export function renderResolveCommand(rc) {
13
- // `$HEAD_SHA` is never in `rc.argv` — it is appended pre-quoted below when
14
- // `requiresHeadSha`. Only `$DISMISS_MESSAGE` (or whitespace-bearing values)
15
- // need quoting here.
16
18
  const needsQuoting = (arg) => {
17
19
  if (arg === "$DISMISS_MESSAGE")
18
20
  return true;
19
- // Assert no characters that would break the naive escaper are present in arg
20
21
  if (/["$`\\]/.test(arg)) {
21
22
  throw new Error(`Unexpected character in argv arg that needsQuoting can't handle: ${JSON.stringify(arg)}`);
22
23
  }
@@ -28,16 +29,23 @@ export function renderResolveCommand(rc) {
28
29
  }
29
30
  return parts.join(" ");
30
31
  }
31
- export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = []) {
32
+ export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = []) {
32
33
  const instructions = [];
34
+ const hasSuggestions = threads.some((t) => t.suggestion);
35
+ if (hasSuggestions) {
36
+ instructions.push(`For each thread marked \`[suggestion]\` under \`## Review threads\`: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\`, one thread at a time. On \`applied: true\` the CLI already resolved the thread — remove its ID from \`--resolve-thread-ids\` in the \`resolve:\` command below. On \`applied: false\` read \`reason\` and \`patch\`, fall through to the manual-edit step, and do not retry the same command. Optionally pass \`--dry-run\` (omitting \`--message\`) to preview the patch without mutating the working tree.`);
37
+ }
33
38
  if (threads.length > 0 || actionableComments.length > 0) {
34
- instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.`);
39
+ const suggestionFallback = hasSuggestions
40
+ ? ` When applying a \`[suggestion]\` thread manually (e.g. after a failed \`commit-suggestion\` run), replace the exact line range shown in the heading (\`path:startLine-endLine\`) with the replacement shown in its \`Replaces lines …\` block verbatim — an empty replacement deletes those lines, a single blank line replaces the range with one blank line.`
41
+ : "";
42
+ instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.${suggestionFallback}`);
35
43
  }
36
44
  const checksWithRunId = checks.filter((c) => c.runId);
37
45
  const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
38
46
  const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
39
47
  if (checksWithRunId.length > 0) {
40
- instructions.push(`For each failing check under \`## Failing checks\` with a run ID: examine the log tail shown in the fenced block when available (or run \`gh run view <runId> --log-failed\` if the block is absent) to decide what to do. If the logs show a transient runner or infrastructure failure (e.g. network timeout, runner setup crash, OOM kill), run \`gh run rerun <runId> --failed\` and stop this iteration — CI will re-run automatically. If the logs show a real test or build failure, apply a code fix.`);
48
+ instructions.push(`For each failing check under \`## Failing checks\` with a run ID, examine the log tail in the fenced block to decide what to do:\n - If the log tail shows a transient runner or infrastructure failure (network timeout, runner setup crash, OOM kill), run \`gh run rerun <runId> --failed\` and stop this iteration — CI will re-run automatically.\n - If the log tail shows a real test or build failure, apply a code fix.\n - If the fenced log block is absent, run \`gh run view <runId> --log-failed\` first to fetch it, then choose between rerun and fix above.`);
41
49
  }
42
50
  if (externalChecks.length > 0) {
43
51
  instructions.push(`For each bullet in \`## Failing checks\` starting with \`external\` (external status check): open the linked URL in a browser to inspect the failure — log tails are not available for external checks.`);
@@ -65,8 +73,13 @@ export function buildFixInstructions(threads, actionableComments, checks, review
65
73
  instructions.push(`Rebase and push: \`git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease\`${captureHint}`);
66
74
  }
67
75
  }
68
- // Only tell the agent to run `resolve:` if the command actually mutates
69
- // GitHub state. A CONFLICTS-only flow has nothing to mutate on GitHub.
76
+ const firstLookTotal = firstLookThreads.length + firstLookComments.length;
77
+ if (firstLookTotal > 0) {
78
+ instructions.push(`Items in \`## First-look items\` are for acknowledgement only — do not pass their IDs to \`--resolve-thread-ids\`, \`--minimize-comment-ids\`, or \`--dismiss-review-ids\`. Acknowledge each one with a one-line classification (e.g. "outdated — addressed by commit abc1234", "resolved — already fixed", "minimized — noise").`);
79
+ }
80
+ if (firstLookSummaries.length > 0) {
81
+ instructions.push(`Review the bodies shown under \`## Review summaries (first look — to be minimized)\` — you are seeing these for the first time. Their IDs are already included in the \`resolve:\` command's \`--minimize-comment-ids\`; if any warrants a \`## Shepherd Journal\` entry, record it before running resolve.`);
82
+ }
70
83
  if (resolveCommand.hasMutations) {
71
84
  const substituteParts = [];
72
85
  if (resolveCommand.requiresHeadSha) {
@@ -81,10 +94,6 @@ export function buildFixInstructions(threads, actionableComments, checks, review
81
94
  if (needsPush && cancelledCount > 0) {
82
95
  instructions.push(`Do not re-run \`gh run cancel\` on the IDs listed under \`## Cancelled runs\` — the CLI cancelled those runs before your push, and your push has already triggered new runs with different IDs.`);
83
96
  }
84
- const firstLookTotal = firstLookThreads.length + firstLookComments.length;
85
- if (firstLookTotal > 0) {
86
- instructions.push(`Items in \`## First-look items\` are for acknowledgement only — do not pass their IDs to \`--resolve-thread-ids\`, \`--minimize-comment-ids\`, or \`--dismiss-review-ids\`. Acknowledge each one with a one-line classification (e.g. "outdated — addressed by commit abc1234", "resolved — already fixed", "minimized — noise").`);
87
- }
88
97
  if (resolveCommand.hasMutations) {
89
98
  instructions.push(`For any large decisions or rejections you made this iteration, add or update a \`## Shepherd Journal\` section in the PR description (\`gh pr edit ${prNumber} --body …\`) summarizing each decision. For threads and comments, use the markdown link shown in its heading above; for reviews, reference the review ID.`);
90
99
  }
@@ -0,0 +1,7 @@
1
+ import { getRepoInfo } from "../github/client.mjs";
2
+ import { resolveLogPath } from "../log/log-file.mjs";
3
+ export async function runLogFile() {
4
+ const { owner, name } = await getRepoInfo();
5
+ const path = await resolveLogPath({ owner, repo: name });
6
+ return { path };
7
+ }
@@ -16,7 +16,12 @@ export async function runMonitor(opts) {
16
16
  if (!Number.isFinite(maxTurns) || maxTurns <= 0 || !Number.isInteger(maxTurns)) {
17
17
  throw new Error(`Invalid config: watch.maxTurns must be a positive integer, got ${JSON.stringify(maxTurns)}`);
18
18
  }
19
- const loopTag = `# pr-shepherd-loop:pr=${prNumber}`;
19
+ // No space after `#` — `# text` is a CommonMark ATX heading; `#text` is not.
20
+ // Trailing `:` prevents substring false positives: without it, the dedup grep
21
+ // for pr=1 would match a cron prompt for pr=135. Both the CronList check in
22
+ // step 1 of formatMonitorResult's ## Instructions and the in-prompt Self-dedup
23
+ // block depend on this exact string — don't change the format.
24
+ const loopTag = `#pr-shepherd-loop:pr=${prNumber}:`;
20
25
  const loopPrompt = buildLoopPrompt(prNumber, loopTag, opts.readyDelaySuffix);
21
26
  const loopArgs = `${interval} --max-turns ${maxTurns} --expires ${expiresHours}h`;
22
27
  return { prNumber, loopTag, loopArgs, loopPrompt };
@@ -56,9 +61,7 @@ function validateReadyDelaySuffix(readyDelaySuffix) {
56
61
  }
57
62
  function buildLoopPrompt(prNumber, loopTag, readyDelaySuffix) {
58
63
  const validatedDelay = validateReadyDelaySuffix(readyDelaySuffix);
59
- const iterateCmd = validatedDelay
60
- ? `npx pr-shepherd iterate ${prNumber} --no-cache --ready-delay ${validatedDelay}`
61
- : `npx pr-shepherd iterate ${prNumber} --no-cache`;
64
+ const iterateCmd = `npx pr-shepherd iterate ${prNumber}${validatedDelay ? ` --ready-delay ${validatedDelay}` : ""}`;
62
65
  return [
63
66
  loopTag,
64
67
  "",
@@ -7,8 +7,8 @@
7
7
  */
8
8
  import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
9
9
  import { join, dirname } from "node:path";
10
- import { tmpdir } from "node:os";
11
10
  import { SAFE_SEGMENT } from "../util/path-segment.mjs";
11
+ import { resolveStateBase } from "../state/base.mjs";
12
12
  /**
13
13
  * Update the ready-delay state machine and return the current decision.
14
14
  *
@@ -64,7 +64,7 @@ function readySincePath(pr, owner, repo) {
64
64
  throw new Error(`Invalid path segment "${field}": ${value}`);
65
65
  }
66
66
  }
67
- const base = process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
67
+ const base = resolveStateBase();
68
68
  return join(base, `${owner}-${repo}`, String(pr), "ready-since.txt");
69
69
  }
70
70
  async function safeUnlink(path) {
@@ -28,10 +28,13 @@ export function buildFetchInstructions(prNumber, result) {
28
28
  }
29
29
  if (hasCodeItems) {
30
30
  instructions.push(`Read and edit each file referenced under \`## Actionable Review Threads\`, \`## Actionable PR Comments\`, and \`## Pending CHANGES_REQUESTED reviews\` above. Reclassify each fixed item as Fixed. If an item is too complex to address, leave it as Actionable for the final report.`);
31
- instructions.push(`Commit changed files: \`git add <files>\` (not \`git add -A\`) \`&& git commit -m "<descriptive message>"\`. If the fixes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Then rebase and push: \`BASE_BRANCH=$(gh pr view ${prNumber} --json baseRefName --jq .baseRefName) && git fetch origin && git rebase "origin/$BASE_BRANCH" && git push --force-with-lease\`. Cancel stale in-progress runs: \`BRANCH=$(git rev-parse --abbrev-ref HEAD) && gh run list --branch "$BRANCH" --status in_progress --json databaseId --jq '.[].databaseId' | xargs -I{} gh run cancel {}\`.`);
31
+ instructions.push(`Commit changed files: \`git add <files>\` (not \`git add -A\`) \`&& git commit -m "<descriptive message>"\`.`);
32
+ instructions.push(`Keep the PR title and description current: if the fixes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
33
+ instructions.push(`Rebase and push: \`BASE_BRANCH=$(gh pr view ${prNumber} --json baseRefName --jq .baseRefName) && git fetch origin && git rebase "origin/$BASE_BRANCH" && git push --force-with-lease\`.`);
34
+ instructions.push(`Cancel stale in-progress runs: \`BRANCH=$(git rev-parse --abbrev-ref HEAD) && CURRENT_SHA=$(git rev-parse HEAD) && gh run list --branch "$BRANCH" --status in_progress --json databaseId,headSha --jq ".[] | select(.headSha != \\"$CURRENT_SHA\\") | .databaseId" | xargs -I{} gh run cancel {}\`.`);
32
35
  }
33
36
  const requireShaHint = hasCodeItems
34
- ? ` Include \`--require-sha $(git rev-parse HEAD)\` only when the commit-and-push step above ran.`
37
+ ? ` Include \`--require-sha $(git rev-parse HEAD)\` only when the rebase-and-push step above ran.`
35
38
  : "";
36
39
  const dismissNote = changesRequestedReviews.length > 0
37
40
  ? ` For \`--dismiss-review-ids\`: \`--message\` is required with one specific sentence describing the fix or the reason for not acting (no boilerplate like "address review comments"); omit \`--message\` when not dismissing. Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`, never \`--dismiss-review-ids\`.`
@@ -3,7 +3,7 @@ import { fetchPrBatch } from "../github/batch.mjs";
3
3
  import { getOutdatedThreads } from "../comments/outdated.mjs";
4
4
  import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mjs";
5
5
  import { loadConfig } from "../config/load.mjs";
6
- import { parseSuggestion } from "../suggestions/parse.mjs";
6
+ import { extractSuggestion } from "../suggestions/extract.mjs";
7
7
  import { buildFetchInstructions } from "./resolve-instructions.mjs";
8
8
  import { loadSeenSet, markSeen } from "../state/seen-comments.mjs";
9
9
  /**
@@ -80,25 +80,6 @@ export async function runResolveFetch(opts) {
80
80
  };
81
81
  return { ...result, instructions: buildFetchInstructions(prNumber, result) };
82
82
  }
83
- /**
84
- * Attach a parsed suggestion block to a thread if the comment body contains one
85
- * and the thread has a resolvable line anchor. Threads without `path`/`line`
86
- * (rare — usually file-level comments) can't accept a suggestion commit.
87
- */
88
- function extractSuggestion(thread) {
89
- if (!thread.path || thread.line === null)
90
- return null;
91
- const parsed = parseSuggestion(thread.body);
92
- if (!parsed)
93
- return null;
94
- const startLine = thread.startLine ?? thread.line;
95
- return {
96
- startLine,
97
- endLine: thread.line,
98
- lines: parsed.lines,
99
- author: thread.author,
100
- };
101
- }
102
83
  /**
103
84
  * Mutation mode: resolve/minimize/dismiss by ID.
104
85
  */
@@ -2,35 +2,46 @@
2
2
  * `shepherd status PR1 [PR2 PR3 …]`
3
3
  *
4
4
  * Fetches readiness status for one or more PRs and prints a table.
5
- * Uses a separate lightweight GraphQL query (MULTI_PR_STATUS_QUERY) per PR
6
- * rather than the heavy batch query, since we only need summary data.
5
+ * Issues a single GraphQL request with one alias per PR number rather than
6
+ * N separate requests, so the round-trip count is always 1 (plus optional
7
+ * per-PR pagination calls when a PR has > 100 review threads).
7
8
  *
8
9
  * Exit code: 0 if all PRs are READY, non-zero otherwise.
9
10
  */
10
11
  import { graphql, getRepoInfo } from "../github/client.mjs";
11
- import { MULTI_PR_STATUS_QUERY, MULTI_PR_STATUS_QUERY_WITH_CURSOR } from "../github/queries.mjs";
12
+ import { MULTI_PR_STATUS_QUERY_WITH_CURSOR } from "../github/queries.mjs";
12
13
  export async function runStatus(opts) {
14
+ if (opts.prNumbers.length === 0)
15
+ return [];
13
16
  const repo = await getRepoInfo();
14
- const summaries = await Promise.all(opts.prNumbers.map((pr) => fetchSummary(pr, repo.owner, repo.name)));
17
+ const doc = buildBatchStatusQuery(opts.prNumbers);
18
+ const result = await graphql(doc, {
19
+ owner: repo.owner,
20
+ repo: repo.name,
21
+ });
22
+ const summaries = await Promise.all(opts.prNumbers.map((pr) => {
23
+ const rawPr = result.data.repository[`pr_${pr}`];
24
+ if (!rawPr) {
25
+ throw new Error(`PR #${pr} not found in ${repo.owner}/${repo.name}`);
26
+ }
27
+ return paginateAndBuild(pr, rawPr, repo.owner, repo.name);
28
+ }));
15
29
  return summaries;
16
30
  }
17
31
  // ---------------------------------------------------------------------------
18
32
  // Internal
19
33
  // ---------------------------------------------------------------------------
20
- async function fetchSummary(pr, owner, repo) {
21
- const result = await graphql(MULTI_PR_STATUS_QUERY, {
22
- owner,
23
- repo,
24
- pr,
25
- });
26
- const p = result.data.repository.pullRequest;
27
- if (!p) {
28
- throw new Error(`PR #${pr} not found in ${owner}/${repo}`);
29
- }
34
+ function buildBatchStatusQuery(prNumbers) {
35
+ const uniquePrs = [...new Set(prNumbers.filter((n) => n > 0))];
36
+ const f = "number title state isDraft mergeStateStatus reviewDecision " +
37
+ "reviewThreads(last:100){totalCount pageInfo{hasPreviousPage startCursor} nodes{isResolved}} " +
38
+ "commits(last:1){nodes{commit{statusCheckRollup{state}}}}";
39
+ const aliases = uniquePrs.map((n) => `pr_${n}:pullRequest(number:${n}){${f}}`).join(" ");
40
+ return `query MultiPrStatusBatch($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){${aliases}}}`;
41
+ }
42
+ async function paginateAndBuild(pr, p, owner, repo) {
30
43
  let allNodes = p.reviewThreads.nodes;
31
- // If the response was truncated, fetch additional pages to get the full count.
32
44
  if (p.reviewThreads.totalCount > p.reviewThreads.nodes.length) {
33
- // Fetch additional pages backward until we have all threads.
34
45
  const MAX_THREAD_PAGES = 10;
35
46
  let pagesFetched = 0;
36
47
  const totalCount = p.reviewThreads.totalCount;
@@ -59,7 +70,6 @@ async function fetchSummary(pr, owner, repo) {
59
70
  }
60
71
  const unresolvedThreads = allNodes.filter((n) => !n.isResolved).length;
61
72
  const ciState = p.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null;
62
- // If we still have fewer nodes than totalCount, report truncation.
63
73
  const threadsTruncated = p.reviewThreads.totalCount > allNodes.length;
64
74
  return {
65
75
  number: p.number,
@@ -77,17 +87,19 @@ async function fetchSummary(pr, owner, repo) {
77
87
  // Output helpers
78
88
  // ---------------------------------------------------------------------------
79
89
  export function formatStatusTable(summaries, repoFull) {
80
- const lines = [`\n# ${repoFull} — PR status (${summaries.length})\n`];
81
- for (const s of summaries) {
82
- const verdict = deriveVerdict(s);
83
- const ciLabel = s.ciState ?? "—";
84
- const title = s.title.slice(0, 50);
85
- const truncNote = s.threadsTruncated
86
- ? " (threads truncated — run shepherd check for full count)"
87
- : "";
88
- lines.push(`PR #${String(s.number).padEnd(5)} ${title.padEnd(52)} ${verdict.padEnd(12)} ${ciLabel}${truncNote}`);
89
- }
90
- return lines.join("\n");
90
+ const heading = `# ${repoFull} — PR status (${summaries.length})`;
91
+ if (summaries.length === 0)
92
+ return heading;
93
+ const rows = summaries.map((s) => {
94
+ const raw = s.title.length > 50 ? `${s.title.slice(0, 47)}...` : s.title;
95
+ const title = raw.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\n/g, " ");
96
+ return `| #${s.number} | ${title} | ${deriveVerdict(s)} | ${s.ciState ?? "—"} |`;
97
+ });
98
+ const table = ["| PR | Title | Verdict | CI |", "| --- | --- | --- | --- |", ...rows].join("\n");
99
+ const footnotes = summaries
100
+ .filter((s) => s.threadsTruncated)
101
+ .map((s) => `> Note: PR #${s.number} threads truncated — run \`pr-shepherd check ${s.number}\` for full count.`);
102
+ return [heading, table, ...footnotes].join("\n\n");
91
103
  }
92
104
  export function deriveVerdict(s) {
93
105
  if (s.state === "MERGED")