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

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,8 +2,8 @@
2
2
  // @bun
3
3
 
4
4
  // packages/runtime/src/control-plane/hooks/completion-verification.ts
5
- import { appendFileSync as appendFileSync2, existsSync as existsSync21, mkdirSync as mkdirSync11, readFileSync as readFileSync12, writeFileSync as writeFileSync11 } from "fs";
6
- import { resolve as resolve24 } from "path";
5
+ import { appendFileSync as appendFileSync2, existsSync as existsSync21, mkdirSync as mkdirSync12, readFileSync as readFileSync12, writeFileSync as writeFileSync12 } from "fs";
6
+ import { resolve as resolve25 } from "path";
7
7
  import {
8
8
  escapeRegExp as escapeRegExp2,
9
9
  resolveBunCli,
@@ -1641,8 +1641,8 @@ function isAgentRuntimeContextPath(path) {
1641
1641
  }
1642
1642
 
1643
1643
  // packages/runtime/src/control-plane/native/git-ops.ts
1644
- import { existsSync as existsSync20, lstatSync, mkdirSync as mkdirSync10, readFileSync as readFileSync11, writeFileSync as writeFileSync10 } from "fs";
1645
- import { dirname as dirname11, isAbsolute as isAbsolute2, resolve as resolve23 } from "path";
1644
+ import { existsSync as existsSync20, lstatSync, mkdirSync as mkdirSync11, readFileSync as readFileSync11, writeFileSync as writeFileSync11 } from "fs";
1645
+ import { dirname as dirname11, isAbsolute as isAbsolute2, resolve as resolve24 } from "path";
1646
1646
  import { fileURLToPath as fileURLToPath2 } from "url";
1647
1647
 
1648
1648
  // packages/runtime/src/control-plane/runtime/baked-secrets.ts
@@ -1731,8 +1731,8 @@ function expandShellValue(rawValue, env) {
1731
1731
  }
1732
1732
 
1733
1733
  // packages/runtime/src/control-plane/native/task-ops.ts
1734
- import { appendFileSync, existsSync as existsSync19, mkdirSync as mkdirSync9, readFileSync as readFileSync10, writeFileSync as writeFileSync9 } from "fs";
1735
- import { resolve as resolve22 } from "path";
1734
+ import { appendFileSync, existsSync as existsSync19, mkdirSync as mkdirSync10, readFileSync as readFileSync10, writeFileSync as writeFileSync10 } from "fs";
1735
+ import { resolve as resolve23 } from "path";
1736
1736
 
1737
1737
  // packages/runtime/src/build-time-config.ts
1738
1738
  function normalizeBuildConfig(value) {
@@ -4239,18 +4239,915 @@ ${JSON.stringify(result, null, 2)}
4239
4239
  }
4240
4240
 
4241
4241
  // packages/runtime/src/control-plane/native/verifier.ts
4242
- import { existsSync as existsSync18, mkdirSync as mkdirSync8, writeFileSync as writeFileSync8 } from "fs";
4242
+ import { existsSync as existsSync18, mkdirSync as mkdirSync9, writeFileSync as writeFileSync9 } from "fs";
4243
+ import { resolve as resolve22 } from "path";
4244
+
4245
+ // packages/runtime/src/control-plane/native/pr-review-gate.ts
4246
+ import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync8 } from "fs";
4243
4247
  import { resolve as resolve21 } from "path";
4248
+ function parseJsonObject(value) {
4249
+ if (!value?.trim())
4250
+ return { value: {}, error: "empty JSON output" };
4251
+ try {
4252
+ const parsed = JSON.parse(value);
4253
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { value: parsed } : { value: {}, error: "JSON output was not an object" };
4254
+ } catch (error) {
4255
+ return { value: {}, error: error instanceof Error ? error.message : String(error) };
4256
+ }
4257
+ }
4258
+ function flattenPaginatedArray(value) {
4259
+ if (!Array.isArray(value))
4260
+ return null;
4261
+ if (value.every((entry) => Array.isArray(entry))) {
4262
+ return value.flatMap((entry) => entry);
4263
+ }
4264
+ return value;
4265
+ }
4266
+ function parseJsonArray(value) {
4267
+ if (!value?.trim())
4268
+ return { value: [], error: "empty JSON output" };
4269
+ try {
4270
+ const parsed = JSON.parse(value);
4271
+ const flattened = flattenPaginatedArray(parsed);
4272
+ return flattened ? { value: flattened } : { value: [], error: "JSON output was not an array" };
4273
+ } catch (error) {
4274
+ return { value: [], error: error instanceof Error ? error.message : String(error) };
4275
+ }
4276
+ }
4277
+ function parseGithubPrUrl(prUrl) {
4278
+ const match = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/i.exec(prUrl.trim());
4279
+ if (!match)
4280
+ return null;
4281
+ const prNumber = Number.parseInt(match[3], 10);
4282
+ if (!Number.isFinite(prNumber))
4283
+ return null;
4284
+ return { owner: match[1], repo: match[2], repoName: `${match[1]}/${match[2]}`, prNumber };
4285
+ }
4286
+ function checkName(check) {
4287
+ return String(check.name ?? check.context ?? "").trim();
4288
+ }
4289
+ function checkState(check) {
4290
+ return String(check.conclusion ?? check.state ?? check.status ?? "").trim().toLowerCase();
4291
+ }
4292
+ function isGreptileLabel(value) {
4293
+ return String(value ?? "").toLowerCase().includes("greptile");
4294
+ }
4295
+ function isGreptileGithubLogin(value) {
4296
+ const login = String(value ?? "").toLowerCase().replace(/\[bot\]$/, "");
4297
+ return login === "greptile" || login === "greptile-ai" || login === "greptileai" || login === "greptile-apps";
4298
+ }
4299
+ function isPassingCheck(check) {
4300
+ const state = checkState(check);
4301
+ return ["success", "successful", "passed", "neutral", "skipped", "completed"].includes(state);
4302
+ }
4303
+ function isPendingCheck(check) {
4304
+ const state = checkState(check);
4305
+ return ["pending", "queued", "in_progress", "waiting", "requested", "expected", "action_required"].includes(state);
4306
+ }
4307
+ function isFailingCheck(check) {
4308
+ const state = checkState(check);
4309
+ return ["failure", "failed", "timed_out", "action_required", "cancelled", "canceled", "error"].includes(state);
4310
+ }
4311
+ function wildcardToRegExp(pattern) {
4312
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
4313
+ return new RegExp(`^${escaped}$`, "i");
4314
+ }
4315
+ function isAllowedFailure(name, allowedFailures) {
4316
+ return allowedFailures.some((pattern) => wildcardToRegExp(pattern).test(name));
4317
+ }
4318
+ function greptileScorePatterns() {
4319
+ return [
4320
+ /\b(?:confidence\s+score|confidence|rating|score)\s*:?\s*(\d+)\s*\/\s*(\d+)/gi,
4321
+ /\b(\d+)\s*\/\s*(\d+)\s*(?:confidence|rating|score)/gi,
4322
+ /\bgreptile[^\n]{0,80}?(\d+)\s*\/\s*(\d+)/gi
4323
+ ];
4324
+ }
4325
+ function parseGreptileScores(input) {
4326
+ const text = stripHtml(input);
4327
+ const seen = new Set;
4328
+ const scores = [];
4329
+ for (const pattern of greptileScorePatterns()) {
4330
+ for (const match of text.matchAll(pattern)) {
4331
+ const value = Number.parseInt(match[1] || "", 10);
4332
+ const scale = Number.parseInt(match[2] || "", 10);
4333
+ if (!Number.isFinite(value) || !Number.isFinite(scale) || scale <= 0)
4334
+ continue;
4335
+ const raw = match[0] || `${value}/${scale}`;
4336
+ const key = `${match.index ?? -1}:${value}/${scale}:${raw.toLowerCase()}`;
4337
+ if (seen.has(key))
4338
+ continue;
4339
+ seen.add(key);
4340
+ scores.push({ value, scale, raw });
4341
+ }
4342
+ }
4343
+ return scores;
4344
+ }
4345
+ function parseGreptileScore(input) {
4346
+ return parseGreptileScores(input)[0] ?? null;
4347
+ }
4348
+ function stripHtml(input) {
4349
+ return input.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\r/g, "").replace(/\n{3,}/g, `
4350
+
4351
+ `).trim();
4352
+ }
4353
+ function containsBlockerText(input) {
4354
+ const text = stripHtml(input).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
4355
+ 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);
4356
+ }
4357
+ function containsGreptileNegativeVerdict(input) {
4358
+ const text = stripHtml(input).replace(/\s+/g, " ").trim();
4359
+ if (!text)
4360
+ return false;
4361
+ 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);
4362
+ }
4363
+ function isStrictFiveOfFive(score) {
4364
+ return score.value === 5 && score.scale === 5;
4365
+ }
4366
+ function containsConflictingScoreText(input) {
4367
+ return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
4368
+ }
4369
+ function firstString(record, keys) {
4370
+ for (const key of keys) {
4371
+ const value = record[key];
4372
+ if (typeof value === "string")
4373
+ return value;
4374
+ }
4375
+ return "";
4376
+ }
4377
+ function arrayField(record, key) {
4378
+ const value = record[key];
4379
+ return Array.isArray(value) ? value : [];
4380
+ }
4381
+ async function runJsonArray(command, args, cwd) {
4382
+ const result = await command(args, { cwd });
4383
+ const label = `gh ${args.join(" ")}`;
4384
+ if (result.exitCode !== 0) {
4385
+ return { value: [], error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
4386
+ }
4387
+ const parsed = parseJsonArray(result.stdout);
4388
+ return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
4389
+ }
4390
+ async function runJsonObject(command, args, cwd) {
4391
+ const result = await command(args, { cwd });
4392
+ const label = `gh ${args.join(" ")}`;
4393
+ if (result.exitCode !== 0) {
4394
+ return { value: {}, error: `${label} failed (${result.exitCode}): ${result.stderr ?? result.stdout ?? ""}`.trim() };
4395
+ }
4396
+ const parsed = parseJsonObject(result.stdout);
4397
+ return parsed.error ? { value: parsed.value, error: `${label} returned invalid JSON: ${parsed.error}` } : { value: parsed.value };
4398
+ }
4399
+ function normalizeStatusCheck(entry) {
4400
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
4401
+ return null;
4402
+ const record = entry;
4403
+ const name = firstString(record, ["name", "context"]);
4404
+ if (!name.trim())
4405
+ return null;
4406
+ const output = record.output && typeof record.output === "object" && !Array.isArray(record.output) ? record.output : null;
4407
+ const app = record.app && typeof record.app === "object" && !Array.isArray(record.app) ? record.app : null;
4408
+ return {
4409
+ __typename: typeof record.__typename === "string" ? record.__typename : null,
4410
+ name,
4411
+ context: typeof record.context === "string" ? record.context : null,
4412
+ status: typeof record.status === "string" ? record.status : null,
4413
+ state: typeof record.state === "string" ? record.state : null,
4414
+ conclusion: typeof record.conclusion === "string" ? record.conclusion : null,
4415
+ detailsUrl: typeof record.detailsUrl === "string" ? record.detailsUrl : typeof record.details_url === "string" ? record.details_url : typeof record.html_url === "string" ? record.html_url : typeof record.link === "string" ? record.link : null,
4416
+ link: typeof record.link === "string" ? record.link : typeof record.html_url === "string" ? record.html_url : null,
4417
+ headSha: typeof record.headSha === "string" ? record.headSha : null,
4418
+ head_sha: typeof record.head_sha === "string" ? record.head_sha : null,
4419
+ output: output ? {
4420
+ title: typeof output.title === "string" ? output.title : null,
4421
+ summary: typeof output.summary === "string" ? output.summary : null,
4422
+ text: typeof output.text === "string" ? output.text : null
4423
+ } : null,
4424
+ app: app ? {
4425
+ slug: typeof app.slug === "string" ? app.slug : null,
4426
+ name: typeof app.name === "string" ? app.name : null,
4427
+ owner: app.owner && typeof app.owner === "object" ? app.owner : null
4428
+ } : null
4429
+ };
4430
+ }
4431
+ function normalizeReview(entry) {
4432
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
4433
+ return null;
4434
+ const record = entry;
4435
+ return {
4436
+ id: typeof record.id === "string" ? record.id : typeof record.id === "number" ? String(record.id) : null,
4437
+ state: typeof record.state === "string" ? record.state : null,
4438
+ body: typeof record.body === "string" ? record.body : null,
4439
+ commit_id: typeof record.commit_id === "string" ? record.commit_id : typeof record.commitId === "string" ? record.commitId : record.commit && typeof record.commit === "object" && typeof record.commit.oid === "string" ? record.commit.oid : null,
4440
+ html_url: typeof record.html_url === "string" ? record.html_url : typeof record.url === "string" ? record.url : null,
4441
+ author: record.author && typeof record.author === "object" ? record.author : record.user && typeof record.user === "object" ? record.user : null
4442
+ };
4443
+ }
4444
+ function normalizeReviewComment(entry) {
4445
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
4446
+ return null;
4447
+ const record = entry;
4448
+ const body = typeof record.body === "string" ? record.body : null;
4449
+ const path = typeof record.path === "string" ? record.path : null;
4450
+ if (!body && !path)
4451
+ return null;
4452
+ return {
4453
+ id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
4454
+ user: record.user && typeof record.user === "object" ? record.user : null,
4455
+ author: record.author && typeof record.author === "object" ? record.author : null,
4456
+ body,
4457
+ path,
4458
+ html_url: typeof record.html_url === "string" ? record.html_url : null,
4459
+ url: typeof record.url === "string" ? record.url : null,
4460
+ commit_id: typeof record.commit_id === "string" ? record.commit_id : null,
4461
+ original_commit_id: typeof record.original_commit_id === "string" ? record.original_commit_id : null
4462
+ };
4463
+ }
4464
+ function normalizeIssueComment(entry) {
4465
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
4466
+ return null;
4467
+ const record = entry;
4468
+ const body = typeof record.body === "string" ? record.body : null;
4469
+ if (!body)
4470
+ return null;
4471
+ return {
4472
+ id: typeof record.id === "string" || typeof record.id === "number" ? record.id : null,
4473
+ user: record.user && typeof record.user === "object" ? record.user : null,
4474
+ author: record.author && typeof record.author === "object" ? record.author : null,
4475
+ body,
4476
+ html_url: typeof record.html_url === "string" ? record.html_url : null,
4477
+ url: typeof record.url === "string" ? record.url : null,
4478
+ created_at: typeof record.created_at === "string" ? record.created_at : null
4479
+ };
4480
+ }
4481
+ function normalizeReviewThread(entry) {
4482
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
4483
+ return null;
4484
+ const record = entry;
4485
+ return {
4486
+ id: typeof record.id === "string" ? record.id : null,
4487
+ isResolved: typeof record.isResolved === "boolean" ? record.isResolved : null,
4488
+ isOutdated: typeof record.isOutdated === "boolean" ? record.isOutdated : null,
4489
+ comments: record.comments && typeof record.comments === "object" ? record.comments : null
4490
+ };
4491
+ }
4492
+ function relevantIssueComment(comment) {
4493
+ const login = comment.user?.login ?? comment.author?.login ?? "";
4494
+ const body = comment.body ?? "";
4495
+ 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);
4496
+ }
4497
+ function latestThreadComment(thread) {
4498
+ const nodes = thread.comments?.nodes ?? [];
4499
+ return nodes.length > 0 ? nodes[nodes.length - 1] : null;
4500
+ }
4501
+ function unresolvedThreadSummaries(threads) {
4502
+ return threads.flatMap((thread) => {
4503
+ if (thread.isResolved === true || thread.isOutdated === true)
4504
+ return [];
4505
+ const latest = latestThreadComment(thread);
4506
+ if (!latest)
4507
+ return ["Unresolved review thread"];
4508
+ const path = latest.path ? ` on ${latest.path}` : "";
4509
+ return [`Unresolved review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
4510
+ });
4511
+ }
4512
+ function collectBodies(evidence) {
4513
+ return [
4514
+ evidence.title ?? "",
4515
+ evidence.body,
4516
+ ...evidence.reviews.map((review) => review.body ?? ""),
4517
+ ...evidence.changedFileReviewComments.map((comment) => comment.body ?? ""),
4518
+ ...evidence.relevantIssueComments.map((comment) => comment.body ?? ""),
4519
+ ...evidence.reviewThreads.flatMap((thread) => thread.comments?.nodes?.map((comment) => comment.body ?? "") ?? []),
4520
+ ...(evidence.apiSignals ?? []).map((signal) => signal.body ?? "")
4521
+ ].filter((body) => body.trim().length > 0);
4522
+ }
4523
+ function bodyExcerpt(body) {
4524
+ const text = stripHtml(body).replace(/\s+/g, " ").trim();
4525
+ return text.length > 240 ? `${text.slice(0, 237)}...` : text;
4526
+ }
4527
+ function makeGreptileSignal(input) {
4528
+ const scores = parseGreptileScores(input.body);
4529
+ const reviewedSha = input.reviewedSha?.trim() || null;
4530
+ const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
4531
+ const blocker = input.blocker ?? (containsBlockerText(input.body) || containsGreptileNegativeVerdict(input.body));
4532
+ const explicitApproval = input.explicitApproval ?? false;
4533
+ return {
4534
+ source: input.source,
4535
+ trusted: input.trusted,
4536
+ authorLogin: input.authorLogin ?? null,
4537
+ reviewedSha,
4538
+ current,
4539
+ stale: current === false,
4540
+ score: scores[0] ?? null,
4541
+ scores,
4542
+ explicitApproval,
4543
+ blocker,
4544
+ actionable: input.actionable ?? blocker,
4545
+ bodyExcerpt: bodyExcerpt(input.body),
4546
+ body: input.body,
4547
+ allScores: scores
4548
+ };
4549
+ }
4550
+ function reviewAuthorLogin(review) {
4551
+ return review.author?.login ?? null;
4552
+ }
4553
+ function commentAuthorLogin(comment) {
4554
+ return comment.user?.login ?? comment.author?.login ?? null;
4555
+ }
4556
+ function collectGreptileSignals(evidence) {
4557
+ const signals = [];
4558
+ const contextSources = [
4559
+ { source: "pr-title", body: evidence.title ?? "" },
4560
+ { source: "pr-body", body: evidence.body }
4561
+ ];
4562
+ for (const context of contextSources) {
4563
+ if (!context.body.trim())
4564
+ continue;
4565
+ if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
4566
+ continue;
4567
+ const contextBlocker = containsBlockerText(context.body);
4568
+ signals.push(makeGreptileSignal({
4569
+ source: context.source,
4570
+ body: context.body,
4571
+ currentHeadSha: evidence.currentHeadSha,
4572
+ trusted: false,
4573
+ blocker: contextBlocker,
4574
+ actionable: contextBlocker
4575
+ }));
4576
+ }
4577
+ for (const apiSignal of evidence.apiSignals ?? []) {
4578
+ const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
4579
+
4580
+ `);
4581
+ if (!body.trim())
4582
+ continue;
4583
+ signals.push(makeGreptileSignal({
4584
+ source: "api",
4585
+ body,
4586
+ currentHeadSha: evidence.currentHeadSha,
4587
+ trusted: true,
4588
+ reviewedSha: apiSignal.reviewedSha ?? null,
4589
+ explicitApproval: false
4590
+ }));
4591
+ }
4592
+ for (const review of evidence.reviews) {
4593
+ const login = reviewAuthorLogin(review);
4594
+ if (!isGreptileGithubLogin(login))
4595
+ continue;
4596
+ const state = String(review.state ?? "").toUpperCase();
4597
+ const body = [state ? `Review state: ${state}` : "", review.body ?? ""].filter(Boolean).join(`
4598
+
4599
+ `);
4600
+ if (!body.trim())
4601
+ continue;
4602
+ const dismissed = state === "DISMISSED";
4603
+ signals.push(makeGreptileSignal({
4604
+ source: "github-review",
4605
+ body,
4606
+ currentHeadSha: evidence.currentHeadSha,
4607
+ trusted: !dismissed,
4608
+ authorLogin: login,
4609
+ reviewedSha: review.commit_id ?? null,
4610
+ explicitApproval: undefined,
4611
+ blocker: state === "CHANGES_REQUESTED" || undefined
4612
+ }));
4613
+ }
4614
+ for (const comment of evidence.changedFileReviewComments) {
4615
+ const login = commentAuthorLogin(comment);
4616
+ const body = comment.body ?? "";
4617
+ if (!body.trim() || !isGreptileGithubLogin(login))
4618
+ continue;
4619
+ signals.push(makeGreptileSignal({
4620
+ source: "changed-file-comment",
4621
+ body,
4622
+ currentHeadSha: evidence.currentHeadSha,
4623
+ trusted: true,
4624
+ authorLogin: login,
4625
+ reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
4626
+ }));
4627
+ }
4628
+ for (const comment of evidence.relevantIssueComments) {
4629
+ const login = commentAuthorLogin(comment);
4630
+ const body = comment.body ?? "";
4631
+ if (!body.trim() || !isGreptileGithubLogin(login))
4632
+ continue;
4633
+ signals.push(makeGreptileSignal({
4634
+ source: "issue-comment",
4635
+ body,
4636
+ currentHeadSha: evidence.currentHeadSha,
4637
+ trusted: true,
4638
+ authorLogin: login
4639
+ }));
4640
+ }
4641
+ for (const thread of evidence.reviewThreads) {
4642
+ if (thread.isOutdated === true || thread.isResolved === true)
4643
+ continue;
4644
+ for (const comment of thread.comments?.nodes ?? []) {
4645
+ const login = comment.author?.login ?? null;
4646
+ const body = comment.body ?? "";
4647
+ if (!body.trim() || !isGreptileGithubLogin(login))
4648
+ continue;
4649
+ signals.push(makeGreptileSignal({
4650
+ source: "review-thread",
4651
+ body,
4652
+ currentHeadSha: evidence.currentHeadSha,
4653
+ trusted: true,
4654
+ authorLogin: login
4655
+ }));
4656
+ }
4657
+ }
4658
+ for (const check of evidence.checks) {
4659
+ if (!isGreptileLabel(checkName(check)))
4660
+ continue;
4661
+ const reviewedSha = check.headSha ?? check.head_sha ?? null;
4662
+ const label = `${checkName(check)} (${checkState(check) || "unknown"})`;
4663
+ const body = [label, check.output?.title ?? "", check.output?.summary ?? "", check.output?.text ?? ""].filter((entry) => entry.trim().length > 0).join(`
4664
+
4665
+ `);
4666
+ signals.push(makeGreptileSignal({
4667
+ source: "github-check",
4668
+ body,
4669
+ currentHeadSha: evidence.currentHeadSha,
4670
+ trusted: false,
4671
+ reviewedSha,
4672
+ explicitApproval: false,
4673
+ blocker: isFailingCheck(check),
4674
+ actionable: isFailingCheck(check)
4675
+ }));
4676
+ }
4677
+ return signals;
4678
+ }
4679
+ function unresolvedGreptileThreadSummaries(threads) {
4680
+ return threads.flatMap((thread) => {
4681
+ if (thread.isResolved === true || thread.isOutdated === true)
4682
+ return [];
4683
+ const comments = thread.comments?.nodes ?? [];
4684
+ if (!comments.some((comment) => isGreptileGithubLogin(comment.author?.login)))
4685
+ return [];
4686
+ const latest = latestThreadComment(thread);
4687
+ if (!latest)
4688
+ return ["Unresolved Greptile review thread"];
4689
+ const path = latest.path ? ` on ${latest.path}` : "";
4690
+ return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
4691
+ });
4692
+ }
4693
+ function issueLevelBlockerSummaries(comments) {
4694
+ return comments.flatMap((comment) => {
4695
+ const body = comment.body?.trim() ?? "";
4696
+ if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
4697
+ return [];
4698
+ const login = commentAuthorLogin(comment) ?? "unknown";
4699
+ const author = isGreptileGithubLogin(login) ? `Greptile issue comment by ${login}` : `Issue-level PR comment by ${login}`;
4700
+ return [`${author}: ${body}`];
4701
+ });
4702
+ }
4703
+ function reviewBodyBlockerSummaries(reviews) {
4704
+ return reviews.flatMap((review) => {
4705
+ const login = reviewAuthorLogin(review) ?? "unknown";
4706
+ if (isGreptileGithubLogin(login))
4707
+ return [];
4708
+ const body = review.body?.trim() ?? "";
4709
+ if (!body || !containsBlockerText(body) && !containsConflictingScoreText(body))
4710
+ return [];
4711
+ const state = review.state ? ` (${review.state})` : "";
4712
+ return [`PR review summary by ${login}${state}: ${body}`];
4713
+ });
4714
+ }
4715
+ function signalLabel(signal) {
4716
+ const source = signal.source.replace(/-/g, " ");
4717
+ const author = signal.authorLogin ? ` by ${signal.authorLogin}` : "";
4718
+ const sha = signal.reviewedSha ? ` at ${signal.reviewedSha}` : "";
4719
+ return `${source}${author}${sha}`;
4720
+ }
4721
+ function deriveGreptileEvidence(input) {
4722
+ const rawBodies = collectBodies(input);
4723
+ const signals = collectGreptileSignals(input);
4724
+ const trustedSignals = signals.filter((signal) => signal.trusted);
4725
+ const trustedScoreEntries = trustedSignals.flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
4726
+ const contextScoreEntries = signals.filter((signal) => !signal.trusted).flatMap((signal) => signal.allScores.map((score2) => ({ score: score2, signal })));
4727
+ const allScoreEntries = [...trustedScoreEntries, ...contextScoreEntries];
4728
+ const staleSignals = signals.filter((signal) => !!signal.reviewedSha && signal.reviewedSha !== input.currentHeadSha && (signal.trusted || signal.source === "github-check"));
4729
+ const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
4730
+ const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
4731
+ const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
4732
+ const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && isStrictFiveOfFive(entry.score)) ?? null;
4733
+ const approvedByScore = !!approvingScoreEntry;
4734
+ const approvedByExplicitMapping = false;
4735
+ const approvingSignal = approvingScoreEntry?.signal ?? null;
4736
+ const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
4737
+ const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
4738
+ const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
4739
+ const blockerSignals = signals.filter((signal) => signal.source !== "changed-file-comment" && (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
4740
+ const staleBlockingSignals = [];
4741
+ const blockers = [
4742
+ ...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
4743
+ ...reviewBodyBlockerSummaries(input.reviews),
4744
+ ...issueLevelBlockerSummaries(input.relevantIssueComments),
4745
+ ...lowScoreEntries.map((entry) => `Greptile score from ${signalLabel(entry.signal)} is ${entry.score.value}/${entry.score.scale}; strict merge requires trusted current-head 5/5.`),
4746
+ ...staleBlockingSignals.map((signal) => `Greptile blocking signal from ${signalLabel(signal)} is stale; current PR head is ${input.currentHeadSha || "unknown"}.`),
4747
+ ...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
4748
+ ];
4749
+ const unresolvedComments = [
4750
+ ...unresolvedGreptileThreadSummaries(input.reviewThreads)
4751
+ ];
4752
+ const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
4753
+ const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
4754
+ const completedGreptileCheck = greptileChecks.some((check) => {
4755
+ const reviewedSha2 = check.headSha ?? check.head_sha ?? null;
4756
+ return reviewedSha2 === input.currentHeadSha && (isPassingCheck(check) || isFailingCheck(check));
4757
+ });
4758
+ const completedGreptileReview = greptileReviews.some((review) => {
4759
+ const state = String(review.state ?? "").toUpperCase();
4760
+ const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
4761
+ return completedState && review.commit_id === input.currentHeadSha;
4762
+ });
4763
+ const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
4764
+ const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
4765
+ const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
4766
+ const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
4767
+ const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
4768
+ const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
4769
+ const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : "unproven";
4770
+ 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";
4771
+ return {
4772
+ source,
4773
+ currentHeadSha: input.currentHeadSha,
4774
+ reviewedSha,
4775
+ fresh,
4776
+ completed,
4777
+ approved,
4778
+ score,
4779
+ explicitApproval: approvedByExplicitMapping,
4780
+ blockers,
4781
+ unresolvedComments,
4782
+ rawBodies,
4783
+ signals: signals.map(({ body: _body, allScores: _allScores, ...signal }) => signal),
4784
+ mapping
4785
+ };
4786
+ }
4787
+ function isGreptileCheckDetail(check) {
4788
+ return isGreptileLabel(checkName(check)) || isGreptileGithubLogin(check.app?.slug) || isGreptileGithubLogin(check.app?.owner?.login) || isGreptileLabel(check.app?.name);
4789
+ }
4790
+ async function collectGreptileCheckDetails(input) {
4791
+ const checkRunsRead = await runJsonArray(input.command, [
4792
+ "api",
4793
+ `repos/${input.repoName}/commits/${input.headSha}/check-runs`,
4794
+ "--paginate",
4795
+ "--slurp",
4796
+ "--jq",
4797
+ "map(.check_runs // []) | add // []"
4798
+ ], input.projectRoot);
4799
+ const checkRuns = checkRunsRead.value.map(normalizeStatusCheck).filter((entry) => !!entry).filter(isGreptileCheckDetail);
4800
+ return checkRunsRead.error ? { value: checkRuns, error: checkRunsRead.error } : { value: checkRuns };
4801
+ }
4802
+ async function collectReviewThreads(input) {
4803
+ const reviewThreads = [];
4804
+ let afterCursor = null;
4805
+ for (let page = 0;page < 100; page += 1) {
4806
+ const afterLiteral = afterCursor ? JSON.stringify(afterCursor) : "null";
4807
+ const threadsResponse = await runJsonObject(input.command, [
4808
+ "api",
4809
+ "graphql",
4810
+ "-F",
4811
+ `owner=${input.owner}`,
4812
+ "-F",
4813
+ `name=${input.name}`,
4814
+ "-F",
4815
+ `prNumber=${input.prNumber}`,
4816
+ "-f",
4817
+ `query=query($owner: String!, $name: String!, $prNumber: Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$prNumber) { reviewThreads(first: 100, after: ${afterLiteral}) { nodes { id isResolved isOutdated comments(first: 100) { nodes { author { login } body path url createdAt } pageInfo { hasNextPage endCursor } } } pageInfo { hasNextPage endCursor } } } } }`
4818
+ ], input.projectRoot);
4819
+ if (threadsResponse.error) {
4820
+ return { value: reviewThreads, error: threadsResponse.error };
4821
+ }
4822
+ const data = threadsResponse.value.data;
4823
+ const repository = data?.repository;
4824
+ const pullRequest = repository?.pullRequest;
4825
+ const threads = pullRequest?.reviewThreads;
4826
+ const nodes = threads?.nodes;
4827
+ if (!Array.isArray(nodes)) {
4828
+ return { value: reviewThreads, error: "GitHub reviewThreads response did not include a nodes array" };
4829
+ }
4830
+ const normalized = nodes.map(normalizeReviewThread).filter((entry) => !!entry);
4831
+ reviewThreads.push(...normalized);
4832
+ const truncatedCommentThread = normalized.find((thread) => thread.comments?.pageInfo?.hasNextPage === true);
4833
+ if (truncatedCommentThread) {
4834
+ return { value: reviewThreads, error: `GitHub review thread ${truncatedCommentThread.id ?? "unknown"} has more than 100 comments; nested pagination is incomplete` };
4835
+ }
4836
+ const pageInfo = threads?.pageInfo;
4837
+ if (!pageInfo) {
4838
+ if (nodes.length >= 100) {
4839
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination metadata missing after a full page" };
4840
+ }
4841
+ return { value: reviewThreads };
4842
+ }
4843
+ if (pageInfo.hasNextPage !== true) {
4844
+ return { value: reviewThreads };
4845
+ }
4846
+ if (typeof pageInfo.endCursor !== "string" || !pageInfo.endCursor.trim()) {
4847
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination reported hasNextPage without endCursor" };
4848
+ }
4849
+ afterCursor = pageInfo.endCursor;
4850
+ }
4851
+ return { value: reviewThreads, error: "GitHub reviewThreads pagination exceeded 100 pages" };
4852
+ }
4853
+ async function collectPrReviewEvidence(input) {
4854
+ const parsed = parseGithubPrUrl(input.prUrl);
4855
+ if (!parsed) {
4856
+ throw new Error(`Cannot parse GitHub PR URL: ${input.prUrl}`);
4857
+ }
4858
+ const readErrors = [];
4859
+ const viewRead = await runJsonObject(input.command, [
4860
+ "pr",
4861
+ "view",
4862
+ input.prUrl,
4863
+ "--json",
4864
+ "title,body,headRefOid,headRefName,baseRefName,state,isDraft,mergeable,mergeStateStatus,reviewDecision,reviews,statusCheckRollup"
4865
+ ], input.projectRoot);
4866
+ if (viewRead.error)
4867
+ readErrors.push(viewRead.error);
4868
+ const view = viewRead.value;
4869
+ if (!Array.isArray(view.statusCheckRollup)) {
4870
+ readErrors.push("gh pr view did not return required statusCheckRollup array");
4871
+ }
4872
+ if (!Array.isArray(view.reviews)) {
4873
+ readErrors.push("gh pr view did not return required reviews array");
4874
+ }
4875
+ const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
4876
+ const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
4877
+ const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
4878
+ const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
4879
+ if (reviewCommentsRead.error)
4880
+ readErrors.push(reviewCommentsRead.error);
4881
+ const reviewComments = reviewCommentsRead.value.map(normalizeReviewComment).filter((entry) => !!entry);
4882
+ const issueCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/issues/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
4883
+ if (issueCommentsRead.error)
4884
+ readErrors.push(issueCommentsRead.error);
4885
+ const issueComments = issueCommentsRead.value.map(normalizeIssueComment).filter((entry) => !!entry).filter(relevantIssueComment);
4886
+ const reviewThreadsRead = await collectReviewThreads({
4887
+ command: input.command,
4888
+ projectRoot: input.projectRoot,
4889
+ owner: parsed.owner,
4890
+ name: parsed.repo,
4891
+ prNumber: parsed.prNumber
4892
+ });
4893
+ if (reviewThreadsRead.error)
4894
+ readErrors.push(reviewThreadsRead.error);
4895
+ const reviewThreads = reviewThreadsRead.value;
4896
+ const greptileRollupChecks = statusCheckRollup.filter((check) => isGreptileLabel(checkName(check)));
4897
+ let greptileCheckDetails = [];
4898
+ if (headSha && greptileRollupChecks.length > 0) {
4899
+ const checkDetailsRead = await collectGreptileCheckDetails({
4900
+ command: input.command,
4901
+ projectRoot: input.projectRoot,
4902
+ repoName: parsed.repoName,
4903
+ headSha
4904
+ });
4905
+ if (checkDetailsRead.error)
4906
+ readErrors.push(checkDetailsRead.error);
4907
+ greptileCheckDetails = checkDetailsRead.value;
4908
+ if (!checkDetailsRead.error && greptileCheckDetails.length === 0) {
4909
+ readErrors.push("Greptile check details could not be found for the current PR head");
4910
+ }
4911
+ }
4912
+ const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
4913
+ 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})` : ""}`);
4914
+ const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
4915
+ const evidenceBase = {
4916
+ title: firstString(view, ["title"]),
4917
+ body: firstString(view, ["body"]),
4918
+ reviews,
4919
+ changedFileReviewComments: reviewComments,
4920
+ relevantIssueComments: issueComments,
4921
+ reviewThreads,
4922
+ checks: checksWithGreptileDetails,
4923
+ currentHeadSha: headSha,
4924
+ apiSignals: input.apiSignals ?? []
4925
+ };
4926
+ const greptile = deriveGreptileEvidence(evidenceBase);
4927
+ return {
4928
+ prUrl: input.prUrl,
4929
+ prNumber: parsed.prNumber,
4930
+ repoName: parsed.repoName,
4931
+ title: evidenceBase.title,
4932
+ body: evidenceBase.body,
4933
+ headSha,
4934
+ headRefName: firstString(view, ["headRefName"]),
4935
+ baseRefName: firstString(view, ["baseRefName"]),
4936
+ state: firstString(view, ["state"]),
4937
+ isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
4938
+ mergeable: firstString(view, ["mergeable"]),
4939
+ mergeStateStatus: firstString(view, ["mergeStateStatus"]),
4940
+ reviewDecision: firstString(view, ["reviewDecision"]),
4941
+ reviews,
4942
+ reviewThreads,
4943
+ changedFileReviewComments: reviewComments,
4944
+ relevantIssueComments: issueComments,
4945
+ statusCheckRollup: checksWithGreptileDetails,
4946
+ checkFailures,
4947
+ pendingChecks,
4948
+ readErrors,
4949
+ greptile
4950
+ };
4951
+ }
4952
+ function evaluateEvidence(evidence) {
4953
+ const reasons = [];
4954
+ const warnings = [];
4955
+ let pending = false;
4956
+ if (evidence.readErrors.length > 0) {
4957
+ reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
4958
+ }
4959
+ if (!evidence.headSha)
4960
+ reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
4961
+ if (evidence.checkFailures.length > 0)
4962
+ reasons.push(...evidence.checkFailures);
4963
+ if (evidence.pendingChecks.length > 0) {
4964
+ pending = true;
4965
+ reasons.push(...evidence.pendingChecks);
4966
+ }
4967
+ const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
4968
+ if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
4969
+ reasons.push(`Required review is unresolved (${evidence.reviewDecision}).`);
4970
+ }
4971
+ const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
4972
+ if (unresolvedThreads.length > 0)
4973
+ reasons.push(...unresolvedThreads);
4974
+ const greptile = evidence.greptile;
4975
+ if (greptile.mapping === "missing")
4976
+ reasons.push("Missing Greptile check/review evidence for this PR.");
4977
+ const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
4978
+ if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
4979
+ reasons.push(`Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`);
4980
+ }
4981
+ if (!greptile.completed) {
4982
+ pending = true;
4983
+ reasons.push("Greptile check/review has not completed for the current PR head.");
4984
+ }
4985
+ if (!greptile.fresh)
4986
+ reasons.push("Greptile approval is not tied to the current PR head SHA.");
4987
+ if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
4988
+ reasons.push(`Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`);
4989
+ }
4990
+ if (!greptile.score && greptile.mapping !== "score-5-of-5") {
4991
+ reasons.push("No parseable Greptile 5/5 score or explicit approved mapping was found from trusted current-head evidence; merge is blocked.");
4992
+ }
4993
+ if (greptile.mapping === "unproven") {
4994
+ reasons.push("Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.");
4995
+ }
4996
+ if (greptile.blockers.length > 0) {
4997
+ reasons.push(...greptile.blockers.map((entry) => `Greptile/blocker text: ${entry.trim().slice(0, 500)}`));
4998
+ }
4999
+ if (greptile.unresolvedComments.length > 0)
5000
+ reasons.push(...greptile.unresolvedComments);
5001
+ if (!greptile.approved)
5002
+ warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
5003
+ return { reasons: Array.from(new Set(reasons)), warnings, pending };
5004
+ }
5005
+ function evaluateStrictPrMergeGate(evidence) {
5006
+ const evaluated = evaluateEvidence(evidence);
5007
+ const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
5008
+ return {
5009
+ approved,
5010
+ pending: evaluated.pending,
5011
+ reasons: evaluated.reasons,
5012
+ warnings: evaluated.warnings,
5013
+ actionableFeedback: evaluated.reasons,
5014
+ evidence
5015
+ };
5016
+ }
5017
+ function promptExcerpt(value, maxChars = 4000) {
5018
+ return value.length > maxChars ? `${value.slice(0, maxChars)}
5019
+
5020
+ [truncated for prompt; see full evidence artifact]` : value;
5021
+ }
5022
+ function promptJsonExcerpt(value, maxChars = 6000) {
5023
+ return promptExcerpt(JSON.stringify(value, null, 2), maxChars);
5024
+ }
5025
+ function buildStrictPrGateSteeringPrompt(result) {
5026
+ const evidence = result.evidence;
5027
+ const unresolvedReviewThreads = evidence.reviewThreads.filter((thread) => thread.isResolved !== true && thread.isOutdated !== true);
5028
+ const lines = [
5029
+ `Strict PR merge gate blocked ${evidence.prUrl}.`,
5030
+ `PR title: ${evidence.title || "(empty)"}`,
5031
+ `Current PR head SHA: ${evidence.headSha || "unknown"}`,
5032
+ `Greptile mapping: ${evidence.greptile.mapping}`,
5033
+ evidence.greptile.score ? `Greptile score: ${evidence.greptile.score.value}/${evidence.greptile.score.scale}` : "Greptile score: not proven",
5034
+ "",
5035
+ "Gate reasons:",
5036
+ ...result.reasons.length ? result.reasons.map((reason) => `- ${reason}`) : ["- No reasons recorded"],
5037
+ "",
5038
+ "Required evidence read status:",
5039
+ evidence.readErrors.length ? JSON.stringify(evidence.readErrors, null, 2) : "All required PR evidence surfaces were read completely.",
5040
+ "",
5041
+ "Full PR title:",
5042
+ evidence.title || "(empty)",
5043
+ "",
5044
+ "PR body excerpt:",
5045
+ evidence.body ? promptExcerpt(evidence.body) : "(empty)",
5046
+ "",
5047
+ "All review comments on changed files:",
5048
+ evidence.changedFileReviewComments.length ? promptJsonExcerpt(evidence.changedFileReviewComments) : "[]",
5049
+ "",
5050
+ "Unresolved review threads:",
5051
+ unresolvedReviewThreads.length ? promptJsonExcerpt(unresolvedReviewThreads) : "[]",
5052
+ "",
5053
+ "Relevant issue-level PR comments:",
5054
+ evidence.relevantIssueComments.length ? promptJsonExcerpt(evidence.relevantIssueComments) : "[]",
5055
+ "",
5056
+ "CI/check failures and pending checks:",
5057
+ promptJsonExcerpt({ failures: evidence.checkFailures, pending: evidence.pendingChecks, rollup: evidence.statusCheckRollup }),
5058
+ "",
5059
+ "Greptile evidence:",
5060
+ promptJsonExcerpt(evidence.greptile)
5061
+ ];
5062
+ if (result.artifacts) {
5063
+ lines.push("", "Full evidence artifacts:", JSON.stringify(result.artifacts, null, 2));
5064
+ }
5065
+ return lines.join(`
5066
+ `);
5067
+ }
5068
+ function persistPrReviewCycleArtifacts(input) {
5069
+ const cycleName = input.final ? `${input.cycle}-final` : String(input.cycle);
5070
+ const taskArtifactRoot = input.artifactRoot?.trim() ? input.artifactRoot : resolve21(input.projectRoot, "artifacts", input.taskId);
5071
+ const root = resolve21(taskArtifactRoot, "pr-review-cycles", cycleName);
5072
+ mkdirSync8(root, { recursive: true });
5073
+ const finalMergeGateResultPath = input.final ? resolve21(taskArtifactRoot, "merge-gate-final.json") : undefined;
5074
+ const paths = {
5075
+ root,
5076
+ prTitlePath: resolve21(root, "pr-title.md"),
5077
+ prBodyPath: resolve21(root, "pr-body.md"),
5078
+ prCommentsPath: resolve21(root, "pr-comments.json"),
5079
+ reviewThreadsPath: resolve21(root, "review-threads.json"),
5080
+ reviewCommentsPath: resolve21(root, "review-comments.json"),
5081
+ checkRollupPath: resolve21(root, "check-rollup.json"),
5082
+ greptileEvidencePath: resolve21(root, "greptile-evidence.json"),
5083
+ mergeGateResultPath: resolve21(root, "merge-gate-result.json"),
5084
+ steeringPromptPath: resolve21(root, "agent-steering-prompt.md"),
5085
+ ...finalMergeGateResultPath ? { finalMergeGateResultPath } : {}
5086
+ };
5087
+ writeFileSync8(paths.prTitlePath, input.result.evidence.title || "", "utf8");
5088
+ writeFileSync8(paths.prBodyPath, input.result.evidence.body || "", "utf8");
5089
+ writeFileSync8(paths.prCommentsPath, `${JSON.stringify(input.result.evidence.relevantIssueComments, null, 2)}
5090
+ `, "utf8");
5091
+ writeFileSync8(paths.reviewThreadsPath, `${JSON.stringify(input.result.evidence.reviewThreads, null, 2)}
5092
+ `, "utf8");
5093
+ writeFileSync8(paths.reviewCommentsPath, `${JSON.stringify(input.result.evidence.changedFileReviewComments, null, 2)}
5094
+ `, "utf8");
5095
+ writeFileSync8(paths.checkRollupPath, `${JSON.stringify(input.result.evidence.statusCheckRollup, null, 2)}
5096
+ `, "utf8");
5097
+ writeFileSync8(paths.greptileEvidencePath, `${JSON.stringify(input.result.evidence.greptile, null, 2)}
5098
+ `, "utf8");
5099
+ const mergeGatePayload = {
5100
+ approved: input.result.approved,
5101
+ pending: input.result.pending,
5102
+ reasons: input.result.reasons,
5103
+ warnings: input.result.warnings,
5104
+ actionableFeedback: input.result.actionableFeedback,
5105
+ prUrl: input.result.evidence.prUrl,
5106
+ title: input.result.evidence.title,
5107
+ headSha: input.result.evidence.headSha,
5108
+ readErrors: input.result.evidence.readErrors,
5109
+ greptile: input.result.evidence.greptile,
5110
+ evidence: input.result.evidence,
5111
+ cycleArtifactRoot: root
5112
+ };
5113
+ writeFileSync8(paths.mergeGateResultPath, `${JSON.stringify(mergeGatePayload, null, 2)}
5114
+ `, "utf8");
5115
+ if (paths.finalMergeGateResultPath) {
5116
+ writeFileSync8(paths.finalMergeGateResultPath, `${JSON.stringify(mergeGatePayload, null, 2)}
5117
+ `, "utf8");
5118
+ }
5119
+ writeFileSync8(paths.steeringPromptPath, input.steeringPrompt, "utf8");
5120
+ return paths;
5121
+ }
5122
+ async function runStrictPrMergeGate(input) {
5123
+ const evidence = await collectPrReviewEvidence(input);
5124
+ const base = evaluateStrictPrMergeGate(evidence);
5125
+ const preliminaryPrompt = buildStrictPrGateSteeringPrompt({ ...base, artifacts: undefined });
5126
+ const artifacts = persistPrReviewCycleArtifacts({
5127
+ projectRoot: input.projectRoot,
5128
+ taskId: input.taskId,
5129
+ cycle: input.cycle,
5130
+ artifactRoot: input.artifactRoot,
5131
+ result: base,
5132
+ steeringPrompt: preliminaryPrompt,
5133
+ final: input.final
5134
+ });
5135
+ const steeringPrompt = buildStrictPrGateSteeringPrompt({ ...base, artifacts });
5136
+ writeFileSync8(artifacts.steeringPromptPath, steeringPrompt, "utf8");
5137
+ return { ...base, artifacts, steeringPrompt };
5138
+ }
5139
+
5140
+ // packages/runtime/src/control-plane/native/verifier.ts
4244
5141
  async function verifyTask(options) {
4245
5142
  const paths = resolveHarnessPaths(options.projectRoot);
4246
5143
  const taskId = options.taskId;
4247
5144
  const normalizedTaskId = lookupTask(options.projectRoot, taskId);
4248
5145
  const artifactDir = artifactDirForId(options.projectRoot, taskId);
4249
- mkdirSync8(artifactDir, { recursive: true });
4250
- const validationSummaryPath = resolve21(artifactDir, "validation-summary.json");
4251
- const reviewFeedbackPath = resolve21(artifactDir, "review-feedback.md");
4252
- const reviewStatePath = resolve21(artifactDir, "review-state.json");
4253
- const greptileRawPath = resolve21(artifactDir, "review-greptile-raw.json");
5146
+ mkdirSync9(artifactDir, { recursive: true });
5147
+ const validationSummaryPath = resolve22(artifactDir, "validation-summary.json");
5148
+ const reviewFeedbackPath = resolve22(artifactDir, "review-feedback.md");
5149
+ const reviewStatePath = resolve22(artifactDir, "review-state.json");
5150
+ const greptileRawPath = resolve22(artifactDir, "review-greptile-raw.json");
4254
5151
  const prStates = readPrMetadata(options.projectRoot, taskId);
4255
5152
  const prState = prStates[0] || null;
4256
5153
  const localReasons = [];
@@ -4271,12 +5168,12 @@ async function verifyTask(options) {
4271
5168
  }
4272
5169
  }
4273
5170
  for (const file of ["task-result.json", "decision-log.md", "next-actions.md", "changed-files.txt"]) {
4274
- const requiredPath = resolve21(artifactDir, file);
5171
+ const requiredPath = resolve22(artifactDir, file);
4275
5172
  if (!existsSync18(requiredPath)) {
4276
5173
  localReasons.push(`[Artifact Quality] Missing required artifact file: ${requiredPath}`);
4277
5174
  }
4278
5175
  }
4279
- const taskResultPath = resolve21(artifactDir, "task-result.json");
5176
+ const taskResultPath = resolve22(artifactDir, "task-result.json");
4280
5177
  if (existsSync18(taskResultPath)) {
4281
5178
  const taskResult = await readJsonFile2(taskResultPath);
4282
5179
  const artifactStatus = typeof taskResult?.status === "string" ? taskResult.status.trim().toLowerCase() : "";
@@ -4290,7 +5187,7 @@ async function verifyTask(options) {
4290
5187
  localReasons.push("[Artifact Quality] task-result.json next actions indicate remaining implementation scope.");
4291
5188
  }
4292
5189
  }
4293
- const nextActionsPath = resolve21(artifactDir, "next-actions.md");
5190
+ const nextActionsPath = resolve22(artifactDir, "next-actions.md");
4294
5191
  if (existsSync18(nextActionsPath)) {
4295
5192
  const nextActionsContent = await Bun.file(nextActionsPath).text();
4296
5193
  if (nextActionsContent.includes("TODO: Replace this scaffold") || nextActionsContent.includes("bd-<downstream-task-id>")) {
@@ -4328,7 +5225,7 @@ async function verifyTask(options) {
4328
5225
  aiReasons.push(`[AI Review] Required mode needs a completed Greptile approval; current verdict is ${ai.verdict}.`);
4329
5226
  }
4330
5227
  if (persistArtifacts && ai.rawResponse) {
4331
- writeFileSync8(greptileRawPath, `${ai.rawResponse}
5228
+ writeFileSync9(greptileRawPath, `${ai.rawResponse}
4332
5229
  `, "utf-8");
4333
5230
  }
4334
5231
  } else if (!options.skipAiReview && reviewMode === "off") {
@@ -4837,7 +5734,7 @@ function writeFeedbackFile(options) {
4837
5734
  if (options.aiRawFeedback) {
4838
5735
  lines.push("## Raw Reviewer Feedback", "", "```text", options.aiRawFeedback, "```", "");
4839
5736
  }
4840
- writeFileSync8(options.output, `${lines.join(`
5737
+ writeFileSync9(options.output, `${lines.join(`
4841
5738
  `)}
4842
5739
  `, "utf-8");
4843
5740
  }
@@ -4854,7 +5751,7 @@ function writeReviewStateFile(options) {
4854
5751
  ai_warnings: options.aiWarnings,
4855
5752
  updated_at: nowIso()
4856
5753
  };
4857
- writeFileSync8(options.output, `${JSON.stringify(payload, null, 2)}
5754
+ writeFileSync9(options.output, `${JSON.stringify(payload, null, 2)}
4858
5755
  `, "utf-8");
4859
5756
  }
4860
5757
  async function runGreptileReviewForPr(options) {
@@ -5036,7 +5933,8 @@ async function runGreptileReviewForPr(options) {
5036
5933
  }
5037
5934
  };
5038
5935
  }
5039
- if (/not safe to merge|unsafe to merge|do not merge|blocker/i.test(reviewBody)) {
5936
+ const blockerScanBody = stripHtml(reviewBody).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
5937
+ if (/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(blockerScanBody)) {
5040
5938
  reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
5041
5939
  return {
5042
5940
  verdict: "REJECT",
@@ -5052,44 +5950,78 @@ async function runGreptileReviewForPr(options) {
5052
5950
  }
5053
5951
  };
5054
5952
  }
5055
- if (score) {
5056
- if (score.scale === 5 && score.value < 5 && options.reviewMode === "required") {
5057
- reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; required mode needs 5/5 before merge.`);
5058
- return {
5059
- verdict: "REJECT",
5060
- feedback,
5061
- reasons,
5062
- warnings,
5063
- rawPayload: {
5064
- pr: options.prState,
5065
- codeReviews: reviewsPayload,
5066
- selectedReview,
5067
- reviewDetails,
5068
- comments: commentsPayload,
5069
- score
5070
- }
5071
- };
5072
- }
5073
- if (score.scale === 5 && score.value <= 2) {
5074
- reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; this requires rework before merge.`);
5075
- return {
5076
- verdict: "REJECT",
5077
- feedback,
5078
- reasons,
5079
- warnings,
5080
- rawPayload: {
5081
- pr: options.prState,
5082
- codeReviews: reviewsPayload,
5083
- selectedReview,
5084
- reviewDetails,
5085
- comments: commentsPayload,
5086
- score
5953
+ if (score?.scale === 5 && score.value < 5) {
5954
+ reasons.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; strict review requires 5/5 before merge.`);
5955
+ return {
5956
+ verdict: "REJECT",
5957
+ feedback,
5958
+ reasons,
5959
+ warnings,
5960
+ rawPayload: {
5961
+ pr: options.prState,
5962
+ codeReviews: reviewsPayload,
5963
+ selectedReview,
5964
+ reviewDetails,
5965
+ comments: commentsPayload,
5966
+ score
5967
+ }
5968
+ };
5969
+ }
5970
+ const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
5971
+ let strictGate = null;
5972
+ try {
5973
+ const strictEvidence = await collectStrictPrEvidenceForVerifier({
5974
+ projectRoot: options.projectRoot,
5975
+ taskId: options.taskId,
5976
+ prUrl,
5977
+ apiSignals: [{
5978
+ id: selectedReview.id,
5979
+ body: reviewBody,
5980
+ reviewedSha: selectedReview.metadata?.checkHeadSha ?? null,
5981
+ status: selectedReview.status
5982
+ }]
5983
+ });
5984
+ strictGate = evaluateStrictPrMergeGate(strictEvidence);
5985
+ } catch (error) {
5986
+ reasons.push(`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`);
5987
+ return {
5988
+ verdict: "REJECT",
5989
+ feedback,
5990
+ reasons,
5991
+ warnings,
5992
+ rawPayload: {
5993
+ pr: options.prState,
5994
+ codeReviews: reviewsPayload,
5995
+ selectedReview,
5996
+ reviewDetails,
5997
+ comments: commentsPayload,
5998
+ score
5999
+ }
6000
+ };
6001
+ }
6002
+ if (!strictGate.approved) {
6003
+ return {
6004
+ verdict: strictGate.pending ? "SKIP" : "REJECT",
6005
+ feedback,
6006
+ reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
6007
+ warnings: [...warnings, ...strictGate.warnings],
6008
+ rawPayload: {
6009
+ pr: options.prState,
6010
+ codeReviews: reviewsPayload,
6011
+ selectedReview,
6012
+ reviewDetails,
6013
+ comments: commentsPayload,
6014
+ score,
6015
+ strictGate: {
6016
+ approved: strictGate.approved,
6017
+ pending: strictGate.pending,
6018
+ reasons: strictGate.reasons,
6019
+ warnings: strictGate.warnings,
6020
+ greptile: strictGate.evidence.greptile,
6021
+ readErrors: strictGate.evidence.readErrors
5087
6022
  }
5088
- };
5089
- }
5090
- if (score.scale === 5 && score.value < 5) {
5091
- warnings.push(`[AI Review] ${repoName}#${prNumber} completed with Greptile confidence ${score.value}/${score.scale}; continue only after reviewing remaining risk.`);
5092
- }
6023
+ }
6024
+ };
5093
6025
  }
5094
6026
  return {
5095
6027
  verdict: "APPROVE",
@@ -5101,7 +6033,15 @@ async function runGreptileReviewForPr(options) {
5101
6033
  codeReviews: reviewsPayload,
5102
6034
  selectedReview,
5103
6035
  reviewDetails,
5104
- comments: commentsPayload
6036
+ comments: commentsPayload,
6037
+ strictGate: {
6038
+ approved: strictGate.approved,
6039
+ pending: strictGate.pending,
6040
+ reasons: strictGate.reasons,
6041
+ warnings: strictGate.warnings,
6042
+ greptile: strictGate.evidence.greptile,
6043
+ readErrors: strictGate.evidence.readErrors
6044
+ }
5105
6045
  }
5106
6046
  };
5107
6047
  }
@@ -5125,7 +6065,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5125
6065
  let threads = [];
5126
6066
  let actionableThreads = [];
5127
6067
  let checkRollup = [];
5128
- let checkState = { pending: false, completed: false };
6068
+ let checkState2 = { pending: false, completed: false };
5129
6069
  for (let attempt = 0;; attempt += 1) {
5130
6070
  reviews = runGhJson(options.projectRoot, ["api", `repos/${repoName}/pulls/${prNumber}/reviews`]);
5131
6071
  selectedReview = pickRelevantGithubGreptileReview(reviews, expectedHeadSha);
@@ -5134,15 +6074,15 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5134
6074
  threads = loadGithubReviewThreads(options.projectRoot, repoName, prNumber);
5135
6075
  actionableThreads = filterActionableGithubGreptileThreads(threads);
5136
6076
  checkRollup = loadGithubPullRequestCheckRollup(options.projectRoot, repoName, prNumber);
5137
- checkState = classifyGithubGreptileCheckState(checkRollup);
5138
- const approvedViaReviewedAncestor2 = !selectedReview && !!fallbackReview?.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
6077
+ checkState2 = classifyGithubGreptileCheckState(checkRollup);
6078
+ const approvedViaReviewedAncestor = !selectedReview && !!fallbackReview?.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
5139
6079
  if (!shouldContinueGithubGreptileFallbackPolling({
5140
6080
  attempt,
5141
6081
  pollAttempts: options.pollAttempts,
5142
- checkState,
6082
+ checkState: checkState2,
5143
6083
  fallbackReview,
5144
6084
  selectedReview,
5145
- approvedViaReviewedAncestor: approvedViaReviewedAncestor2
6085
+ approvedViaReviewedAncestor
5146
6086
  })) {
5147
6087
  break;
5148
6088
  }
@@ -5170,7 +6110,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5170
6110
  ].filter(Boolean).join(`
5171
6111
  `);
5172
6112
  const warnings = buildGithubGreptileFallbackWarnings(options);
5173
- if (checkState.pending) {
6113
+ if (checkState2.pending) {
5174
6114
  return {
5175
6115
  verdict: "SKIP",
5176
6116
  feedback,
@@ -5181,34 +6121,20 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5181
6121
  rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
5182
6122
  };
5183
6123
  }
5184
- const approvedViaCompletedCheck = isGithubGreptileCheckApproved(checkRollup);
5185
- if (!fallbackReview) {
5186
- if (approvedViaCompletedCheck) {
5187
- warnings.push(`[AI Review Warning] ${repoName}#${prNumber} has no GitHub Greptile review object, but the Greptile check completed successfully and no unresolved Greptile threads remain.`);
5188
- return {
5189
- verdict: "APPROVE",
5190
- feedback,
5191
- reasons: [],
5192
- warnings,
5193
- rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
5194
- };
5195
- }
5196
- return {
5197
- verdict: "SKIP",
5198
- feedback,
5199
- reasons: [
5200
- `[AI Review] Greptile GitHub review for ${repoName}#${prNumber} is not available.`
5201
- ],
5202
- warnings,
5203
- rawPayload: { ...buildGithubGreptileFallbackRawPayload(options), reviews, threads, checkRollup }
5204
- };
5205
- }
5206
- const approvedViaReviewedAncestor = !selectedReview && !!fallbackReview.commit_id && !!expectedHeadSha && isCommitAncestorOfPrHead(options.projectRoot, options.prState, fallbackReview.commit_id, expectedHeadSha);
5207
- if (actionableThreads.length > 0) {
6124
+ const prUrl = options.prState.url || `https://github.com/${repoName}/pull/${prNumber}`;
6125
+ let strictGate;
6126
+ try {
6127
+ const strictEvidence = await collectStrictPrEvidenceForVerifier({
6128
+ projectRoot: options.projectRoot,
6129
+ taskId: options.taskId,
6130
+ prUrl
6131
+ });
6132
+ strictGate = evaluateStrictPrMergeGate(strictEvidence);
6133
+ } catch (error) {
5208
6134
  return {
5209
6135
  verdict: "REJECT",
5210
6136
  feedback,
5211
- reasons: actionableThreads.map((comment) => `[AI Review] ${repoName}#${prNumber} has unresolved Greptile comment on ${comment.path}: ${summarizeComment(comment.body || "")}`),
6137
+ reasons: [`[AI Review] Strict Greptile evidence collection failed for ${repoName}#${prNumber}: ${error instanceof Error ? error.message : String(error)}`],
5212
6138
  warnings,
5213
6139
  rawPayload: {
5214
6140
  pr: options.prState,
@@ -5221,44 +6147,30 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5221
6147
  }
5222
6148
  };
5223
6149
  }
5224
- if (!selectedReview && !approvedViaReviewedAncestor) {
5225
- if (approvedViaCompletedCheck) {
5226
- warnings.push(`[AI Review Warning] ${repoName}#${prNumber} has no fresh Greptile GitHub review on the current head, but the Greptile check completed successfully and all Greptile threads are resolved.`);
5227
- return {
5228
- verdict: "APPROVE",
5229
- feedback,
5230
- reasons: [],
5231
- warnings,
5232
- rawPayload: {
5233
- pr: options.prState,
5234
- selectedReview: fallbackReview,
5235
- reviews,
5236
- threads,
5237
- checkRollup,
5238
- ...buildGithubGreptileFallbackRawPayload(options)
5239
- }
5240
- };
5241
- }
6150
+ if (!strictGate.approved) {
5242
6151
  return {
5243
- verdict: "SKIP",
6152
+ verdict: strictGate.pending ? "SKIP" : "REJECT",
5244
6153
  feedback,
5245
- reasons: [
5246
- `[AI Review] Greptile GitHub review for ${repoName}#${prNumber} is not available for the current head.`
5247
- ],
5248
- warnings,
6154
+ reasons: strictGate.reasons.map((reason) => reason.startsWith("[AI Review]") ? reason : `[AI Review] ${reason}`),
6155
+ warnings: [...warnings, ...strictGate.warnings],
5249
6156
  rawPayload: {
5250
6157
  pr: options.prState,
5251
6158
  selectedReview: fallbackReview,
5252
6159
  reviews,
5253
6160
  threads,
5254
6161
  checkRollup,
6162
+ actionableThreads,
6163
+ strictGate: {
6164
+ approved: strictGate.approved,
6165
+ pending: strictGate.pending,
6166
+ reasons: strictGate.reasons,
6167
+ warnings: strictGate.warnings,
6168
+ greptile: strictGate.evidence.greptile
6169
+ },
5255
6170
  ...buildGithubGreptileFallbackRawPayload(options)
5256
6171
  }
5257
6172
  };
5258
6173
  }
5259
- if (approvedViaReviewedAncestor) {
5260
- warnings.push(`[AI Review Warning] ${repoName}#${prNumber} has no fresh Greptile review on the current head, but the latest reviewed commit is an ancestor and all Greptile threads are resolved.`);
5261
- }
5262
6174
  return {
5263
6175
  verdict: "APPROVE",
5264
6176
  feedback,
@@ -5270,6 +6182,13 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5270
6182
  reviews,
5271
6183
  threads,
5272
6184
  checkRollup,
6185
+ strictGate: {
6186
+ approved: strictGate.approved,
6187
+ pending: strictGate.pending,
6188
+ reasons: strictGate.reasons,
6189
+ warnings: strictGate.warnings,
6190
+ greptile: strictGate.evidence.greptile
6191
+ },
5273
6192
  ...buildGithubGreptileFallbackRawPayload(options)
5274
6193
  }
5275
6194
  };
@@ -5382,21 +6301,25 @@ function shouldTriggerGreptileReview(existingReview, expectedHeadSha) {
5382
6301
  if ((existingReview.metadata?.checkHeadSha || "") !== expectedHeadSha) {
5383
6302
  return true;
5384
6303
  }
5385
- return isGreptileReviewTerminal(existingReview.status);
6304
+ return false;
5386
6305
  }
5387
6306
  function shouldContinueGreptileMcpPolling(options) {
5388
6307
  if (options.githubCheckState.completed) {
5389
6308
  return false;
5390
6309
  }
6310
+ const hasRemainingBudget = options.attempt + 1 < options.pollAttempts;
6311
+ if (!hasRemainingBudget) {
6312
+ return false;
6313
+ }
5391
6314
  if (options.selectedReview && !isGreptileReviewTerminal(options.selectedReview.status)) {
5392
6315
  return true;
5393
6316
  }
5394
- return options.attempt + 1 < options.pollAttempts;
6317
+ return true;
5395
6318
  }
5396
6319
  function shouldContinueGithubGreptileFallbackPolling(options) {
5397
6320
  const waitingForVisiblePendingReview = options.checkState.pending && (!options.fallbackReview || !options.selectedReview && !options.approvedViaReviewedAncestor);
5398
6321
  if (waitingForVisiblePendingReview) {
5399
- return true;
6322
+ return options.attempt + 1 < options.pollAttempts;
5400
6323
  }
5401
6324
  const reviewNotVisibleYet = !options.fallbackReview && !options.checkState.pending && !options.checkState.completed;
5402
6325
  if (reviewNotVisibleYet) {
@@ -5455,6 +6378,20 @@ function runGhJson(projectRoot, args) {
5455
6378
  throw new Error(`gh ${args.join(" ")} returned malformed JSON: ${result.stdout}`);
5456
6379
  }
5457
6380
  }
6381
+ async function collectStrictPrEvidenceForVerifier(input) {
6382
+ return collectPrReviewEvidence({
6383
+ projectRoot: input.projectRoot,
6384
+ prUrl: input.prUrl,
6385
+ taskId: input.taskId,
6386
+ runId: "verifier",
6387
+ cycle: 0,
6388
+ apiSignals: input.apiSignals ?? [],
6389
+ command: async (args, options) => {
6390
+ const result = runCapture(["gh", ...args], options?.cwd ?? input.projectRoot);
6391
+ return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
6392
+ }
6393
+ });
6394
+ }
5458
6395
  function deriveRepoName(projectRoot, prState) {
5459
6396
  const fromUrl = /github\.com\/([^/]+\/[^/]+)\/pull\/\d+/.exec(prState.url || "");
5460
6397
  if (fromUrl?.[1]) {
@@ -5469,8 +6406,9 @@ function resolvePrHeadSha(projectRoot, prState) {
5469
6406
  const repoRoot = resolvePrRepoRoot(projectRoot, prState);
5470
6407
  return runCapture(["git", "-C", repoRoot, "rev-parse", "HEAD"], projectRoot).stdout.trim();
5471
6408
  }
5472
- function isGreptileGithubLogin(login) {
5473
- return (login || "").replace(/\[bot\]$/, "") === "greptile-apps";
6409
+ function isGreptileGithubLogin2(login) {
6410
+ const normalized = (login || "").toLowerCase().replace(/\[bot\]$/, "");
6411
+ return normalized === "greptile" || normalized === "greptile-ai" || normalized === "greptileai" || normalized === "greptile-apps";
5474
6412
  }
5475
6413
  function pickRelevantGithubGreptileReview(reviews, expectedHeadSha) {
5476
6414
  const matching = sortGithubGreptileReviews(reviews);
@@ -5487,7 +6425,7 @@ function pickLatestGithubGreptileReview(reviews) {
5487
6425
  return sortGithubGreptileReviews(reviews)[0] || null;
5488
6426
  }
5489
6427
  function sortGithubGreptileReviews(reviews) {
5490
- return reviews.filter((review) => isGreptileGithubLogin(review.user?.login)).sort((left, right) => Date.parse(right.submitted_at || "") - Date.parse(left.submitted_at || ""));
6428
+ return reviews.filter((review) => isGreptileGithubLogin2(review.user?.login)).sort((left, right) => Date.parse(right.submitted_at || "") - Date.parse(left.submitted_at || ""));
5491
6429
  }
5492
6430
  function loadGithubPullRequestCheckRollup(projectRoot, repoName, prNumber) {
5493
6431
  const response = runGhJson(projectRoot, [
@@ -5560,32 +6498,6 @@ function classifyGithubGreptileCheckState(checks) {
5560
6498
  }
5561
6499
  return { pending: false, completed: false };
5562
6500
  }
5563
- function isGithubGreptileCheckApproved(checks) {
5564
- const greptileChecks = checks.filter((check) => {
5565
- const label = (check.name || check.context || "").toLowerCase();
5566
- return label.includes("greptile");
5567
- });
5568
- if (greptileChecks.length === 0) {
5569
- return false;
5570
- }
5571
- for (const check of greptileChecks) {
5572
- if ((check.__typename || "") === "CheckRun") {
5573
- if ((check.status || "").toUpperCase() !== "COMPLETED") {
5574
- return false;
5575
- }
5576
- const conclusion = (check.conclusion || "").toUpperCase();
5577
- if (!["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion)) {
5578
- return false;
5579
- }
5580
- continue;
5581
- }
5582
- const state = (check.state || "").toUpperCase();
5583
- if (!["SUCCESS", "NEUTRAL", "SKIPPED"].includes(state)) {
5584
- return false;
5585
- }
5586
- }
5587
- return true;
5588
- }
5589
6501
  function loadGithubReviewThreads(projectRoot, repoName, prNumber) {
5590
6502
  const [owner, name] = repoName.split("/");
5591
6503
  if (!owner || !name) {
@@ -5611,7 +6523,7 @@ function filterActionableGithubGreptileThreads(threads) {
5611
6523
  return [];
5612
6524
  }
5613
6525
  const comments = thread.comments?.nodes || [];
5614
- const latestGreptileComment = [...comments].reverse().find((comment) => isGreptileGithubLogin(comment.author?.login));
6526
+ const latestGreptileComment = [...comments].reverse().find((comment) => isGreptileGithubLogin2(comment.author?.login));
5615
6527
  if (!latestGreptileComment?.path?.trim()) {
5616
6528
  return [];
5617
6529
  }
@@ -5620,7 +6532,7 @@ function filterActionableGithubGreptileThreads(threads) {
5620
6532
  }
5621
6533
  function resolvePrRepoRoot(projectRoot, prState) {
5622
6534
  const runtimeWorkspace = process.env.RIG_TASK_WORKSPACE?.trim();
5623
- if (prState.target === "monorepo" && runtimeWorkspace && existsSync18(resolve21(runtimeWorkspace, ".git"))) {
6535
+ if (prState.target === "monorepo" && runtimeWorkspace && existsSync18(resolve22(runtimeWorkspace, ".git"))) {
5624
6536
  return runtimeWorkspace;
5625
6537
  }
5626
6538
  const paths = resolveHarnessPaths(projectRoot);
@@ -5633,11 +6545,6 @@ function isCommitAncestorOfPrHead(projectRoot, prState, reviewedCommit, headComm
5633
6545
  const repoRoot = resolvePrRepoRoot(projectRoot, prState);
5634
6546
  return runCapture(["git", "-C", repoRoot, "merge-base", "--is-ancestor", reviewedCommit, headCommit], projectRoot).exitCode === 0;
5635
6547
  }
5636
- function stripHtml(input) {
5637
- return input.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\r/g, "").replace(/\n{3,}/g, `
5638
-
5639
- `).trim();
5640
- }
5641
6548
  function summarizeComment(input) {
5642
6549
  const text = stripHtml(input).replace(/\s+/g, " ").trim();
5643
6550
  return text.length > 160 ? `${text.slice(0, 157)}...` : text;
@@ -5646,31 +6553,14 @@ function asGreptileInfrastructureWarning(reason) {
5646
6553
  return reason.startsWith("[AI Review]") ? reason.replace("[AI Review]", "[AI Review Warning]") : reason;
5647
6554
  }
5648
6555
  function isAiReviewApproved(input) {
6556
+ if (input.aiVerdict === "REJECT" && input.aiReasons.length > 0) {
6557
+ return false;
6558
+ }
5649
6559
  if (input.reviewMode !== "required") {
5650
6560
  return true;
5651
6561
  }
5652
6562
  return input.aiVerdict === "APPROVE" && input.aiReasons.length === 0;
5653
6563
  }
5654
- function parseGreptileScore(input) {
5655
- const text = stripHtml(input);
5656
- const patterns = [
5657
- /confidence score:\s*(\d+)\s*\/\s*(\d+)/i,
5658
- /\bscore:\s*(\d+)\s*\/\s*(\d+)/i,
5659
- /\b(\d+)\s*\/\s*(\d+)\s*(?:confidence|score)/i
5660
- ];
5661
- for (const pattern of patterns) {
5662
- const match = pattern.exec(text);
5663
- if (!match) {
5664
- continue;
5665
- }
5666
- const value = Number.parseInt(match[1] || "", 10);
5667
- const scale = Number.parseInt(match[2] || "", 10);
5668
- if (Number.isFinite(value) && Number.isFinite(scale) && scale > 0) {
5669
- return { value, scale };
5670
- }
5671
- }
5672
- return null;
5673
- }
5674
6564
 
5675
6565
  // packages/runtime/src/control-plane/provider/runtime-instructions.ts
5676
6566
  var CLAUDE_ROUTER_TOOL_NAMES = [
@@ -5710,14 +6600,14 @@ function taskArtifacts(projectRoot, taskId) {
5710
6600
  throw new Error("No active task.");
5711
6601
  }
5712
6602
  const paths = resolveHarnessPaths(projectRoot);
5713
- const artifactDir = resolve22(paths.artifactsDir, activeTask);
5714
- mkdirSync9(artifactDir, { recursive: true });
6603
+ const artifactDir = resolve23(paths.artifactsDir, activeTask);
6604
+ mkdirSync10(artifactDir, { recursive: true });
5715
6605
  const changed = changedFilesForTask(projectRoot, activeTask, true);
5716
- writeFileSync9(resolve22(artifactDir, "changed-files.txt"), `${changed.join(`
6606
+ writeFileSync10(resolve23(artifactDir, "changed-files.txt"), `${changed.join(`
5717
6607
  `)}
5718
6608
  `, "utf-8");
5719
6609
  console.log(`changed-files.txt: ${changed.length} files`);
5720
- const taskResultPath = resolve22(artifactDir, "task-result.json");
6610
+ const taskResultPath = resolve23(artifactDir, "task-result.json");
5721
6611
  if (!existsSync19(taskResultPath)) {
5722
6612
  const template = {
5723
6613
  task_id: activeTask,
@@ -5725,24 +6615,24 @@ function taskArtifacts(projectRoot, taskId) {
5725
6615
  summary: "TODO: Write a one-line summary of what you did",
5726
6616
  completed_at: nowIso()
5727
6617
  };
5728
- writeFileSync9(taskResultPath, `${JSON.stringify(template, null, 2)}
6618
+ writeFileSync10(taskResultPath, `${JSON.stringify(template, null, 2)}
5729
6619
  `, "utf-8");
5730
6620
  console.log("task-result.json: created (update the summary!)");
5731
6621
  } else {
5732
6622
  console.log("task-result.json: already exists");
5733
6623
  }
5734
- const decisionLogPath = resolve22(artifactDir, "decision-log.md");
6624
+ const decisionLogPath = resolve23(artifactDir, "decision-log.md");
5735
6625
  if (!existsSync19(decisionLogPath)) {
5736
6626
  const content = `# Decision Log: ${activeTask}
5737
6627
 
5738
6628
  Record key decisions here using: rig-agent record decision "..."
5739
6629
  `;
5740
- writeFileSync9(decisionLogPath, content, "utf-8");
6630
+ writeFileSync10(decisionLogPath, content, "utf-8");
5741
6631
  console.log("decision-log.md: created (record your decisions!)");
5742
6632
  } else {
5743
6633
  console.log("decision-log.md: already exists");
5744
6634
  }
5745
- const nextActionsPath = resolve22(artifactDir, "next-actions.md");
6635
+ const nextActionsPath = resolve23(artifactDir, "next-actions.md");
5746
6636
  if (!existsSync19(nextActionsPath)) {
5747
6637
  const content = [
5748
6638
  `# Next Actions: ${activeTask}`,
@@ -5760,12 +6650,12 @@ Record key decisions here using: rig-agent record decision "..."
5760
6650
  ""
5761
6651
  ].join(`
5762
6652
  `);
5763
- writeFileSync9(nextActionsPath, content, "utf-8");
6653
+ writeFileSync10(nextActionsPath, content, "utf-8");
5764
6654
  console.log("next-actions.md: created (add recommendations for downstream tasks!)");
5765
6655
  } else {
5766
6656
  console.log("next-actions.md: already exists");
5767
6657
  }
5768
- const validationSummaryPath = resolve22(artifactDir, "validation-summary.json");
6658
+ const validationSummaryPath = resolve23(artifactDir, "validation-summary.json");
5769
6659
  if (existsSync19(validationSummaryPath)) {
5770
6660
  console.log("validation-summary.json: already exists");
5771
6661
  } else {
@@ -5832,7 +6722,7 @@ function collectTaskChangedFiles(projectRoot, taskId, includeCommitted) {
5832
6722
  [projectRoot, ""],
5833
6723
  [monorepoRepoRoot, ""]
5834
6724
  ]) {
5835
- if (!existsSync19(resolve22(repo, ".git"))) {
6725
+ if (!existsSync19(resolve23(repo, ".git"))) {
5836
6726
  continue;
5837
6727
  }
5838
6728
  if (includeCommitted && repo === monorepoRepoRoot) {
@@ -5870,8 +6760,8 @@ function filterTaskChangedFiles(projectRoot, taskId, files, scoped) {
5870
6760
  }
5871
6761
  function resolveTaskMonorepoRoot(projectRoot) {
5872
6762
  const runtimeWorkspace = loadRuntimeContextFromEnv()?.workspaceDir || process.env.RIG_TASK_WORKSPACE?.trim();
5873
- if (runtimeWorkspace && existsSync19(resolve22(runtimeWorkspace, ".git"))) {
5874
- return resolve22(runtimeWorkspace);
6763
+ if (runtimeWorkspace && existsSync19(resolve23(runtimeWorkspace, ".git"))) {
6764
+ return resolve23(runtimeWorkspace);
5875
6765
  }
5876
6766
  return resolveHarnessPaths(projectRoot).monorepoRoot;
5877
6767
  }
@@ -5899,7 +6789,7 @@ function resolveRuntimeInitialHeadCommit(projectRoot, repo) {
5899
6789
  const runtimeContext = loadRuntimeContextFromEnv();
5900
6790
  if (runtimeContext?.initialHeadCommits?.monorepo?.trim()) {
5901
6791
  const monorepoRoot = resolveTaskMonorepoRoot(projectRoot);
5902
- if (resolve22(monorepoRoot) === resolve22(repo)) {
6792
+ if (resolve23(monorepoRoot) === resolve23(repo)) {
5903
6793
  return runtimeContext.initialHeadCommits.monorepo.trim();
5904
6794
  }
5905
6795
  }
@@ -5909,7 +6799,7 @@ function resolveMonorepoBaseCommit(projectRoot, repo) {
5909
6799
  const runtimeContext = loadRuntimeContextFromEnv();
5910
6800
  if (runtimeContext?.monorepoBaseCommit?.trim()) {
5911
6801
  const monorepoRoot = resolveTaskMonorepoRoot(projectRoot);
5912
- if (resolve22(monorepoRoot) === resolve22(repo)) {
6802
+ if (resolve23(monorepoRoot) === resolve23(repo)) {
5913
6803
  return runtimeContext.monorepoBaseCommit.trim();
5914
6804
  }
5915
6805
  }
@@ -5943,7 +6833,7 @@ function resolveRuntimeDirtyBaseline(projectRoot, repo) {
5943
6833
  return new Set;
5944
6834
  }
5945
6835
  const monorepoRoot = resolveTaskMonorepoRoot(projectRoot);
5946
- const selected = resolve22(repo) === resolve22(monorepoRoot) ? dirtyFiles.monorepo : resolve22(repo) === resolve22(projectRoot) ? dirtyFiles.project : undefined;
6836
+ const selected = resolve23(repo) === resolve23(monorepoRoot) ? dirtyFiles.monorepo : resolve23(repo) === resolve23(projectRoot) ? dirtyFiles.project : undefined;
5947
6837
  return new Set((selected || []).map((file) => normalizeChangedFilePath(file)).filter(Boolean));
5948
6838
  }
5949
6839
  function normalizeChangedFilePath(file) {
@@ -6001,8 +6891,8 @@ function isRuntimeGatewayGhPath(candidate) {
6001
6891
  }
6002
6892
  function resolveOptionalMonorepoRoot(projectRoot) {
6003
6893
  const runtimeWorkspace = process.env.RIG_TASK_WORKSPACE?.trim();
6004
- if (runtimeWorkspace && existsSync20(resolve23(runtimeWorkspace, ".git"))) {
6005
- return resolve23(runtimeWorkspace);
6894
+ if (runtimeWorkspace && existsSync20(resolve24(runtimeWorkspace, ".git"))) {
6895
+ return resolve24(runtimeWorkspace);
6006
6896
  }
6007
6897
  try {
6008
6898
  return resolveMonorepoRoot2(projectRoot);
@@ -6054,7 +6944,7 @@ function gitSyncBranch(projectRoot, taskId, targetRepo = "monorepo") {
6054
6944
  }
6055
6945
  const repoRoot = targetRepo === "monorepo" ? resolveOptionalMonorepoRoot(projectRoot) || resolveMonorepoRoot2(projectRoot) : projectRoot;
6056
6946
  const repoLabel = targetRepo === "monorepo" ? "Monorepo" : "Project";
6057
- if (!existsSync20(resolve23(repoRoot, ".git"))) {
6947
+ if (!existsSync20(resolve24(repoRoot, ".git"))) {
6058
6948
  throw new Error(`${repoLabel} repo not found at ${repoRoot}`);
6059
6949
  }
6060
6950
  const branchId = resolveTaskBranchId(projectRoot, resolvedTask);
@@ -6114,7 +7004,7 @@ function gitOpenPr(options) {
6114
7004
  } else if (taskId) {
6115
7005
  gitSyncBranch(options.projectRoot, taskId, "project");
6116
7006
  }
6117
- if (!existsSync20(resolve23(repoRoot, ".git"))) {
7007
+ if (!existsSync20(resolve24(repoRoot, ".git"))) {
6118
7008
  throw new Error(`Repository not available for open-pr target ${target}: ${repoRoot}`);
6119
7009
  }
6120
7010
  const branch = branchName(options.projectRoot, repoRoot);
@@ -6267,8 +7157,9 @@ function defaultPrCloseoutLine(taskId, repoNameWithOwner) {
6267
7157
  const sourceIssueId = loadRuntimeContextFromEnv()?.sourceTask?.sourceIssueId;
6268
7158
  if (sourceIssueId) {
6269
7159
  const match = sourceIssueId.match(/^([^#]+)#(\d+)$/);
6270
- if (match) {
6271
- const [, sourceRepo, issueNumber] = match;
7160
+ if (match?.[1] && match[2]) {
7161
+ const sourceRepo = match[1];
7162
+ const issueNumber = match[2];
6272
7163
  return sourceRepo.toLowerCase() === repoNameWithOwner.toLowerCase() ? `Closes #${issueNumber}` : `Closes ${sourceRepo}#${issueNumber}`;
6273
7164
  }
6274
7165
  }
@@ -6346,7 +7237,7 @@ function gitMergePr(options) {
6346
7237
  }
6347
7238
  const repoRoot = resolveRepoRoot(options.projectRoot, options.pr.target);
6348
7239
  const repoNameWithOwner = resolveRepoNameWithOwner(options.projectRoot, repoRoot);
6349
- if (!existsSync20(resolve23(repoRoot, ".git"))) {
7240
+ if (!existsSync20(resolve24(repoRoot, ".git"))) {
6350
7241
  throw new Error(`Repository not available for merge-pr target ${options.pr.target}: ${repoRoot}`);
6351
7242
  }
6352
7243
  const prState = readPrViewState(gh, repoRoot, repoNameWithOwner, options.pr.url);
@@ -6363,56 +7254,43 @@ function gitMergePr(options) {
6363
7254
  if (isDraft) {
6364
7255
  throw new Error(`Cannot auto-merge draft PR ${options.pr.url}.`);
6365
7256
  }
7257
+ const strictGateHeadSha = options.strictGateHeadSha?.trim();
7258
+ if (!strictGateHeadSha) {
7259
+ throw new Error(`Refusing to merge PR ${options.pr.url}: strict merge gate did not provide a current head SHA.`);
7260
+ }
6366
7261
  const mergeArgs = withGhRepo([gh, "pr", "merge", options.pr.url], repoNameWithOwner);
6367
7262
  const method = options.method || "squash";
6368
7263
  mergeArgs.push(method === "merge" ? "--merge" : method === "rebase" ? "--rebase" : "--squash");
7264
+ mergeArgs.push("--match-head-commit", strictGateHeadSha);
6369
7265
  if (options.deleteBranch !== false) {
6370
7266
  mergeArgs.push("--delete-branch");
6371
7267
  }
6372
- const autoMergeArgs = [...mergeArgs, "--auto"];
6373
- const autoMerge = runCapture2(autoMergeArgs, repoRoot);
6374
- if (autoMerge.exitCode === 0) {
6375
- const postAutoMergeState = readPrViewState(gh, repoRoot, repoNameWithOwner, options.pr.url);
6376
- if (postAutoMergeState.state === "MERGED" || postAutoMergeState.mergedAt) {
6377
- console.log(`Merged PR (${options.pr.repoLabel}): ${options.pr.url}`);
6378
- return { status: "merged", url: options.pr.url };
6379
- }
6380
- if (postAutoMergeState.state === "OPEN" && postAutoMergeState.autoMergeRequest) {
6381
- if (canAdminMergeApprovedPr(postAutoMergeState)) {
6382
- const adminMergeArgs = [...mergeArgs];
6383
- if (postAutoMergeState.headRefOid) {
6384
- adminMergeArgs.push("--match-head-commit", postAutoMergeState.headRefOid);
6385
- }
6386
- adminMergeArgs.push("--admin");
6387
- const adminMerge = runCapture2(adminMergeArgs, repoRoot);
6388
- if (adminMerge.exitCode === 0) {
6389
- const postAdminMergeState = readPrViewState(gh, repoRoot, repoNameWithOwner, options.pr.url);
6390
- if (postAdminMergeState.state === "MERGED" || postAdminMergeState.mergedAt) {
6391
- console.log(`Merged PR (${options.pr.repoLabel}) with admin fallback: ${options.pr.url}`);
6392
- return { status: "merged", url: options.pr.url };
6393
- }
6394
- throw new Error(`Admin merge command succeeded for PR ${options.pr.url} in ${options.pr.repoLabel}, but GitHub still reports it open.`);
6395
- }
6396
- const adminMergeMessage = `${adminMerge.stderr}
6397
- ${adminMerge.stdout}`.trim();
6398
- if (!/admin|administrator|permission|not permitted|not allowed/i.test(adminMergeMessage)) {
6399
- throw new Error(`Failed to admin-merge PR ${options.pr.url} in ${options.pr.repoLabel}: ${adminMergeMessage}`);
6400
- }
7268
+ const directMerge = runCapture2(mergeArgs, repoRoot);
7269
+ if (directMerge.exitCode === 0) {
7270
+ console.log(`Merged PR (${options.pr.repoLabel}): ${options.pr.url}`);
7271
+ return { status: "merged", url: options.pr.url };
7272
+ }
7273
+ const postDirectState = readPrViewState(gh, repoRoot, repoNameWithOwner, options.pr.url);
7274
+ if (canAdminMergeApprovedPr(postDirectState)) {
7275
+ const adminMergeArgs = [...mergeArgs, "--admin"];
7276
+ const adminMerge = runCapture2(adminMergeArgs, repoRoot);
7277
+ if (adminMerge.exitCode === 0) {
7278
+ const postAdminMergeState = readPrViewState(gh, repoRoot, repoNameWithOwner, options.pr.url);
7279
+ if (postAdminMergeState.state === "MERGED" || postAdminMergeState.mergedAt) {
7280
+ console.log(`Merged PR (${options.pr.repoLabel}) with admin fallback: ${options.pr.url}`);
7281
+ return { status: "merged", url: options.pr.url };
6401
7282
  }
6402
- console.log(`Auto-merge enabled (${options.pr.repoLabel}): ${options.pr.url}`);
6403
- return { status: "auto-merge-enabled", url: options.pr.url };
7283
+ throw new Error(`Admin merge command succeeded for PR ${options.pr.url} in ${options.pr.repoLabel}, but GitHub still reports it open.`);
7284
+ }
7285
+ const adminMergeMessage = `${adminMerge.stderr}
7286
+ ${adminMerge.stdout}`.trim();
7287
+ if (!/admin|administrator|permission|not permitted|not allowed/i.test(adminMergeMessage)) {
7288
+ throw new Error(`Failed to admin-merge PR ${options.pr.url} in ${options.pr.repoLabel}: ${adminMergeMessage}`);
6404
7289
  }
6405
- throw new Error(`Auto-merge command succeeded for PR ${options.pr.url} in ${options.pr.repoLabel}, but GitHub did not report a merged or auto-merge-enabled state.`);
6406
- }
6407
- const autoMergeMessage = `${autoMerge.stderr}
6408
- ${autoMerge.stdout}`.trim();
6409
- const autoMergeUnsupported = /auto.?merge.*(not enabled|not allowed|disabled|unsupported)|enablePullRequestAutoMerge|Auto merge is not allowed/i.test(autoMergeMessage);
6410
- if (!autoMergeUnsupported) {
6411
- throw new Error(`Failed to auto-merge PR ${options.pr.url} in ${options.pr.repoLabel}: ${autoMergeMessage}`);
6412
7290
  }
6413
- runOrThrow(options.projectRoot, mergeArgs, `Failed to merge PR ${options.pr.url} in ${options.pr.repoLabel}`);
6414
- console.log(`Merged PR (${options.pr.repoLabel}): ${options.pr.url}`);
6415
- return { status: "merged", url: options.pr.url };
7291
+ const directMergeMessage = `${directMerge.stderr}
7292
+ ${directMerge.stdout}`.trim();
7293
+ throw new Error(`Failed to merge PR ${options.pr.url} in ${options.pr.repoLabel}: ${directMergeMessage}`);
6416
7294
  }
6417
7295
  function assertPrHasNoGitConflicts(prState, repoLabel, baseRef) {
6418
7296
  const mergeable = prState.mergeable.toUpperCase();
@@ -6423,8 +7301,8 @@ function assertPrHasNoGitConflicts(prState, repoLabel, baseRef) {
6423
7301
  }
6424
7302
  function writePrMetadata(projectRoot, taskId, result) {
6425
7303
  const dir = artifactDirForId(projectRoot, taskId);
6426
- mkdirSync10(dir, { recursive: true });
6427
- const path = resolve23(dir, "pr-state.json");
7304
+ mkdirSync11(dir, { recursive: true });
7305
+ const path = resolve24(dir, "pr-state.json");
6428
7306
  let prs = {};
6429
7307
  if (existsSync20(path)) {
6430
7308
  try {
@@ -6444,11 +7322,11 @@ function writePrMetadata(projectRoot, taskId, result) {
6444
7322
  ...primary || {},
6445
7323
  updated_at: nowIso()
6446
7324
  };
6447
- writeFileSync10(path, `${JSON.stringify(artifact, null, 2)}
7325
+ writeFileSync11(path, `${JSON.stringify(artifact, null, 2)}
6448
7326
  `, "utf-8");
6449
7327
  }
6450
7328
  function readPrMetadata(projectRoot, taskId) {
6451
- const path = resolve23(artifactDirForId(projectRoot, taskId), "pr-state.json");
7329
+ const path = resolve24(artifactDirForId(projectRoot, taskId), "pr-state.json");
6452
7330
  if (!existsSync20(path)) {
6453
7331
  return [];
6454
7332
  }
@@ -6525,7 +7403,7 @@ function resolveGithubCliBinary(projectRoot) {
6525
7403
  }
6526
7404
  const explicitPathEntries = (process.env.PATH || "").split(":").map((entry) => entry.trim()).filter(Boolean);
6527
7405
  for (const entry of explicitPathEntries) {
6528
- candidates.add(resolve23(entry, "gh"));
7406
+ candidates.add(resolve24(entry, "gh"));
6529
7407
  }
6530
7408
  const bunResolved = Bun.which("gh");
6531
7409
  if (bunResolved) {
@@ -6562,7 +7440,7 @@ function resolveRepoNameWithOwner(projectRoot, repoRoot) {
6562
7440
  return resolveGithubRepoNameWithOwnerFromGitRoot(projectRoot, repoRoot, repoRoot, visited);
6563
7441
  }
6564
7442
  function resolveGithubRepoNameWithOwnerFromGitRoot(projectRoot, gitRoot, cwd, visited) {
6565
- const normalizedGitRoot = resolve23(gitRoot);
7443
+ const normalizedGitRoot = resolve24(gitRoot);
6566
7444
  if (visited.has(normalizedGitRoot)) {
6567
7445
  return "";
6568
7446
  }
@@ -6634,7 +7512,7 @@ function resolveNetworkRemoteName(projectRoot, repoRoot, repoNameWithOwner) {
6634
7512
  return remotes.includes("origin") ? "origin" : remotes[0];
6635
7513
  }
6636
7514
  function gitQuery(projectRoot, gitRoot, cwd, ...args) {
6637
- const gitArgs = existsSync20(resolve23(gitRoot, ".git")) ? gitCmd(projectRoot, gitRoot, ...args) : [resolveGitBinary(projectRoot), "--git-dir", gitRoot, ...args];
7515
+ const gitArgs = existsSync20(resolve24(gitRoot, ".git")) ? gitCmd(projectRoot, gitRoot, ...args) : [resolveGitBinary(projectRoot), "--git-dir", gitRoot, ...args];
6638
7516
  return runCapture2(gitArgs, cwd, projectRoot);
6639
7517
  }
6640
7518
  function resolveLocalGitRemoteRoot(remoteUrl, gitRoot) {
@@ -6652,7 +7530,7 @@ function resolveLocalGitRemoteRoot(remoteUrl, gitRoot) {
6652
7530
  } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(normalized) || /^[^@]+@[^:]+:.+$/.test(normalized)) {
6653
7531
  return "";
6654
7532
  } else if (!isAbsolute2(normalized)) {
6655
- candidate = resolve23(gitRoot, normalized);
7533
+ candidate = resolve24(gitRoot, normalized);
6656
7534
  }
6657
7535
  return existsSync20(candidate) ? candidate : "";
6658
7536
  }
@@ -6781,7 +7659,7 @@ function inferReviewerFromChangedFiles(projectRoot, repoRoot, baseRef, branchRef
6781
7659
  return best;
6782
7660
  }
6783
7661
  function commitRepo(projectRoot, repo, label, message, allowEmpty, scoped, files, changedFilesManifest) {
6784
- if (!existsSync20(resolve23(repo, ".git"))) {
7662
+ if (!existsSync20(resolve24(repo, ".git"))) {
6785
7663
  console.log(`Skipping ${label}: repo not available (${repo})`);
6786
7664
  return;
6787
7665
  }
@@ -6813,7 +7691,7 @@ function commitRepo(projectRoot, repo, label, message, allowEmpty, scoped, files
6813
7691
  console.log(`Committed ${label}: ${message}`);
6814
7692
  }
6815
7693
  function readChangedFilesManifest(projectRoot, taskId) {
6816
- const manifestPath = resolve23(artifactDirForId(projectRoot, taskId), "changed-files.txt");
7694
+ const manifestPath = resolve24(artifactDirForId(projectRoot, taskId), "changed-files.txt");
6817
7695
  if (!existsSync20(manifestPath)) {
6818
7696
  return [];
6819
7697
  }
@@ -6821,10 +7699,10 @@ function readChangedFilesManifest(projectRoot, taskId) {
6821
7699
  return [...new Set(files)];
6822
7700
  }
6823
7701
  function refreshChangedFilesManifest(projectRoot, taskId) {
6824
- const manifestPath = resolve23(artifactDirForId(projectRoot, taskId), "changed-files.txt");
6825
- mkdirSync10(dirname11(manifestPath), { recursive: true });
7702
+ const manifestPath = resolve24(artifactDirForId(projectRoot, taskId), "changed-files.txt");
7703
+ mkdirSync11(dirname11(manifestPath), { recursive: true });
6826
7704
  const changedFiles = changedFilesForTask(projectRoot, taskId, true);
6827
- writeFileSync10(manifestPath, `${changedFiles.join(`
7705
+ writeFileSync11(manifestPath, `${changedFiles.join(`
6828
7706
  `)}
6829
7707
  `, "utf-8");
6830
7708
  return manifestPath;
@@ -6937,7 +7815,7 @@ function repoHasPathChange(projectRoot, repoRoot, relativePath) {
6937
7815
  return result.exitCode === 0 && result.stdout.trim().length > 0;
6938
7816
  }
6939
7817
  function stageExcludePathspecs(repoRoot) {
6940
- const patterns = existsSync20(resolve23(repoRoot, ".rig", "task-config.json")) ? [...TASK_RUNTIME_STAGE_EXCLUDES, ...GENERATED_STAGE_EXCLUDES] : [".rig/**", ...GENERATED_STAGE_EXCLUDES];
7818
+ const patterns = existsSync20(resolve24(repoRoot, ".rig", "task-config.json")) ? [...TASK_RUNTIME_STAGE_EXCLUDES, ...GENERATED_STAGE_EXCLUDES] : [".rig/**", ...GENERATED_STAGE_EXCLUDES];
6941
7819
  return patterns.map((pattern) => `:(glob,exclude)${pattern}`);
6942
7820
  }
6943
7821
  function pathResolvesBeyondSymlink(repoRoot, relativePath) {
@@ -6947,7 +7825,7 @@ function pathResolvesBeyondSymlink(repoRoot, relativePath) {
6947
7825
  }
6948
7826
  let current = repoRoot;
6949
7827
  for (let index = 0;index < parts.length - 1; index += 1) {
6950
- current = resolve23(current, parts[index]);
7828
+ current = resolve24(current, parts[index]);
6951
7829
  try {
6952
7830
  if (lstatSync(current).isSymbolicLink()) {
6953
7831
  return true;
@@ -7017,11 +7895,11 @@ function runCapture2(command, cwd, projectRoot = cwd) {
7017
7895
  }
7018
7896
  function runtimeGitEnv(projectRoot) {
7019
7897
  const { ctx, runtimeRoot } = resolveRuntimeMetadata(projectRoot);
7020
- const runtimeHome = runtimeRoot ? resolve23(runtimeRoot, "home") : "";
7021
- const runtimeTmp = runtimeRoot ? resolve23(runtimeRoot, "tmp") : "";
7022
- const runtimeCache = runtimeRoot ? resolve23(runtimeRoot, "cache") : "";
7023
- const runtimeKnownHosts = runtimeHome ? resolve23(runtimeHome, ".ssh", "known_hosts") : "";
7024
- const runtimeKey = runtimeHome ? resolve23(runtimeHome, ".ssh", "rig-agent-key") : "";
7898
+ const runtimeHome = runtimeRoot ? resolve24(runtimeRoot, "home") : "";
7899
+ const runtimeTmp = runtimeRoot ? resolve24(runtimeRoot, "tmp") : "";
7900
+ const runtimeCache = runtimeRoot ? resolve24(runtimeRoot, "cache") : "";
7901
+ const runtimeKnownHosts = runtimeHome ? resolve24(runtimeHome, ".ssh", "known_hosts") : "";
7902
+ const runtimeKey = runtimeHome ? resolve24(runtimeHome, ".ssh", "rig-agent-key") : "";
7025
7903
  const env = {};
7026
7904
  if (ctx?.workspaceDir) {
7027
7905
  env.PROJECT_RIG_ROOT = projectRoot;
@@ -7113,7 +7991,7 @@ function loadPersistedRuntimeSecrets(runtimeRoot) {
7113
7991
  if (!runtimeRoot) {
7114
7992
  return {};
7115
7993
  }
7116
- const path = resolve23(runtimeRoot, "runtime-secrets.json");
7994
+ const path = resolve24(runtimeRoot, "runtime-secrets.json");
7117
7995
  if (!existsSync20(path)) {
7118
7996
  return {};
7119
7997
  }
@@ -7126,13 +8004,13 @@ function loadPersistedRuntimeSecrets(runtimeRoot) {
7126
8004
  }
7127
8005
  }
7128
8006
  function ensureRuntimeOpenSslConfig(runtimeHome) {
7129
- const sslDir = resolve23(runtimeHome, ".ssl");
7130
- const sslConfig = resolve23(sslDir, "openssl.cnf");
8007
+ const sslDir = resolve24(runtimeHome, ".ssl");
8008
+ const sslConfig = resolve24(sslDir, "openssl.cnf");
7131
8009
  if (!existsSync20(sslDir)) {
7132
- mkdirSync10(sslDir, { recursive: true });
8010
+ mkdirSync11(sslDir, { recursive: true });
7133
8011
  }
7134
8012
  if (!existsSync20(sslConfig)) {
7135
- writeFileSync10(sslConfig, `# Rig runtime OpenSSL config placeholder
8013
+ writeFileSync11(sslConfig, `# Rig runtime OpenSSL config placeholder
7136
8014
  `);
7137
8015
  }
7138
8016
  return sslConfig;
@@ -7150,7 +8028,7 @@ function resolveRuntimeMetadata(projectRoot) {
7150
8028
  if (contextFile) {
7151
8029
  return {
7152
8030
  ctx,
7153
- runtimeRoot: dirname11(resolve23(contextFile))
8031
+ runtimeRoot: dirname11(resolve24(contextFile))
7154
8032
  };
7155
8033
  }
7156
8034
  const inferredContextFile = findRuntimeContextFile2(projectRoot);
@@ -7166,9 +8044,9 @@ function resolveRuntimeMetadata(projectRoot) {
7166
8044
  return { ctx, runtimeRoot: "" };
7167
8045
  }
7168
8046
  function findRuntimeContextFile2(startPath) {
7169
- let current = resolve23(startPath);
8047
+ let current = resolve24(startPath);
7170
8048
  while (true) {
7171
- const candidate = resolve23(current, "runtime-context.json");
8049
+ const candidate = resolve24(current, "runtime-context.json");
7172
8050
  if (existsSync20(candidate)) {
7173
8051
  return candidate;
7174
8052
  }
@@ -7221,6 +8099,7 @@ async function main() {
7221
8099
  }
7222
8100
  const paths = resolveHarnessPaths(projectRoot);
7223
8101
  let failed = false;
8102
+ let sourceCloseoutAllowed = false;
7224
8103
  console.log(`=== Completion Verification: ${taskId} ===`);
7225
8104
  const scopes = await resolveTaskScopes(projectRoot, taskId);
7226
8105
  const taskChangedFiles = changedFilesForTask(projectRoot, taskId, true);
@@ -7370,12 +8249,35 @@ async function main() {
7370
8249
  console.log("Auto-merge: skipped (no PR metadata found)");
7371
8250
  } else {
7372
8251
  let mergePending = false;
8252
+ let cycle = 0;
7373
8253
  for (const pr of prs) {
8254
+ cycle += 1;
8255
+ const gate = await runStrictPrMergeGate({
8256
+ projectRoot,
8257
+ prUrl: pr.url,
8258
+ taskId,
8259
+ runId: "completion-verification",
8260
+ cycle,
8261
+ final: true,
8262
+ command: async (args, options) => {
8263
+ const result = runCapture(["gh", ...args], options?.cwd ?? projectRoot);
8264
+ return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
8265
+ }
8266
+ });
8267
+ if (!gate.approved) {
8268
+ console.log(`FAIL: Strict merge gate blocked PR (${pr.repoLabel}): ${pr.url}`);
8269
+ for (const reason of gate.reasons) {
8270
+ console.log(`- ${reason}`);
8271
+ }
8272
+ failed = true;
8273
+ continue;
8274
+ }
7374
8275
  const mergeResult = gitMergePr({
7375
8276
  projectRoot,
7376
8277
  pr,
7377
8278
  method: "squash",
7378
- deleteBranch: true
8279
+ deleteBranch: true,
8280
+ strictGateHeadSha: gate.evidence.headSha
7379
8281
  });
7380
8282
  if (mergeResult.status === "auto-merge-enabled") {
7381
8283
  mergePending = true;
@@ -7384,7 +8286,8 @@ async function main() {
7384
8286
  }
7385
8287
  if (mergePending) {
7386
8288
  failed = true;
7387
- } else {
8289
+ } else if (!failed) {
8290
+ sourceCloseoutAllowed = true;
7388
8291
  console.log("OK: Auto-merge complete");
7389
8292
  }
7390
8293
  }
@@ -7397,19 +8300,23 @@ async function main() {
7397
8300
  console.log(`
7398
8301
  [post] Auto-merge: skipped (not in policy completion.checks)`);
7399
8302
  }
7400
- const artifactDir = resolve24(paths.artifactsDir, taskId);
7401
- mkdirSync11(artifactDir, { recursive: true });
7402
- writeFileSync11(resolve24(artifactDir, "review-status.txt"), failed ? `REJECTED
8303
+ const artifactDir = resolve25(paths.artifactsDir, taskId);
8304
+ mkdirSync12(artifactDir, { recursive: true });
8305
+ writeFileSync12(resolve25(artifactDir, "review-status.txt"), failed ? `REJECTED
7403
8306
  ` : `APPROVED
7404
8307
  `, "utf-8");
7405
8308
  if (!failed) {
7406
8309
  await recordTaskRepoCommits(projectRoot, taskId, paths);
7407
- const closeout = await closeCompletedTaskSource(projectRoot, taskId);
7408
- if (!closeout.ok) {
7409
- console.log(`FAIL: ${closeout.message}`);
7410
- failed = true;
8310
+ if (sourceCloseoutAllowed) {
8311
+ const closeout = await closeCompletedTaskSource(projectRoot, taskId);
8312
+ if (!closeout.ok) {
8313
+ console.log(`FAIL: ${closeout.message}`);
8314
+ failed = true;
8315
+ } else {
8316
+ console.log(`OK: ${closeout.message}`);
8317
+ }
7411
8318
  } else {
7412
- console.log(`OK: ${closeout.message}`);
8319
+ console.log("Task source closeout skipped until an approved PR merge completes.");
7413
8320
  }
7414
8321
  }
7415
8322
  if (!failed) {
@@ -7442,7 +8349,7 @@ async function runBunTool(args, cwd) {
7442
8349
  };
7443
8350
  }
7444
8351
  async function runProtoQualityGate(monorepoRoot) {
7445
- const protosDir = resolve24(monorepoRoot, "packages", "protos");
8352
+ const protosDir = resolve25(monorepoRoot, "packages", "protos");
7446
8353
  if (!existsSync21(protosDir)) {
7447
8354
  console.log(`FAIL: Proto workspace not found at ${protosDir}`);
7448
8355
  return false;
@@ -7491,7 +8398,7 @@ async function runProtoQualityGate(monorepoRoot) {
7491
8398
  } else {
7492
8399
  console.log("OK: Generated TypeScript compiles");
7493
8400
  }
7494
- const workflowPath = resolve24(monorepoRoot, ".github", "workflows", "pull-request-gate.yml");
8401
+ const workflowPath = resolve25(monorepoRoot, ".github", "workflows", "pull-request-gate.yml");
7495
8402
  if (!existsSync21(workflowPath)) {
7496
8403
  console.log(`FAIL: Missing workflow gate file at ${workflowPath}`);
7497
8404
  ok = false;
@@ -7536,9 +8443,9 @@ async function readJsonFileIfPresent(path) {
7536
8443
  }
7537
8444
  async function recordVerifierFailure(projectRoot, taskId, paths) {
7538
8445
  const failedApproachesPath = paths.failedApproachesPath;
7539
- const artifactDir = resolve24(paths.artifactsDir, taskId);
7540
- const reviewStatePath = resolve24(artifactDir, "review-state.json");
7541
- const reviewFeedbackPath = resolve24(artifactDir, "review-feedback.md");
8446
+ const artifactDir = resolve25(paths.artifactsDir, taskId);
8447
+ const reviewStatePath = resolve25(artifactDir, "review-state.json");
8448
+ const reviewFeedbackPath = resolve25(artifactDir, "review-feedback.md");
7542
8449
  let summary = "Verifier rejected completion. Read review-feedback.md for required fixes.";
7543
8450
  const parsedReviewState = await readJsonFileIfPresent(reviewStatePath);
7544
8451
  if (parsedReviewState) {
@@ -7552,8 +8459,8 @@ async function recordVerifierFailure(projectRoot, taskId, paths) {
7552
8459
  const content = readFileSync12(failedApproachesPath, "utf-8");
7553
8460
  attempts = (content.match(new RegExp(`^## ${escapeRegExp2(taskId)}\\b`, "gm")) || []).length + 1;
7554
8461
  } else {
7555
- mkdirSync11(resolve24(failedApproachesPath, ".."), { recursive: true });
7556
- writeFileSync11(failedApproachesPath, `# Failed Approaches
8462
+ mkdirSync12(resolve25(failedApproachesPath, ".."), { recursive: true });
8463
+ writeFileSync12(failedApproachesPath, `# Failed Approaches
7557
8464
 
7558
8465
  `, "utf-8");
7559
8466
  }
@@ -7591,8 +8498,8 @@ async function recordTaskRepoCommits(projectRoot, taskId, paths) {
7591
8498
  recorded_at: new Date().toISOString(),
7592
8499
  repos
7593
8500
  };
7594
- mkdirSync11(resolve24(statePath, ".."), { recursive: true });
7595
- writeFileSync11(statePath, `${JSON.stringify(state, null, 2)}
8501
+ mkdirSync12(resolve25(statePath, ".."), { recursive: true });
8502
+ writeFileSync12(statePath, `${JSON.stringify(state, null, 2)}
7596
8503
  `, "utf-8");
7597
8504
  }
7598
8505
  }