pr-shepherd 0.16.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -13
  3. package/bin/cli/args.mjs +2 -0
  4. package/bin/cli/clean-formatter.mjs +20 -0
  5. package/bin/cli/default-iterate.mjs +1 -1
  6. package/bin/cli/duration-flag.mjs +22 -0
  7. package/bin/cli/exit-codes.mjs +14 -0
  8. package/bin/cli/fix-formatter.mjs +4 -12
  9. package/bin/cli/formatters.mjs +6 -10
  10. package/bin/cli/handlers.mjs +72 -36
  11. package/bin/cli/iterate-emitter.mjs +19 -0
  12. package/bin/cli/iterate-flags.mjs +21 -0
  13. package/bin/cli/iterate-formatter.mjs +50 -13
  14. package/bin/cli/iterate-instructions.mjs +13 -17
  15. package/bin/cli/iterate-lean.mjs +9 -12
  16. package/bin/cli/list-formatters.mjs +21 -1
  17. package/bin/cli/poll-handler.mjs +42 -0
  18. package/bin/cli/runner.mjs +13 -3
  19. package/bin/cli-parser.clean.test-support.mjs +45 -0
  20. package/bin/cli-parser.iterate-fix.test-support.mjs +0 -4
  21. package/bin/cli-parser.iterate-fixtures.mjs +5 -2
  22. package/bin/cli-parser.iterate.test-support.mjs +0 -4
  23. package/bin/cli-parser.mjs +13 -2
  24. package/bin/commands/check-terminal-report.mjs +1 -0
  25. package/bin/commands/check.mjs +1 -0
  26. package/bin/commands/check.test-support.mjs +1 -0
  27. package/bin/commands/clean.mjs +156 -0
  28. package/bin/commands/clean.test-support.mjs +48 -0
  29. package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -0
  30. package/bin/commands/commit-suggestion.test-support.mjs +1 -0
  31. package/bin/commands/iterate/check-instructions.mjs +19 -17
  32. package/bin/commands/iterate/classify.mjs +37 -9
  33. package/bin/commands/iterate/escalate.mjs +26 -28
  34. package/bin/commands/iterate/fix-code.mjs +28 -6
  35. package/bin/commands/iterate/index.mjs +2 -0
  36. package/bin/commands/iterate/render.mjs +51 -40
  37. package/bin/commands/iterate-test-support.mjs +1 -0
  38. package/bin/commands/poll.mjs +32 -0
  39. package/bin/commands/poll.test-support.mjs +77 -0
  40. package/bin/commands/resolve-instructions.mjs +2 -5
  41. package/bin/comments/resolve.mjs +70 -11
  42. package/bin/github/batch-parser-helpers.mjs +38 -0
  43. package/bin/github/batch-parsers.mjs +12 -38
  44. package/bin/github/client.mjs +10 -1
  45. package/bin/github/gql/batch-pr.gql +9 -0
  46. package/bin/state/base.mjs +2 -1
  47. package/bin/state/seen-comments.mjs +29 -15
  48. package/package.json +1 -1
  49. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  50. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +16 -5
  51. package/bin/agent-runtime.mjs +0 -7
@@ -1,5 +1,16 @@
1
1
  import { buildPrShepherdCommand } from "../../cli/runner.mjs";
2
2
  import { shouldMinimizeAuthor } from "../../comments/minimize-policy.mjs";
3
+ function dedupeIds(ids) {
4
+ const seen = new Set();
5
+ const out = [];
6
+ for (const id of ids) {
7
+ if (seen.has(id))
8
+ continue;
9
+ seen.add(id);
10
+ out.push(id);
11
+ }
12
+ return out;
13
+ }
3
14
  export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all") {
4
15
  // First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
5
16
  // they are already minimized server-side (body changed after minimize was applied).
@@ -28,26 +39,43 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
28
39
  }
29
40
  export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber, runner) {
30
41
  const argv = buildPrShepherdCommand(["resolve", String(prNumber)], { runner }).argv;
31
- const threadIds = [...threads.map((t) => t.id), ...resolutionOnlyThreads.map((t) => t.id)];
42
+ const resolveThreadIds = dedupeIds(threads.map((t) => t.id));
43
+ const threadIds = dedupeIds([...resolveThreadIds, ...resolutionOnlyThreads.map((t) => t.id)]);
32
44
  if (threadIds.length > 0) {
33
45
  argv.push("--resolve-thread-ids", threadIds.join(","));
34
46
  }
35
47
  if (allCommentIds.length > 0) {
36
48
  argv.push("--minimize-comment-ids", allCommentIds.join(","));
37
49
  }
38
- const hasDismiss = reviews.length > 0;
50
+ const commentIdSet = new Set(allCommentIds);
51
+ const filteredReviewIds = [];
52
+ const droppedDismissReviewIds = [];
53
+ for (const review of reviews) {
54
+ if (commentIdSet.has(review.id))
55
+ droppedDismissReviewIds.push(review.id);
56
+ else
57
+ filteredReviewIds.push(review.id);
58
+ }
59
+ const hasDismiss = filteredReviewIds.length > 0;
39
60
  if (hasDismiss) {
40
- argv.push("--dismiss-review-ids", reviews.map((r) => r.id).join(","));
61
+ argv.push("--dismiss-review-ids", filteredReviewIds.join(","));
41
62
  argv.push("--message", "$DISMISS_MESSAGE");
42
63
  }
43
- // A push is required when threads, CI failures, or changes-requested reviews are present — the
44
- // CLI knows those imply code edits. Comments are surfaced for the agent to evaluate; the CLI
45
- // cannot know whether a given comment will require a push, so comments are excluded here.
46
- const requiresHeadSha = threads.length > 0 || checks.length > 0 || reviews.length > 0;
47
64
  // hasMutations = we appended at least one of --resolve-thread-ids,
48
65
  // --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
49
66
  // (rather than derived from argv.length) so callers don't couple to the
50
67
  // base-argv shape.
51
- const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 || reviews.length > 0;
52
- return { argv, requiresHeadSha, requiresDismissMessage: hasDismiss, hasMutations };
68
+ const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 || filteredReviewIds.length > 0;
69
+ // `requiresHeadSha` is only added when this resolve command includes a
70
+ // mutation that can race with a moving HEAD: resolving actionable threads,
71
+ // dismissing CHANGES_REQUESTED reviews, or addressing failing checks.
72
+ const hasCodeMutations = hasMutations && (threads.length > 0 || checks.length > 0 || filteredReviewIds.length > 0);
73
+ const requiresHeadSha = hasCodeMutations;
74
+ return {
75
+ argv,
76
+ requiresHeadSha,
77
+ requiresDismissMessage: hasDismiss,
78
+ ...(droppedDismissReviewIds.length > 0 ? { droppedDismissReviewIds } : undefined),
79
+ hasMutations,
80
+ };
53
81
  }
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from "../../config/load.mjs";
2
- export function checkEscalateTriggers(actionableThreads, resolutionOnlyThreads, actionableComments, changesRequestedReviews, failingChecks, threadAttempts, hasConflicts) {
2
+ export function checkEscalateTriggers(actionableThreads, threadAttempts) {
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.
@@ -7,17 +7,7 @@ export function checkEscalateTriggers(actionableThreads, resolutionOnlyThreads,
7
7
  if (thrashThreads.length > 0) {
8
8
  triggers.push("fix-thrash");
9
9
  }
10
- // Trigger 2: PR-level CHANGES_REQUESTED with no inline threads/comments/CI to act on.
11
- // Skip when there are merge conflicts — fix_code handles conflict resolution, not escalation.
12
- if (changesRequestedReviews.length > 0 &&
13
- actionableThreads.length === 0 &&
14
- resolutionOnlyThreads.length === 0 &&
15
- actionableComments.length === 0 &&
16
- failingChecks.length === 0 &&
17
- !hasConflicts) {
18
- triggers.push("pr-level-changes-requested");
19
- }
20
- // Trigger 3: actionable thread has no file/line — cannot locate code to edit.
10
+ // Trigger 2: actionable thread has no file/line — cannot locate code to edit.
21
11
  const unlocatable = actionableThreads.filter((t) => t.path === null || t.line === null);
22
12
  if (unlocatable.length > 0) {
23
13
  triggers.push("thread-missing-location");
@@ -55,7 +45,7 @@ export function validateBaseBranch(raw) {
55
45
  }
56
46
  export function buildEscalateHumanMessage(escalate, pr) {
57
47
  const lines = [];
58
- lines.push("⚠️ /pr-shepherd:pr-shepherd paused — needs human direction");
48
+ lines.push("⚠️ /pr-shepherd:pr-shepherd paused — manual intervention required");
59
49
  lines.push("");
60
50
  lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
61
51
  lines.push("");
@@ -66,23 +56,34 @@ export function buildEscalateHumanMessage(escalate, pr) {
66
56
  if (hasItems) {
67
57
  lines.push("");
68
58
  lines.push("## Items needing attention");
59
+ lines.push("");
69
60
  for (const t of escalate.unresolvedThreads) {
70
61
  const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
71
- const firstLine = t.body.split("\n")[0] ?? "";
72
- lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}): ${firstLine}`);
62
+ lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}):`);
63
+ lines.push("");
64
+ for (const bodyLine of t.body.split("\n"))
65
+ lines.push(` > ${bodyLine}`);
66
+ lines.push("");
73
67
  }
74
68
  for (const r of escalate.changesRequestedReviews) {
75
- const firstLine = r.body.split("\n")[0] ?? "";
76
- lines.push(`- review \`${r.id}\` (@${r.author}): ${firstLine}`);
69
+ lines.push(`- review \`${r.id}\` (@${r.author}):`);
70
+ lines.push("");
71
+ for (const bodyLine of r.body.split("\n"))
72
+ lines.push(` > ${bodyLine}`);
73
+ lines.push("");
77
74
  }
78
75
  for (const c of escalate.ambiguousComments) {
79
- const firstLine = c.body.split("\n")[0] ?? "";
80
- lines.push(`- comment \`${c.id}\` (@${c.author}): ${firstLine}`);
76
+ lines.push(`- comment \`${c.id}\` (@${c.author}):`);
77
+ lines.push("");
78
+ for (const bodyLine of c.body.split("\n"))
79
+ lines.push(` > ${bodyLine}`);
80
+ lines.push("");
81
81
  }
82
82
  }
83
83
  if (escalate.thrashHistory && escalate.thrashHistory.length > 0) {
84
84
  lines.push("");
85
85
  lines.push("## Fix attempts");
86
+ lines.push("");
86
87
  for (const a of escalate.thrashHistory) {
87
88
  lines.push(`- thread \`${a.threadId}\` attempted ${a.attempts} times`);
88
89
  }
@@ -90,26 +91,23 @@ export function buildEscalateHumanMessage(escalate, pr) {
90
91
  lines.push("");
91
92
  lines.push("---");
92
93
  lines.push("");
93
- lines.push(`After fixing manually, rerun \`/pr-shepherd:pr-shepherd ${pr}\` to resume.`);
94
+ lines.push(`After completing manual fixes (and pushing if required), rerun \`/pr-shepherd:pr-shepherd ${pr}\` to resume.`);
94
95
  return lines.join("\n");
95
96
  }
96
97
  export function buildEscalateSuggestion(triggers, detail) {
97
98
  if (triggers.includes("stall-timeout")) {
98
99
  const mins = detail ?? "30";
99
- return `No progress detected for ${mins} minute${parseInt(mins, 10) === 1 ? "" : "s"} — state has not changed. Inspect the PR and resume manually once the blocking issue is resolved.`;
100
+ return `No progress detected for ${mins} minute${parseInt(mins, 10) === 1 ? "" : "s"} — state has not changed. This is a manual checkpoint: inspect the PR and apply a manual fix before resuming.`;
100
101
  }
101
102
  if (triggers.includes("base-branch-unknown")) {
102
103
  const reason = detail ? ` (${detail})` : "";
103
- return `Could not determine the PR's base branch${reason} — refusing to emit a rebase that could force-push onto the wrong base. Run the rebase manually against the PR's real target branch.`;
104
+ return `Could not determine the PR's base branch${reason} — automated rebases are paused because branch safety is unclear. Run the rebase manually against the PR's real target branch.`;
104
105
  }
105
106
  if (triggers.includes("fix-thrash")) {
106
- return "Same thread(s) attempted multiple times without resolutionfix manually then rerun /pr-shepherd:pr-shepherd";
107
- }
108
- if (triggers.includes("pr-level-changes-requested")) {
109
- return "Reviewer requested changes but left no inline comments — read the review and act manually";
107
+ return "Same thread(s) reached the automated attempt limittreat this as a manual handoff. Apply the fix by hand.";
110
108
  }
111
109
  if (triggers.includes("thread-missing-location")) {
112
- return "Review thread has no file/line reference — cannot locate code to edit automatically";
110
+ return "Review thread has no file/line reference — automated location routing failed and manual handling is required.";
113
111
  }
114
- return "Ambiguous state — inspect the PR and act manually";
112
+ return "Ambiguous state — automated handling cannot proceed safely. Inspect the PR and act manually.";
115
113
  }
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
2
3
  import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
3
4
  import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
@@ -16,7 +17,7 @@ export async function handleFixCode(ctx) {
16
17
  currentAttempts[t.id] = (currentAttempts[t.id] ?? 0) + 1;
17
18
  }
18
19
  }
19
- const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.threads.resolutionOnly, report.comments.actionable, report.changesRequestedReviews, failingChecks, currentAttempts, report.mergeStatus.status === "CONFLICTS");
20
+ const escalateTriggers = checkEscalateTriggers(report.threads.actionable, currentAttempts);
20
21
  if (escalateTriggers.triggers.length > 0) {
21
22
  const escalateBase = {
22
23
  triggers: escalateTriggers.triggers,
@@ -52,14 +53,35 @@ export async function handleFixCode(ctx) {
52
53
  const checks = toAgentChecks(failingChecks);
53
54
  const { changesRequestedReviews } = report;
54
55
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
55
- const hasGuaranteedSupersedingPush = threads.length > 0 || checks.length > 0 || changesRequestedReviews.length > 0 || hasConflicts;
56
- const inProgressRunIds = hasGuaranteedSupersedingPush
57
- ? buildInProgressRunIds(report, cancelledSet)
58
- : [];
56
+ // Only surface in-progress runs when a push is plausible resolution-only and
57
+ // summary-only iterations have no path to a push, so listing runs would prompt
58
+ // unnecessary cancellation.
59
+ const pushLikely = threads.length > 0 ||
60
+ checks.length > 0 ||
61
+ hasConflicts ||
62
+ changesRequestedReviews.length > 0 ||
63
+ actionableComments.length > 0;
64
+ const inProgressRunIds = pushLikely ? buildInProgressRunIds(report, cancelledSet) : [];
59
65
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
60
66
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
61
67
  const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, cliRunner);
62
- if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
68
+ const overlappingReviewIds = resolveCommand.droppedDismissReviewIds ?? [];
69
+ if (overlappingReviewIds.length > 0) {
70
+ process.stderr.write(`pr-shepherd: resolve command overlap: ${overlappingReviewIds.length} ` +
71
+ `review IDs were also in minimize/comment IDs and were dropped from --dismiss-review-ids: ` +
72
+ `${overlappingReviewIds.join(", ")}\n`);
73
+ }
74
+ // Safety: if the base branch is unknown, escalate when a push is plausible — the agent
75
+ // would need the correct base to rebase safely. This is a conservative guard, not a
76
+ // prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
77
+ // resolution-only threads also need a known base in case the agent does push.
78
+ const pushIsPlausible = threads.length > 0 ||
79
+ checks.length > 0 ||
80
+ hasConflicts ||
81
+ changesRequestedReviews.length > 0 ||
82
+ actionableComments.length > 0 ||
83
+ resolutionOnlyThreads.length > 0;
84
+ if (baseLookup.isFallback && pushIsPlausible) {
63
85
  const fallbackEscalateBase = {
64
86
  triggers: ["base-branch-unknown"],
65
87
  unresolvedThreads: [...threads, ...resolutionOnlyThreads.map(toAgentThread)],
@@ -45,6 +45,7 @@ export async function runIterate(opts) {
45
45
  state: report.mergeStatus.state,
46
46
  summary: buildSummary(report),
47
47
  baseBranch: report.baseBranch,
48
+ branchProtection: report.branchProtection,
48
49
  checks: buildRelevantChecks(report),
49
50
  action: "cancel",
50
51
  reason: report.mergeStatus.state === "MERGED" ? "merged" : "closed",
@@ -84,6 +85,7 @@ export async function runIterate(opts) {
84
85
  remainingSeconds: readyState.remainingSeconds,
85
86
  summary: buildSummary(report),
86
87
  baseBranch: report.baseBranch,
88
+ branchProtection: report.branchProtection,
87
89
  checks: buildRelevantChecks(report),
88
90
  };
89
91
  if (readyState.shouldCancel) {
@@ -2,12 +2,11 @@ import { renderShellCommand } from "../../cli/runner.mjs";
2
2
  import { buildFailingCheckInstructions } from "./check-instructions.mjs";
3
3
  import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
4
4
  import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
5
- export const FIX_INSTRUCTION_STOP_AFTER_PUSH = "Stop this iteration — CI needs time to run on the new push before the next tick.";
6
- export const FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK = "Stop this iteration before the next tick.";
5
+ export const FIX_INSTRUCTION_STOP = "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.";
7
6
  /**
8
7
  * Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE`, `$HEAD_SHA`, and
9
- * whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is appended separately
10
- * when `requiresHeadSha` is set.
8
+ * whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is never in `argv` —
9
+ * `renderResolveCommand` appends `--require-sha "$HEAD_SHA"` when `requiresHeadSha` is set.
11
10
  */
12
11
  export function renderResolveCommand(rc) {
13
12
  const parts = [...rc.argv];
@@ -16,10 +15,41 @@ export function renderResolveCommand(rc) {
16
15
  }
17
16
  return renderShellCommand(parts);
18
17
  }
19
- export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner) {
18
+ export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner) {
20
19
  const instructions = [];
20
+ const hasNonConflictHints = threads.length > 0 ||
21
+ checks.length > 0 ||
22
+ changesRequestedReviews.length > 0 ||
23
+ actionableComments.length > 0;
24
+ // Leading decision or mandatory instruction depending on what actionable items exist.
25
+ if (hasNonConflictHints) {
26
+ const actionableSections = [];
27
+ if (threads.length > 0)
28
+ actionableSections.push("`## Review threads`");
29
+ if (actionableComments.length > 0)
30
+ actionableSections.push("`## Actionable comments`");
31
+ if (checks.length > 0)
32
+ actionableSections.push("`## Failing checks`");
33
+ if (changesRequestedReviews.length > 0)
34
+ actionableSections.push("`## Changes-requested reviews`");
35
+ const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
36
+ const resolveClause = resolveCommand.hasMutations ? ", then run the `resolve:` command" : "";
37
+ if (hasConflicts) {
38
+ // Conflicts make push mandatory regardless of whether code edits are needed.
39
+ instructions.push(`The branch has merge conflicts that require rebase before merging. Apply any code edits for items ${sectionRef}, commit if edits were made, rebase onto \`origin/${baseBranch}\` per your repository's conventions, push${resolveClause}.`);
40
+ }
41
+ else {
42
+ const skipClause = resolveCommand.hasMutations
43
+ ? "skip cancellation/commit/push and run the `resolve:` command"
44
+ : "no push is needed";
45
+ instructions.push(`Decide for each item ${sectionRef} whether a code change is warranted. **If any code changes are needed:** cancel in-progress runs first, apply edits, commit, rebase, push${resolveClause}. **If no code changes are needed:** ${skipClause}.`);
46
+ }
47
+ }
48
+ else if (hasConflicts) {
49
+ instructions.push(`The branch has merge conflicts — rebase onto \`origin/${baseBranch}\` per your repository's conventions to resolve them, then push.`);
50
+ }
21
51
  if (inProgressRunIds.length > 0) {
22
- instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id>\` before applying code fixes. If \`gh\` reports a run is already completed, ignore it and continue with the next ID.`);
52
+ instructions.push(`If you decide to push new commits: cancel each in-progress run listed under \`## In-progress runs\` before applying code fixes (e.g. \`gh run cancel <id>\`). Runs may complete between the tick and your action; treat cancellation errors on already-finished runs as non-fatal. Skip this step if you are only resolving threads without pushing — the existing runs remain relevant.`);
23
53
  }
24
54
  const hasSuggestions = threads.some((t) => t.suggestion);
25
55
  if (hasSuggestions) {
@@ -35,25 +65,25 @@ export function buildFixInstructions(threads, actionableComments, checks, review
35
65
  instructions.push(`Resolve the threads under \`## Review threads to resolve\` with the \`resolve:\` command shown below. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
36
66
  }
37
67
  instructions.push(...buildFailingCheckInstructions(checks));
38
- if (reviews.length > 0) {
68
+ if (changesRequestedReviews.length > 0) {
39
69
  instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
40
70
  }
41
- const hasCodeChanges = threads.length > 0 || checks.length > 0 || reviews.length > 0;
42
- const needsPush = hasCodeChanges || hasConflicts;
43
- if (hasCodeChanges) {
44
- instructions.push(`Commit changed files: \`git add <files> && git commit -m "<descriptive message>"\``);
45
- instructions.push(`Keep the PR title and description current: if the changes 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.`);
71
+ if (hasNonConflictHints) {
72
+ instructions.push(`If you applied code edits: commit them with a descriptive message, then rebase onto \`origin/${baseBranch}\` per your repository's conventions before pushing.`);
46
73
  }
47
- if (needsPush) {
48
- const captureHint = resolveCommand.requiresHeadSha
49
- ? ` — capture \`HEAD_SHA=$(git rev-parse HEAD)\``
50
- : "";
51
- if (hasConflicts) {
52
- instructions.push(`Rebase with conflict resolution: run \`git fetch origin && git rebase origin/${baseBranch}\`. If the rebase halts with conflicts, edit the conflicted files to resolve them, \`git add <files>\`, then \`git rebase --continue\`. Repeat until the rebase completes, then \`git push --force-with-lease\`${captureHint}.`);
74
+ if (resolveCommand.hasMutations) {
75
+ const substituteParts = [];
76
+ if (resolveCommand.requiresHeadSha) {
77
+ substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
53
78
  }
54
- else {
55
- instructions.push(`Rebase and push: \`git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease\`${captureHint}`);
79
+ if (resolveCommand.requiresDismissMessage) {
80
+ substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence description of what you changed`);
56
81
  }
82
+ const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
83
+ instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
84
+ }
85
+ if (cancelledCount > 0) {
86
+ instructions.push(`Do not re-run \`gh run cancel\` on the IDs listed under \`## Cancelled runs\` — those runs were already cancelled by the CLI before this turn.`);
57
87
  }
58
88
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
59
89
  if (firstLookTotal > 0) {
@@ -68,28 +98,9 @@ export function buildFixInstructions(threads, actionableComments, checks, review
68
98
  if (editedTotal > 0) {
69
99
  instructions.push(`Items under \`## Review summaries (edited since first look)\` and any first-look bullet tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body before deciding whether any matching \`## Review threads to resolve\` item should be resolved.`);
70
100
  }
71
- if (resolveCommand.hasMutations) {
72
- const substituteParts = [];
73
- if (resolveCommand.requiresHeadSha) {
74
- substituteParts.push(`"$HEAD_SHA" with the pushed commit SHA`);
75
- }
76
- if (resolveCommand.requiresDismissMessage) {
77
- substituteParts.push(`$DISMISS_MESSAGE with a one-sentence description of what you changed`);
78
- }
79
- const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
80
- instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
81
- }
82
- if (needsPush && cancelledCount > 0) {
83
- 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.`);
84
- }
85
101
  if (resolveCommand.hasMutations) {
86
102
  instructions.push(buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS));
87
103
  }
88
- if (needsPush) {
89
- instructions.push(FIX_INSTRUCTION_STOP_AFTER_PUSH);
90
- }
91
- else {
92
- instructions.push(FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK);
93
- }
104
+ instructions.push(FIX_INSTRUCTION_STOP);
94
105
  return instructions;
95
106
  }
@@ -89,6 +89,7 @@ function makeReport(overrides = {}) {
89
89
  firstLookSummaries: [],
90
90
  editedSummaries: [],
91
91
  approvedReviews: [],
92
+ branchProtection: null,
92
93
  ...overrides,
93
94
  };
94
95
  }
@@ -0,0 +1,32 @@
1
+ import { runIterate } from "./iterate/index.mjs";
2
+ function sleep(ms) {
3
+ return new Promise((resolve) => setTimeout(resolve, ms));
4
+ }
5
+ function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
6
+ if (process.stderr.isTTY || verbose) {
7
+ process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — sleeping ${sleepSeconds}s\n`);
8
+ }
9
+ }
10
+ const MAX_TIMER_MS = 2 ** 31 - 1;
11
+ export async function runPoll(opts) {
12
+ const { intervalSeconds, timeoutSeconds, ...iterateOpts } = opts;
13
+ const intervalMs = Math.min(intervalSeconds * 1000, MAX_TIMER_MS);
14
+ const timeoutMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
15
+ const start = Date.now();
16
+ let tick = 0;
17
+ let lastResult;
18
+ const verbose = opts.verbose === true;
19
+ while (true) {
20
+ tick += 1;
21
+ lastResult = await runIterate(iterateOpts);
22
+ if (lastResult.action !== "wait")
23
+ return lastResult;
24
+ const elapsedMs = Date.now() - start;
25
+ const remainingMs = timeoutMs - elapsedMs;
26
+ if (remainingMs <= 0)
27
+ return lastResult;
28
+ const nextSleepMs = Math.min(intervalMs, remainingMs);
29
+ writeTickProgress(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), verbose);
30
+ await sleep(nextSleepMs);
31
+ }
32
+ }
@@ -0,0 +1,77 @@
1
+ import { vi, beforeEach, afterEach } from "vitest";
2
+ vi.mock("./iterate/index.mts", () => ({ runIterate: vi.fn() }));
3
+ import { runIterate } from "./iterate/index.mjs";
4
+ const mockRunIterate = vi.mocked(runIterate);
5
+ function makeWaitResult(overrides = {}) {
6
+ return {
7
+ action: "wait",
8
+ pr: 42,
9
+ repo: "owner/repo",
10
+ status: "IN_PROGRESS",
11
+ state: "OPEN",
12
+ mergeStateStatus: "BLOCKED",
13
+ mergeStatus: "BLOCKED",
14
+ reviewDecision: "REVIEW_REQUIRED",
15
+ blockingBotReviewInProgress: false,
16
+ isDraft: false,
17
+ shouldCancel: false,
18
+ remainingSeconds: 0,
19
+ summary: { passing: 2, failing: 0, inProgress: 1, skipped: 0, filtered: 0 },
20
+ baseBranch: "main",
21
+ checks: [],
22
+ log: "WAIT: 2 passing, 1 in-progress",
23
+ ...overrides,
24
+ };
25
+ }
26
+ function makeCancelResult() {
27
+ return {
28
+ action: "cancel",
29
+ pr: 42,
30
+ repo: "owner/repo",
31
+ status: "READY",
32
+ state: "MERGED",
33
+ mergeStateStatus: "CLEAN",
34
+ mergeStatus: "CLEAN",
35
+ reviewDecision: "APPROVED",
36
+ blockingBotReviewInProgress: false,
37
+ isDraft: false,
38
+ shouldCancel: true,
39
+ remainingSeconds: 0,
40
+ summary: { passing: 3, failing: 0, inProgress: 0, skipped: 0, filtered: 0 },
41
+ baseBranch: "main",
42
+ checks: [],
43
+ reason: "merged",
44
+ log: "CANCEL: PR #42 is merged — stopping",
45
+ };
46
+ }
47
+ function makeMarkReadyResult() {
48
+ return {
49
+ action: "mark_ready",
50
+ pr: 42,
51
+ repo: "owner/repo",
52
+ status: "READY",
53
+ state: "OPEN",
54
+ mergeStateStatus: "CLEAN",
55
+ mergeStatus: "CLEAN",
56
+ reviewDecision: "APPROVED",
57
+ blockingBotReviewInProgress: false,
58
+ isDraft: false,
59
+ shouldCancel: false,
60
+ remainingSeconds: 0,
61
+ summary: { passing: 3, failing: 0, inProgress: 0, skipped: 0, filtered: 0 },
62
+ baseBranch: "main",
63
+ checks: [],
64
+ markedReady: true,
65
+ log: "MARKED READY: PR #42 converted from draft to ready for review",
66
+ };
67
+ }
68
+ function registerPollHooks() {
69
+ beforeEach(() => {
70
+ vi.clearAllMocks();
71
+ vi.useFakeTimers();
72
+ });
73
+ afterEach(() => {
74
+ vi.useRealTimers();
75
+ });
76
+ }
77
+ export { mockRunIterate, makeWaitResult, makeCancelResult, makeMarkReadyResult, registerPollHooks };
@@ -37,16 +37,13 @@ export function buildFetchInstructions(prNumber, result, runner) {
37
37
  }
38
38
  if (hasCodeItems) {
39
39
  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.`);
40
- instructions.push(`Commit changed files: \`git add <files>\` (not \`git add -A\`) \`&& git commit -m "<descriptive message>"\`.`);
41
- 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.`);
42
- 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\`.`);
43
- 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 {}\`.`);
40
+ instructions.push(`If you applied code edits: commit them with a descriptive message, cancel any stale in-progress runs, then rebase and push per your repository's conventions.`);
44
41
  }
45
42
  if (resolutionOnlyThreads.length > 0) {
46
43
  instructions.push(`Resolve each thread under \`## Review threads to resolve\` with \`--resolve-thread-ids\`. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
47
44
  }
48
45
  const requireShaHint = hasCodeItems
49
- ? ` Include \`--require-sha $(git rev-parse HEAD)\` only when the rebase-and-push step above ran.`
46
+ ? ` Include \`--require-sha $(git rev-parse HEAD)\` only when you pushed new commits.`
50
47
  : "";
51
48
  const dismissNote = changesRequestedReviews.length > 0
52
49
  ? ` 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\`.`