pullfrog 0.1.36 → 0.1.38

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.
package/dist/cli.mjs CHANGED
@@ -102064,7 +102064,7 @@ var providers = {
102064
102064
  envVars: ["OPENCODE_API_KEY"],
102065
102065
  models: {
102066
102066
  "glm-5.1": {
102067
- displayName: "GLM 5.1",
102067
+ displayName: "GLM 5.2",
102068
102068
  resolve: "opencode-go/glm-5.2",
102069
102069
  openRouterResolve: "openrouter/z-ai/glm-5.2",
102070
102070
  preferred: true
@@ -103001,7 +103001,7 @@ var import_semver = __toESM(require_semver2(), 1);
103001
103001
  // package.json
103002
103002
  var package_default = {
103003
103003
  name: "pullfrog",
103004
- version: "0.1.36",
103004
+ version: "0.1.38",
103005
103005
  type: "module",
103006
103006
  bin: {
103007
103007
  pullfrog: "dist/cli.mjs",
@@ -104375,6 +104375,7 @@ async function finalizeAgentResult(params) {
104375
104375
  }
104376
104376
  var REFLECTION_SKIP_MODES = /* @__PURE__ */ new Set(["IncrementalReview"]);
104377
104377
  function shouldRunReflection(mode) {
104378
+ if (process.env.PULLFROG_DISABLE_LEARNINGS_REFLECTION === "1") return false;
104378
104379
  if (!mode) return true;
104379
104380
  return !REFLECTION_SKIP_MODES.has(mode);
104380
104381
  }
@@ -151595,6 +151596,7 @@ function initToolState(params) {
151595
151596
  progressComment: resolved,
151596
151597
  hadProgressComment: !!resolved,
151597
151598
  prepushFailureCount: 0,
151599
+ noopReviewSubmissions: 0,
151598
151600
  backgroundProcesses: /* @__PURE__ */ new Map(),
151599
151601
  usageEntries: []
151600
151602
  };
@@ -158383,6 +158385,14 @@ var PushBranch = type({
158383
158385
  "cross-repo runs only: the writable secondary repo whose checkout to push from (bare name, from list_repos). omit for the primary repo."
158384
158386
  )
158385
158387
  });
158388
+ function assertWritableMode(ctx, toolName) {
158389
+ const mode = ctx.toolState?.selectedMode;
158390
+ if (mode && NON_COMMITTING_MODES.has(mode)) {
158391
+ throw new Error(
158392
+ `${toolName} is blocked in ${mode} mode \u2014 review and plan runs must not push or commit code. finish by submitting your review (create_pull_request_review) or plan. if this PR genuinely needs a code change, that is a separate task from reviewing it.`
158393
+ );
158394
+ }
158395
+ }
158386
158396
  function assertPushTarget(ctx, params) {
158387
158397
  const branch = params.branch;
158388
158398
  const pushDest = params.pushDest;
@@ -158507,6 +158517,7 @@ function PushBranchTool(ctx) {
158507
158517
  if (pushPermission === "disabled") {
158508
158518
  throw new Error("Push is disabled. This repository is configured for read-only access.");
158509
158519
  }
158520
+ assertWritableMode(ctx, "push_branch");
158510
158521
  const rc = resolveRepoCtx(ctx, repo);
158511
158522
  if (rc.access === "read") {
158512
158523
  throw new Error(
@@ -158633,6 +158644,7 @@ function CommitChangesTool(ctx) {
158633
158644
  if (pushPermission === "disabled") {
158634
158645
  throw new Error("Push is disabled. This repository is configured for read-only access.");
158635
158646
  }
158647
+ assertWritableMode(ctx, "commit_changes");
158636
158648
  const branch = $2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
158637
158649
  if (branch === "HEAD") {
158638
158650
  throw new Error(
@@ -158971,6 +158983,7 @@ function DeleteBranchTool(ctx) {
158971
158983
  "Branch deletion requires push: enabled permission. Current mode only allows pushing to non-protected branches."
158972
158984
  );
158973
158985
  }
158986
+ assertWritableMode(ctx, "delete_branch");
158974
158987
  rejectSpecialRef(params.branchName, "branchName");
158975
158988
  if (params.branchName === defaultBranch) {
158976
158989
  throw new Error(
@@ -159004,6 +159017,7 @@ function PushTagsTool(ctx) {
159004
159017
  "Tag pushing requires push: enabled permission. Current mode only allows pushing branches."
159005
159018
  );
159006
159019
  }
159020
+ assertWritableMode(ctx, "push_tags");
159007
159021
  validateTagName(params.tag);
159008
159022
  const pushArgs = [...params.force ? ["-f"] : [], "origin", `refs/tags/${params.tag}`];
159009
159023
  await pushWithRetry(pushArgs, ctx.gitToken, ctx.refreshGitToken);
@@ -159033,7 +159047,10 @@ function formatModelLabel(params) {
159033
159047
  modelAliases.find((a) => a.resolve === params.model || a.openRouterResolve === params.model);
159034
159048
  const displayName = alias?.displayName ?? params.model;
159035
159049
  if (params.oss) {
159036
- return `\`${displayName}\` (free via [Pullfrog for OSS](https://pullfrog.com/for-oss))`;
159050
+ const ossBase = `\`${displayName}\` (free via [Pullfrog for OSS](https://pullfrog.com/for-oss))`;
159051
+ if (params.clamped?.reason !== "oss") return ossBase;
159052
+ const configured = isAutoTier(params.clamped.from) ? "the intelligent tier" : `\`${resolveDisplayAlias(params.clamped.from)?.displayName ?? params.clamped.from}\``;
159053
+ return `${ossBase} (${configured} not used \u2014 the program covers this model; add its [provider key](https://docs.pullfrog.com/models) to run your pick)`;
159037
159054
  }
159038
159055
  const base = alias?.isFree ? `\`${displayName}\` (free)` : `\`${displayName}\``;
159039
159056
  if (params.fallbackFrom) {
@@ -159944,12 +159961,23 @@ function duplicateReviewDecision(params) {
159944
159961
  reason: `review ${existing.id} was already submitted in this session; ignoring duplicate call (call \`checkout_pr\` again first if new commits were pushed)`
159945
159962
  };
159946
159963
  }
159964
+ var VERDICT_MARKERS = ["> \u2705", "> \u2139\uFE0F", "> [!IMPORTANT]", "> [!CAUTION]"];
159965
+ var MIN_UNMARKED_BODY_LENGTH = 20;
159966
+ var PLACEHOLDER_PATTERN = /\b(test|testing|placeholder|temp|foo|bar|asdf|dummy|sample)\b/i;
159967
+ var PLACEHOLDER_SCAN_LENGTH = 50;
159968
+ function isDegenerateReviewBody(body) {
159969
+ const trimmed = body.trim();
159970
+ if (VERDICT_MARKERS.some((marker) => trimmed.startsWith(marker))) return false;
159971
+ if (trimmed.length < MIN_UNMARKED_BODY_LENGTH) return true;
159972
+ return trimmed.length < PLACEHOLDER_SCAN_LENGTH && PLACEHOLDER_PATTERN.test(trimmed);
159973
+ }
159974
+ var MAX_CONSECUTIVE_NOOP_SUBMISSIONS = 4;
159947
159975
  function reviewSkipDecision(params) {
159948
159976
  if (params.body || params.hasComments) return null;
159949
159977
  if (!params.approved) {
159950
159978
  return {
159951
159979
  kind: "no-issues",
159952
- reason: params.requestChanges ? "request_changes with no body or comments \u2014 nothing to block on" : "no issues found \u2014 nothing to post"
159980
+ reason: params.requestChanges ? "request_changes with no body or comments \u2014 nothing to block on" : "this call carried neither `body` nor `comments`, so nothing was posted. if you found no issues, you are done \u2014 do not call this tool again. if you meant to submit a review, the `body` parameter was missing from your tool call: resend it with `body` set to the full review text. if a resend drops `body` again, do not keep retrying and do not probe with placeholder text \u2014 write the review into `create_issue_comment` instead, which takes the same text under a smaller schema and is editable after posting. every successful call here posts a permanent, publicly visible review."
159953
159981
  };
159954
159982
  }
159955
159983
  if (!params.prApproveEnabled) {
@@ -159977,9 +160005,15 @@ function formatDroppedCommentsNote(dropped) {
159977
160005
  }
159978
160006
  var CreatePullRequestReview = type({
159979
160007
  pull_number: type.number.describe("The pull request number to review"),
160008
+ // REQUIRED on purpose, not because an empty review is invalid — pass "" for
160009
+ // that. models that emit tool calls without schema-constrained decoding drop
160010
+ // optional parameters and keep required ones; in the incident log every
160011
+ // required parameter survived and this one, when optional, vanished ~15 times
160012
+ // running. required-ness is the only pressure that empirically held.
160013
+ // see wiki/review-approval.md.
159980
160014
  body: type.string.describe(
159981
- "1-2 sentence high-level summary with urgency level, critical callouts, and feedback about code outside the diff. Specific feedback on diff lines goes in 'comments' array."
159982
- ).optional(),
160015
+ `1-2 sentence high-level summary with urgency level, critical callouts, and feedback about code outside the diff. Specific feedback on diff lines goes in 'comments' array. ALWAYS pass this parameter \u2014 pass an empty string "" when approving with no commentary, never omit it.`
160016
+ ),
159983
160017
  approved: type.boolean.describe(
159984
160018
  "Set to true to submit as an approval. Use for `> \u2705 No new issues found.` reviews where the PR is mergeable as-is and nothing in the body warrants code changes \u2014 approving also suppresses the Fix-button footer affordance so users don't dispatch a fix run on non-actionable feedback. Reserve approved: false for `> \u2139\uFE0F ...` (minor suggestions inline), `> [!IMPORTANT]` (recommended changes), and `> [!CAUTION]` (critical) reviews. Defaults to false (comment-only review). Mutually exclusive with request_changes. Approval is REJECTED while any unresolved Pullfrog review thread remains open on the PR (not just the latest commit's diff): resolve the threads the current code addresses (reply + resolve_review_thread) first, or submit a non-approving review if a real issue remains."
159985
160019
  ).optional(),
@@ -160024,6 +160058,11 @@ function CreatePullRequestReviewTool(ctx) {
160024
160058
  );
160025
160059
  }
160026
160060
  if (body) body = fixDoubleEscapedString(body);
160061
+ if (body && isDegenerateReviewBody(body)) {
160062
+ throw new Error(
160063
+ `refusing to submit a review whose body looks like placeholder text: ${JSON.stringify(body)}. every submitted review is permanent and publicly visible on the contributor's PR \u2014 never probe this tool with test content. if the tool has been rejecting your submissions, the cause is your payload, not the tool: re-send with \`body\` set to the full review text, opening with a verdict marker (${VERDICT_MARKERS.join(", ")}).`
160064
+ );
160065
+ }
160027
160066
  const primary = primaryRepoState(ctx.toolState);
160028
160067
  primary.issueNumber = pull_number;
160029
160068
  const dup = duplicateReviewDecision({
@@ -160068,9 +160107,16 @@ function CreatePullRequestReviewTool(ctx) {
160068
160107
  prApproveEnabled: ctx.prApproveEnabled && !selfAuthored
160069
160108
  });
160070
160109
  if (skip) {
160110
+ ctx.toolState.noopReviewSubmissions += 1;
160071
160111
  log.info(`skipping review submission: ${skip.reason}`);
160112
+ if (ctx.toolState.noopReviewSubmissions >= MAX_CONSECUTIVE_NOOP_SUBMISSIONS) {
160113
+ throw new Error(
160114
+ `${ctx.toolState.noopReviewSubmissions} consecutive review submissions posted nothing \u2014 every one carried an empty payload. stop calling this tool: the run will report the review as unsubmitted. if you have review feedback, your tool calls are dropping the \`body\` parameter \u2014 write the review into \`create_issue_comment\` instead, which takes the same text and is recoverable if it goes wrong.`
160115
+ );
160116
+ }
160072
160117
  return { success: true, skipped: true, reason: skip.reason };
160073
160118
  }
160119
+ ctx.toolState.noopReviewSubmissions = 0;
160074
160120
  let event = approved ? "APPROVE" : request_changes ? "REQUEST_CHANGES" : "COMMENT";
160075
160121
  if (event === "APPROVE" || event === "REQUEST_CHANGES") {
160076
160122
  if (!ctx.prApproveEnabled) {
@@ -165901,8 +165947,13 @@ async function resolveProxyModel(ctx) {
165901
165947
  `\xBB no model selected \u2014 using the cost-optimized default (${ctx.proxyModel}); pick a model in your Pullfrog repo settings for stronger reviews.`
165902
165948
  );
165903
165949
  }
165904
- if (!ctx.oss && ctx.payload.model && ctx.proxyModel === DEFAULT_PROXY_MODEL && resolveOpenRouterModel(ctx.payload.model) !== DEFAULT_PROXY_MODEL) {
165905
- if (isCardGatedModel(ctx.payload.model)) {
165950
+ if (ctx.payload.model && ctx.proxyModel === DEFAULT_PROXY_MODEL && resolveOpenRouterModel(ctx.payload.model) !== DEFAULT_PROXY_MODEL) {
165951
+ if (ctx.oss) {
165952
+ ctx.toolState.modelClamped = { from: ctx.payload.model, reason: "oss" };
165953
+ log.info(
165954
+ `\xBB ${ctx.payload.model} overridden \u2014 Pullfrog for OSS covers ${ctx.proxyModel}; add a provider key in your Pullfrog settings to run your configured model.`
165955
+ );
165956
+ } else if (isCardGatedModel(ctx.payload.model)) {
165906
165957
  ctx.toolState.modelClamped = { from: ctx.payload.model, reason: "card" };
165907
165958
  log.warning(
165908
165959
  `\xBB ${ctx.payload.model} needs a card on file \u2014 using the efficient default (${ctx.proxyModel}); add a card in your Pullfrog org billing settings.`
@@ -168423,7 +168474,7 @@ async function runCli4(input) {
168423
168474
  }
168424
168475
 
168425
168476
  // cli.ts
168426
- var VERSION10 = "0.1.36";
168477
+ var VERSION10 = "0.1.38";
168427
168478
  var bin = basename2(process.argv[1] || "");
168428
168479
  var PROG = bin === "pf" || bin === "pullfrog" ? bin : "pullfrog";
168429
168480
  var rawArgs = process.argv.slice(2);
package/dist/index.js CHANGED
@@ -100135,7 +100135,7 @@ var providers = {
100135
100135
  envVars: ["OPENCODE_API_KEY"],
100136
100136
  models: {
100137
100137
  "glm-5.1": {
100138
- displayName: "GLM 5.1",
100138
+ displayName: "GLM 5.2",
100139
100139
  resolve: "opencode-go/glm-5.2",
100140
100140
  openRouterResolve: "openrouter/z-ai/glm-5.2",
100141
100141
  preferred: true
@@ -101072,7 +101072,7 @@ var import_semver = __toESM(require_semver2(), 1);
101072
101072
  // package.json
101073
101073
  var package_default = {
101074
101074
  name: "pullfrog",
101075
- version: "0.1.36",
101075
+ version: "0.1.38",
101076
101076
  type: "module",
101077
101077
  bin: {
101078
101078
  pullfrog: "dist/cli.mjs",
@@ -102446,6 +102446,7 @@ async function finalizeAgentResult(params) {
102446
102446
  }
102447
102447
  var REFLECTION_SKIP_MODES = /* @__PURE__ */ new Set(["IncrementalReview"]);
102448
102448
  function shouldRunReflection(mode) {
102449
+ if (process.env.PULLFROG_DISABLE_LEARNINGS_REFLECTION === "1") return false;
102449
102450
  if (!mode) return true;
102450
102451
  return !REFLECTION_SKIP_MODES.has(mode);
102451
102452
  }
@@ -149708,6 +149709,7 @@ function initToolState(params) {
149708
149709
  progressComment: resolved,
149709
149710
  hadProgressComment: !!resolved,
149710
149711
  prepushFailureCount: 0,
149712
+ noopReviewSubmissions: 0,
149711
149713
  backgroundProcesses: /* @__PURE__ */ new Map(),
149712
149714
  usageEntries: []
149713
149715
  };
@@ -156496,6 +156498,14 @@ var PushBranch = type({
156496
156498
  "cross-repo runs only: the writable secondary repo whose checkout to push from (bare name, from list_repos). omit for the primary repo."
156497
156499
  )
156498
156500
  });
156501
+ function assertWritableMode(ctx, toolName) {
156502
+ const mode = ctx.toolState?.selectedMode;
156503
+ if (mode && NON_COMMITTING_MODES.has(mode)) {
156504
+ throw new Error(
156505
+ `${toolName} is blocked in ${mode} mode \u2014 review and plan runs must not push or commit code. finish by submitting your review (create_pull_request_review) or plan. if this PR genuinely needs a code change, that is a separate task from reviewing it.`
156506
+ );
156507
+ }
156508
+ }
156499
156509
  function assertPushTarget(ctx, params) {
156500
156510
  const branch = params.branch;
156501
156511
  const pushDest = params.pushDest;
@@ -156620,6 +156630,7 @@ function PushBranchTool(ctx) {
156620
156630
  if (pushPermission === "disabled") {
156621
156631
  throw new Error("Push is disabled. This repository is configured for read-only access.");
156622
156632
  }
156633
+ assertWritableMode(ctx, "push_branch");
156623
156634
  const rc = resolveRepoCtx(ctx, repo);
156624
156635
  if (rc.access === "read") {
156625
156636
  throw new Error(
@@ -156746,6 +156757,7 @@ function CommitChangesTool(ctx) {
156746
156757
  if (pushPermission === "disabled") {
156747
156758
  throw new Error("Push is disabled. This repository is configured for read-only access.");
156748
156759
  }
156760
+ assertWritableMode(ctx, "commit_changes");
156749
156761
  const branch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
156750
156762
  if (branch === "HEAD") {
156751
156763
  throw new Error(
@@ -157084,6 +157096,7 @@ function DeleteBranchTool(ctx) {
157084
157096
  "Branch deletion requires push: enabled permission. Current mode only allows pushing to non-protected branches."
157085
157097
  );
157086
157098
  }
157099
+ assertWritableMode(ctx, "delete_branch");
157087
157100
  rejectSpecialRef(params.branchName, "branchName");
157088
157101
  if (params.branchName === defaultBranch) {
157089
157102
  throw new Error(
@@ -157117,6 +157130,7 @@ function PushTagsTool(ctx) {
157117
157130
  "Tag pushing requires push: enabled permission. Current mode only allows pushing branches."
157118
157131
  );
157119
157132
  }
157133
+ assertWritableMode(ctx, "push_tags");
157120
157134
  validateTagName(params.tag);
157121
157135
  const pushArgs = [...params.force ? ["-f"] : [], "origin", `refs/tags/${params.tag}`];
157122
157136
  await pushWithRetry(pushArgs, ctx.gitToken, ctx.refreshGitToken);
@@ -157146,7 +157160,10 @@ function formatModelLabel(params) {
157146
157160
  modelAliases.find((a) => a.resolve === params.model || a.openRouterResolve === params.model);
157147
157161
  const displayName = alias?.displayName ?? params.model;
157148
157162
  if (params.oss) {
157149
- return `\`${displayName}\` (free via [Pullfrog for OSS](https://pullfrog.com/for-oss))`;
157163
+ const ossBase = `\`${displayName}\` (free via [Pullfrog for OSS](https://pullfrog.com/for-oss))`;
157164
+ if (params.clamped?.reason !== "oss") return ossBase;
157165
+ const configured = isAutoTier(params.clamped.from) ? "the intelligent tier" : `\`${resolveDisplayAlias(params.clamped.from)?.displayName ?? params.clamped.from}\``;
157166
+ return `${ossBase} (${configured} not used \u2014 the program covers this model; add its [provider key](https://docs.pullfrog.com/models) to run your pick)`;
157150
157167
  }
157151
157168
  const base = alias?.isFree ? `\`${displayName}\` (free)` : `\`${displayName}\``;
157152
157169
  if (params.fallbackFrom) {
@@ -158057,12 +158074,23 @@ function duplicateReviewDecision(params) {
158057
158074
  reason: `review ${existing.id} was already submitted in this session; ignoring duplicate call (call \`checkout_pr\` again first if new commits were pushed)`
158058
158075
  };
158059
158076
  }
158077
+ var VERDICT_MARKERS = ["> \u2705", "> \u2139\uFE0F", "> [!IMPORTANT]", "> [!CAUTION]"];
158078
+ var MIN_UNMARKED_BODY_LENGTH = 20;
158079
+ var PLACEHOLDER_PATTERN = /\b(test|testing|placeholder|temp|foo|bar|asdf|dummy|sample)\b/i;
158080
+ var PLACEHOLDER_SCAN_LENGTH = 50;
158081
+ function isDegenerateReviewBody(body) {
158082
+ const trimmed = body.trim();
158083
+ if (VERDICT_MARKERS.some((marker) => trimmed.startsWith(marker))) return false;
158084
+ if (trimmed.length < MIN_UNMARKED_BODY_LENGTH) return true;
158085
+ return trimmed.length < PLACEHOLDER_SCAN_LENGTH && PLACEHOLDER_PATTERN.test(trimmed);
158086
+ }
158087
+ var MAX_CONSECUTIVE_NOOP_SUBMISSIONS = 4;
158060
158088
  function reviewSkipDecision(params) {
158061
158089
  if (params.body || params.hasComments) return null;
158062
158090
  if (!params.approved) {
158063
158091
  return {
158064
158092
  kind: "no-issues",
158065
- reason: params.requestChanges ? "request_changes with no body or comments \u2014 nothing to block on" : "no issues found \u2014 nothing to post"
158093
+ reason: params.requestChanges ? "request_changes with no body or comments \u2014 nothing to block on" : "this call carried neither `body` nor `comments`, so nothing was posted. if you found no issues, you are done \u2014 do not call this tool again. if you meant to submit a review, the `body` parameter was missing from your tool call: resend it with `body` set to the full review text. if a resend drops `body` again, do not keep retrying and do not probe with placeholder text \u2014 write the review into `create_issue_comment` instead, which takes the same text under a smaller schema and is editable after posting. every successful call here posts a permanent, publicly visible review."
158066
158094
  };
158067
158095
  }
158068
158096
  if (!params.prApproveEnabled) {
@@ -158090,9 +158118,15 @@ function formatDroppedCommentsNote(dropped) {
158090
158118
  }
158091
158119
  var CreatePullRequestReview = type({
158092
158120
  pull_number: type.number.describe("The pull request number to review"),
158121
+ // REQUIRED on purpose, not because an empty review is invalid — pass "" for
158122
+ // that. models that emit tool calls without schema-constrained decoding drop
158123
+ // optional parameters and keep required ones; in the incident log every
158124
+ // required parameter survived and this one, when optional, vanished ~15 times
158125
+ // running. required-ness is the only pressure that empirically held.
158126
+ // see wiki/review-approval.md.
158093
158127
  body: type.string.describe(
158094
- "1-2 sentence high-level summary with urgency level, critical callouts, and feedback about code outside the diff. Specific feedback on diff lines goes in 'comments' array."
158095
- ).optional(),
158128
+ `1-2 sentence high-level summary with urgency level, critical callouts, and feedback about code outside the diff. Specific feedback on diff lines goes in 'comments' array. ALWAYS pass this parameter \u2014 pass an empty string "" when approving with no commentary, never omit it.`
158129
+ ),
158096
158130
  approved: type.boolean.describe(
158097
158131
  "Set to true to submit as an approval. Use for `> \u2705 No new issues found.` reviews where the PR is mergeable as-is and nothing in the body warrants code changes \u2014 approving also suppresses the Fix-button footer affordance so users don't dispatch a fix run on non-actionable feedback. Reserve approved: false for `> \u2139\uFE0F ...` (minor suggestions inline), `> [!IMPORTANT]` (recommended changes), and `> [!CAUTION]` (critical) reviews. Defaults to false (comment-only review). Mutually exclusive with request_changes. Approval is REJECTED while any unresolved Pullfrog review thread remains open on the PR (not just the latest commit's diff): resolve the threads the current code addresses (reply + resolve_review_thread) first, or submit a non-approving review if a real issue remains."
158098
158132
  ).optional(),
@@ -158137,6 +158171,11 @@ function CreatePullRequestReviewTool(ctx) {
158137
158171
  );
158138
158172
  }
158139
158173
  if (body) body = fixDoubleEscapedString(body);
158174
+ if (body && isDegenerateReviewBody(body)) {
158175
+ throw new Error(
158176
+ `refusing to submit a review whose body looks like placeholder text: ${JSON.stringify(body)}. every submitted review is permanent and publicly visible on the contributor's PR \u2014 never probe this tool with test content. if the tool has been rejecting your submissions, the cause is your payload, not the tool: re-send with \`body\` set to the full review text, opening with a verdict marker (${VERDICT_MARKERS.join(", ")}).`
158177
+ );
158178
+ }
158140
158179
  const primary = primaryRepoState(ctx.toolState);
158141
158180
  primary.issueNumber = pull_number;
158142
158181
  const dup = duplicateReviewDecision({
@@ -158181,9 +158220,16 @@ function CreatePullRequestReviewTool(ctx) {
158181
158220
  prApproveEnabled: ctx.prApproveEnabled && !selfAuthored
158182
158221
  });
158183
158222
  if (skip) {
158223
+ ctx.toolState.noopReviewSubmissions += 1;
158184
158224
  log.info(`skipping review submission: ${skip.reason}`);
158225
+ if (ctx.toolState.noopReviewSubmissions >= MAX_CONSECUTIVE_NOOP_SUBMISSIONS) {
158226
+ throw new Error(
158227
+ `${ctx.toolState.noopReviewSubmissions} consecutive review submissions posted nothing \u2014 every one carried an empty payload. stop calling this tool: the run will report the review as unsubmitted. if you have review feedback, your tool calls are dropping the \`body\` parameter \u2014 write the review into \`create_issue_comment\` instead, which takes the same text and is recoverable if it goes wrong.`
158228
+ );
158229
+ }
158185
158230
  return { success: true, skipped: true, reason: skip.reason };
158186
158231
  }
158232
+ ctx.toolState.noopReviewSubmissions = 0;
158187
158233
  let event = approved ? "APPROVE" : request_changes ? "REQUEST_CHANGES" : "COMMENT";
158188
158234
  if (event === "APPROVE" || event === "REQUEST_CHANGES") {
158189
158235
  if (!ctx.prApproveEnabled) {
@@ -164014,8 +164060,13 @@ async function resolveProxyModel(ctx) {
164014
164060
  `\xBB no model selected \u2014 using the cost-optimized default (${ctx.proxyModel}); pick a model in your Pullfrog repo settings for stronger reviews.`
164015
164061
  );
164016
164062
  }
164017
- if (!ctx.oss && ctx.payload.model && ctx.proxyModel === DEFAULT_PROXY_MODEL && resolveOpenRouterModel(ctx.payload.model) !== DEFAULT_PROXY_MODEL) {
164018
- if (isCardGatedModel(ctx.payload.model)) {
164063
+ if (ctx.payload.model && ctx.proxyModel === DEFAULT_PROXY_MODEL && resolveOpenRouterModel(ctx.payload.model) !== DEFAULT_PROXY_MODEL) {
164064
+ if (ctx.oss) {
164065
+ ctx.toolState.modelClamped = { from: ctx.payload.model, reason: "oss" };
164066
+ log.info(
164067
+ `\xBB ${ctx.payload.model} overridden \u2014 Pullfrog for OSS covers ${ctx.proxyModel}; add a provider key in your Pullfrog settings to run your configured model.`
164068
+ );
164069
+ } else if (isCardGatedModel(ctx.payload.model)) {
164019
164070
  ctx.toolState.modelClamped = { from: ctx.payload.model, reason: "card" };
164020
164071
  log.warning(
164021
164072
  `\xBB ${ctx.payload.model} needs a card on file \u2014 using the efficient default (${ctx.proxyModel}); add a card in your Pullfrog org billing settings.`
package/dist/internal.js CHANGED
@@ -326,7 +326,7 @@ var providers = {
326
326
  envVars: ["OPENCODE_API_KEY"],
327
327
  models: {
328
328
  "glm-5.1": {
329
- displayName: "GLM 5.1",
329
+ displayName: "GLM 5.2",
330
330
  resolve: "opencode-go/glm-5.2",
331
331
  openRouterResolve: "openrouter/z-ai/glm-5.2",
332
332
  preferred: true
@@ -1209,7 +1209,10 @@ function formatModelLabel(params) {
1209
1209
  modelAliases.find((a) => a.resolve === params.model || a.openRouterResolve === params.model);
1210
1210
  const displayName = alias?.displayName ?? params.model;
1211
1211
  if (params.oss) {
1212
- return `\`${displayName}\` (free via [Pullfrog for OSS](https://pullfrog.com/for-oss))`;
1212
+ const ossBase = `\`${displayName}\` (free via [Pullfrog for OSS](https://pullfrog.com/for-oss))`;
1213
+ if (params.clamped?.reason !== "oss") return ossBase;
1214
+ const configured = isAutoTier(params.clamped.from) ? "the intelligent tier" : `\`${resolveDisplayAlias(params.clamped.from)?.displayName ?? params.clamped.from}\``;
1215
+ return `${ossBase} (${configured} not used \u2014 the program covers this model; add its [provider key](https://docs.pullfrog.com/models) to run your pick)`;
1213
1216
  }
1214
1217
  const base = alias?.isFree ? `\`${displayName}\` (free)` : `\`${displayName}\``;
1215
1218
  if (params.fallbackFrom) {
@@ -117,6 +117,31 @@ export declare function duplicateReviewDecision(params: {
117
117
  } | undefined;
118
118
  currentCheckoutSha: string | undefined;
119
119
  }): DuplicateReviewDecision | null;
120
+ /**
121
+ * reject placeholder review bodies at the tool boundary.
122
+ *
123
+ * every successful create_pull_request_review call posts a permanent, publicly
124
+ * visible review that no Pullfrog tool can retract (`edit_issue_comment` 404s
125
+ * on a review id). a weak model that cannot get a real submission through
126
+ * eventually probes the tool with a minimal payload to test whether it works —
127
+ * `test`, `test body`, `placeholder`, `foo` — and that probe lands on a
128
+ * customer's PR. 27 such reviews reached 10 public repos between 2026-06-25 and
129
+ * 2026-07-18. the tool description already says "NEVER submit test or
130
+ * diagnostic reviews"; a prompt instruction is not a control.
131
+ *
132
+ * a body carrying a verdict marker is always accepted — `> ✅ No new issues
133
+ * found.` is a legitimate 24-char review. everything else must clear
134
+ * MIN_UNMARKED_BODY_LENGTH, which every observed placeholder fails and every
135
+ * observed real review passes.
136
+ */
137
+ export declare function isDegenerateReviewBody(body: string): boolean;
138
+ /**
139
+ * consecutive no-op submissions tolerated before the tool stops explaining and
140
+ * starts refusing. kimi-k2.7-code ran ~15 rounds of empty submissions against a
141
+ * misleading skip reason before probing with a placeholder; a bounded budget
142
+ * converts that unbounded loop into a run-visible failure.
143
+ */
144
+ export declare const MAX_CONSECUTIVE_NOOP_SUBMISSIONS = 4;
120
145
  /**
121
146
  * decide whether to skip a review submission before any network call.
122
147
  *
@@ -126,6 +151,15 @@ export declare function duplicateReviewDecision(params: {
126
151
  * 1. `!approved` + empty body/comments: agent's "no issues found" result.
127
152
  * skipping preserves the agent's intent (nothing to post is a fine
128
153
  * outcome for a review run) without a spurious 422.
154
+ * this shape is AMBIGUOUS: it is equally the signature of a weak model
155
+ * that meant to submit a real review but dropped `body` from the tool
156
+ * call. the skip reason must therefore describe the empty PAYLOAD, not
157
+ * assert an empty VERDICT — a reason reading "no issues found" reads as
158
+ * success to the agent, so it retries blind. kimi-k2.7-code looped ~15
159
+ * times against the old wording, then probed the tool with
160
+ * `body: "test"` to check whether it worked at all; that probe posted
161
+ * permanently to a customer PR (software-mansion/TypeGPU#2730), and
162
+ * 26 more like it across 10 repos. see wiki/review-approval.md.
129
163
  * 2. `approved` + `!prApproveEnabled` + empty body/comments: the runtime
130
164
  * downgrades APPROVE to COMMENT when prApproveEnabled is off, and the
131
165
  * resulting empty-COMMENT is exactly the shape GitHub 422s. skipping
@@ -146,7 +180,7 @@ export declare function reviewSkipDecision(params: {
146
180
  export declare function formatDroppedCommentsNote(dropped: DroppedComment[]): string;
147
181
  export declare const CreatePullRequestReview: import("arktype/internal/variants/object.ts").ObjectType<{
148
182
  pull_number: number;
149
- body?: string;
183
+ body: string;
150
184
  approved?: boolean;
151
185
  request_changes?: boolean;
152
186
  commit_id?: string;
@@ -161,7 +195,7 @@ export declare const CreatePullRequestReview: import("arktype/internal/variants/
161
195
  }, {}>;
162
196
  export declare function CreatePullRequestReviewTool(ctx: ToolContext): import("fastmcp").Tool<any, import("@standard-schema/spec").StandardSchemaV1<{
163
197
  pull_number: number;
164
- body?: string;
198
+ body: string;
165
199
  approved?: boolean;
166
200
  request_changes?: boolean;
167
201
  commit_id?: string;
@@ -175,7 +209,7 @@ export declare function CreatePullRequestReviewTool(ctx: ToolContext): import("f
175
209
  }[];
176
210
  }, {
177
211
  pull_number: number;
178
- body?: string;
212
+ body: string;
179
213
  approved?: boolean;
180
214
  request_changes?: boolean;
181
215
  commit_id?: string;
@@ -93,6 +93,7 @@ export interface ToolState {
93
93
  primaryRepoKey: string;
94
94
  selectedMode?: string;
95
95
  prepushFailureCount: number;
96
+ noopReviewSubmissions: number;
96
97
  backgroundProcesses: Map<string, BackgroundProcess>;
97
98
  browserDaemon?: BrowserDaemon | undefined;
98
99
  review?: {
@@ -139,7 +140,7 @@ export interface ToolState {
139
140
  unselectedProxyDefault?: boolean | undefined;
140
141
  modelClamped?: {
141
142
  from: string;
142
- reason: "card" | "noRouterPath";
143
+ reason: "card" | "noRouterPath" | "oss";
143
144
  } | undefined;
144
145
  shaPinned?: boolean | undefined;
145
146
  oss?: boolean | undefined;
@@ -36,7 +36,7 @@ export interface BuildPullfrogFooterParams {
36
36
  */
37
37
  clamped?: {
38
38
  from: string;
39
- reason: "card" | "noRouterPath";
39
+ reason: "card" | "noRouterPath" | "oss";
40
40
  } | undefined;
41
41
  /**
42
42
  * true when the run used the default proxy model only because no model was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pullfrog",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "pullfrog": "dist/cli.mjs",