pr-shepherd 0.10.3 → 0.12.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 (37) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/README.md +51 -9
  3. package/bin/agent-runtime.mjs +7 -0
  4. package/bin/checks/triage.mjs +5 -46
  5. package/bin/cli/args.mjs +10 -2
  6. package/bin/cli/default-iterate.mjs +54 -0
  7. package/bin/cli/duration-flag.mjs +22 -0
  8. package/bin/cli/fix-formatter.mjs +25 -15
  9. package/bin/cli/formatters.mjs +26 -38
  10. package/bin/cli/handlers.mjs +26 -24
  11. package/bin/cli/iterate-formatter.mjs +14 -10
  12. package/bin/cli/iterate-instructions.mjs +75 -0
  13. package/bin/cli/iterate-lean.mjs +59 -6
  14. package/bin/cli/list-formatters.mjs +11 -0
  15. package/bin/cli-parser.iterate-fixtures.mjs +2 -0
  16. package/bin/cli-parser.mjs +15 -1
  17. package/bin/commands/check.mjs +6 -6
  18. package/bin/commands/commit-suggestion.mjs +34 -80
  19. package/bin/commands/iterate/classify.mjs +5 -4
  20. package/bin/commands/iterate/escalate.mjs +2 -1
  21. package/bin/commands/iterate/fix-code.mjs +16 -8
  22. package/bin/commands/iterate/helpers.mjs +34 -0
  23. package/bin/commands/iterate/index.mjs +2 -2
  24. package/bin/commands/iterate/render.mjs +25 -37
  25. package/bin/commands/iterate/stall.mjs +3 -1
  26. package/bin/commands/iterate.mjs +1 -1
  27. package/bin/commands/monitor.mjs +68 -17
  28. package/bin/commands/ready-delay.mjs +2 -1
  29. package/bin/commands/resolve-instructions.mjs +8 -4
  30. package/bin/commands/resolve.mjs +4 -4
  31. package/bin/config.json +1 -3
  32. package/bin/index.mjs +1 -0
  33. package/bin/reporters/agent.mjs +6 -2
  34. package/bin/reporters/check-instructions.mjs +11 -5
  35. package/bin/reporters/json.mjs +2 -2
  36. package/bin/reporters/text.mjs +24 -16
  37. package/package.json +3 -2
@@ -1,5 +1,6 @@
1
1
  import { formatFixCodeResult } from "./fix-formatter.mjs";
2
2
  import { joinSections } from "../util/markdown.mjs";
3
+ import { adaptIterateLog, buildSimpleIterateInstructions, numberInstructions, } from "./iterate-instructions.mjs";
3
4
  /**
4
5
  * Format an IterateResult as human-readable Markdown.
5
6
  *
@@ -17,6 +18,9 @@ import { joinSections } from "../util/markdown.mjs";
17
18
  */
18
19
  export function formatIterateResult(result, opts) {
19
20
  const verbose = opts?.verbose ?? false;
21
+ const runtime = opts?.runtime ?? "claude";
22
+ const readyDelaySuffix = opts?.readyDelaySuffix;
23
+ const retryInterval = opts?.retryInterval;
20
24
  const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
21
25
  const reviewDecisionSeg = result.mergeStatus === "BLOCKED" && result.reviewDecision
22
26
  ? ` · **reviewDecision** \`${result.reviewDecision}\``
@@ -51,34 +55,34 @@ export function formatIterateResult(result, opts) {
51
55
  // placeholders that add no value. Emit only heading + log + Instructions.
52
56
  return joinSections([
53
57
  verbose ? header : heading,
54
- result.log,
55
- "## Instructions\n\n1. End this iteration — the next cron fire will recheck once CI starts reporting.",
58
+ adaptIterateLog(result.log, runtime),
59
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval))}`,
56
60
  ]);
57
61
  case "wait":
58
62
  return joinSections([
59
63
  header,
60
- result.log,
61
- "## Instructions\n\n1. End this iteration — the next cron fire will recheck.",
64
+ adaptIterateLog(result.log, runtime),
65
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval))}`,
62
66
  ]);
63
67
  case "mark_ready":
64
68
  return joinSections([
65
69
  header,
66
- result.log,
67
- "## Instructions\n\n1. The CLI already marked the PR ready for review — end this iteration.",
70
+ adaptIterateLog(result.log, runtime),
71
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval))}`,
68
72
  ]);
69
73
  case "cancel":
70
74
  return joinSections([
71
75
  [`${heading} — ${result.reason}`, "", baseLine, summaryLine].join("\n"),
72
- result.log,
73
- "## Instructions\n\n1. Invoke `/loop cancel` via the Skill tool.\n2. Stop.",
76
+ adaptIterateLog(result.log, runtime),
77
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval))}`,
74
78
  ]);
75
79
  case "escalate":
76
80
  return joinSections([
77
81
  header,
78
82
  result.escalate.humanMessage,
79
- "## Instructions\n\n1. Invoke `/loop cancel` via the Skill tool.\n2. Stop — the PR needs human direction before monitoring can resume.",
83
+ `## Instructions\n\n${numberInstructions(buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval))}`,
80
84
  ]);
81
85
  case "fix_code":
82
- return formatFixCodeResult(header, result);
86
+ return formatFixCodeResult(header, result, { runtime, readyDelaySuffix, retryInterval });
83
87
  }
84
88
  }
@@ -0,0 +1,75 @@
1
+ import { FIX_INSTRUCTION_END_ITERATION, FIX_INSTRUCTION_STOP_AFTER_PUSH, FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK, } from "../commands/iterate/render.mjs";
2
+ const DEFAULT_CODEX_RETRY_INTERVAL = "4m";
3
+ const VALID_RETRY_INTERVAL = /^\d+[smhd]$/;
4
+ export function buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval) {
5
+ const rerunCommand = buildCodexIterateCommand(result.pr, readyDelaySuffix);
6
+ const retryDelay = buildCodexRetryDelay(retryInterval);
7
+ switch (result.action) {
8
+ case "cooldown":
9
+ return [
10
+ runtime === "codex"
11
+ ? `Continue the active Codex goal — wait about ${retryDelay}, then rerun \`${rerunCommand}\` after CI starts reporting.`
12
+ : "End this iteration — the next cron fire will recheck once CI starts reporting.",
13
+ ];
14
+ case "wait":
15
+ return [
16
+ runtime === "codex"
17
+ ? `Continue the active Codex goal — wait about ${retryDelay}, then rerun \`${rerunCommand}\` to recheck.`
18
+ : "End this iteration — the next cron fire will recheck.",
19
+ ];
20
+ case "mark_ready":
21
+ return [
22
+ runtime === "codex"
23
+ ? `The CLI already marked the PR ready for review. Continue the active Codex goal until the ready-delay completes — wait about ${retryDelay}, then rerun \`${rerunCommand}\` to recheck.`
24
+ : "The CLI already marked the PR ready for review — end this iteration.",
25
+ ];
26
+ case "cancel":
27
+ return [
28
+ runtime === "codex"
29
+ ? "Stop — no recurring Codex monitor is running to cancel."
30
+ : "Invoke `/loop cancel` via the Skill tool.",
31
+ "Stop.",
32
+ ];
33
+ case "escalate":
34
+ return [
35
+ runtime === "codex"
36
+ ? "Stop — no recurring Codex monitor is running to cancel."
37
+ : "Invoke `/loop cancel` via the Skill tool.",
38
+ "Stop — the PR needs human direction before monitoring can resume.",
39
+ ];
40
+ }
41
+ }
42
+ export function adaptFixCodeInstructions(instructions, pr, runtime, readyDelaySuffix, retryInterval) {
43
+ if (runtime !== "codex")
44
+ return instructions;
45
+ const rerunCommand = buildCodexIterateCommand(pr, readyDelaySuffix);
46
+ const retryDelay = buildCodexRetryDelay(retryInterval);
47
+ return instructions.map((instruction) => {
48
+ if (instruction === FIX_INSTRUCTION_STOP_AFTER_PUSH) {
49
+ return `Continue the active Codex goal — CI needs time to run on the new push. Wait about ${retryDelay}, then rerun \`${rerunCommand}\` to recheck.`;
50
+ }
51
+ if (instruction === FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK ||
52
+ instruction === FIX_INSTRUCTION_END_ITERATION) {
53
+ return `Continue the active Codex goal — wait about ${retryDelay}, then rerun \`${rerunCommand}\` to recheck.`;
54
+ }
55
+ return instruction;
56
+ });
57
+ }
58
+ export function adaptIterateLog(log, runtime) {
59
+ if (runtime !== "codex")
60
+ return log;
61
+ return log.replace(/\s+—\s+\d+s until auto-cancel/g, "");
62
+ }
63
+ export function buildCodexIterateCommand(pr, readyDelaySuffix) {
64
+ const suffix = readyDelaySuffix?.trim();
65
+ return `npx pr-shepherd ${pr}${suffix ? ` --ready-delay ${suffix}` : ""}`;
66
+ }
67
+ export function buildCodexRetryDelay(retryInterval) {
68
+ const interval = typeof retryInterval === "string" ? retryInterval.trim() : "";
69
+ return VALID_RETRY_INTERVAL.test(interval)
70
+ ? `the configured interval (${interval})`
71
+ : `the configured interval (default ${DEFAULT_CODEX_RETRY_INTERVAL})`;
72
+ }
73
+ export function numberInstructions(instructions) {
74
+ return instructions.map((instruction, i) => `${i + 1}. ${instruction}`).join("\n");
75
+ }
@@ -1,9 +1,14 @@
1
+ import { adaptIterateLog, adaptFixCodeInstructions, buildSimpleIterateInstructions, } from "./iterate-instructions.mjs";
1
2
  /**
2
3
  * Project an IterateResult to a lean JSON shape for the default (non-verbose) output.
3
4
  * Omits fields that are the trivial default (false, 0, empty) or state-gated fields
4
5
  * outside the state where they are meaningful.
5
6
  */
6
- export function projectIterateLean(result) {
7
+ export function projectIterateLean(result, opts) {
8
+ const runtime = opts?.runtime ?? "claude";
9
+ const readyDelaySuffix = opts?.readyDelaySuffix;
10
+ const retryInterval = opts?.retryInterval;
11
+ const simpleInstructions = (r) => buildSimpleIterateInstructions(r, runtime, readyDelaySuffix, retryInterval);
7
12
  const base = {
8
13
  action: result.action,
9
14
  pr: result.pr,
@@ -30,14 +35,31 @@ export function projectIterateLean(result) {
30
35
  };
31
36
  switch (result.action) {
32
37
  case "cooldown":
33
- return { ...base, log: result.log };
38
+ return {
39
+ ...base,
40
+ log: adaptIterateLog(result.log, runtime),
41
+ instructions: simpleInstructions(result),
42
+ };
34
43
  case "wait":
35
- return { ...base, log: result.log };
44
+ return {
45
+ ...base,
46
+ log: adaptIterateLog(result.log, runtime),
47
+ instructions: simpleInstructions(result),
48
+ };
36
49
  case "cancel":
37
- return { ...base, reason: result.reason, log: result.log };
50
+ return {
51
+ ...base,
52
+ reason: result.reason,
53
+ log: adaptIterateLog(result.log, runtime),
54
+ instructions: simpleInstructions(result),
55
+ };
38
56
  case "mark_ready":
39
57
  // drop markedReady — always true, redundant with action discriminator
40
- return { ...base, log: result.log };
58
+ return {
59
+ ...base,
60
+ log: adaptIterateLog(result.log, runtime),
61
+ instructions: simpleInstructions(result),
62
+ };
41
63
  case "fix_code":
42
64
  return {
43
65
  ...base,
@@ -46,6 +68,9 @@ export function projectIterateLean(result) {
46
68
  fix: {
47
69
  mode: result.fix.mode,
48
70
  ...(result.fix.threads.length > 0 && { threads: result.fix.threads }),
71
+ ...(result.fix.resolutionOnlyThreads.length > 0 && {
72
+ resolutionOnlyThreads: result.fix.resolutionOnlyThreads,
73
+ }),
49
74
  ...(result.fix.actionableComments.length > 0 && {
50
75
  actionableComments: result.fix.actionableComments,
51
76
  }),
@@ -67,12 +92,17 @@ export function projectIterateLean(result) {
67
92
  ...(result.fix.firstLookComments.length > 0 && {
68
93
  firstLookComments: result.fix.firstLookComments,
69
94
  }),
95
+ ...(result.fix.inProgressRunIds.length > 0 && {
96
+ inProgressRunIds: result.fix.inProgressRunIds,
97
+ }),
70
98
  ...(result.fix.checks.length > 0 && { checks: result.fix.checks }),
71
99
  ...(result.fix.changesRequestedReviews.length > 0 && {
72
100
  changesRequestedReviews: result.fix.changesRequestedReviews,
73
101
  }),
74
102
  resolveCommand: result.fix.resolveCommand,
75
- ...(result.fix.instructions.length > 0 && { instructions: result.fix.instructions }),
103
+ ...(result.fix.instructions.length > 0 && {
104
+ instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, retryInterval),
105
+ }),
76
106
  },
77
107
  };
78
108
  case "escalate":
@@ -96,6 +126,29 @@ export function projectIterateLean(result) {
96
126
  suggestion: result.escalate.suggestion,
97
127
  humanMessage: result.escalate.humanMessage,
98
128
  },
129
+ instructions: simpleInstructions(result),
99
130
  };
100
131
  }
101
132
  }
133
+ export function projectIterateVerbose(result, opts) {
134
+ const runtime = opts?.runtime ?? "claude";
135
+ const readyDelaySuffix = opts?.readyDelaySuffix;
136
+ const retryInterval = opts?.retryInterval;
137
+ if (result.action === "fix_code") {
138
+ return {
139
+ ...result,
140
+ fix: {
141
+ ...result.fix,
142
+ instructions: adaptFixCodeInstructions(result.fix.instructions, result.pr, runtime, readyDelaySuffix, retryInterval),
143
+ },
144
+ };
145
+ }
146
+ const log = "log" in result && typeof result.log === "string"
147
+ ? { log: adaptIterateLog(result.log, runtime) }
148
+ : {};
149
+ return {
150
+ ...result,
151
+ ...log,
152
+ instructions: buildSimpleIterateInstructions(result, runtime, readyDelaySuffix, retryInterval),
153
+ };
154
+ }
@@ -11,6 +11,12 @@ export function renderFirstLookStatusTag(t) {
11
11
  ? `[status: outdated, auto-resolved${editedSuffix}]`
12
12
  : `[status: ${t.firstLookStatus}${editedSuffix}]`;
13
13
  }
14
+ export function renderThreadResolutionStatusTag(t) {
15
+ const status = [t.isOutdated ? "outdated" : null, t.isMinimized ? "minimized" : null]
16
+ .filter(Boolean)
17
+ .join(", ");
18
+ return status ? `[status: ${status}]` : "[status: unresolved]";
19
+ }
14
20
  export function renderThreadBullet(t, opts = {}) {
15
21
  const link = t.url ? ` [↗](${t.url})` : "";
16
22
  const loc = t.path
@@ -33,3 +39,8 @@ export function renderReviewBullet(r, opts = {}) {
33
39
  const bodySuffix = opts.includeBody && r.body != null && r.body !== "" ? `: ${renderBodyPreview(r.body)}` : "";
34
40
  return `- \`reviewId=${r.id}\` (@${r.author})${bodySuffix}`;
35
41
  }
42
+ export function renderReviewListSection(heading, items) {
43
+ if (items.length === 0)
44
+ return null;
45
+ return `## ${heading}\n\n${items.map((r) => renderReviewBullet(r, { includeBody: true })).join("\n")}`;
46
+ }
@@ -28,6 +28,7 @@ export function makeIterateResult(action = "wait") {
28
28
  fix: {
29
29
  mode: "rebase-and-push",
30
30
  threads: [],
31
+ resolutionOnlyThreads: [],
31
32
  actionableComments: [],
32
33
  reviewSummaryIds: [],
33
34
  firstLookSummaries: [],
@@ -44,6 +45,7 @@ export function makeIterateResult(action = "wait") {
44
45
  instructions: ["End this iteration."],
45
46
  firstLookThreads: [],
46
47
  firstLookComments: [],
48
+ inProgressRunIds: [],
47
49
  },
48
50
  cancelled: [],
49
51
  };
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Usage:
5
5
  * pr-shepherd --version
6
+ * pr-shepherd [PR] [--format text|json] [--cooldown-seconds N] [--ready-delay Nm]
7
+ * [--stall-timeout <duration>] [--no-auto-mark-ready]
8
+ * [--no-auto-cancel-actionable]
6
9
  * pr-shepherd check [PR] [--format text|json]
7
10
  * pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
8
11
  * [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
@@ -22,10 +25,12 @@ import { runLogFile } from "./commands/log-file.mjs";
22
25
  import { formatJson } from "./reporters/json.mjs";
23
26
  import { formatText } from "./reporters/text.mjs";
24
27
  import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
28
+ import { isDefaultIterateInvocation, validateDefaultIterateArgs } from "./cli/default-iterate.mjs";
25
29
  import { statusToExitCode } from "./cli/exit-codes.mjs";
26
30
  import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
27
31
  import { handleCommitSuggestion, handleIterate, handleMonitor, handleStatus, } from "./cli/handlers.mjs";
28
32
  import { setupLog } from "./log/setup.mjs";
33
+ import { detectAgentRuntime } from "./agent-runtime.mjs";
29
34
  // ---------------------------------------------------------------------------
30
35
  // Entry
31
36
  // ---------------------------------------------------------------------------
@@ -43,6 +48,12 @@ export async function main(argv) {
43
48
  }
44
49
  // Initialize the per-worktree log and install a stdout tee.
45
50
  await setupLog(argv);
51
+ if (isDefaultIterateInvocation(subcommand)) {
52
+ if (!validateDefaultIterateArgs(args))
53
+ return;
54
+ await handleIterate(args);
55
+ return;
56
+ }
46
57
  switch (subcommand) {
47
58
  case "check":
48
59
  await handleCheck(args.slice(1));
@@ -80,8 +91,11 @@ function readVersion() {
80
91
  // ---------------------------------------------------------------------------
81
92
  async function handleCheck(args) {
82
93
  const { prNumber, global: globalOpts } = parseCommonArgs(args);
94
+ const runtime = detectAgentRuntime();
83
95
  const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
84
- const output = globalOpts.format === "json" ? formatJson(report) : formatText(report);
96
+ const output = globalOpts.format === "json"
97
+ ? formatJson(report, { runtime })
98
+ : formatText(report, { runtime });
85
99
  process.stdout.write(`${output}\n`);
86
100
  process.exitCode = statusToExitCode(report.status);
87
101
  }
@@ -35,11 +35,9 @@ export async function runCheck(opts) {
35
35
  const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
36
36
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
37
37
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
38
- const triaged = failing.length > 0 && !opts.skipTriage
39
- ? await triageFailingChecks(failing, repo, config.checks.logTailLines, config.checks.logTailChars)
40
- : failing;
38
+ const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
41
39
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
42
- const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
40
+ const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved);
43
41
  const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
44
42
  const outdated = getOutdatedThreads(unresolvedThreads);
45
43
  let autoResolved = [];
@@ -49,7 +47,7 @@ export async function runCheck(opts) {
49
47
  autoResolved = outdated.filter((t) => resolvedIds.includes(t.id));
50
48
  autoResolveErrors = errors;
51
49
  }
52
- const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
50
+ const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated && !t.isMinimized);
53
51
  const outdatedCandidates = batchData.reviewThreads.filter((t) => t.isOutdated);
54
52
  const resolvedCandidates = batchData.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
55
53
  const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
@@ -108,13 +106,14 @@ export async function runCheck(opts) {
108
106
  ...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
109
107
  ]);
110
108
  const actionableThreads = activeThreads;
109
+ const resolutionOnlyThreads = unresolvedThreads.filter((t) => !autoResolvedIds.has(t.id) && (t.isOutdated || t.isMinimized));
111
110
  const actionableComments = visibleComments;
112
111
  const mergeStatus = deriveMergeStatus(batchData);
113
112
  const blockedByFilteredCheck = mergeStatus.status === "BLOCKED" &&
114
113
  !verdict.anyFailing &&
115
114
  !verdict.anyInProgress &&
116
115
  verdict.filteredNames.length > 0;
117
- const status = computeStatus(verdict, actionableThreads.length, actionableComments.length, mergeStatus, batchData.changesRequestedReviews.length);
116
+ const status = computeStatus(verdict, actionableThreads.length + resolutionOnlyThreads.length, actionableComments.length, mergeStatus, batchData.changesRequestedReviews.length);
118
117
  return {
119
118
  pr: prNumber,
120
119
  nodeId: batchData.nodeId,
@@ -133,6 +132,7 @@ export async function runCheck(opts) {
133
132
  },
134
133
  threads: {
135
134
  actionable: actionableThreads,
135
+ resolutionOnly: resolutionOnlyThreads,
136
136
  autoResolved,
137
137
  autoResolveErrors,
138
138
  firstLook: firstLookThreads,
@@ -1,11 +1,8 @@
1
1
  import { execFile as execFileCb } from "node:child_process";
2
- import { readFile, writeFile, unlink } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
2
+ import { readFile } from "node:fs/promises";
5
3
  import { promisify } from "node:util";
6
4
  import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/client.mjs";
7
5
  import { fetchPrBatch } from "../github/batch.mjs";
8
- import { applyResolveOptions } from "../comments/resolve.mjs";
9
6
  import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
10
7
  import { buildUnifiedDiff } from "../suggestions/patch.mjs";
11
8
  const execFile = promisify(execFileCb);
@@ -13,13 +10,9 @@ export async function runCommitSuggestion(opts) {
13
10
  if (!opts.threadId) {
14
11
  throw new Error("--thread-id is required");
15
12
  }
16
- if (!opts.dryRun && (!opts.message || opts.message.trim() === "")) {
13
+ if (!opts.message || opts.message.trim() === "") {
17
14
  throw new Error("--message is required and must be non-empty");
18
15
  }
19
- const { stdout: statusOut } = await execFile("git", ["status", "--porcelain"]);
20
- if (statusOut.trim() !== "") {
21
- throw new Error("Working tree has uncommitted changes. Commit or stash them before running commit-suggestion.");
22
- }
23
16
  const repo = await getRepoInfo();
24
17
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
25
18
  if (prNumber === null) {
@@ -56,6 +49,17 @@ export async function runCommitSuggestion(opts) {
56
49
  if (!thread.path || thread.line === null) {
57
50
  throw new Error(`Thread ${opts.threadId} has no file/line anchor.`);
58
51
  }
52
+ // Validate the target file is clean before generating the patch, so the emitted
53
+ // `git add -- <file>` instruction cannot accidentally stage unrelated local edits.
54
+ const { stdout: fileStatus } = await execFile("git", [
55
+ "status",
56
+ "--porcelain",
57
+ "--",
58
+ thread.path,
59
+ ]);
60
+ if (fileStatus.trim() !== "") {
61
+ throw new Error(`${thread.path} has uncommitted changes. Commit or stash them before running commit-suggestion.`);
62
+ }
59
63
  const parsed = parseSuggestion(thread.body);
60
64
  if (!parsed) {
61
65
  throw new Error(`Thread ${opts.threadId} has no suggestion block in the comment body.`);
@@ -75,74 +79,25 @@ export async function runCommitSuggestion(opts) {
75
79
  endLine,
76
80
  replacementLines: parsed.lines,
77
81
  });
78
- const patchFile = join(tmpdir(), `pr-shepherd-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
79
- let patchError = null;
80
- try {
81
- await writeFile(patchFile, patch, { mode: 0o600 });
82
- try {
83
- await execFile("git", ["apply", "--check", patchFile]);
84
- }
85
- catch (err) {
86
- patchError = (err.stderr?.trim() || String(err)).trim();
87
- }
88
- if (opts.dryRun) {
89
- return {
90
- pr: prNumber,
91
- repo: `${repo.owner}/${repo.name}`,
92
- threadId: opts.threadId,
93
- path: filePath,
94
- startLine,
95
- endLine,
96
- author: thread.author,
97
- applied: false,
98
- dryRun: true,
99
- valid: patchError === null,
100
- reason: patchError !== null ? `git apply rejected the patch: ${patchError}` : null,
101
- patch,
102
- postActionInstruction: patchError === null ? "Re-run without --dry-run to apply and commit." : "",
103
- };
104
- }
105
- if (patchError !== null) {
106
- return {
107
- pr: prNumber,
108
- repo: `${repo.owner}/${repo.name}`,
109
- threadId: opts.threadId,
110
- path: filePath,
111
- startLine,
112
- endLine,
113
- author: thread.author,
114
- applied: false,
115
- reason: `git apply rejected the patch: ${patchError}`,
116
- patch,
117
- postActionInstruction: "",
118
- };
119
- }
120
- try {
121
- await execFile("git", ["apply", patchFile]);
122
- }
123
- catch (applyErr) {
124
- try {
125
- await execFile("git", ["checkout", "--", filePath]);
126
- }
127
- catch {
128
- // best-effort rollback
129
- }
130
- throw applyErr;
131
- }
132
- }
133
- finally {
134
- await unlink(patchFile).catch(() => undefined);
135
- }
136
- await execFile("git", ["add", "--", filePath]);
137
82
  const coAuthor = `Co-authored-by: ${thread.author} <${thread.author}@users.noreply.github.com>`;
138
83
  const commitBody = opts.description ? `${opts.description}\n\n${coAuthor}` : coAuthor;
139
- await execFile("git", ["commit", "-m", opts.message, "-m", commitBody]);
140
- const { stdout: shaOut } = await execFile("git", ["rev-parse", "HEAD"]);
141
- const commitSha = shaOut.trim();
142
- const resolveResult = await applyResolveOptions(prNumber, repo, {
143
- resolveThreadIds: [opts.threadId],
144
- });
145
- const resolveErrors = resolveResult.errors;
84
+ const commitMessageArg = opts.message;
85
+ const commitBodyArg = commitBody;
86
+ const quotedPath = `'${filePath.replace(/'/g, "'\\''")}'`;
87
+ const range = startLine === endLine ? `line ${startLine}` : `lines ${startLine}–${endLine}`;
88
+ const sq = (s) => `'${s.replace(/'/g, "'\\''")}'`;
89
+ const commitCmd = [
90
+ "git commit",
91
+ `-m ${sq(commitMessageArg)}`,
92
+ ...commitBodyArg.split("\n\n").map((p) => `-m ${sq(p)}`),
93
+ ].join(" ");
94
+ const postActionInstructions = [
95
+ `Apply the patch to \`${filePath}\`: run \`git apply\` with the diff shown above, or edit the file directly using the line range (${range}).`,
96
+ `Stage the file: \`git add -- ${quotedPath}\``,
97
+ `Commit: \`${commitCmd}\``,
98
+ `Resolve the thread on GitHub: \`npx pr-shepherd resolve ${prNumber} --resolve-thread-ids ${opts.threadId}\``,
99
+ `Push when ready: \`git push\` (or \`git push --force-with-lease\` after rebasing).`,
100
+ ];
146
101
  return {
147
102
  pr: prNumber,
148
103
  repo: `${repo.owner}/${repo.name}`,
@@ -151,11 +106,10 @@ export async function runCommitSuggestion(opts) {
151
106
  startLine,
152
107
  endLine,
153
108
  author: thread.author,
154
- applied: true,
155
- commitSha,
156
109
  patch,
157
- postActionInstruction: resolveErrors.length > 0
158
- ? `Commit created (${commitSha}), but failed to resolve thread ${opts.threadId}: ${resolveErrors.join("; ")}. Run \`git push\` then resolve manually.`
159
- : "Run `git push` (or `git push --force-with-lease` after rebasing) to publish the commit.",
110
+ commitMessage: commitMessageArg,
111
+ commitBody: commitBodyArg,
112
+ filesToStage: [filePath],
113
+ postActionInstructions,
160
114
  };
161
115
  }
@@ -20,10 +20,11 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals)
20
20
  surfacedApprovals: approvals,
21
21
  };
22
22
  }
23
- export function buildResolveCommand(threads, allCommentIds, reviews, checks, prNumber) {
23
+ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber) {
24
24
  const argv = ["npx", "pr-shepherd", "resolve", String(prNumber)];
25
- if (threads.length > 0) {
26
- argv.push("--resolve-thread-ids", threads.map((t) => t.id).join(","));
25
+ const threadIds = [...threads.map((t) => t.id), ...resolutionOnlyThreads.map((t) => t.id)];
26
+ if (threadIds.length > 0) {
27
+ argv.push("--resolve-thread-ids", threadIds.join(","));
27
28
  }
28
29
  if (allCommentIds.length > 0) {
29
30
  argv.push("--minimize-comment-ids", allCommentIds.join(","));
@@ -41,6 +42,6 @@ export function buildResolveCommand(threads, allCommentIds, reviews, checks, prN
41
42
  // --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
42
43
  // (rather than derived from argv.length) so callers don't couple to the
43
44
  // base-argv shape.
44
- const hasMutations = threads.length > 0 || allCommentIds.length > 0 || reviews.length > 0;
45
+ const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 || reviews.length > 0;
45
46
  return { argv, requiresHeadSha, requiresDismissMessage: hasDismiss, hasMutations };
46
47
  }
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from "../../config/load.mjs";
2
- export function checkEscalateTriggers(actionableThreads, actionableComments, changesRequestedReviews, failingChecks, threadAttempts, hasConflicts) {
2
+ export function checkEscalateTriggers(actionableThreads, resolutionOnlyThreads, actionableComments, changesRequestedReviews, failingChecks, threadAttempts, hasConflicts) {
3
3
  const triggers = [];
4
4
  const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
5
5
  // Trigger 1: fix thrash — same thread dispatched too many times without resolving.
@@ -11,6 +11,7 @@ export function checkEscalateTriggers(actionableThreads, actionableComments, cha
11
11
  // Skip when there are merge conflicts — fix_code handles conflict resolution, not escalation.
12
12
  if (changesRequestedReviews.length > 0 &&
13
13
  actionableThreads.length === 0 &&
14
+ resolutionOnlyThreads.length === 0 &&
14
15
  actionableComments.length === 0 &&
15
16
  failingChecks.length === 0 &&
16
17
  !hasConflicts) {