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 +60 -9
- package/dist/index.js +59 -8
- package/dist/internal.js +5 -2
- package/dist/mcp/review.d.ts +37 -3
- package/dist/toolState.d.ts +2 -1
- package/dist/utils/buildPullfrogFooter.d.ts +1 -1
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
159982
|
-
)
|
|
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 (
|
|
165905
|
-
if (
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
158095
|
-
)
|
|
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 (
|
|
164018
|
-
if (
|
|
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.
|
|
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
|
-
|
|
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) {
|
package/dist/mcp/review.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
212
|
+
body: string;
|
|
179
213
|
approved?: boolean;
|
|
180
214
|
request_changes?: boolean;
|
|
181
215
|
commit_id?: string;
|
package/dist/toolState.d.ts
CHANGED
|
@@ -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
|