pr-shepherd 0.8.0 → 0.9.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.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Project an IterateResult to a lean JSON shape for the default (non-verbose) output.
3
+ * Omits fields that are the trivial default (false, 0, empty) or state-gated fields
4
+ * outside the state where they are meaningful.
5
+ */
6
+ export function projectIterateLean(result) {
7
+ const base = {
8
+ action: result.action,
9
+ pr: result.pr,
10
+ repo: result.repo || undefined,
11
+ status: result.status,
12
+ state: result.state,
13
+ mergeStateStatus: result.mergeStateStatus,
14
+ ...(result.mergeStatus === "BLOCKED" &&
15
+ result.reviewDecision !== null && { reviewDecision: result.reviewDecision }),
16
+ ...(result.copilotReviewInProgress && { copilotReviewInProgress: true }),
17
+ ...(result.isDraft && { isDraft: true }),
18
+ summary: {
19
+ passing: result.summary.passing,
20
+ ...(result.summary.skipped > 0 && { skipped: result.summary.skipped }),
21
+ ...(result.summary.filtered > 0 && { filtered: result.summary.filtered }),
22
+ ...(result.summary.inProgress > 0 && { inProgress: result.summary.inProgress }),
23
+ },
24
+ // remainingSeconds: only when the ready-delay timer is actively counting down
25
+ ...(result.status === "READY" &&
26
+ result.remainingSeconds > 0 && {
27
+ remainingSeconds: result.remainingSeconds,
28
+ }),
29
+ ...(result.baseBranch && { baseBranch: result.baseBranch }),
30
+ };
31
+ switch (result.action) {
32
+ case "cooldown":
33
+ return { ...base, log: result.log };
34
+ case "wait":
35
+ return { ...base, log: result.log };
36
+ case "cancel":
37
+ return { ...base, reason: result.reason, log: result.log };
38
+ case "mark_ready":
39
+ // drop markedReady — always true, redundant with action discriminator
40
+ return { ...base, log: result.log };
41
+ case "fix_code":
42
+ return {
43
+ ...base,
44
+ ...(result.checks.length > 0 && { checks: result.checks }),
45
+ ...(result.cancelled.length > 0 && { cancelled: result.cancelled }),
46
+ fix: {
47
+ mode: result.fix.mode,
48
+ ...(result.fix.threads.length > 0 && { threads: result.fix.threads }),
49
+ ...(result.fix.actionableComments.length > 0 && {
50
+ actionableComments: result.fix.actionableComments,
51
+ }),
52
+ ...(result.fix.noiseCommentIds.length > 0 && {
53
+ noiseCommentIds: result.fix.noiseCommentIds,
54
+ }),
55
+ ...(result.fix.reviewSummaryIds.length > 0 && {
56
+ reviewSummaryIds: result.fix.reviewSummaryIds,
57
+ }),
58
+ ...(result.fix.surfacedApprovals.length > 0 && {
59
+ surfacedApprovals: result.fix.surfacedApprovals,
60
+ }),
61
+ ...(result.fix.firstLookThreads.length > 0 && {
62
+ firstLookThreads: result.fix.firstLookThreads,
63
+ }),
64
+ ...(result.fix.firstLookComments.length > 0 && {
65
+ firstLookComments: result.fix.firstLookComments,
66
+ }),
67
+ ...(result.fix.checks.length > 0 && { checks: result.fix.checks }),
68
+ ...(result.fix.changesRequestedReviews.length > 0 && {
69
+ changesRequestedReviews: result.fix.changesRequestedReviews,
70
+ }),
71
+ resolveCommand: result.fix.resolveCommand,
72
+ ...(result.fix.instructions.length > 0 && { instructions: result.fix.instructions }),
73
+ },
74
+ };
75
+ case "escalate":
76
+ return {
77
+ ...base,
78
+ escalate: {
79
+ ...(result.escalate.triggers.length > 0 && { triggers: result.escalate.triggers }),
80
+ ...(result.escalate.unresolvedThreads.length > 0 && {
81
+ unresolvedThreads: result.escalate.unresolvedThreads,
82
+ }),
83
+ ...(result.escalate.ambiguousComments.length > 0 && {
84
+ ambiguousComments: result.escalate.ambiguousComments,
85
+ }),
86
+ ...(result.escalate.changesRequestedReviews.length > 0 && {
87
+ changesRequestedReviews: result.escalate.changesRequestedReviews,
88
+ }),
89
+ ...(result.escalate.attemptHistory &&
90
+ result.escalate.attemptHistory.length > 0 && {
91
+ attemptHistory: result.escalate.attemptHistory,
92
+ }),
93
+ suggestion: result.escalate.suggestion,
94
+ humanMessage: result.escalate.humanMessage,
95
+ },
96
+ };
97
+ }
98
+ }
@@ -5,6 +5,8 @@ export function makeIterateResult(action = "wait") {
5
5
  status: "IN_PROGRESS",
6
6
  state: "OPEN",
7
7
  mergeStateStatus: "BLOCKED",
8
+ mergeStatus: "BLOCKED",
9
+ reviewDecision: null,
8
10
  copilotReviewInProgress: false,
9
11
  isDraft: false,
10
12
  shouldCancel: false,
@@ -17,8 +19,6 @@ export function makeIterateResult(action = "wait") {
17
19
  return { ...base, action: "cooldown", log: "SKIP: CI still starting" };
18
20
  if (action === "wait")
19
21
  return { ...base, action: "wait", log: "WAIT: 0 passing, 1 in-progress" };
20
- if (action === "rerun_ci")
21
- return { ...base, action: "rerun_ci", log: "RERAN: run-99 (typecheck — transient)", reran: [] };
22
22
  if (action === "mark_ready")
23
23
  return { ...base, action: "mark_ready", markedReady: true, log: "MARKED READY: PR 42" };
24
24
  if (action === "fix_code") {
@@ -31,7 +31,7 @@ export function makeIterateResult(action = "wait") {
31
31
  actionableComments: [],
32
32
  noiseCommentIds: [],
33
33
  reviewSummaryIds: [],
34
- surfacedSummaries: [],
34
+ surfacedApprovals: [],
35
35
  checks: [],
36
36
  changesRequestedReviews: [],
37
37
  resolveCommand: {
@@ -41,12 +41,19 @@ export function makeIterateResult(action = "wait") {
41
41
  hasMutations: false,
42
42
  },
43
43
  instructions: ["End this iteration."],
44
+ firstLookThreads: [],
45
+ firstLookComments: [],
44
46
  },
45
47
  cancelled: [],
46
48
  };
47
49
  }
48
50
  if (action === "cancel")
49
- return { ...base, action: "cancel", log: "CANCEL: PR #42 — stopping monitor" };
51
+ return {
52
+ ...base,
53
+ action: "cancel",
54
+ reason: "ready-delay-elapsed",
55
+ log: "CANCEL: PR #42 — stopping monitor",
56
+ };
50
57
  if (action === "escalate") {
51
58
  return {
52
59
  ...base,
@@ -9,15 +9,18 @@ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, me
9
9
  return "FAILING";
10
10
  if (verdict.anyInProgress)
11
11
  return "IN_PROGRESS";
12
- // BLOCKED solely because a human reviewer hasn't approved yet shepherd is done, hand off.
13
- // copilotReviewInProgress means a bot still owes a review, which is not this case.
12
+ // BLOCKED with no remaining shepherd work hand off via ready-delay regardless of why GitHub
13
+ // is BLOCKED (review pending, insufficient approvals, branch-protection rule, etc.).
14
+ // Requires hasChecks so that a PR with zero relevant checks (CI never started, or all
15
+ // filtered/skipped) doesn't prematurely trigger READY before any check has reported.
16
+ // copilotReviewInProgress is still excluded — a bot review is shepherd's problem, not a hand-off.
14
17
  if (verdict.allPassed &&
18
+ verdict.hasChecks &&
15
19
  unresolvedThreads === 0 &&
16
20
  unresolvedComments === 0 &&
17
21
  changesRequestedReviews === 0 &&
18
22
  mergeStatus.status === "BLOCKED" &&
19
- !mergeStatus.copilotReviewInProgress &&
20
- mergeStatus.reviewDecision === "REVIEW_REQUIRED") {
23
+ !mergeStatus.copilotReviewInProgress) {
21
24
  return "READY";
22
25
  }
23
26
  if (mergeStatus.status === "BLOCKED" ||
@@ -21,15 +21,17 @@ import { autoResolveOutdated } from "../comments/resolve.mjs";
21
21
  import { deriveMergeStatus } from "../merge-status/derive.mjs";
22
22
  import { loadConfig } from "../config/load.mjs";
23
23
  import { computeStatus } from "./check-status.mjs";
24
+ import { loadSeenSet, markSeen } from "../state/seen-comments.mjs";
24
25
  export async function runCheck(opts) {
25
26
  const repo = await getRepoInfo();
26
27
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
27
28
  if (prNumber === null) {
28
29
  throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
29
30
  }
31
+ const config = loadConfig();
30
32
  // Only paginate APPROVED reviews when the caller will actually minimize them.
31
33
  // Otherwise the first-page cap of 50 (already in the batch) is plenty — no extra round-trip.
32
- const paginateApprovedReviews = loadConfig().iterate.minimizeReviewSummaries.approvals;
34
+ const paginateApprovedReviews = config.iterate.minimizeApprovals;
33
35
  const result = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews });
34
36
  let batchData = result.data;
35
37
  // GraphQL sometimes returns UNKNOWN for mergeable/mergeStateStatus while the
@@ -52,8 +54,11 @@ export async function runCheck(opts) {
52
54
  const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
53
55
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
54
56
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
55
- // Triage failures (fetch logs) — skipped when caller will short-circuit before needing failureKind.
56
- const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
57
+ // Triage failures (fetch job info + log tails) — skipped when caller short-circuits early.
58
+ const triaged = failing.length > 0 && !opts.skipTriage
59
+ ? await triageFailingChecks(failing, repo, config.checks.logTailLines)
60
+ : failing;
61
+ const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
57
62
  // Resolve threads and comments.
58
63
  const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
59
64
  const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
@@ -67,6 +72,36 @@ export async function runCheck(opts) {
67
72
  autoResolveErrors = errors;
68
73
  }
69
74
  const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
75
+ // First-look: collect previously-hidden items not yet seen by the agent.
76
+ const outdatedCandidates = batchData.reviewThreads.filter((t) => t.isOutdated);
77
+ const resolvedCandidates = batchData.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
78
+ const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
79
+ const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
80
+ const seenSet = await loadSeenSet(stateKey);
81
+ const autoResolvedIds = new Set(autoResolved.map((t) => t.id));
82
+ const firstLookThreads = [
83
+ ...outdatedCandidates
84
+ .filter((t) => !seenSet.has(t.id))
85
+ .map((t) => ({
86
+ ...t,
87
+ firstLookStatus: "outdated",
88
+ autoResolved: autoResolvedIds.has(t.id),
89
+ })),
90
+ ...resolvedCandidates
91
+ .filter((t) => !seenSet.has(t.id))
92
+ .map((t) => ({ ...t, firstLookStatus: "resolved" })),
93
+ ...minimizedThreadCandidates
94
+ .filter((t) => !seenSet.has(t.id))
95
+ .map((t) => ({ ...t, firstLookStatus: "minimized" })),
96
+ ];
97
+ const firstLookComments = minimizedCommentCandidates
98
+ .filter((c) => !seenSet.has(c.id))
99
+ .map((c) => ({ ...c, firstLookStatus: "minimized" }));
100
+ // Mark first-look items as seen (best-effort — markSeen never throws).
101
+ await Promise.allSettled([
102
+ ...firstLookThreads.map((t) => markSeen(stateKey, t.id)),
103
+ ...firstLookComments.map((c) => markSeen(stateKey, c.id)),
104
+ ]);
70
105
  // Actionable: all active threads and all visible comments (no classification — LLM handles triage).
71
106
  const actionableThreads = activeThreads;
72
107
  const actionableComments = visibleComments;
@@ -99,9 +134,11 @@ export async function runCheck(opts) {
99
134
  actionable: actionableThreads,
100
135
  autoResolved,
101
136
  autoResolveErrors,
137
+ firstLook: firstLookThreads,
102
138
  },
103
139
  comments: {
104
140
  actionable: actionableComments,
141
+ firstLook: firstLookComments,
105
142
  },
106
143
  changesRequestedReviews: batchData.changesRequestedReviews,
107
144
  reviewSummaries: batchData.reviewSummaries,
@@ -1,31 +1,11 @@
1
- // Logins treated as bot authors regardless of the GitHub Bot/User user type.
2
- // Mirrors plugin/skills/resolve/SKILL.md §3 — kept in sync with the resolve triage guidance.
3
- const KNOWN_BOT_LOGINS = new Set([
4
- "copilot-pull-request-reviewer",
5
- "gemini-code-assist",
6
- "coderabbitai",
7
- ]);
8
- function isBotAuthor(login) {
9
- const bare = login.replace(/\[bot\]$/, "");
10
- if (bare !== login)
11
- return true;
12
- return KNOWN_BOT_LOGINS.has(bare);
13
- }
14
- export function classifyReviewSummaries(summaries, approvals, cfg) {
15
- const minimizeIds = [];
16
- const surfacedSummaries = [];
17
- for (const r of summaries) {
18
- const enabled = isBotAuthor(r.author) ? cfg.bots : cfg.humans;
19
- if (enabled)
20
- minimizeIds.push(r.id);
21
- else
22
- surfacedSummaries.push(r);
23
- }
24
- if (cfg.approvals) {
1
+ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals) {
2
+ const minimizeIds = summaries.map((r) => r.id);
3
+ if (minimizeApprovals) {
25
4
  for (const r of approvals)
26
5
  minimizeIds.push(r.id);
6
+ return { minimizeIds, surfacedApprovals: [] };
27
7
  }
28
- return { minimizeIds, surfacedSummaries };
8
+ return { minimizeIds, surfacedApprovals: approvals };
29
9
  }
30
10
  // Patterns that indicate a comment is bot-generated noise rather than actionable feedback.
31
11
  // Conservative: only match explicit known patterns to avoid accidentally suppressing real reviews.
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from "../../config/load.mjs";
2
- export function checkEscalateTriggers(actionableThreads, actionableComments, changesRequestedReviews, actionableChecks, threadAttempts, hasConflicts) {
2
+ export function checkEscalateTriggers(actionableThreads, 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.
@@ -12,7 +12,7 @@ export function checkEscalateTriggers(actionableThreads, actionableComments, cha
12
12
  if (changesRequestedReviews.length > 0 &&
13
13
  actionableThreads.length === 0 &&
14
14
  actionableComments.length === 0 &&
15
- actionableChecks.length === 0 &&
15
+ failingChecks.length === 0 &&
16
16
  !hasConflicts) {
17
17
  triggers.push("pr-level-changes-requested");
18
18
  }
@@ -6,8 +6,8 @@ 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, surfacedSummaries, } = ctx;
10
- const actionableChecks = report.checks.failing.filter((f) => f.failureKind === "actionable");
9
+ const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, surfacedApprovals, } = ctx;
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;
13
13
  // Accumulate across shas — only increment when a push is detected (sha changed)
@@ -17,7 +17,7 @@ export async function handleFixCode(ctx) {
17
17
  currentAttempts[t.id] = (currentAttempts[t.id] ?? 0) + 1;
18
18
  }
19
19
  }
20
- const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.comments.actionable, report.changesRequestedReviews, actionableChecks, currentAttempts, report.mergeStatus.status === "CONFLICTS");
20
+ const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.comments.actionable, report.changesRequestedReviews, failingChecks, currentAttempts, report.mergeStatus.status === "CONFLICTS");
21
21
  if (escalateTriggers.triggers.length > 0) {
22
22
  const escalateBase = {
23
23
  triggers: escalateTriggers.triggers,
@@ -41,7 +41,7 @@ export async function handleFixCode(ctx) {
41
41
  let cancelled = [];
42
42
  if (!opts.noAutoCancelActionable) {
43
43
  const uniqueRunIds = [
44
- ...new Set(actionableChecks.map((c) => c.runId).filter((id) => id !== null)),
44
+ ...new Set(failingChecks.map((c) => c.runId).filter((id) => id !== null)),
45
45
  ];
46
46
  const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id, repoOwner, repoName)));
47
47
  cancelled = results.filter((id) => id !== null);
@@ -49,7 +49,7 @@ export async function handleFixCode(ctx) {
49
49
  const baseLookup = validateBaseBranch(report.baseBranch);
50
50
  const threads = report.threads.actionable.map(toAgentThread);
51
51
  const { actionable: actionableComments, noiseIds: noiseCommentIds } = classifyComments(report.comments.actionable.map(toAgentComment));
52
- const checks = toAgentChecks(actionableChecks);
52
+ const checks = toAgentChecks(failingChecks);
53
53
  const { changesRequestedReviews } = report;
54
54
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
55
55
  const allCommentIds = [
@@ -75,7 +75,9 @@ export async function handleFixCode(ctx) {
75
75
  },
76
76
  };
77
77
  }
78
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length);
78
+ const firstLookThreads = report.threads.firstLook;
79
+ const firstLookComments = report.comments.firstLook;
80
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments);
79
81
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
80
82
  ...base,
81
83
  baseBranch: baseLookup.branch,
@@ -86,11 +88,13 @@ export async function handleFixCode(ctx) {
86
88
  actionableComments,
87
89
  noiseCommentIds,
88
90
  reviewSummaryIds,
89
- surfacedSummaries,
91
+ surfacedApprovals,
90
92
  checks,
91
93
  changesRequestedReviews,
92
94
  resolveCommand,
93
95
  instructions,
96
+ firstLookThreads,
97
+ firstLookComments,
94
98
  },
95
99
  cancelled,
96
100
  }, report, reviewSummaryIds);
@@ -13,7 +13,8 @@ export function buildSummary(report) {
13
13
  /**
14
14
  * Build the full list of CI checks relevant to PR readiness: triggered by a PR
15
15
  * event (or StatusContext with null event), completed, and not skipped/neutral.
16
- * Includes both passing and failing. Failing entries carry failureKind + errorExcerpt.
16
+ * Includes both passing and failing. Failing entries carry workflowName, jobName,
17
+ * failedStep, and summary.
17
18
  */
18
19
  export function buildRelevantChecks(report) {
19
20
  const excluded = new Set([null, "SKIPPED", "NEUTRAL"]);
@@ -41,10 +42,10 @@ export function buildRelevantChecks(report) {
41
42
  conclusion,
42
43
  runId: c.runId,
43
44
  detailsUrl: c.detailsUrl || null,
44
- failureKind: c.failureKind,
45
- workflowName: c.workflowName,
46
- failedStep: c.failedStep,
47
- summary: c.summary,
45
+ ...(c.workflowName !== undefined && { workflowName: c.workflowName }),
46
+ ...(c.jobName !== undefined && { jobName: c.jobName }),
47
+ ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
48
+ ...(c.summary !== undefined && { summary: c.summary }),
48
49
  },
49
50
  ];
50
51
  });
@@ -91,6 +92,8 @@ export function buildCooldownResult(prNumber, readyDelaySeconds) {
91
92
  status: "UNKNOWN",
92
93
  state: "UNKNOWN",
93
94
  mergeStateStatus: "UNKNOWN",
95
+ mergeStatus: "UNKNOWN",
96
+ reviewDecision: null,
94
97
  copilotReviewInProgress: false,
95
98
  isDraft: false,
96
99
  shouldCancel: false,
@@ -9,7 +9,6 @@ import { classifyReviewSummaries } from "./classify.mjs";
9
9
  import { applyStallGuard } from "./stall.mjs";
10
10
  import { buildWaitLog } from "./render.mjs";
11
11
  import { handleFixCode } from "./fix-code.mjs";
12
- import { buildRerunCiResult } from "./steps.mjs";
13
12
  export async function runIterate(opts) {
14
13
  const config = loadConfig();
15
14
  const cooldownSeconds = opts.cooldownSeconds ?? config.iterate.cooldownSeconds;
@@ -36,6 +35,8 @@ export async function runIterate(opts) {
36
35
  repo: report.repo,
37
36
  status: report.status,
38
37
  mergeStateStatus: report.mergeStatus.mergeStateStatus,
38
+ mergeStatus: report.mergeStatus.status,
39
+ reviewDecision: report.mergeStatus.reviewDecision,
39
40
  copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
40
41
  isDraft: report.mergeStatus.isDraft,
41
42
  shouldCancel: true,
@@ -45,6 +46,7 @@ export async function runIterate(opts) {
45
46
  baseBranch: report.baseBranch,
46
47
  checks: buildRelevantChecks(report),
47
48
  action: "cancel",
49
+ reason: report.mergeStatus.state === "MERGED" ? "merged" : "closed",
48
50
  log: `CANCEL: PR #${report.pr} is ${state} — stopping monitor`,
49
51
  };
50
52
  }
@@ -60,6 +62,8 @@ export async function runIterate(opts) {
60
62
  status: report.status,
61
63
  state: report.mergeStatus.state,
62
64
  mergeStateStatus: report.mergeStatus.mergeStateStatus,
65
+ mergeStatus: report.mergeStatus.status,
66
+ reviewDecision: report.mergeStatus.reviewDecision,
63
67
  copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
64
68
  isDraft: report.mergeStatus.isDraft,
65
69
  shouldCancel: readyState.shouldCancel,
@@ -69,23 +73,31 @@ export async function runIterate(opts) {
69
73
  checks: buildRelevantChecks(report),
70
74
  };
71
75
  if (readyState.shouldCancel) {
76
+ let cancelNote;
77
+ if (base.mergeStatus !== "BLOCKED")
78
+ cancelNote = "has been ready for review";
79
+ else if (base.reviewDecision === "REVIEW_REQUIRED")
80
+ cancelNote = "is awaiting human review";
81
+ else if (base.reviewDecision === "APPROVED")
82
+ cancelNote = "is awaiting additional approvals";
83
+ else
84
+ cancelNote = "is awaiting human review or branch protection resolution";
72
85
  return {
73
86
  ...base,
74
87
  action: "cancel",
75
- log: `CANCEL: PR #${base.pr} has been ready for review — ready-delay elapsed, stopping monitor`,
88
+ reason: "ready-delay-elapsed",
89
+ log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping monitor`,
76
90
  };
77
91
  }
78
92
  const headSha = (await getCurrentHeadSha()) ?? "unknown";
79
93
  const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
80
- const { minimizeIds: reviewSummaryIds, surfacedSummaries } = classifyReviewSummaries(report.reviewSummaries, report.approvedReviews, config.iterate.minimizeReviewSummaries);
81
- const actionableChecks = report.checks.failing.filter((f) => f.failureKind === "actionable");
94
+ const { minimizeIds: reviewSummaryIds, surfacedApprovals } = classifyReviewSummaries(report.reviewSummaries, report.approvedReviews, config.iterate.minimizeApprovals);
82
95
  const hasActionableWork = report.threads.actionable.length > 0 ||
83
96
  report.comments.actionable.length > 0 ||
84
97
  report.changesRequestedReviews.length > 0 ||
85
- actionableChecks.length > 0 ||
98
+ report.checks.failing.length > 0 ||
86
99
  report.mergeStatus.status === "CONFLICTS" ||
87
- reviewSummaryIds.length > 0 ||
88
- surfacedSummaries.length > 0;
100
+ reviewSummaryIds.length > 0;
89
101
  if (hasActionableWork) {
90
102
  return handleFixCode({
91
103
  base,
@@ -98,13 +110,9 @@ export async function runIterate(opts) {
98
110
  repoOwner,
99
111
  repoName,
100
112
  reviewSummaryIds,
101
- surfacedSummaries,
113
+ surfacedApprovals,
102
114
  });
103
115
  }
104
- const transientChecks = report.checks.failing.filter((f) => (f.failureKind === "timeout" || f.failureKind === "cancelled") && f.runId !== null);
105
- if (transientChecks.length > 0) {
106
- return buildRerunCiResult(transientChecks, base, prNumber, stallKey, stallTimeoutSeconds, headSha, report, reviewSummaryIds);
107
- }
108
116
  const canMarkReady = report.status === "READY" &&
109
117
  report.mergeStatus.isDraft &&
110
118
  !report.mergeStatus.copilotReviewInProgress &&
@@ -28,22 +28,19 @@ export function renderResolveCommand(rc) {
28
28
  }
29
29
  return parts.join(" ");
30
30
  }
31
- export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount) {
31
+ export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = []) {
32
32
  const instructions = [];
33
33
  if (threads.length > 0 || actionableComments.length > 0) {
34
34
  instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.`);
35
35
  }
36
- // Mirror the truthiness checks in `formatIterateResult` (cli/iterate-formatter.mts) so each
37
- // AgentCheck maps to the same bullet shape here as there: runId → runId
38
- // bullet, else detailsUrl → external bullet, else `(no runId)` bullet.
39
36
  const checksWithRunId = checks.filter((c) => c.runId);
40
37
  const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
41
38
  const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
42
39
  if (checksWithRunId.length > 0) {
43
- instructions.push(`For each bullet in \`## Failing checks\` whose backticked locator is a numeric runId (GitHub Actions): run \`gh run view <runId> --log-failed\`, identify the failure, and apply the fix.`);
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.`);
44
41
  }
45
42
  if (externalChecks.length > 0) {
46
- 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 — \`gh run view\` cannot fetch logs for external checks.`);
43
+ 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.`);
47
44
  }
48
45
  if (bareChecks.length > 0) {
49
46
  instructions.push(`For each bullet in \`## Failing checks\` starting with \`(no runId)\`: there is no run or details URL to inspect. Escalate these to a human — they require manual investigation outside the pr-shepherd flow.`);
@@ -84,6 +81,13 @@ export function buildFixInstructions(threads, actionableComments, checks, review
84
81
  if (needsPush && cancelledCount > 0) {
85
82
  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.`);
86
83
  }
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
+ if (resolveCommand.hasMutations) {
89
+ 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
+ }
87
91
  if (needsPush) {
88
92
  instructions.push(`Stop this iteration — CI needs time to run on the new push before the next tick.`);
89
93
  }
@@ -96,15 +100,20 @@ export function buildFixInstructions(threads, actionableComments, checks, review
96
100
  return instructions;
97
101
  }
98
102
  export function buildWaitLog(base) {
99
- const { summary, mergeStateStatus, remainingSeconds } = base;
103
+ const { summary, remainingSeconds } = base;
100
104
  const parts = [`WAIT: ${summary.passing} passing, ${summary.inProgress} in-progress`];
101
- switch (mergeStateStatus) {
105
+ switch (base.mergeStatus) {
106
+ case "BLOCKED":
107
+ if (base.reviewDecision === "REVIEW_REQUIRED")
108
+ parts.push("awaiting human review");
109
+ else if (base.reviewDecision === "APPROVED")
110
+ parts.push("awaiting additional approvals");
111
+ else
112
+ parts.push("awaiting human review or branch protection");
113
+ break;
102
114
  case "BEHIND":
103
115
  parts.push("branch is behind base");
104
116
  break;
105
- case "BLOCKED":
106
- parts.push("blocked by pending reviews or required status checks");
107
- break;
108
117
  case "DRAFT":
109
118
  parts.push("PR is a draft");
110
119
  break;
@@ -3,7 +3,7 @@ import { toAgentThread, toAgentComment } from "../../reporters/agent.mjs";
3
3
  import { buildEscalateSuggestion, buildEscalateHumanMessage } from "./escalate.mjs";
4
4
  export function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
5
5
  const checks = [
6
- ...report.checks.failing.map((f) => `failing:${f.name}:${f.failureKind ?? ""}`),
6
+ ...report.checks.failing.map((f) => `failing:${f.name}:${f.conclusion}`),
7
7
  ...report.checks.inProgress.map((p) => `inProgress:${p.name}`),
8
8
  ].sort();
9
9
  const threads = report.threads.actionable.map((t) => t.id).sort();
@@ -4,13 +4,15 @@
4
4
  * `buildFixInstructions` in `commands/iterate/render.mts`).
5
5
  */
6
6
  export function buildFetchInstructions(prNumber, result) {
7
- const { actionableThreads, actionableComments, changesRequestedReviews, reviewSummaries, commitSuggestionsEnabled, } = result;
7
+ const { actionableThreads, firstLookThreads, actionableComments, firstLookComments, changesRequestedReviews, reviewSummaries, commitSuggestionsEnabled, } = result;
8
+ const firstLookTotal = firstLookThreads.length + firstLookComments.length;
8
9
  const total = actionableThreads.length +
9
10
  actionableComments.length +
10
11
  changesRequestedReviews.length +
11
- reviewSummaries.length;
12
+ reviewSummaries.length +
13
+ firstLookTotal;
12
14
  if (total === 0) {
13
- return ["No actionable items — end this invocation."];
15
+ return ["No actionable items and no first-look items — end this invocation."];
14
16
  }
15
17
  const hasCodeItems = actionableThreads.length > 0 ||
16
18
  actionableComments.length > 0 ||
@@ -18,6 +20,9 @@ export function buildFetchInstructions(prNumber, result) {
18
20
  const hasSuggestions = commitSuggestionsEnabled && actionableThreads.some((t) => t.suggestion != null);
19
21
  const instructions = [];
20
22
  instructions.push(`Classify every item listed above into exactly one of: Fixed / Actionable / Not relevant / Outdated / Acknowledge. Do not silently skip any item. Bot-authored review summaries (authors whose name contains \`[bot]\` or matches \`copilot-pull-request-reviewer\`, \`gemini-code-assist\`) default to Acknowledge with reason "bot summary — no actionable content" unless the body calls out an unaddressed issue.`);
23
+ if (firstLookTotal > 0) {
24
+ 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").`);
25
+ }
21
26
  if (hasSuggestions) {
22
27
  instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\`, one thread at a time. On \`applied: true\` mark it Fixed — the CLI already resolved the thread, so exclude the ID from \`--resolve-thread-ids\`. On \`applied: false\` read \`reason\` and \`patch\`, then fall through to the manual fix step — do not retry the same command. Optionally pass \`--dry-run\` (omitting \`--message\`) if you want to inspect the unified diff before it mutates the working tree — the CLI validates with \`git apply --check\`, returns the patch and \`valid: true/false\`, and exits \`1\` on drift without committing or resolving the thread.`);
23
28
  }
@@ -34,6 +39,7 @@ export function buildFetchInstructions(prNumber, result) {
34
39
  ? ` Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`.`
35
40
  : "";
36
41
  instructions.push(`Run \`npx pr-shepherd resolve ${prNumber} [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids> --message "<reason>"]\` with only the non-empty flag subsets. Skip the command entirely if all three ID lists are empty.${requireShaHint}${dismissNote}`);
42
+ 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 each item's bullet above; for reviews, reference the review ID.`);
37
43
  instructions.push(`Report: echo the CLI's mutation output, then one line per Acknowledged item: \`Acknowledged <id> (@<author>): <reason>\`. If any fetched item was neither resolved nor acknowledged, stop and escalate: "<N> item(s) fetched but not acted on or acknowledged — need human direction before closing".`);
38
44
  return instructions;
39
45
  }