@h-rig/runtime 0.0.6-alpha.12 → 0.0.6-alpha.14

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.
@@ -2,6 +2,51 @@
2
2
  // packages/runtime/src/control-plane/native/pr-review-gate.ts
3
3
  import { mkdirSync, writeFileSync } from "fs";
4
4
  import { resolve } from "path";
5
+
6
+ // packages/runtime/src/control-plane/runtime/baked-secrets.ts
7
+ var BAKED_RUNTIME_SECRETS = {
8
+ ANTHROPIC_API_KEY: typeof RIG_BAKED_ANTHROPIC_API_KEY !== "undefined" ? RIG_BAKED_ANTHROPIC_API_KEY : "",
9
+ OPENAI_API_KEY: typeof RIG_BAKED_OPENAI_API_KEY !== "undefined" ? RIG_BAKED_OPENAI_API_KEY : "",
10
+ OPENROUTER_API_KEY: typeof RIG_BAKED_OPENROUTER_API_KEY !== "undefined" ? RIG_BAKED_OPENROUTER_API_KEY : "",
11
+ AI_REVIEW_MODE: typeof RIG_BAKED_AI_REVIEW_MODE !== "undefined" ? RIG_BAKED_AI_REVIEW_MODE : "",
12
+ AI_REVIEW_PROVIDER: typeof RIG_BAKED_AI_REVIEW_PROVIDER !== "undefined" ? RIG_BAKED_AI_REVIEW_PROVIDER : "",
13
+ GREPTILE_API_BASE: typeof RIG_BAKED_GREPTILE_API_BASE !== "undefined" ? RIG_BAKED_GREPTILE_API_BASE : "",
14
+ GREPTILE_REMOTE: typeof RIG_BAKED_GREPTILE_REMOTE !== "undefined" ? RIG_BAKED_GREPTILE_REMOTE : "",
15
+ GREPTILE_REPOSITORY: typeof RIG_BAKED_GREPTILE_REPOSITORY !== "undefined" ? RIG_BAKED_GREPTILE_REPOSITORY : "",
16
+ GREPTILE_CONTEXT_BRANCH: typeof RIG_BAKED_GREPTILE_CONTEXT_BRANCH !== "undefined" ? RIG_BAKED_GREPTILE_CONTEXT_BRANCH : "",
17
+ GREPTILE_DEFAULT_BRANCH: typeof RIG_BAKED_GREPTILE_DEFAULT_BRANCH !== "undefined" ? RIG_BAKED_GREPTILE_DEFAULT_BRANCH : "",
18
+ GREPTILE_API_KEY: typeof RIG_BAKED_GREPTILE_API_KEY !== "undefined" ? RIG_BAKED_GREPTILE_API_KEY : "",
19
+ GREPTILE_GITHUB_TOKEN: typeof RIG_BAKED_GREPTILE_GITHUB_TOKEN !== "undefined" ? RIG_BAKED_GREPTILE_GITHUB_TOKEN : "",
20
+ GREPTILE_POLL_ATTEMPTS: typeof RIG_BAKED_GREPTILE_POLL_ATTEMPTS !== "undefined" ? RIG_BAKED_GREPTILE_POLL_ATTEMPTS : "",
21
+ GREPTILE_POLL_INTERVAL_MS: typeof RIG_BAKED_GREPTILE_POLL_INTERVAL_MS !== "undefined" ? RIG_BAKED_GREPTILE_POLL_INTERVAL_MS : "",
22
+ GH_TOKEN: typeof RIG_BAKED_GITHUB_TOKEN !== "undefined" ? RIG_BAKED_GITHUB_TOKEN : "",
23
+ GITHUB_TOKEN: typeof RIG_BAKED_GITHUB_TOKEN !== "undefined" ? RIG_BAKED_GITHUB_TOKEN : "",
24
+ GITHUB_SSH_KEY: typeof RIG_BAKED_GITHUB_SSH_KEY !== "undefined" ? RIG_BAKED_GITHUB_SSH_KEY : "",
25
+ AWS_ACCESS_KEY_ID: typeof RIG_BAKED_AWS_ACCESS_KEY_ID !== "undefined" ? RIG_BAKED_AWS_ACCESS_KEY_ID : "",
26
+ AWS_SECRET_ACCESS_KEY: typeof RIG_BAKED_AWS_SECRET_ACCESS_KEY !== "undefined" ? RIG_BAKED_AWS_SECRET_ACCESS_KEY : "",
27
+ AWS_REGION: typeof RIG_BAKED_AWS_REGION !== "undefined" ? RIG_BAKED_AWS_REGION : "",
28
+ LINEAR_API_KEY: typeof RIG_BAKED_LINEAR_API_KEY !== "undefined" ? RIG_BAKED_LINEAR_API_KEY : "",
29
+ LINEAR_WEBHOOK_SECRET: typeof RIG_BAKED_LINEAR_WEBHOOK_SECRET !== "undefined" ? RIG_BAKED_LINEAR_WEBHOOK_SECRET : ""
30
+ };
31
+ function resolveRuntimeSecrets(env, baked = BAKED_RUNTIME_SECRETS) {
32
+ const resolved = {};
33
+ const keys = new Set([
34
+ ...Object.keys(BAKED_RUNTIME_SECRETS),
35
+ ...Object.keys(baked)
36
+ ]);
37
+ for (const key of keys) {
38
+ const envValue = env[key]?.trim();
39
+ const bakedValue = baked[key]?.trim();
40
+ if (envValue) {
41
+ resolved[key] = envValue;
42
+ } else if (bakedValue) {
43
+ resolved[key] = bakedValue;
44
+ }
45
+ }
46
+ return resolved;
47
+ }
48
+
49
+ // packages/runtime/src/control-plane/native/pr-review-gate.ts
5
50
  function parseJsonObject(value) {
6
51
  if (!value?.trim())
7
52
  return { value: {}, error: "empty JSON output" };
@@ -106,13 +151,7 @@ function stripHtml(input) {
106
151
  }
107
152
  function containsBlockerText(input) {
108
153
  const text = stripHtml(input).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
109
- return /not safe(?: to merge)?|unsafe(?: to merge)?|do not merge|cannot merge|blockers?|must fix|changes requested|please fix|needs? fix|fix this|address this/i.test(text);
110
- }
111
- function containsGreptileNegativeVerdict(input) {
112
- const text = stripHtml(input).replace(/\s+/g, " ").trim();
113
- if (!text)
114
- return false;
115
- return /\b(?:status|verdict|review state|state|conclusion|result)\s*:?\s*(?:reject(?:ed|ion)?|skip(?:ped)?|fail(?:ed|ure)?|changes[_ ]requested|not approved)\b/i.test(text) || /\bgreptile\b.{0,160}\b(?:reject(?:ed|s|ion)?|skip(?:ped|s)?|fail(?:ed|s|ure)?|changes requested|did not approve|not approved)\b/i.test(text);
154
+ return /not safe(?: to merge)?|unsafe(?: to merge)?|do not merge|cannot merge|blockers?|must fix|changes requested|please fix|needs? fix|fix this|address this|\breject(?:ed|ion)?\b|\bskip(?:ped)?\b|status\s*:\s*(?:reject(?:ed)?|skip(?:ped)?|failed)/i.test(text);
116
155
  }
117
156
  function isStrictFiveOfFive(score) {
118
157
  return score.value === 5 && score.scale === 5;
@@ -120,6 +159,189 @@ function isStrictFiveOfFive(score) {
120
159
  function containsConflictingScoreText(input) {
121
160
  return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
122
161
  }
162
+ function greptileStatusVerdict(status) {
163
+ const normalized = String(status ?? "").trim().toUpperCase().replace(/[\s-]+/g, "_");
164
+ if (!normalized)
165
+ return null;
166
+ if (["APPROVE", "APPROVED"].includes(normalized))
167
+ return "approved";
168
+ if (["REJECT", "REJECTED", "CHANGES_REQUESTED", "CHANGE_REQUESTED"].includes(normalized))
169
+ return "rejected";
170
+ if (["SKIP", "SKIPPED"].includes(normalized))
171
+ return "skipped";
172
+ if (["FAIL", "FAILED", "FAILURE", "ERROR"].includes(normalized))
173
+ return "failed";
174
+ if (["PENDING", "QUEUED", "IN_PROGRESS", "RUNNING", "STARTED", "REQUESTED", "REVIEWING_FILES", "GENERATING_SUMMARY"].includes(normalized))
175
+ return "pending";
176
+ if (["COMPLETE", "COMPLETED"].includes(normalized))
177
+ return "completed";
178
+ return null;
179
+ }
180
+ function isBlockingGreptileVerdict(verdict) {
181
+ return verdict === "rejected" || verdict === "skipped" || verdict === "failed";
182
+ }
183
+ function greptileRequestTimeoutMs(env) {
184
+ const fallback = 30000;
185
+ const parsed = Number.parseInt(env.GREPTILE_REQUEST_TIMEOUT_MS || `${fallback}`, 10);
186
+ return Number.isFinite(parsed) && parsed >= 1000 ? parsed : fallback;
187
+ }
188
+ function normalizeGreptileMcpCodeReview(entry, fallbackId) {
189
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
190
+ return null;
191
+ const record = entry;
192
+ const id = typeof record.id === "string" ? record.id.trim() : fallbackId?.trim() ?? "";
193
+ if (!id)
194
+ return null;
195
+ const metadataRecord = record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? record.metadata : null;
196
+ return {
197
+ id,
198
+ status: typeof record.status === "string" ? record.status : null,
199
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : null,
200
+ body: typeof record.body === "string" ? record.body : null,
201
+ metadata: metadataRecord ? { checkHeadSha: typeof metadataRecord.checkHeadSha === "string" ? metadataRecord.checkHeadSha : null } : null
202
+ };
203
+ }
204
+ function uniqueGreptileCodeReviews(reviews) {
205
+ const seen = new Set;
206
+ const unique = [];
207
+ for (const review of reviews) {
208
+ if (seen.has(review.id))
209
+ continue;
210
+ seen.add(review.id);
211
+ unique.push(review);
212
+ }
213
+ return unique;
214
+ }
215
+ function selectGreptileApiReviewsForGate(reviews, headSha) {
216
+ const sorted = [...reviews].sort((left, right) => Date.parse(right.createdAt ?? "") - Date.parse(left.createdAt ?? ""));
217
+ const current = headSha ? sorted.filter((review) => review.metadata?.checkHeadSha === headSha) : [];
218
+ const untied = sorted.filter((review) => !review.metadata?.checkHeadSha);
219
+ const latest = sorted.slice(0, 1);
220
+ return uniqueGreptileCodeReviews([...current, ...untied, ...latest]);
221
+ }
222
+ function greptileApiSignalFromCodeReview(review, details) {
223
+ const selected = details ?? review;
224
+ return {
225
+ id: selected.id || review.id,
226
+ body: selected.body ?? review.body ?? null,
227
+ reviewedSha: selected.metadata?.checkHeadSha ?? review.metadata?.checkHeadSha ?? null,
228
+ status: selected.status ?? review.status ?? null
229
+ };
230
+ }
231
+ async function callGreptileMcpToolForGate(input) {
232
+ const controller = new AbortController;
233
+ const timeoutId = setTimeout(() => {
234
+ controller.abort(new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`));
235
+ }, input.timeoutMs);
236
+ let response;
237
+ try {
238
+ response = await input.fetchFn(input.apiBase, {
239
+ method: "POST",
240
+ headers: {
241
+ Authorization: `Bearer ${input.apiKey}`,
242
+ "Content-Type": "application/json"
243
+ },
244
+ body: JSON.stringify({
245
+ jsonrpc: "2.0",
246
+ id: `rig-strict-gate-${input.name}-${Date.now()}`,
247
+ method: "tools/call",
248
+ params: { name: input.name, arguments: input.args }
249
+ }),
250
+ signal: controller.signal
251
+ });
252
+ } catch (error) {
253
+ if (controller.signal.aborted) {
254
+ throw controller.signal.reason instanceof Error ? controller.signal.reason : new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`);
255
+ }
256
+ throw error;
257
+ } finally {
258
+ clearTimeout(timeoutId);
259
+ }
260
+ const raw = await response.text();
261
+ if (!response.ok) {
262
+ throw new Error(`HTTP ${response.status}: ${raw}`);
263
+ }
264
+ let envelope;
265
+ try {
266
+ envelope = JSON.parse(raw);
267
+ } catch {
268
+ throw new Error(`Malformed MCP response: ${raw}`);
269
+ }
270
+ if (envelope.error?.message) {
271
+ throw new Error(envelope.error.message);
272
+ }
273
+ const text = (envelope.result?.content ?? []).filter((item) => item.type === "text" && typeof item.text === "string").map((item) => item.text ?? "").join(`
274
+ `).trim();
275
+ if (!text) {
276
+ throw new Error(`MCP tool ${input.name} returned no text payload.`);
277
+ }
278
+ return text;
279
+ }
280
+ async function callGreptileMcpToolJsonForGate(input) {
281
+ const text = await callGreptileMcpToolForGate(input);
282
+ try {
283
+ return JSON.parse(text);
284
+ } catch {
285
+ throw new Error(`MCP tool ${input.name} returned malformed JSON: ${text}`);
286
+ }
287
+ }
288
+ async function collectConfiguredGreptileApiSignals(input) {
289
+ if (!input.enabled || input.options?.enabled === false) {
290
+ return { signals: [], errors: [] };
291
+ }
292
+ const env = input.options?.env ?? process.env;
293
+ const secrets = resolveRuntimeSecrets(env);
294
+ const apiKey = secrets.GREPTILE_API_KEY?.trim() ?? "";
295
+ if (!apiKey) {
296
+ return { signals: [], errors: [] };
297
+ }
298
+ const fetchFn = input.options?.fetch ?? globalThis.fetch;
299
+ if (typeof fetchFn !== "function") {
300
+ return { signals: [], errors: ["Greptile API/MCP evidence read failed: fetch is not available."] };
301
+ }
302
+ const apiBase = secrets.GREPTILE_API_BASE?.trim() || "https://api.greptile.com/mcp";
303
+ const remote = secrets.GREPTILE_REMOTE?.trim() || "github";
304
+ const repository = secrets.GREPTILE_REPOSITORY?.trim() || input.repoName;
305
+ const defaultBranch = secrets.GREPTILE_DEFAULT_BRANCH?.trim() || input.baseRefName?.trim() || "main";
306
+ const timeoutMs = greptileRequestTimeoutMs(env);
307
+ try {
308
+ const listPayload = await callGreptileMcpToolJsonForGate({
309
+ apiBase,
310
+ apiKey,
311
+ name: "list_code_reviews",
312
+ args: {
313
+ name: repository,
314
+ remote,
315
+ defaultBranch,
316
+ prNumber: input.prNumber,
317
+ limit: 20
318
+ },
319
+ timeoutMs,
320
+ fetchFn
321
+ });
322
+ const reviews = (listPayload.codeReviews ?? []).map((entry) => normalizeGreptileMcpCodeReview(entry)).filter((review) => !!review);
323
+ const selectedReviews = selectGreptileApiReviewsForGate(reviews, input.headSha);
324
+ const signals = [];
325
+ for (const review of selectedReviews) {
326
+ const detailsPayload = await callGreptileMcpToolJsonForGate({
327
+ apiBase,
328
+ apiKey,
329
+ name: "get_code_review",
330
+ args: { codeReviewId: review.id },
331
+ timeoutMs,
332
+ fetchFn
333
+ });
334
+ const details = normalizeGreptileMcpCodeReview(detailsPayload.codeReview, review.id) ?? review;
335
+ signals.push(greptileApiSignalFromCodeReview(review, details));
336
+ }
337
+ return { signals, errors: [] };
338
+ } catch (error) {
339
+ return {
340
+ signals: [],
341
+ errors: [`Greptile API/MCP evidence read failed: ${error instanceof Error ? error.message : String(error)}`]
342
+ };
343
+ }
344
+ }
123
345
  function firstString(record, keys) {
124
346
  for (const key of keys) {
125
347
  const value = record[key];
@@ -246,7 +468,7 @@ function normalizeReviewThread(entry) {
246
468
  function relevantIssueComment(comment) {
247
469
  const login = comment.user?.login ?? comment.author?.login ?? "";
248
470
  const body = comment.body ?? "";
249
- return isGreptileGithubLogin(login) || /greptile|blocker|unsafe|not safe|do not merge|must fix|please fix|changes requested|score|confidence|\b\d+\s*\/\s*5\b/i.test(body);
471
+ return isGreptileGithubLogin(login) || containsBlockerText(body) || /greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(body);
250
472
  }
251
473
  function latestThreadComment(thread) {
252
474
  const nodes = thread.comments?.nodes ?? [];
@@ -282,7 +504,8 @@ function makeGreptileSignal(input) {
282
504
  const scores = parseGreptileScores(input.body);
283
505
  const reviewedSha = input.reviewedSha?.trim() || null;
284
506
  const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
285
- const blocker = input.blocker ?? (containsBlockerText(input.body) || containsGreptileNegativeVerdict(input.body));
507
+ const verdict = input.verdict ?? null;
508
+ const blocker = input.blocker ?? (isBlockingGreptileVerdict(verdict) || containsBlockerText(input.body));
286
509
  const explicitApproval = input.explicitApproval ?? false;
287
510
  return {
288
511
  source: input.source,
@@ -294,6 +517,7 @@ function makeGreptileSignal(input) {
294
517
  score: scores[0] ?? null,
295
518
  scores,
296
519
  explicitApproval,
520
+ verdict,
297
521
  blocker,
298
522
  actionable: input.actionable ?? blocker,
299
523
  bodyExcerpt: bodyExcerpt(input.body),
@@ -316,9 +540,9 @@ function collectGreptileSignals(evidence) {
316
540
  for (const context of contextSources) {
317
541
  if (!context.body.trim())
318
542
  continue;
319
- if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
320
- continue;
321
543
  const contextBlocker = containsBlockerText(context.body);
544
+ if (!contextBlocker && !/greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(context.body))
545
+ continue;
322
546
  signals.push(makeGreptileSignal({
323
547
  source: context.source,
324
548
  body: context.body,
@@ -331,16 +555,16 @@ function collectGreptileSignals(evidence) {
331
555
  for (const apiSignal of evidence.apiSignals ?? []) {
332
556
  const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
333
557
 
334
- `);
335
- if (!body.trim())
336
- continue;
558
+ `) || "Status: UNKNOWN";
559
+ const verdict = greptileStatusVerdict(apiSignal.status);
337
560
  signals.push(makeGreptileSignal({
338
561
  source: "api",
339
562
  body,
340
563
  currentHeadSha: evidence.currentHeadSha,
341
564
  trusted: true,
342
565
  reviewedSha: apiSignal.reviewedSha ?? null,
343
- explicitApproval: false
566
+ explicitApproval: verdict === "approved",
567
+ verdict
344
568
  }));
345
569
  }
346
570
  for (const review of evidence.reviews) {
@@ -365,20 +589,6 @@ function collectGreptileSignals(evidence) {
365
589
  blocker: state === "CHANGES_REQUESTED" || undefined
366
590
  }));
367
591
  }
368
- for (const comment of evidence.changedFileReviewComments) {
369
- const login = commentAuthorLogin(comment);
370
- const body = comment.body ?? "";
371
- if (!body.trim() || !isGreptileGithubLogin(login))
372
- continue;
373
- signals.push(makeGreptileSignal({
374
- source: "changed-file-comment",
375
- body,
376
- currentHeadSha: evidence.currentHeadSha,
377
- trusted: true,
378
- authorLogin: login,
379
- reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
380
- }));
381
- }
382
592
  for (const comment of evidence.relevantIssueComments) {
383
593
  const login = commentAuthorLogin(comment);
384
594
  const body = comment.body ?? "";
@@ -444,6 +654,9 @@ function unresolvedGreptileThreadSummaries(threads) {
444
654
  return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
445
655
  });
446
656
  }
657
+ function actionableChangedFileCommentSummaries(_comments) {
658
+ return [];
659
+ }
447
660
  function issueLevelBlockerSummaries(comments) {
448
661
  return comments.flatMap((comment) => {
449
662
  const body = comment.body?.trim() ?? "";
@@ -483,14 +696,21 @@ function deriveGreptileEvidence(input) {
483
696
  const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
484
697
  const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
485
698
  const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
486
- const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && isStrictFiveOfFive(entry.score)) ?? null;
699
+ const currentPendingApiSignals = trustedSignals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && isCurrentOrUntied(signal));
700
+ const signalCanApproveByScore = (signal) => {
701
+ if (signal.source === "api")
702
+ return signal.verdict === "approved" || signal.verdict === "completed";
703
+ return signal.verdict !== "pending" && !isBlockingGreptileVerdict(signal.verdict);
704
+ };
705
+ const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && signalCanApproveByScore(entry.signal) && isStrictFiveOfFive(entry.score)) ?? null;
706
+ const approvingExplicitSignal = trustedSignals.find((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha && signal.explicitApproval === true && !signal.blocker) ?? null;
487
707
  const approvedByScore = !!approvingScoreEntry;
488
- const approvedByExplicitMapping = false;
489
- const approvingSignal = approvingScoreEntry?.signal ?? null;
708
+ const approvedByExplicitMapping = !!approvingExplicitSignal;
709
+ const approvingSignal = approvingScoreEntry?.signal ?? approvingExplicitSignal;
490
710
  const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
491
711
  const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
492
712
  const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
493
- const blockerSignals = signals.filter((signal) => signal.source !== "changed-file-comment" && (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
713
+ const blockerSignals = signals.filter((signal) => (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
494
714
  const staleBlockingSignals = [];
495
715
  const blockers = [
496
716
  ...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
@@ -501,7 +721,8 @@ function deriveGreptileEvidence(input) {
501
721
  ...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
502
722
  ];
503
723
  const unresolvedComments = [
504
- ...unresolvedGreptileThreadSummaries(input.reviewThreads)
724
+ ...unresolvedGreptileThreadSummaries(input.reviewThreads),
725
+ ...actionableChangedFileCommentSummaries(input.changedFileReviewComments)
505
726
  ];
506
727
  const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
507
728
  const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
@@ -514,13 +735,14 @@ function deriveGreptileEvidence(input) {
514
735
  const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
515
736
  return completedState && review.commit_id === input.currentHeadSha;
516
737
  });
738
+ const completedGreptileApi = trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha && (signal.verdict === "approved" || signal.verdict === "rejected" || signal.verdict === "skipped" || signal.verdict === "failed" || signal.verdict === "completed"));
517
739
  const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
518
740
  const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
519
741
  const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
520
- const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
742
+ const completed = completedGreptileCheck || completedGreptileReview || completedGreptileApi || !!approvingSignal;
521
743
  const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
522
- const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
523
- const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : "unproven";
744
+ const approved = fresh && completed && !blockers.length && !unresolvedComments.length && currentPendingApiSignals.length === 0 && (approvedByScore || approvedByExplicitMapping);
745
+ const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : approvedByExplicitMapping ? "explicit-approved" : "unproven";
524
746
  const source = approvingSignal?.source === "api" ? "api" : approvingSignal?.source === "github-review" ? "github-review" : approvingSignal?.source === "changed-file-comment" || approvingSignal?.source === "issue-comment" || approvingSignal?.source === "review-thread" ? "github-comment" : greptileReviews.length > 0 && greptileChecks.length > 0 ? "combined" : greptileReviews.length > 0 ? "github-review" : greptileChecks.length > 0 ? "github-check" : signals.some((signal) => signal.source === "pr-body" || signal.source === "pr-title") ? "pr-body" : "missing";
525
747
  return {
526
748
  source,
@@ -627,6 +849,7 @@ async function collectPrReviewEvidence(input) {
627
849
  readErrors.push("gh pr view did not return required reviews array");
628
850
  }
629
851
  const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
852
+ const baseRefName = firstString(view, ["baseRefName"]);
630
853
  const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
631
854
  const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
632
855
  const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
@@ -664,6 +887,17 @@ async function collectPrReviewEvidence(input) {
664
887
  }
665
888
  }
666
889
  const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
890
+ const shouldCollectConfiguredGreptileApi = input.greptileApi?.enabled !== false;
891
+ const configuredGreptileApiRead = await collectConfiguredGreptileApiSignals({
892
+ enabled: shouldCollectConfiguredGreptileApi,
893
+ options: input.greptileApi,
894
+ repoName: parsed.repoName,
895
+ prNumber: parsed.prNumber,
896
+ headSha,
897
+ baseRefName
898
+ });
899
+ readErrors.push(...configuredGreptileApiRead.errors);
900
+ const apiSignals = [...input.apiSignals ?? [], ...configuredGreptileApiRead.signals];
667
901
  const checkFailures = statusCheckRollup.filter((check) => !isGreptileLabel(checkName(check)) && isFailingCheck(check) && !isAllowedFailure(checkName(check), input.allowedFailures ?? [])).map((check) => `Check failed: ${checkName(check)}${check.detailsUrl || check.link ? ` (${check.detailsUrl ?? check.link})` : ""}`);
668
902
  const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
669
903
  const evidenceBase = {
@@ -675,7 +909,7 @@ async function collectPrReviewEvidence(input) {
675
909
  reviewThreads,
676
910
  checks: checksWithGreptileDetails,
677
911
  currentHeadSha: headSha,
678
- apiSignals: input.apiSignals ?? []
912
+ apiSignals
679
913
  };
680
914
  const greptile = deriveGreptileEvidence(evidenceBase);
681
915
  return {
@@ -686,7 +920,7 @@ async function collectPrReviewEvidence(input) {
686
920
  body: evidenceBase.body,
687
921
  headSha,
688
922
  headRefName: firstString(view, ["headRefName"]),
689
- baseRefName: firstString(view, ["baseRefName"]),
923
+ baseRefName,
690
924
  state: firstString(view, ["state"]),
691
925
  isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
692
926
  mergeable: firstString(view, ["mergeable"]),
@@ -703,71 +937,251 @@ async function collectPrReviewEvidence(input) {
703
937
  greptile
704
938
  };
705
939
  }
940
+ function capGateMessage(value, maxChars = 1200) {
941
+ const normalized = value.trim();
942
+ return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}
943
+ [truncated for gate summary; see full evidence artifact]` : normalized;
944
+ }
706
945
  function evaluateEvidence(evidence) {
707
- const reasons = [];
946
+ const reasonDetails = [];
708
947
  const warnings = [];
709
- let pending = false;
710
- if (evidence.readErrors.length > 0) {
711
- reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
712
- }
713
- if (!evidence.headSha)
714
- reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
715
- if (evidence.checkFailures.length > 0)
716
- reasons.push(...evidence.checkFailures);
717
- if (evidence.pendingChecks.length > 0) {
718
- pending = true;
719
- reasons.push(...evidence.pendingChecks);
948
+ const seen = new Set;
949
+ const addReason = (reason) => {
950
+ const capped = { ...reason, message: capGateMessage(reason.message) };
951
+ const key = `${capped.code}:${capped.message}`;
952
+ if (seen.has(key))
953
+ return;
954
+ seen.add(key);
955
+ reasonDetails.push(capped);
956
+ };
957
+ const greptile = evidence.greptile;
958
+ const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
959
+ const hasPendingGreptileCheck = evidence.pendingChecks.some((check) => /greptile/i.test(check));
960
+ const pendingGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
961
+ const unknownGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && !signal.verdict && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
962
+ const awaitingFreshGreptileProof = hasPendingGreptileCheck || pendingGreptileApiSignals.length > 0 || !greptile.completed || greptile.mapping === "missing" || greptile.mapping === "stale";
963
+ for (const error of evidence.readErrors) {
964
+ addReason({
965
+ code: "read_error",
966
+ reasonClass: "reject",
967
+ surface: error.startsWith("Greptile API/MCP") ? "greptile" : "github",
968
+ suggestedAction: "needs_attention",
969
+ message: `Required PR evidence surface could not be read completely: ${error}`,
970
+ headSha: evidence.headSha || null
971
+ });
972
+ }
973
+ if (!evidence.headSha) {
974
+ addReason({
975
+ code: "missing_head_sha",
976
+ reasonClass: "reject",
977
+ surface: "github",
978
+ suggestedAction: "needs_attention",
979
+ message: "PR head SHA could not be read; current-head Greptile approval cannot be proven.",
980
+ headSha: null
981
+ });
982
+ }
983
+ for (const failure of evidence.checkFailures) {
984
+ addReason({
985
+ code: "ci_failed",
986
+ reasonClass: "reject",
987
+ surface: "ci",
988
+ suggestedAction: "fix",
989
+ message: failure,
990
+ headSha: evidence.headSha || null
991
+ });
992
+ }
993
+ for (const pendingCheck of evidence.pendingChecks) {
994
+ addReason({
995
+ code: "check_pending",
996
+ reasonClass: "pending",
997
+ surface: "ci",
998
+ suggestedAction: "wait",
999
+ message: pendingCheck,
1000
+ headSha: evidence.headSha || null
1001
+ });
720
1002
  }
721
1003
  const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
722
1004
  if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
723
- reasons.push(`Required review is unresolved (${evidence.reviewDecision}).`);
1005
+ addReason({
1006
+ code: "review_decision_blocking",
1007
+ reasonClass: "reject",
1008
+ surface: "review",
1009
+ suggestedAction: "fix",
1010
+ message: `Required review is unresolved (${evidence.reviewDecision}).`,
1011
+ headSha: evidence.headSha || null
1012
+ });
1013
+ }
1014
+ for (const thread of unresolvedThreadSummaries(evidence.reviewThreads)) {
1015
+ addReason({
1016
+ code: "review_thread_unresolved",
1017
+ reasonClass: "reject",
1018
+ surface: "review",
1019
+ suggestedAction: "fix",
1020
+ message: thread,
1021
+ headSha: evidence.headSha || null
1022
+ });
1023
+ }
1024
+ if (greptile.mapping === "missing") {
1025
+ addReason({
1026
+ code: "greptile_missing",
1027
+ reasonClass: "pending",
1028
+ surface: "greptile",
1029
+ suggestedAction: "wait",
1030
+ message: "Missing Greptile check/review evidence for this PR.",
1031
+ headSha: evidence.headSha || null
1032
+ });
724
1033
  }
725
- const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
726
- if (unresolvedThreads.length > 0)
727
- reasons.push(...unresolvedThreads);
728
- const greptile = evidence.greptile;
729
- if (greptile.mapping === "missing")
730
- reasons.push("Missing Greptile check/review evidence for this PR.");
731
- const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
732
1034
  if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
733
- reasons.push(`Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`);
1035
+ addReason({
1036
+ code: "greptile_stale",
1037
+ reasonClass: "pending",
1038
+ surface: "greptile",
1039
+ suggestedAction: "wait",
1040
+ message: `Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`,
1041
+ headSha: evidence.headSha || null,
1042
+ reviewedSha: greptile.reviewedSha ?? staleSignal?.reviewedSha ?? null
1043
+ });
1044
+ }
1045
+ for (const signal of pendingGreptileApiSignals) {
1046
+ addReason({
1047
+ code: "greptile_pending",
1048
+ reasonClass: "pending",
1049
+ surface: "greptile",
1050
+ suggestedAction: "wait",
1051
+ message: `Greptile API/MCP review is pending for the current PR head${signal.bodyExcerpt ? `: ${signal.bodyExcerpt}` : "."}`,
1052
+ headSha: evidence.headSha || null,
1053
+ reviewedSha: signal.reviewedSha ?? null
1054
+ });
1055
+ }
1056
+ for (const signal of unknownGreptileApiSignals) {
1057
+ addReason({
1058
+ code: "greptile_api_status_unknown",
1059
+ reasonClass: "reject",
1060
+ surface: "greptile",
1061
+ suggestedAction: "needs_attention",
1062
+ message: `Greptile API/MCP review status is unknown; merge requires a known terminal APPROVED/COMPLETED 5/5 result or a known conservative status${signal.bodyExcerpt ? `: ${signal.bodyExcerpt}` : "."}`,
1063
+ headSha: evidence.headSha || null,
1064
+ reviewedSha: signal.reviewedSha ?? null
1065
+ });
734
1066
  }
735
1067
  if (!greptile.completed) {
736
- pending = true;
737
- reasons.push("Greptile check/review has not completed for the current PR head.");
1068
+ addReason({
1069
+ code: "greptile_pending",
1070
+ reasonClass: "pending",
1071
+ surface: "greptile",
1072
+ suggestedAction: "wait",
1073
+ message: "Greptile check/review has not completed for the current PR head.",
1074
+ headSha: evidence.headSha || null,
1075
+ reviewedSha: greptile.reviewedSha ?? null
1076
+ });
1077
+ }
1078
+ if (!greptile.fresh) {
1079
+ addReason({
1080
+ code: "greptile_not_current_head",
1081
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
1082
+ surface: "greptile",
1083
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
1084
+ message: "Greptile approval is not tied to the current PR head SHA.",
1085
+ headSha: evidence.headSha || null,
1086
+ reviewedSha: greptile.reviewedSha ?? null
1087
+ });
738
1088
  }
739
- if (!greptile.fresh)
740
- reasons.push("Greptile approval is not tied to the current PR head SHA.");
741
1089
  if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
742
- reasons.push(`Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`);
1090
+ addReason({
1091
+ code: "greptile_score_not_5",
1092
+ reasonClass: "reject",
1093
+ surface: "greptile",
1094
+ suggestedAction: "fix",
1095
+ message: `Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`,
1096
+ headSha: evidence.headSha || null,
1097
+ reviewedSha: greptile.reviewedSha ?? null
1098
+ });
743
1099
  }
744
- if (!greptile.score && greptile.mapping !== "score-5-of-5") {
745
- reasons.push("No parseable Greptile 5/5 score or explicit approved mapping was found from trusted current-head evidence; merge is blocked.");
1100
+ const hasApprovedMapping = greptile.mapping === "score-5-of-5" || greptile.mapping === "explicit-approved";
1101
+ if (!greptile.score && !hasApprovedMapping) {
1102
+ addReason({
1103
+ code: "greptile_score_missing",
1104
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
1105
+ surface: "greptile",
1106
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
1107
+ message: "No parseable Greptile 5/5 score or direct current-head Greptile API APPROVED mapping was found from trusted evidence; merge is blocked.",
1108
+ headSha: evidence.headSha || null,
1109
+ reviewedSha: greptile.reviewedSha ?? null
1110
+ });
746
1111
  }
747
1112
  if (greptile.mapping === "unproven") {
748
- reasons.push("Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.");
1113
+ addReason({
1114
+ code: "greptile_mapping_unproven",
1115
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
1116
+ surface: "greptile",
1117
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
1118
+ message: "Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.",
1119
+ headSha: evidence.headSha || null,
1120
+ reviewedSha: greptile.reviewedSha ?? null
1121
+ });
749
1122
  }
750
- if (greptile.blockers.length > 0) {
751
- reasons.push(...greptile.blockers.map((entry) => `Greptile/blocker text: ${entry.trim().slice(0, 500)}`));
1123
+ for (const blocker of greptile.blockers) {
1124
+ addReason({
1125
+ code: "greptile_blocker_text",
1126
+ reasonClass: "reject",
1127
+ surface: "greptile",
1128
+ suggestedAction: "fix",
1129
+ message: `Greptile/blocker text: ${blocker}`,
1130
+ headSha: evidence.headSha || null,
1131
+ reviewedSha: greptile.reviewedSha ?? null
1132
+ });
1133
+ }
1134
+ for (const comment of greptile.unresolvedComments) {
1135
+ addReason({
1136
+ code: "greptile_unresolved_comment",
1137
+ reasonClass: "reject",
1138
+ surface: "greptile",
1139
+ suggestedAction: "fix",
1140
+ message: comment,
1141
+ headSha: evidence.headSha || null,
1142
+ reviewedSha: greptile.reviewedSha ?? null
1143
+ });
752
1144
  }
753
- if (greptile.unresolvedComments.length > 0)
754
- reasons.push(...greptile.unresolvedComments);
755
1145
  if (!greptile.approved)
756
1146
  warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
757
- return { reasons: Array.from(new Set(reasons)), warnings, pending };
1147
+ const pending = reasonDetails.length > 0 && reasonDetails.every((reason) => reason.reasonClass === "pending");
1148
+ return { reasons: reasonDetails.map((reason) => reason.message), reasonDetails, warnings, pending };
758
1149
  }
759
1150
  function evaluateStrictPrMergeGate(evidence) {
760
1151
  const evaluated = evaluateEvidence(evidence);
761
- const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
1152
+ const approved = evaluated.reasonDetails.length === 0 && evidence.greptile.approved;
762
1153
  return {
763
1154
  approved,
764
1155
  pending: evaluated.pending,
765
1156
  reasons: evaluated.reasons,
1157
+ reasonDetails: evaluated.reasonDetails,
766
1158
  warnings: evaluated.warnings,
767
1159
  actionableFeedback: evaluated.reasons,
768
1160
  evidence
769
1161
  };
770
1162
  }
1163
+ function strictMergeHeadShaFromGate(result, prUrl) {
1164
+ if (!result.approved) {
1165
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate is not approved.`);
1166
+ }
1167
+ if (result.evidence.prUrl !== prUrl) {
1168
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate evidence belongs to ${result.evidence.prUrl}.`);
1169
+ }
1170
+ const headSha = result.evidence.headSha?.trim();
1171
+ if (!headSha) {
1172
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate did not provide a current head SHA.`);
1173
+ }
1174
+ if (!/^[0-9a-f]{40}$/i.test(headSha)) {
1175
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate head is not a raw 40-character commit SHA.`);
1176
+ }
1177
+ if (!result.evidence.greptile.fresh || result.evidence.greptile.currentHeadSha !== headSha) {
1178
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate approval is not tied to head ${headSha}.`);
1179
+ }
1180
+ if (result.evidence.greptile.mapping !== "score-5-of-5" && result.evidence.greptile.mapping !== "explicit-approved") {
1181
+ throw new Error(`Refusing to merge ${prUrl}: strict merge gate mapping is ${result.evidence.greptile.mapping}.`);
1182
+ }
1183
+ return headSha;
1184
+ }
771
1185
  function promptExcerpt(value, maxChars = 4000) {
772
1186
  return value.length > maxChars ? `${value.slice(0, maxChars)}
773
1187
 
@@ -779,6 +1193,10 @@ function promptJsonExcerpt(value, maxChars = 6000) {
779
1193
  function buildStrictPrGateSteeringPrompt(result) {
780
1194
  const evidence = result.evidence;
781
1195
  const unresolvedReviewThreads = evidence.reviewThreads.filter((thread) => thread.isResolved !== true && thread.isOutdated !== true);
1196
+ const displayedReasons = result.reasons.slice(0, 20).map((reason) => `- ${promptExcerpt(reason, 1200)}`);
1197
+ if (result.reasons.length > displayedReasons.length) {
1198
+ displayedReasons.push(`- ${result.reasons.length - displayedReasons.length} additional gate reasons omitted from prompt; see merge-gate-result.json.`);
1199
+ }
782
1200
  const lines = [
783
1201
  `Strict PR merge gate blocked ${evidence.prUrl}.`,
784
1202
  `PR title: ${evidence.title || "(empty)"}`,
@@ -787,10 +1205,13 @@ function buildStrictPrGateSteeringPrompt(result) {
787
1205
  evidence.greptile.score ? `Greptile score: ${evidence.greptile.score.value}/${evidence.greptile.score.scale}` : "Greptile score: not proven",
788
1206
  "",
789
1207
  "Gate reasons:",
790
- ...result.reasons.length ? result.reasons.map((reason) => `- ${reason}`) : ["- No reasons recorded"],
1208
+ ...displayedReasons.length ? displayedReasons : ["- No reasons recorded"],
1209
+ "",
1210
+ "Structured gate reason details:",
1211
+ result.reasonDetails.length ? promptJsonExcerpt(result.reasonDetails, 4000) : "[]",
791
1212
  "",
792
1213
  "Required evidence read status:",
793
- evidence.readErrors.length ? JSON.stringify(evidence.readErrors, null, 2) : "All required PR evidence surfaces were read completely.",
1214
+ evidence.readErrors.length ? promptJsonExcerpt(evidence.readErrors, 2000) : "All required PR evidence surfaces were read completely.",
794
1215
  "",
795
1216
  "Full PR title:",
796
1217
  evidence.title || "(empty)",
@@ -854,6 +1275,7 @@ function persistPrReviewCycleArtifacts(input) {
854
1275
  approved: input.result.approved,
855
1276
  pending: input.result.pending,
856
1277
  reasons: input.result.reasons,
1278
+ reasonDetails: input.result.reasonDetails,
857
1279
  warnings: input.result.warnings,
858
1280
  actionableFeedback: input.result.actionableFeedback,
859
1281
  prUrl: input.result.evidence.prUrl,
@@ -1227,10 +1649,7 @@ async function runRepoDefaultMerge(input) {
1227
1649
  const merge = input.config?.merge ?? {};
1228
1650
  if (merge.mode === "off")
1229
1651
  return;
1230
- const matchHeadSha = input.matchHeadSha?.trim();
1231
- if (!matchHeadSha) {
1232
- throw new Error(`Refusing to merge ${input.prUrl}: strict merge gate did not provide a current head SHA.`);
1233
- }
1652
+ const matchHeadSha = strictMergeHeadShaFromGate(input.strictGate, input.prUrl);
1234
1653
  const method = merge.method ?? "repo-default";
1235
1654
  const args = ["pr", "merge", input.prUrl];
1236
1655
  if (method === "repo-default") {
@@ -1252,17 +1671,17 @@ function shouldAttemptRigMerge(config) {
1252
1671
  return mode !== "off" && mode !== "pr-ready";
1253
1672
  }
1254
1673
  function isPendingOnlyGate(result) {
1255
- if (!result.pending)
1256
- return false;
1257
- const evidence = result.evidence;
1258
- if (evidence.readErrors.length > 0 || evidence.checkFailures.length > 0)
1259
- return false;
1260
- if (evidence.greptile.blockers.length > 0 || evidence.greptile.unresolvedComments.length > 0)
1261
- return false;
1262
- const conflictingScore = evidence.greptile.signals.some((signal) => (!signal.reviewedSha || signal.reviewedSha === evidence.headSha) && signal.score && !(signal.score.scale === 5 && signal.score.value === 5));
1263
- if (conflictingScore)
1264
- return false;
1265
- return evidence.pendingChecks.length > 0 || !evidence.greptile.completed || evidence.greptile.mapping === "missing" || evidence.greptile.mapping === "stale" || evidence.greptile.mapping === "unproven";
1674
+ return result.pending && result.reasonDetails.length > 0 && result.reasonDetails.every((reason) => reason.reasonClass === "pending" && reason.suggestedAction === "wait");
1675
+ }
1676
+ async function syncBranchAfterPrFeedback(input) {
1677
+ if (!input.gitCommand)
1678
+ return;
1679
+ await commitRunChanges({
1680
+ cwd: input.projectRoot,
1681
+ message: `rig: address PR feedback for task ${input.taskId}`,
1682
+ command: input.gitCommand
1683
+ });
1684
+ await runChecked(input.gitCommand, ["push", "--set-upstream", "origin", input.branch], input.projectRoot, "git");
1266
1685
  }
1267
1686
  async function runPrAutomation(input) {
1268
1687
  const prConfig = input.config?.pr ?? {};
@@ -1342,6 +1761,7 @@ ${createResult.stdout ?? ""}`) : null;
1342
1761
  ...latestFeedback.map((entry) => `- ${entry}`)
1343
1762
  ].join(`
1344
1763
  `));
1764
+ await syncBranchAfterPrFeedback({ projectRoot: input.projectRoot, taskId: input.taskId, branch: input.branch, gitCommand: input.gitCommand });
1345
1765
  continue;
1346
1766
  }
1347
1767
  const gate = await runStrictPrMergeGate({
@@ -1352,7 +1772,8 @@ ${createResult.stdout ?? ""}`) : null;
1352
1772
  cycle: iteration,
1353
1773
  command: input.command,
1354
1774
  artifactRoot: input.artifactRoot,
1355
- allowedFailures: input.config?.merge?.allowedFailures ?? []
1775
+ allowedFailures: input.config?.merge?.allowedFailures ?? [],
1776
+ greptileApi: input.greptileApi
1356
1777
  });
1357
1778
  latestFeedback = [...gate.actionableFeedback];
1358
1779
  if (gate.approved) {
@@ -1366,20 +1787,33 @@ ${createResult.stdout ?? ""}`) : null;
1366
1787
  command: input.command,
1367
1788
  artifactRoot: input.artifactRoot,
1368
1789
  allowedFailures: input.config?.merge?.allowedFailures ?? [],
1790
+ greptileApi: input.greptileApi,
1369
1791
  final: true
1370
1792
  });
1371
1793
  if (finalGate.approved) {
1372
1794
  await input.lifecycle?.onMergeStarted?.({ prUrl });
1373
- await runRepoDefaultMerge({ prUrl, config: input.config, command: input.command, cwd: input.projectRoot, matchHeadSha: finalGate.evidence.headSha });
1795
+ await runRepoDefaultMerge({ prUrl, config: input.config, command: input.command, cwd: input.projectRoot, strictGate: finalGate });
1374
1796
  await input.lifecycle?.onMerged?.({ prUrl });
1375
1797
  return { status: "merged", prUrl, iterations: iteration, actionableFeedback: [], merged: true };
1376
1798
  }
1377
1799
  latestFeedback = [...finalGate.actionableFeedback];
1800
+ if (isPendingOnlyGate(finalGate)) {
1801
+ const timeoutMs = positiveInt(prConfig.pendingTimeoutMs, 600000);
1802
+ const pollMs = positiveInt(prConfig.pendingPollMs, 15000);
1803
+ if (iteration >= maxPrFixIterations || timeoutMs <= 0 || pendingElapsedMs >= timeoutMs) {
1804
+ return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
1805
+ }
1806
+ const sleepMs = Math.min(pollMs, timeoutMs - pendingElapsedMs);
1807
+ await (input.sleep ?? Bun.sleep)(sleepMs);
1808
+ pendingElapsedMs += sleepMs;
1809
+ continue;
1810
+ }
1378
1811
  if (iteration >= maxPrFixIterations || prConfig.autoFixChecks === false && prConfig.autoFixReview === false) {
1379
1812
  return { status: "needs_attention", prUrl, iterations: iteration, actionableFeedback: latestFeedback, merged: false };
1380
1813
  }
1381
1814
  await input.lifecycle?.onFeedback?.({ prUrl, iteration, feedback: latestFeedback });
1382
1815
  await input.steerPi(finalGate.steeringPrompt);
1816
+ await syncBranchAfterPrFeedback({ projectRoot: input.projectRoot, taskId: input.taskId, branch: input.branch, gitCommand: input.gitCommand });
1383
1817
  continue;
1384
1818
  }
1385
1819
  if (isPendingOnlyGate(gate)) {
@@ -1399,6 +1833,7 @@ ${createResult.stdout ?? ""}`) : null;
1399
1833
  }
1400
1834
  await input.lifecycle?.onFeedback?.({ prUrl, iteration, feedback: latestFeedback });
1401
1835
  await input.steerPi(gate.steeringPrompt);
1836
+ await syncBranchAfterPrFeedback({ projectRoot: input.projectRoot, taskId: input.taskId, branch: input.branch, gitCommand: input.gitCommand });
1402
1837
  }
1403
1838
  return { status: "needs_attention", prUrl, iterations: maxPrFixIterations, actionableFeedback: latestFeedback, merged: false };
1404
1839
  }