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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3739,41 +3739,6 @@ function resolveRuntimeSecrets(env, baked = BAKED_RUNTIME_SECRETS) {
3739
3739
  // packages/runtime/src/control-plane/native/git-ops.ts
3740
3740
  import { existsSync as existsSync18, lstatSync, mkdirSync as mkdirSync8, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
3741
3741
  import { dirname as dirname11, isAbsolute as isAbsolute2, resolve as resolve21 } from "path";
3742
- var TASK_ARTIFACT_STAGE_FALLBACK = new Set([
3743
- "changed-files.txt",
3744
- "contract-changes.md",
3745
- "decision-log.md",
3746
- "git-state.txt",
3747
- "next-actions.md",
3748
- "pr-state.json",
3749
- "task-result.json",
3750
- "validation-summary.json"
3751
- ]);
3752
- function readPrMetadata(projectRoot, taskId) {
3753
- const path = resolve21(artifactDirForId(projectRoot, taskId), "pr-state.json");
3754
- if (!existsSync18(path)) {
3755
- return [];
3756
- }
3757
- try {
3758
- const parsed = JSON.parse(readFileSync9(path, "utf-8"));
3759
- if (!parsed || typeof parsed !== "object") {
3760
- return [];
3761
- }
3762
- if (parsed.prs && typeof parsed.prs === "object") {
3763
- return Object.values(parsed.prs).filter(isGitOpenPrResult);
3764
- }
3765
- return isGitOpenPrResult(parsed) ? [parsed] : [];
3766
- } catch {
3767
- return [];
3768
- }
3769
- }
3770
- function isGitOpenPrResult(value) {
3771
- if (!value || typeof value !== "object" || Array.isArray(value)) {
3772
- return false;
3773
- }
3774
- const record = value;
3775
- return typeof record.url === "string" && typeof record.branch === "string" && typeof record.base === "string" && (record.target === "project" || record.target === "monorepo") && typeof record.repoLabel === "string";
3776
- }
3777
3742
 
3778
3743
  // packages/runtime/src/control-plane/native/pr-review-gate.ts
3779
3744
  function parseJsonObject(value) {
@@ -3883,13 +3848,7 @@ function stripHtml(input) {
3883
3848
  }
3884
3849
  function containsBlockerText(input) {
3885
3850
  const text = stripHtml(input).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
3886
- 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);
3887
- }
3888
- function containsGreptileNegativeVerdict(input) {
3889
- const text = stripHtml(input).replace(/\s+/g, " ").trim();
3890
- if (!text)
3891
- return false;
3892
- 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);
3851
+ return /not safe(?: to merge)?|unsafe(?: to merge)?|do not merge|cannot merge|blockers?|must fix|changes requested|please fix|needs? fix|fix this|address this|\breject(?:ed|ion)?\b|\bskip(?:ped)?\b|status\s*:\s*(?:reject(?:ed)?|skip(?:ped)?|failed)/i.test(text);
3893
3852
  }
3894
3853
  function isStrictFiveOfFive(score) {
3895
3854
  return score.value === 5 && score.scale === 5;
@@ -3897,6 +3856,189 @@ function isStrictFiveOfFive(score) {
3897
3856
  function containsConflictingScoreText(input) {
3898
3857
  return parseGreptileScores(input).some((score) => !isStrictFiveOfFive(score));
3899
3858
  }
3859
+ function greptileStatusVerdict(status) {
3860
+ const normalized = String(status ?? "").trim().toUpperCase().replace(/[\s-]+/g, "_");
3861
+ if (!normalized)
3862
+ return null;
3863
+ if (["APPROVE", "APPROVED"].includes(normalized))
3864
+ return "approved";
3865
+ if (["REJECT", "REJECTED", "CHANGES_REQUESTED", "CHANGE_REQUESTED"].includes(normalized))
3866
+ return "rejected";
3867
+ if (["SKIP", "SKIPPED"].includes(normalized))
3868
+ return "skipped";
3869
+ if (["FAIL", "FAILED", "FAILURE", "ERROR"].includes(normalized))
3870
+ return "failed";
3871
+ if (["PENDING", "QUEUED", "IN_PROGRESS", "RUNNING", "STARTED", "REQUESTED", "REVIEWING_FILES", "GENERATING_SUMMARY"].includes(normalized))
3872
+ return "pending";
3873
+ if (["COMPLETE", "COMPLETED"].includes(normalized))
3874
+ return "completed";
3875
+ return null;
3876
+ }
3877
+ function isBlockingGreptileVerdict(verdict) {
3878
+ return verdict === "rejected" || verdict === "skipped" || verdict === "failed";
3879
+ }
3880
+ function greptileRequestTimeoutMs(env) {
3881
+ const fallback = 30000;
3882
+ const parsed = Number.parseInt(env.GREPTILE_REQUEST_TIMEOUT_MS || `${fallback}`, 10);
3883
+ return Number.isFinite(parsed) && parsed >= 1000 ? parsed : fallback;
3884
+ }
3885
+ function normalizeGreptileMcpCodeReview(entry, fallbackId) {
3886
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3887
+ return null;
3888
+ const record = entry;
3889
+ const id = typeof record.id === "string" ? record.id.trim() : fallbackId?.trim() ?? "";
3890
+ if (!id)
3891
+ return null;
3892
+ const metadataRecord = record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? record.metadata : null;
3893
+ return {
3894
+ id,
3895
+ status: typeof record.status === "string" ? record.status : null,
3896
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : null,
3897
+ body: typeof record.body === "string" ? record.body : null,
3898
+ metadata: metadataRecord ? { checkHeadSha: typeof metadataRecord.checkHeadSha === "string" ? metadataRecord.checkHeadSha : null } : null
3899
+ };
3900
+ }
3901
+ function uniqueGreptileCodeReviews(reviews) {
3902
+ const seen = new Set;
3903
+ const unique2 = [];
3904
+ for (const review of reviews) {
3905
+ if (seen.has(review.id))
3906
+ continue;
3907
+ seen.add(review.id);
3908
+ unique2.push(review);
3909
+ }
3910
+ return unique2;
3911
+ }
3912
+ function selectGreptileApiReviewsForGate(reviews, headSha) {
3913
+ const sorted = [...reviews].sort((left, right) => Date.parse(right.createdAt ?? "") - Date.parse(left.createdAt ?? ""));
3914
+ const current = headSha ? sorted.filter((review) => review.metadata?.checkHeadSha === headSha) : [];
3915
+ const untied = sorted.filter((review) => !review.metadata?.checkHeadSha);
3916
+ const latest = sorted.slice(0, 1);
3917
+ return uniqueGreptileCodeReviews([...current, ...untied, ...latest]);
3918
+ }
3919
+ function greptileApiSignalFromCodeReview(review, details) {
3920
+ const selected = details ?? review;
3921
+ return {
3922
+ id: selected.id || review.id,
3923
+ body: selected.body ?? review.body ?? null,
3924
+ reviewedSha: selected.metadata?.checkHeadSha ?? review.metadata?.checkHeadSha ?? null,
3925
+ status: selected.status ?? review.status ?? null
3926
+ };
3927
+ }
3928
+ async function callGreptileMcpToolForGate(input) {
3929
+ const controller = new AbortController;
3930
+ const timeoutId = setTimeout(() => {
3931
+ controller.abort(new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`));
3932
+ }, input.timeoutMs);
3933
+ let response;
3934
+ try {
3935
+ response = await input.fetchFn(input.apiBase, {
3936
+ method: "POST",
3937
+ headers: {
3938
+ Authorization: `Bearer ${input.apiKey}`,
3939
+ "Content-Type": "application/json"
3940
+ },
3941
+ body: JSON.stringify({
3942
+ jsonrpc: "2.0",
3943
+ id: `rig-strict-gate-${input.name}-${Date.now()}`,
3944
+ method: "tools/call",
3945
+ params: { name: input.name, arguments: input.args }
3946
+ }),
3947
+ signal: controller.signal
3948
+ });
3949
+ } catch (error) {
3950
+ if (controller.signal.aborted) {
3951
+ throw controller.signal.reason instanceof Error ? controller.signal.reason : new Error(`Greptile MCP tool ${input.name} timed out after ${input.timeoutMs}ms.`);
3952
+ }
3953
+ throw error;
3954
+ } finally {
3955
+ clearTimeout(timeoutId);
3956
+ }
3957
+ const raw = await response.text();
3958
+ if (!response.ok) {
3959
+ throw new Error(`HTTP ${response.status}: ${raw}`);
3960
+ }
3961
+ let envelope;
3962
+ try {
3963
+ envelope = JSON.parse(raw);
3964
+ } catch {
3965
+ throw new Error(`Malformed MCP response: ${raw}`);
3966
+ }
3967
+ if (envelope.error?.message) {
3968
+ throw new Error(envelope.error.message);
3969
+ }
3970
+ const text = (envelope.result?.content ?? []).filter((item) => item.type === "text" && typeof item.text === "string").map((item) => item.text ?? "").join(`
3971
+ `).trim();
3972
+ if (!text) {
3973
+ throw new Error(`MCP tool ${input.name} returned no text payload.`);
3974
+ }
3975
+ return text;
3976
+ }
3977
+ async function callGreptileMcpToolJsonForGate(input) {
3978
+ const text = await callGreptileMcpToolForGate(input);
3979
+ try {
3980
+ return JSON.parse(text);
3981
+ } catch {
3982
+ throw new Error(`MCP tool ${input.name} returned malformed JSON: ${text}`);
3983
+ }
3984
+ }
3985
+ async function collectConfiguredGreptileApiSignals(input) {
3986
+ if (!input.enabled || input.options?.enabled === false) {
3987
+ return { signals: [], errors: [] };
3988
+ }
3989
+ const env = input.options?.env ?? process.env;
3990
+ const secrets = resolveRuntimeSecrets(env);
3991
+ const apiKey = secrets.GREPTILE_API_KEY?.trim() ?? "";
3992
+ if (!apiKey) {
3993
+ return { signals: [], errors: [] };
3994
+ }
3995
+ const fetchFn = input.options?.fetch ?? globalThis.fetch;
3996
+ if (typeof fetchFn !== "function") {
3997
+ return { signals: [], errors: ["Greptile API/MCP evidence read failed: fetch is not available."] };
3998
+ }
3999
+ const apiBase = secrets.GREPTILE_API_BASE?.trim() || "https://api.greptile.com/mcp";
4000
+ const remote = secrets.GREPTILE_REMOTE?.trim() || "github";
4001
+ const repository = secrets.GREPTILE_REPOSITORY?.trim() || input.repoName;
4002
+ const defaultBranch = secrets.GREPTILE_DEFAULT_BRANCH?.trim() || input.baseRefName?.trim() || "main";
4003
+ const timeoutMs = greptileRequestTimeoutMs(env);
4004
+ try {
4005
+ const listPayload = await callGreptileMcpToolJsonForGate({
4006
+ apiBase,
4007
+ apiKey,
4008
+ name: "list_code_reviews",
4009
+ args: {
4010
+ name: repository,
4011
+ remote,
4012
+ defaultBranch,
4013
+ prNumber: input.prNumber,
4014
+ limit: 20
4015
+ },
4016
+ timeoutMs,
4017
+ fetchFn
4018
+ });
4019
+ const reviews = (listPayload.codeReviews ?? []).map((entry) => normalizeGreptileMcpCodeReview(entry)).filter((review) => !!review);
4020
+ const selectedReviews = selectGreptileApiReviewsForGate(reviews, input.headSha);
4021
+ const signals = [];
4022
+ for (const review of selectedReviews) {
4023
+ const detailsPayload = await callGreptileMcpToolJsonForGate({
4024
+ apiBase,
4025
+ apiKey,
4026
+ name: "get_code_review",
4027
+ args: { codeReviewId: review.id },
4028
+ timeoutMs,
4029
+ fetchFn
4030
+ });
4031
+ const details = normalizeGreptileMcpCodeReview(detailsPayload.codeReview, review.id) ?? review;
4032
+ signals.push(greptileApiSignalFromCodeReview(review, details));
4033
+ }
4034
+ return { signals, errors: [] };
4035
+ } catch (error) {
4036
+ return {
4037
+ signals: [],
4038
+ errors: [`Greptile API/MCP evidence read failed: ${error instanceof Error ? error.message : String(error)}`]
4039
+ };
4040
+ }
4041
+ }
3900
4042
  function firstString(record, keys) {
3901
4043
  for (const key of keys) {
3902
4044
  const value = record[key];
@@ -4023,7 +4165,7 @@ function normalizeReviewThread(entry) {
4023
4165
  function relevantIssueComment(comment) {
4024
4166
  const login = comment.user?.login ?? comment.author?.login ?? "";
4025
4167
  const body = comment.body ?? "";
4026
- 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);
4168
+ return isGreptileGithubLogin(login) || containsBlockerText(body) || /greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(body);
4027
4169
  }
4028
4170
  function latestThreadComment(thread) {
4029
4171
  const nodes = thread.comments?.nodes ?? [];
@@ -4059,7 +4201,8 @@ function makeGreptileSignal(input) {
4059
4201
  const scores = parseGreptileScores(input.body);
4060
4202
  const reviewedSha = input.reviewedSha?.trim() || null;
4061
4203
  const current = reviewedSha ? reviewedSha === input.currentHeadSha : null;
4062
- const blocker = input.blocker ?? (containsBlockerText(input.body) || containsGreptileNegativeVerdict(input.body));
4204
+ const verdict = input.verdict ?? null;
4205
+ const blocker = input.blocker ?? (isBlockingGreptileVerdict(verdict) || containsBlockerText(input.body));
4063
4206
  const explicitApproval = input.explicitApproval ?? false;
4064
4207
  return {
4065
4208
  source: input.source,
@@ -4071,6 +4214,7 @@ function makeGreptileSignal(input) {
4071
4214
  score: scores[0] ?? null,
4072
4215
  scores,
4073
4216
  explicitApproval,
4217
+ verdict,
4074
4218
  blocker,
4075
4219
  actionable: input.actionable ?? blocker,
4076
4220
  bodyExcerpt: bodyExcerpt(input.body),
@@ -4093,9 +4237,9 @@ function collectGreptileSignals(evidence) {
4093
4237
  for (const context of contextSources) {
4094
4238
  if (!context.body.trim())
4095
4239
  continue;
4096
- if (!/greptile|score|confidence|\b\d+\s*\/\s*5\b|blocker|unsafe|not safe|do not merge|changes requested/i.test(context.body))
4097
- continue;
4098
4240
  const contextBlocker = containsBlockerText(context.body);
4241
+ if (!contextBlocker && !/greptile|score|confidence|\b\d+\s*\/\s*5\b/i.test(context.body))
4242
+ continue;
4099
4243
  signals.push(makeGreptileSignal({
4100
4244
  source: context.source,
4101
4245
  body: context.body,
@@ -4108,16 +4252,16 @@ function collectGreptileSignals(evidence) {
4108
4252
  for (const apiSignal of evidence.apiSignals ?? []) {
4109
4253
  const body = [apiSignal.status ? `Status: ${apiSignal.status}` : "", apiSignal.body ?? ""].filter(Boolean).join(`
4110
4254
 
4111
- `);
4112
- if (!body.trim())
4113
- continue;
4255
+ `) || "Status: UNKNOWN";
4256
+ const verdict = greptileStatusVerdict(apiSignal.status);
4114
4257
  signals.push(makeGreptileSignal({
4115
4258
  source: "api",
4116
4259
  body,
4117
4260
  currentHeadSha: evidence.currentHeadSha,
4118
4261
  trusted: true,
4119
4262
  reviewedSha: apiSignal.reviewedSha ?? null,
4120
- explicitApproval: false
4263
+ explicitApproval: verdict === "approved",
4264
+ verdict
4121
4265
  }));
4122
4266
  }
4123
4267
  for (const review of evidence.reviews) {
@@ -4142,20 +4286,6 @@ function collectGreptileSignals(evidence) {
4142
4286
  blocker: state === "CHANGES_REQUESTED" || undefined
4143
4287
  }));
4144
4288
  }
4145
- for (const comment of evidence.changedFileReviewComments) {
4146
- const login = commentAuthorLogin(comment);
4147
- const body = comment.body ?? "";
4148
- if (!body.trim() || !isGreptileGithubLogin(login))
4149
- continue;
4150
- signals.push(makeGreptileSignal({
4151
- source: "changed-file-comment",
4152
- body,
4153
- currentHeadSha: evidence.currentHeadSha,
4154
- trusted: true,
4155
- authorLogin: login,
4156
- reviewedSha: comment.commit_id ?? comment.original_commit_id ?? null
4157
- }));
4158
- }
4159
4289
  for (const comment of evidence.relevantIssueComments) {
4160
4290
  const login = commentAuthorLogin(comment);
4161
4291
  const body = comment.body ?? "";
@@ -4221,6 +4351,9 @@ function unresolvedGreptileThreadSummaries(threads) {
4221
4351
  return [`Unresolved Greptile review thread${path}: ${(latest.body ?? "").trim() || "(empty comment)"}`];
4222
4352
  });
4223
4353
  }
4354
+ function actionableChangedFileCommentSummaries(_comments) {
4355
+ return [];
4356
+ }
4224
4357
  function issueLevelBlockerSummaries(comments) {
4225
4358
  return comments.flatMap((comment) => {
4226
4359
  const body = comment.body?.trim() ?? "";
@@ -4260,14 +4393,21 @@ function deriveGreptileEvidence(input) {
4260
4393
  const isCurrentOrUntied = (signal) => !signal.reviewedSha || signal.reviewedSha === input.currentHeadSha;
4261
4394
  const currentOrUntiedScoreEntries = allScoreEntries.filter((entry) => isCurrentOrUntied(entry.signal));
4262
4395
  const lowScoreEntries = currentOrUntiedScoreEntries.filter((entry) => !isStrictFiveOfFive(entry.score));
4263
- const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && isStrictFiveOfFive(entry.score)) ?? null;
4396
+ const currentPendingApiSignals = trustedSignals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && isCurrentOrUntied(signal));
4397
+ const signalCanApproveByScore = (signal) => {
4398
+ if (signal.source === "api")
4399
+ return signal.verdict === "approved" || signal.verdict === "completed";
4400
+ return signal.verdict !== "pending" && !isBlockingGreptileVerdict(signal.verdict);
4401
+ };
4402
+ const approvingScoreEntry = trustedScoreEntries.find((entry) => entry.signal.reviewedSha === input.currentHeadSha && entry.signal.source !== "github-check" && signalCanApproveByScore(entry.signal) && isStrictFiveOfFive(entry.score)) ?? null;
4403
+ const approvingExplicitSignal = trustedSignals.find((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha && signal.explicitApproval === true && !signal.blocker) ?? null;
4264
4404
  const approvedByScore = !!approvingScoreEntry;
4265
- const approvedByExplicitMapping = false;
4266
- const approvingSignal = approvingScoreEntry?.signal ?? null;
4405
+ const approvedByExplicitMapping = !!approvingExplicitSignal;
4406
+ const approvingSignal = approvingScoreEntry?.signal ?? approvingExplicitSignal;
4267
4407
  const lowestScore = lowScoreEntries.map((entry) => entry.score).sort((left, right) => left.value - right.value)[0] ?? null;
4268
4408
  const score = lowestScore ?? approvingScoreEntry?.score ?? trustedScoreEntries[0]?.score ?? contextScoreEntries[0]?.score ?? null;
4269
4409
  const failedGreptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)) && isFailingCheck(check)).map((check) => `${checkName(check)} (${checkState(check) || "failed"})`);
4270
- const blockerSignals = signals.filter((signal) => signal.source !== "changed-file-comment" && (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
4410
+ const blockerSignals = signals.filter((signal) => (signal.blocker || signal.actionable) && (!signal.reviewedSha || signal.reviewedSha === input.currentHeadSha));
4271
4411
  const staleBlockingSignals = [];
4272
4412
  const blockers = [
4273
4413
  ...blockerSignals.map((signal) => `${signalLabel(signal)}: ${signal.bodyExcerpt || "blocker text"}`),
@@ -4278,7 +4418,8 @@ function deriveGreptileEvidence(input) {
4278
4418
  ...failedGreptileChecks.map((entry) => `Greptile check failed: ${entry}`)
4279
4419
  ];
4280
4420
  const unresolvedComments = [
4281
- ...unresolvedGreptileThreadSummaries(input.reviewThreads)
4421
+ ...unresolvedGreptileThreadSummaries(input.reviewThreads),
4422
+ ...actionableChangedFileCommentSummaries(input.changedFileReviewComments)
4282
4423
  ];
4283
4424
  const greptileChecks = input.checks.filter((check) => isGreptileLabel(checkName(check)));
4284
4425
  const greptileReviews = input.reviews.filter((review) => isGreptileGithubLogin(review.author?.login));
@@ -4291,13 +4432,14 @@ function deriveGreptileEvidence(input) {
4291
4432
  const completedState = ["APPROVED", "COMMENTED", "CHANGES_REQUESTED"].includes(state) || !!review.body?.trim();
4292
4433
  return completedState && review.commit_id === input.currentHeadSha;
4293
4434
  });
4435
+ const completedGreptileApi = trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha && (signal.verdict === "approved" || signal.verdict === "rejected" || signal.verdict === "skipped" || signal.verdict === "failed" || signal.verdict === "completed"));
4294
4436
  const approvalReviewedSha = approvingSignal?.reviewedSha ?? null;
4295
4437
  const reviewedSha = approvalReviewedSha ?? staleSignals[0]?.reviewedSha ?? trustedSignals.map((signal) => signal.reviewedSha ?? null).find(Boolean) ?? null;
4296
4438
  const fresh = !!approvalReviewedSha && approvalReviewedSha === input.currentHeadSha;
4297
- const completed = completedGreptileCheck || completedGreptileReview || !!approvingSignal || trustedSignals.some((signal) => signal.source === "api" && signal.reviewedSha === input.currentHeadSha);
4439
+ const completed = completedGreptileCheck || completedGreptileReview || completedGreptileApi || !!approvingSignal;
4298
4440
  const hasGreptileEvidence = trustedSignals.length > 0 || signals.some((signal) => /greptile/i.test(signal.body));
4299
- const approved = fresh && completed && !blockers.length && !unresolvedComments.length && (approvedByScore || approvedByExplicitMapping);
4300
- const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : "unproven";
4441
+ const approved = fresh && completed && !blockers.length && !unresolvedComments.length && currentPendingApiSignals.length === 0 && (approvedByScore || approvedByExplicitMapping);
4442
+ const mapping = !hasGreptileEvidence ? "missing" : staleSignals.length > 0 && !approvingSignal ? "stale" : approvedByScore ? "score-5-of-5" : approvedByExplicitMapping ? "explicit-approved" : "unproven";
4301
4443
  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";
4302
4444
  return {
4303
4445
  source,
@@ -4404,6 +4546,7 @@ async function collectPrReviewEvidence(input) {
4404
4546
  readErrors.push("gh pr view did not return required reviews array");
4405
4547
  }
4406
4548
  const headSha = firstString(view, ["headRefOid", "headSha", "head_sha"]);
4549
+ const baseRefName = firstString(view, ["baseRefName"]);
4407
4550
  const statusCheckRollup = arrayField(view, "statusCheckRollup").map(normalizeStatusCheck).filter((entry) => !!entry);
4408
4551
  const reviews = arrayField(view, "reviews").map(normalizeReview).filter((entry) => !!entry);
4409
4552
  const reviewCommentsRead = await runJsonArray(input.command, ["api", `repos/${parsed.repoName}/pulls/${parsed.prNumber}/comments`, "--paginate", "--slurp"], input.projectRoot);
@@ -4441,6 +4584,17 @@ async function collectPrReviewEvidence(input) {
4441
4584
  }
4442
4585
  }
4443
4586
  const checksWithGreptileDetails = [...statusCheckRollup, ...greptileCheckDetails];
4587
+ const shouldCollectConfiguredGreptileApi = input.greptileApi?.enabled !== false;
4588
+ const configuredGreptileApiRead = await collectConfiguredGreptileApiSignals({
4589
+ enabled: shouldCollectConfiguredGreptileApi,
4590
+ options: input.greptileApi,
4591
+ repoName: parsed.repoName,
4592
+ prNumber: parsed.prNumber,
4593
+ headSha,
4594
+ baseRefName
4595
+ });
4596
+ readErrors.push(...configuredGreptileApiRead.errors);
4597
+ const apiSignals = [...input.apiSignals ?? [], ...configuredGreptileApiRead.signals];
4444
4598
  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})` : ""}`);
4445
4599
  const pendingChecks = statusCheckRollup.filter((check) => isPendingCheck(check) && (isGreptileLabel(checkName(check)) || !isAllowedFailure(checkName(check), input.allowedFailures ?? []))).map((check) => `Check pending: ${checkName(check)}`);
4446
4600
  const evidenceBase = {
@@ -4452,7 +4606,7 @@ async function collectPrReviewEvidence(input) {
4452
4606
  reviewThreads,
4453
4607
  checks: checksWithGreptileDetails,
4454
4608
  currentHeadSha: headSha,
4455
- apiSignals: input.apiSignals ?? []
4609
+ apiSignals
4456
4610
  };
4457
4611
  const greptile = deriveGreptileEvidence(evidenceBase);
4458
4612
  return {
@@ -4463,7 +4617,7 @@ async function collectPrReviewEvidence(input) {
4463
4617
  body: evidenceBase.body,
4464
4618
  headSha,
4465
4619
  headRefName: firstString(view, ["headRefName"]),
4466
- baseRefName: firstString(view, ["baseRefName"]),
4620
+ baseRefName,
4467
4621
  state: firstString(view, ["state"]),
4468
4622
  isDraft: typeof view.isDraft === "boolean" ? view.isDraft : null,
4469
4623
  mergeable: firstString(view, ["mergeable"]),
@@ -4480,72 +4634,267 @@ async function collectPrReviewEvidence(input) {
4480
4634
  greptile
4481
4635
  };
4482
4636
  }
4637
+ function capGateMessage(value, maxChars = 1200) {
4638
+ const normalized = value.trim();
4639
+ return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}
4640
+ [truncated for gate summary; see full evidence artifact]` : normalized;
4641
+ }
4483
4642
  function evaluateEvidence(evidence) {
4484
- const reasons = [];
4643
+ const reasonDetails = [];
4485
4644
  const warnings = [];
4486
- let pending = false;
4487
- if (evidence.readErrors.length > 0) {
4488
- reasons.push(...evidence.readErrors.map((error) => `Required PR evidence surface could not be read completely: ${error}`));
4489
- }
4490
- if (!evidence.headSha)
4491
- reasons.push("PR head SHA could not be read; current-head Greptile approval cannot be proven.");
4492
- if (evidence.checkFailures.length > 0)
4493
- reasons.push(...evidence.checkFailures);
4494
- if (evidence.pendingChecks.length > 0) {
4495
- pending = true;
4496
- reasons.push(...evidence.pendingChecks);
4645
+ const seen = new Set;
4646
+ const addReason = (reason) => {
4647
+ const capped = { ...reason, message: capGateMessage(reason.message) };
4648
+ const key = `${capped.code}:${capped.message}`;
4649
+ if (seen.has(key))
4650
+ return;
4651
+ seen.add(key);
4652
+ reasonDetails.push(capped);
4653
+ };
4654
+ const greptile = evidence.greptile;
4655
+ const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
4656
+ const hasPendingGreptileCheck = evidence.pendingChecks.some((check) => /greptile/i.test(check));
4657
+ const pendingGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && signal.verdict === "pending" && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
4658
+ const unknownGreptileApiSignals = greptile.signals.filter((signal) => signal.source === "api" && !signal.verdict && (!signal.reviewedSha || signal.reviewedSha === evidence.headSha));
4659
+ const awaitingFreshGreptileProof = hasPendingGreptileCheck || pendingGreptileApiSignals.length > 0 || !greptile.completed || greptile.mapping === "missing" || greptile.mapping === "stale";
4660
+ for (const error of evidence.readErrors) {
4661
+ addReason({
4662
+ code: "read_error",
4663
+ reasonClass: "reject",
4664
+ surface: error.startsWith("Greptile API/MCP") ? "greptile" : "github",
4665
+ suggestedAction: "needs_attention",
4666
+ message: `Required PR evidence surface could not be read completely: ${error}`,
4667
+ headSha: evidence.headSha || null
4668
+ });
4669
+ }
4670
+ if (!evidence.headSha) {
4671
+ addReason({
4672
+ code: "missing_head_sha",
4673
+ reasonClass: "reject",
4674
+ surface: "github",
4675
+ suggestedAction: "needs_attention",
4676
+ message: "PR head SHA could not be read; current-head Greptile approval cannot be proven.",
4677
+ headSha: null
4678
+ });
4679
+ }
4680
+ for (const failure of evidence.checkFailures) {
4681
+ addReason({
4682
+ code: "ci_failed",
4683
+ reasonClass: "reject",
4684
+ surface: "ci",
4685
+ suggestedAction: "fix",
4686
+ message: failure,
4687
+ headSha: evidence.headSha || null
4688
+ });
4689
+ }
4690
+ for (const pendingCheck of evidence.pendingChecks) {
4691
+ addReason({
4692
+ code: "check_pending",
4693
+ reasonClass: "pending",
4694
+ surface: "ci",
4695
+ suggestedAction: "wait",
4696
+ message: pendingCheck,
4697
+ headSha: evidence.headSha || null
4698
+ });
4497
4699
  }
4498
4700
  const reviewDecision = String(evidence.reviewDecision ?? "").toUpperCase();
4499
4701
  if (reviewDecision === "CHANGES_REQUESTED" || reviewDecision === "REVIEW_REQUIRED") {
4500
- reasons.push(`Required review is unresolved (${evidence.reviewDecision}).`);
4702
+ addReason({
4703
+ code: "review_decision_blocking",
4704
+ reasonClass: "reject",
4705
+ surface: "review",
4706
+ suggestedAction: "fix",
4707
+ message: `Required review is unresolved (${evidence.reviewDecision}).`,
4708
+ headSha: evidence.headSha || null
4709
+ });
4710
+ }
4711
+ for (const thread of unresolvedThreadSummaries(evidence.reviewThreads)) {
4712
+ addReason({
4713
+ code: "review_thread_unresolved",
4714
+ reasonClass: "reject",
4715
+ surface: "review",
4716
+ suggestedAction: "fix",
4717
+ message: thread,
4718
+ headSha: evidence.headSha || null
4719
+ });
4720
+ }
4721
+ if (greptile.mapping === "missing") {
4722
+ addReason({
4723
+ code: "greptile_missing",
4724
+ reasonClass: "pending",
4725
+ surface: "greptile",
4726
+ suggestedAction: "wait",
4727
+ message: "Missing Greptile check/review evidence for this PR.",
4728
+ headSha: evidence.headSha || null
4729
+ });
4501
4730
  }
4502
- const unresolvedThreads = unresolvedThreadSummaries(evidence.reviewThreads);
4503
- if (unresolvedThreads.length > 0)
4504
- reasons.push(...unresolvedThreads);
4505
- const greptile = evidence.greptile;
4506
- if (greptile.mapping === "missing")
4507
- reasons.push("Missing Greptile check/review evidence for this PR.");
4508
- const staleSignal = greptile.signals.find((signal) => signal.reviewedSha && signal.reviewedSha !== evidence.headSha);
4509
4731
  if (greptile.mapping === "stale" || greptile.reviewedSha && greptile.reviewedSha !== evidence.headSha || !greptile.approved && staleSignal) {
4510
- reasons.push(`Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`);
4732
+ addReason({
4733
+ code: "greptile_stale",
4734
+ reasonClass: "pending",
4735
+ surface: "greptile",
4736
+ suggestedAction: "wait",
4737
+ message: `Greptile evidence is stale (reviewed ${greptile.reviewedSha ?? staleSignal?.reviewedSha ?? "unknown"}, current ${evidence.headSha || "unknown"}).`,
4738
+ headSha: evidence.headSha || null,
4739
+ reviewedSha: greptile.reviewedSha ?? staleSignal?.reviewedSha ?? null
4740
+ });
4741
+ }
4742
+ for (const signal of pendingGreptileApiSignals) {
4743
+ addReason({
4744
+ code: "greptile_pending",
4745
+ reasonClass: "pending",
4746
+ surface: "greptile",
4747
+ suggestedAction: "wait",
4748
+ message: `Greptile API/MCP review is pending for the current PR head${signal.bodyExcerpt ? `: ${signal.bodyExcerpt}` : "."}`,
4749
+ headSha: evidence.headSha || null,
4750
+ reviewedSha: signal.reviewedSha ?? null
4751
+ });
4752
+ }
4753
+ for (const signal of unknownGreptileApiSignals) {
4754
+ addReason({
4755
+ code: "greptile_api_status_unknown",
4756
+ reasonClass: "reject",
4757
+ surface: "greptile",
4758
+ suggestedAction: "needs_attention",
4759
+ message: `Greptile API/MCP review status is unknown; merge requires a known terminal APPROVED/COMPLETED 5/5 result or a known conservative status${signal.bodyExcerpt ? `: ${signal.bodyExcerpt}` : "."}`,
4760
+ headSha: evidence.headSha || null,
4761
+ reviewedSha: signal.reviewedSha ?? null
4762
+ });
4511
4763
  }
4512
4764
  if (!greptile.completed) {
4513
- pending = true;
4514
- reasons.push("Greptile check/review has not completed for the current PR head.");
4765
+ addReason({
4766
+ code: "greptile_pending",
4767
+ reasonClass: "pending",
4768
+ surface: "greptile",
4769
+ suggestedAction: "wait",
4770
+ message: "Greptile check/review has not completed for the current PR head.",
4771
+ headSha: evidence.headSha || null,
4772
+ reviewedSha: greptile.reviewedSha ?? null
4773
+ });
4774
+ }
4775
+ if (!greptile.fresh) {
4776
+ addReason({
4777
+ code: "greptile_not_current_head",
4778
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
4779
+ surface: "greptile",
4780
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
4781
+ message: "Greptile approval is not tied to the current PR head SHA.",
4782
+ headSha: evidence.headSha || null,
4783
+ reviewedSha: greptile.reviewedSha ?? null
4784
+ });
4515
4785
  }
4516
- if (!greptile.fresh)
4517
- reasons.push("Greptile approval is not tied to the current PR head SHA.");
4518
4786
  if (greptile.score && !(greptile.score.scale === 5 && greptile.score.value === 5)) {
4519
- reasons.push(`Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`);
4787
+ addReason({
4788
+ code: "greptile_score_not_5",
4789
+ reasonClass: "reject",
4790
+ surface: "greptile",
4791
+ suggestedAction: "fix",
4792
+ message: `Greptile score is ${greptile.score.value}/${greptile.score.scale}; strict merge requires trusted current-head 5/5.`,
4793
+ headSha: evidence.headSha || null,
4794
+ reviewedSha: greptile.reviewedSha ?? null
4795
+ });
4520
4796
  }
4521
- if (!greptile.score && greptile.mapping !== "score-5-of-5") {
4522
- reasons.push("No parseable Greptile 5/5 score or explicit approved mapping was found from trusted current-head evidence; merge is blocked.");
4797
+ const hasApprovedMapping = greptile.mapping === "score-5-of-5" || greptile.mapping === "explicit-approved";
4798
+ if (!greptile.score && !hasApprovedMapping) {
4799
+ addReason({
4800
+ code: "greptile_score_missing",
4801
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
4802
+ surface: "greptile",
4803
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
4804
+ message: "No parseable Greptile 5/5 score or direct current-head Greptile API APPROVED mapping was found from trusted evidence; merge is blocked.",
4805
+ headSha: evidence.headSha || null,
4806
+ reviewedSha: greptile.reviewedSha ?? null
4807
+ });
4523
4808
  }
4524
4809
  if (greptile.mapping === "unproven") {
4525
- reasons.push("Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.");
4810
+ addReason({
4811
+ code: "greptile_mapping_unproven",
4812
+ reasonClass: awaitingFreshGreptileProof ? "pending" : "reject",
4813
+ surface: "greptile",
4814
+ suggestedAction: awaitingFreshGreptileProof ? "wait" : "ask_greptile",
4815
+ message: "Greptile approval mapping is unproven; PR body/title or a green check alone cannot approve merge.",
4816
+ headSha: evidence.headSha || null,
4817
+ reviewedSha: greptile.reviewedSha ?? null
4818
+ });
4526
4819
  }
4527
- if (greptile.blockers.length > 0) {
4528
- reasons.push(...greptile.blockers.map((entry) => `Greptile/blocker text: ${entry.trim().slice(0, 500)}`));
4820
+ for (const blocker of greptile.blockers) {
4821
+ addReason({
4822
+ code: "greptile_blocker_text",
4823
+ reasonClass: "reject",
4824
+ surface: "greptile",
4825
+ suggestedAction: "fix",
4826
+ message: `Greptile/blocker text: ${blocker}`,
4827
+ headSha: evidence.headSha || null,
4828
+ reviewedSha: greptile.reviewedSha ?? null
4829
+ });
4830
+ }
4831
+ for (const comment of greptile.unresolvedComments) {
4832
+ addReason({
4833
+ code: "greptile_unresolved_comment",
4834
+ reasonClass: "reject",
4835
+ surface: "greptile",
4836
+ suggestedAction: "fix",
4837
+ message: comment,
4838
+ headSha: evidence.headSha || null,
4839
+ reviewedSha: greptile.reviewedSha ?? null
4840
+ });
4529
4841
  }
4530
- if (greptile.unresolvedComments.length > 0)
4531
- reasons.push(...greptile.unresolvedComments);
4532
4842
  if (!greptile.approved)
4533
4843
  warnings.push(`Greptile approval mapping is ${greptile.mapping}.`);
4534
- return { reasons: Array.from(new Set(reasons)), warnings, pending };
4844
+ const pending = reasonDetails.length > 0 && reasonDetails.every((reason) => reason.reasonClass === "pending");
4845
+ return { reasons: reasonDetails.map((reason) => reason.message), reasonDetails, warnings, pending };
4535
4846
  }
4536
4847
  function evaluateStrictPrMergeGate(evidence) {
4537
4848
  const evaluated = evaluateEvidence(evidence);
4538
- const approved = evaluated.reasons.length === 0 && evidence.greptile.approved;
4849
+ const approved = evaluated.reasonDetails.length === 0 && evidence.greptile.approved;
4539
4850
  return {
4540
4851
  approved,
4541
4852
  pending: evaluated.pending,
4542
4853
  reasons: evaluated.reasons,
4854
+ reasonDetails: evaluated.reasonDetails,
4543
4855
  warnings: evaluated.warnings,
4544
4856
  actionableFeedback: evaluated.reasons,
4545
4857
  evidence
4546
4858
  };
4547
4859
  }
4548
4860
 
4861
+ // packages/runtime/src/control-plane/native/git-ops.ts
4862
+ var TASK_ARTIFACT_STAGE_FALLBACK = new Set([
4863
+ "changed-files.txt",
4864
+ "contract-changes.md",
4865
+ "decision-log.md",
4866
+ "git-state.txt",
4867
+ "next-actions.md",
4868
+ "pr-state.json",
4869
+ "task-result.json",
4870
+ "validation-summary.json"
4871
+ ]);
4872
+ function readPrMetadata(projectRoot, taskId) {
4873
+ const path = resolve21(artifactDirForId(projectRoot, taskId), "pr-state.json");
4874
+ if (!existsSync18(path)) {
4875
+ return [];
4876
+ }
4877
+ try {
4878
+ const parsed = JSON.parse(readFileSync9(path, "utf-8"));
4879
+ if (!parsed || typeof parsed !== "object") {
4880
+ return [];
4881
+ }
4882
+ if (parsed.prs && typeof parsed.prs === "object") {
4883
+ return Object.values(parsed.prs).filter(isGitOpenPrResult);
4884
+ }
4885
+ return isGitOpenPrResult(parsed) ? [parsed] : [];
4886
+ } catch {
4887
+ return [];
4888
+ }
4889
+ }
4890
+ function isGitOpenPrResult(value) {
4891
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
4892
+ return false;
4893
+ }
4894
+ const record = value;
4895
+ return typeof record.url === "string" && typeof record.branch === "string" && typeof record.base === "string" && (record.target === "project" || record.target === "monorepo") && typeof record.repoLabel === "string";
4896
+ }
4897
+
4549
4898
  // packages/runtime/src/control-plane/native/verifier.ts
4550
4899
  async function verifyTask(options) {
4551
4900
  const paths = resolveHarnessPaths(options.projectRoot);
@@ -5343,7 +5692,7 @@ async function runGreptileReviewForPr(options) {
5343
5692
  };
5344
5693
  }
5345
5694
  const blockerScanBody = stripHtml(reviewBody).replace(/\b(?:no|without|zero)\s+blockers?\b/gi, " ").replace(/\bno\s+changes\s+requested\b/gi, " ");
5346
- 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)) {
5695
+ 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|\breject(?:ed|ion)?\b|\bskip(?:ped)?\b|status\s*:\s*(?:reject(?:ed)?|skip(?:ped)?|failed)/i.test(blockerScanBody)) {
5347
5696
  reasons.push(`[AI Review] ${repoName}#${prNumber} summary indicates the PR is not safe to merge.`);
5348
5697
  return {
5349
5698
  verdict: "REJECT",
@@ -5425,6 +5774,7 @@ async function runGreptileReviewForPr(options) {
5425
5774
  approved: strictGate.approved,
5426
5775
  pending: strictGate.pending,
5427
5776
  reasons: strictGate.reasons,
5777
+ reasonDetails: strictGate.reasonDetails,
5428
5778
  warnings: strictGate.warnings,
5429
5779
  greptile: strictGate.evidence.greptile,
5430
5780
  readErrors: strictGate.evidence.readErrors
@@ -5447,6 +5797,7 @@ async function runGreptileReviewForPr(options) {
5447
5797
  approved: strictGate.approved,
5448
5798
  pending: strictGate.pending,
5449
5799
  reasons: strictGate.reasons,
5800
+ reasonDetails: strictGate.reasonDetails,
5450
5801
  warnings: strictGate.warnings,
5451
5802
  greptile: strictGate.evidence.greptile,
5452
5803
  readErrors: strictGate.evidence.readErrors
@@ -5573,6 +5924,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5573
5924
  approved: strictGate.approved,
5574
5925
  pending: strictGate.pending,
5575
5926
  reasons: strictGate.reasons,
5927
+ reasonDetails: strictGate.reasonDetails,
5576
5928
  warnings: strictGate.warnings,
5577
5929
  greptile: strictGate.evidence.greptile
5578
5930
  },
@@ -5595,6 +5947,7 @@ async function runGithubGreptileFallbackReviewForPr(options) {
5595
5947
  approved: strictGate.approved,
5596
5948
  pending: strictGate.pending,
5597
5949
  reasons: strictGate.reasons,
5950
+ reasonDetails: strictGate.reasonDetails,
5598
5951
  warnings: strictGate.warnings,
5599
5952
  greptile: strictGate.evidence.greptile
5600
5953
  },
@@ -5716,8 +6069,7 @@ function shouldContinueGreptileMcpPolling(options) {
5716
6069
  if (options.githubCheckState.completed) {
5717
6070
  return false;
5718
6071
  }
5719
- const hasRemainingBudget = options.attempt + 1 < options.pollAttempts;
5720
- if (!hasRemainingBudget) {
6072
+ if (options.attempt + 1 >= options.pollAttempts) {
5721
6073
  return false;
5722
6074
  }
5723
6075
  if (options.selectedReview && !isGreptileReviewTerminal(options.selectedReview.status)) {
@@ -5727,8 +6079,11 @@ function shouldContinueGreptileMcpPolling(options) {
5727
6079
  }
5728
6080
  function shouldContinueGithubGreptileFallbackPolling(options) {
5729
6081
  const waitingForVisiblePendingReview = options.checkState.pending && (!options.fallbackReview || !options.selectedReview && !options.approvedViaReviewedAncestor);
6082
+ if (options.attempt + 1 >= options.pollAttempts) {
6083
+ return false;
6084
+ }
5730
6085
  if (waitingForVisiblePendingReview) {
5731
- return options.attempt + 1 < options.pollAttempts;
6086
+ return true;
5732
6087
  }
5733
6088
  const reviewNotVisibleYet = !options.fallbackReview && !options.checkState.pending && !options.checkState.completed;
5734
6089
  if (reviewNotVisibleYet) {