pullfrog 0.1.35 → 0.1.37

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/index.js CHANGED
@@ -27957,7 +27957,7 @@ var require_cross_spawn = __commonJS({
27957
27957
  var cp = __require("child_process");
27958
27958
  var parse5 = require_parse3();
27959
27959
  var enoent = require_enoent();
27960
- function spawn3(command, args2, options) {
27960
+ function spawn4(command, args2, options) {
27961
27961
  const parsed2 = parse5(command, args2, options);
27962
27962
  const spawned = cp.spawn(parsed2.command, parsed2.args, parsed2.options);
27963
27963
  enoent.hookChildProcess(spawned, parsed2);
@@ -27969,8 +27969,8 @@ var require_cross_spawn = __commonJS({
27969
27969
  result.error = result.error || enoent.verifyENOENTSync(result.status, parsed2);
27970
27970
  return result;
27971
27971
  }
27972
- module.exports = spawn3;
27973
- module.exports.spawn = spawn3;
27972
+ module.exports = spawn4;
27973
+ module.exports.spawn = spawn4;
27974
27974
  module.exports.sync = spawnSync6;
27975
27975
  module.exports._parse = parse5;
27976
27976
  module.exports._enoent = enoent;
@@ -95782,14 +95782,14 @@ var require_turndown_cjs = __commonJS({
95782
95782
  } else if (node2.nodeType === 1) {
95783
95783
  replacement = replacementForNode.call(self2, node2);
95784
95784
  }
95785
- return join25(output, replacement);
95785
+ return join26(output, replacement);
95786
95786
  }, "");
95787
95787
  }
95788
95788
  function postProcess(output) {
95789
95789
  var self2 = this;
95790
95790
  this.rules.forEach(function(rule) {
95791
95791
  if (typeof rule.append === "function") {
95792
- output = join25(output, rule.append(self2.options));
95792
+ output = join26(output, rule.append(self2.options));
95793
95793
  }
95794
95794
  });
95795
95795
  return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, "");
@@ -95801,7 +95801,7 @@ var require_turndown_cjs = __commonJS({
95801
95801
  if (whitespace.leading || whitespace.trailing) content = content.trim();
95802
95802
  return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing;
95803
95803
  }
95804
- function join25(output, replacement) {
95804
+ function join26(output, replacement) {
95805
95805
  var s1 = trimTrailingNewlines(output);
95806
95806
  var s2 = trimLeadingNewlines(replacement);
95807
95807
  var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
@@ -99801,7 +99801,7 @@ var init_file_type = __esm({
99801
99801
  // main.ts
99802
99802
  import { existsSync as existsSync8, readdirSync as readdirSync2 } from "node:fs";
99803
99803
  import { readFile as readFile5 } from "node:fs/promises";
99804
- import { join as join24 } from "node:path";
99804
+ import { join as join25 } from "node:path";
99805
99805
 
99806
99806
  // agents/claude.ts
99807
99807
  import { execFileSync as execFileSync2 } from "node:child_process";
@@ -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.35",
101075
+ version: "0.1.37",
101076
101076
  type: "module",
101077
101077
  bin: {
101078
101078
  pullfrog: "dist/cli.mjs",
@@ -101746,8 +101746,9 @@ HARD CONSTRAINTS (non-negotiable, regardless of orchestrator instructions):
101746
101746
  - Your FIRST action MUST source the diff for review. If the orchestrator's dispatch names a diff PATH on disk (e.g. \`diffPath\` / \`incrementalDiffPath\` from a prior \`checkout_pr\` call), \`read\` that path \u2014 do not invoke git at all. The on-disk diff is the authoritative scope, and dispatches almost always include one; recomputing it via git also fails on shallow GitHub Actions checkouts where the base ref may be unfetched. When BOTH a diff path and a base branch appear in your dispatch, path always wins. When the dispatch names an \`incrementalDiffPath\` alongside \`diffPath\`, prefer the incremental path for scope and consult the full diff only for line-number anchoring.
101747
101747
  - If (and only if) NO diff path was provided, the dispatch names a base branch. Run \`git diff --merge-base origin/<base>\` (single MCP call, captures committed + staged + unstaged work, excludes commits landed on \`origin/<base>\` since your branch forked). The read-only \`git\` MCP tool is the right surface for this \u2014 \`--merge-base\` is a flag git accepts directly, so no shell substitution is needed. Do NOT run bare \`git diff origin/<base>\` or two-dot \`git diff origin/<base>..HEAD\`: those are symmetric diffs that include the inverse of every commit on \`<base>\` your branch is behind, which is pure noise (and the git tool will reject those forms when the divergence is detected). Do NOT try to expand \`$(...)\` subshell forms via the git tool \u2014 it runs git directly without shell interpolation. If \`git diff --merge-base origin/<base>\` fails with \`ambiguous argument 'origin/<base>'\` or \`no merge base\`, the runner is a shallow single-branch checkout AND the orchestrator failed to fetch the base ref before dispatching you. Surface that in one line (which ref is missing, and that the orchestrator needs to fetch it with \`git fetch --no-tags --deepen=1000 origin <base>:refs/remotes/origin/<base>\` before re-dispatching) and stop. Do NOT run \`git fetch\` yourself \u2014 your read-only contract below forbids mutating shell, and the \`git_fetch\` MCP tool is state-changing and therefore prohibited. Do NOT call \`checkout_pr\`, do NOT fetch alternative refs, do NOT list branches or all-refs looking for the work, do NOT run \`gh pr list\`. The orchestrator's dispatch is the source of truth for scope.
101748
101748
  - If the on-disk diff path you were given is empty (or unreadable), that is a checkout / formatting failure on the orchestrator side \u2014 reply EXACTLY: \`no changes in dispatched diff \u2014 scope appears empty; orchestrator should verify checkout_pr output\` (naming the path), do NOT fall through to running \`git diff\` against guessed refs. If the merge-base diff (the fallback path) returns empty AND the orchestrator's dispatch claims there are changes to review, the most likely cause is a pre-commit Build-mode self-review: the orchestrator dispatched you before committing AND there are no uncommitted edits either. Reply EXACTLY: \`no changes detected \u2014 likely pre-commit Build self-review; orchestrator should commit then re-dispatch\` and stop. Do NOT guess PR numbers (e.g. by extrapolating from \`git log\` output), do NOT check out other PRs, do NOT fetch from forks. The empty diff is the diagnosis \u2014 surface it; do not work around it.
101749
+ - Once the mandatory first diff read returns a non-empty scope, batch each next dependency layer: emit all independent read-only file reads, greps, globs, and directory listings together in one assistant turn before awaiting their results. Include MCP queries only when their contract explicitly guarantees they are read-only; a \`get_*\` or \`list_*\` name alone is not proof. Keep dependent calls in later turns, after the results they depend on are available.
101749
101750
  - Read-only tools only. Do NOT write or edit files. Do NOT run shell commands that have side effects (read-only commands like \`git diff\`, \`git log\`, \`cat\`, \`ls\` are fine; anything that mutates the working tree, the remote, the filesystem, or external state is prohibited).
101750
- - Do NOT call any state-changing MCP tool. State-changing means: posts a comment, pushes a branch, creates/updates a PR or issue, changes labels, resolves review threads, persists learnings, sets workflow output, installs dependencies, uploads files, kills processes, etc. Read-only MCP queries (\`get_*\`, \`list_*\`, the \`git\` tool for read-only subcommands like \`diff\`/\`log\`/\`merge-base\`, log inspection, diff retrieval) are fine.
101751
+ - Do NOT call any state-changing MCP tool. State-changing means: posts a comment, pushes a branch, creates/updates a PR or issue, changes labels, resolves review threads, persists learnings, sets workflow output, installs dependencies, uploads files, kills processes, etc. MCP tool names are not evidence of safety: only use queries whose contract explicitly guarantees no side effects. The \`git\` tool is fine only for read-only subcommands like \`diff\`/\`log\`/\`merge-base\`.
101751
101752
  - Do NOT spawn further subagents. You are a leaf reviewer; recursive dispatch pre-aggregates findings through an intermediate model and defeats the design.
101752
101753
  - Test for any tool call before invoking it: would this still be a no-op if reverted? If not, do not call it. Apply this test to tools added after this prompt was written \u2014 the rule is the invariant, not the enumeration.
101753
101754
 
@@ -102010,20 +102011,15 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
102010
102011
  - **immediately** call \`${t("resolve_review_thread")}\` with that thread's \`thread=\` value as \`thread_id\`. Resolve every thread where you (a) made the requested code change in full \u2014 partial fixes leave the thread open \u2014 OR (b) replied with a substantive answer the user explicitly asked for. Do NOT resolve threads where you pushed back on the request and the disagreement is unresolved; leave those open for the human to mediate.
102011
102012
  - call \`${t("report_progress")}\` with a brief summary`
102012
102013
  },
102013
- // Review and IncrementalReview use a 0-or-2+ lens pattern. The default is
102014
- // 0 lenses (orchestrator handles the review solo). Multi-lens (2+
102015
- // reviewfrog subagents in parallel) only fires for substantive PRs or
102016
- // high-stakes-subsystem touches — and when it fires, ALL lenses must
102017
- // dispatch in a single assistant turn or the parallelism win disappears.
102018
- // We never dispatch exactly one lens: a single lens is just a worse,
102019
- // slower version of doing the work yourself.
102014
+ // Review and IncrementalReview route the minimum reviewfrog specialists
102015
+ // needed to cover unresolved, disposition-changing hypotheses. Most runs
102016
+ // use zero or one; multiple orthogonal hypotheses dispatch in parallel.
102020
102017
  //
102021
102018
  // Build mode self-review is a different problem shape: the orchestrator
102022
102019
  // wrote the code, so bias-mitigation comes from delegating to one
102023
102020
  // fresh-eyes subagent that doesn't share the implementation context. A
102024
- // single subagent there is appropriate; the 0-or-2+ rule applies only to
102025
- // the Review/IncrementalReview lens fan-out where independence between
102026
- // perspectives is what's being purchased.
102021
+ // single subagent there is appropriate. Review-mode specialist routing
102022
+ // instead scales with the unresolved hypotheses in someone else's diff.
102027
102023
  //
102028
102024
  // Severity categorization is split across two surfaces: the opening
102029
102025
  // callout (CAUTION/IMPORTANT/ℹ️/✅) sets the review's overall tier, and
@@ -102038,11 +102034,11 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
102038
102034
 
102039
102035
  1. **task list**: create your task list for this run as your first action.
102040
102036
 
102041
- 2. **checkout**: call \`${t("checkout_pr")}\` \u2014 this returns PR metadata and a \`diffPath\`. read the diff TOC end-to-end and treat its file line ranges as your coverage checklist.
102037
+ 2. **checkout**: call \`${t("checkout_pr")}\` \u2014 this returns PR metadata, a \`diffPath\`, and a supplemental \`impactPath\` when change-impact extraction is enabled. read the complete raw diff end-to-end, beginning with the TOC and using its file line ranges as your coverage checklist. only after that, use \`impactPath\` as an explicitly incomplete list of reference leads; it never replaces raw-diff reading or establishes coverage.
102042
102038
 
102043
102039
  3. **triage**: orient yourself on the PR \u2014 identify *what kind of thing this is* (domain it touches, seams it crosses, external contracts it depends on, user-facing surfaces it changes). pull as much context as you need to render a confident, well-grounded review: read related files, grep for callers of changed symbols, check tests that exercise the touched paths, fetch related GitHub state. **you are the synthesizer** \u2014 never delegate understanding to subagents.
102044
102040
 
102045
- if the PR is **genuinely trivial**, skip the fan-out entirely and submit a \`No new issues found.\` review per step 7.
102041
+ if the PR is **genuinely trivial**, skip specialists entirely and submit a \`No new issues found.\` review per step 7.
102046
102042
 
102047
102043
  "Genuinely trivial" (skip):
102048
102044
  - single-word doc typo, whitespace/format-only, comment-only across any number of files
@@ -102061,22 +102057,24 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
102061
102057
  - any "typo fix" in user-facing copy that changes meaning ("approved" \u2192 "denied")
102062
102058
  - mixed diffs where a semantic 1-liner is buried in whitespace/formatting changes
102063
102059
 
102064
- 4. **lens decision \u2014 0 or 2+, NEVER 1**.
102060
+ 4. **specialist decision \u2014 minimum hypothesis coverage**.
102065
102061
 
102066
- The default is **0 lenses**: handle the review yourself end-to-end. Most PRs land here.
102062
+ After full-diff coverage and triage, identify the load-bearing questions you still cannot resolve confidently yourself. A specialist hypothesis is load-bearing only when its answer could yield an actionable finding that changes the review disposition and warrants independent investigation, and falsifiable only when the specialist can return evidence that supports or refutes it. Generic requests for extra confidence, polish, or "another look" do not qualify.
102067
102063
 
102068
- Dispatch **2+ \`${REVIEWER_AGENT_NAME}\` lenses in parallel** ONLY when ALL of the following are true:
102069
- - the PR is substantive (>5 files changed AND >200 net lines), OR touches a high-stakes subsystem (auth, billing, payments, schema migration, webhooks, secrets, RBAC, multi-tenant isolation, cron/scheduling)
102070
- - you can name 2+ distinct concrete failure modes that warrant independent lenses (one lens per failure mode; orthogonal, not overlapping)
102071
- - parallel-orchestrated independent perspectives meaningfully outperform what you'd find solo
102064
+ Route the **minimum number of \`${REVIEWER_AGENT_NAME}\` specialists** needed to cover those unresolved hypotheses. Most reviews need **0 or 1**:
102065
+ - dispatch 0 when you can resolve every disposition-changing question directly
102066
+ - dispatch 1 when exactly one falsifiable, load-bearing hypothesis warrants independent investigation
102067
+ - dispatch 2+ in parallel when multiple orthogonal load-bearing hypotheses remain, or when the user explicitly requests an exhaustive or multi-angle review
102072
102068
 
102073
- **NEVER dispatch exactly one lens.** A single lens is just a more expensive version of doing the work yourself with a worse model \u2014 it adds wall time and a context-handoff for no orthogonality benefit. Either you have at least two genuinely independent failure-mode hypotheses (dispatch all in one turn), or you don't (do the review yourself).
102069
+ **There is NO one-specialist cap or fixed maximum.** Cover every orthogonal load-bearing hypothesis that remains; do not collapse multiple real questions into one broad prompt just to reduce the count. There is also no file-count, line-count, schema, quota, or hard-budget threshold \u2014 diff size is not a proxy for review uncertainty.
102074
102070
 
102075
- When you do go multi-lens, lens framings come in two flavors:
102071
+ The primary reviewer remains responsible for reading the complete raw diff, investigating surrounding code, validating every returned finding, and synthesizing the final review. Specialist reads supplement that work; they never replace it or satisfy the primary's diff-coverage obligation.
102072
+
102073
+ Specialist hypotheses can draw on two kinds of framing:
102076
102074
  - **themed lenses** \u2014 a perspective applied across the whole diff (correctness, security, user-journey, performance, etc.).
102077
102075
  - **subsystem lenses** \u2014 a domain-scoped frame for high-stakes subsystems the PR touches (e.g. "the auth lens", "the billing lens", "the schema-migration lens"). **for high-stakes domains, lead with the subsystem lens rather than the generic themed equivalent** \u2014 "billing-subsystem" outperforms "correctness on billing code" because the framing primes the subagent to remember domain-specific failure modes (double-charges, refund races, currency rounding, dispute flows) the generic lens misses.
102078
102076
 
102079
- starter menu (combine, omit, or invent your own):
102077
+ starter menu for identifying hypotheses (combine, omit, or invent your own; do not dispatch a bare menu label without a falsifiable question):
102080
102078
  - **correctness & invariants** \u2014 bugs, races, error handling, edge cases, state-machine boundaries
102081
102079
  - **impact** \u2014 stale references in code/tests/docs/configs/UI after rename/remove
102082
102080
  - **research-validated assumptions** \u2014 third-party API contracts, SDK semantics, framework directives, version-gated behavior. **only pick when the PR's correctness depends on the contract behaving a specific way** \u2014 not when the API is merely used. The bar is "if the third-party contract differs from what the diff assumes, the PR is incorrect." When dispatched, the subagent must verify load-bearing claims via web search and quote source URLs.
@@ -102091,30 +102089,27 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
102091
102089
 
102092
102090
  The only subagent type is \`${REVIEWER_AGENT_NAME}\` \u2014 used for lens judgment work ("is this safe / correct / well-tested?"), runs on a mid-tier model.
102093
102091
 
102094
- 5. **fan out (only if step 4 said 2+ lenses)**: dispatch every \`${REVIEWER_AGENT_NAME}\` subagent for this run **IN A SINGLE ASSISTANT TURN, AS MULTIPLE PARALLEL TASK TOOL_USE BLOCKS IN ONE MESSAGE.**
102095
-
102096
- \u26A0\uFE0F CRITICAL \u2014 PARALLELISM IS THE ONLY REASON LENSES EXIST. \u26A0\uFE0F
102097
- The default tool-call behavior of Claude Code (and most agent runtimes) is **serial dispatch**: emit one Task call, await result, emit next, await, etc. This collapses your fan-out into a sequential review where each lens adds N \xD7 (orchestrator-think-time + lens-execution-time) to wall time. **YOU MUST OVERRIDE THIS DEFAULT.** Emit ALL of your Task tool_use blocks in the SAME assistant message, BEFORE you read ANY result from ANY of them. If you find yourself emitting one Task call, then thinking about the result, then emitting another \u2014 STOP and re-issue them all together. The whole point of going multi-lens is the wall-clock speedup from parallel execution; serial dispatch defeats it entirely.
102092
+ 5. **dispatch specialists (only if step 4 found unresolved hypotheses)**: dispatch one \`${REVIEWER_AGENT_NAME}\` for one hypothesis. For 2+ hypotheses, emit every Task tool_use block **IN A SINGLE ASSISTANT TURN** before reading any result so the investigations run in parallel rather than serially.
102098
102093
 
102099
- \u2705 Right pattern: one assistant turn with N Task tool_use blocks \u2192 wait \u2192 N results arrive together \u2192 aggregate.
102100
- \u274C Wrong pattern: turn 1 = Task(lens A) \u2192 turn 2 (after A's result) = Task(lens B) \u2192 turn 3 (after B's result) = Task(lens C). This is the failure mode. Do not do this.
102094
+ \u2705 Right multi-specialist pattern: one assistant turn with N Task tool_use blocks \u2192 wait \u2192 N results arrive together \u2192 aggregate.
102095
+ \u274C Wrong multi-specialist pattern: Task(hypothesis A) \u2192 wait for A \u2192 Task(hypothesis B).
102101
102096
 
102102
102097
  You can also include your own \`read\` / \`grep\` / \`webfetch\` calls in the SAME turn as the parallel \`${REVIEWER_AGENT_NAME}\` dispatches \u2014 concurrent context-pulling on the orchestrator side runs in parallel with the lens fan-out and costs zero extra wall time.
102103
102098
 
102104
- if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body \u2014 do not skip the fan-out entirely on a single subagent failure. each subagent gets:
102099
+ if a specialist errors out, times out, or returns nothing usable, retry it once with the same hypothesis. if it still fails, attempt to resolve the hypothesis yourself; if it remains disposition-changing and unresolved, surface the limitation and do not approve. each specialist gets:
102105
102100
  - **the absolute \`diffPath\` (and \`incrementalDiffPath\` if available) from step 2's \`${t("checkout_pr")}\` return, named verbatim in the dispatch prompt** (e.g. \`diffPath: /tmp/pullfrog-XXXX/pr-NNN-SHA.diff\`). the reviewer's baked-in system prompt selects its FIRST action on this token \u2014 paraphrasing ("review the diff", "look at this PR") sends it down the \`git diff origin/<base>\` fallback, which fails on shallow GHA checkouts. the subagent \`read\`s those files for scope; it must NOT re-derive the diff via \`git diff\` (bare \`git diff origin/<base>\` is symmetric and pulls in the inverse of any commits that landed on \`<base>\` since the branch forked \u2014 pure noise, and the git tool rejects it). reading and codebase exploration are still its job.
102106
- - **only one lens** \u2014 never a multi-section "review for X, Y, and Z" prompt
102107
- - **a Task \`description\` set to the lens name** (e.g. \`"security"\`, \`"correctness"\`, \`"billing-subsystem"\`) \u2014 the harness reads this field to label the subagent's log lines so parallel runs can be told apart in CI output. without it, every subagent shows up as \`subagent#N\`.
102101
+ - **exactly one falsifiable hypothesis with explicit scope boundaries** \u2014 ask for evidence that supports or refutes it, never a broad "review for X, Y, and Z" prompt
102102
+ - **a Task \`description\` set to a short hypothesis label** (e.g. \`"webhook-replay"\`, \`"billing-rounding"\`) \u2014 the harness reads this field to label the subagent's log lines so parallel runs can be told apart in CI output. without it, every subagent shows up as \`subagent#N\`.
102108
102103
  - if the lens touches external contracts, instruct the subagent to verify load-bearing claims via web search rather than trust training data, and to quote source URLs in its reasoning. action runs are non-interactive \u2014 there's no human in the loop to catch "I'm pretty sure Stripe does X."
102109
- - ask the subagent to report findings with file paths and NEW line numbers from the diff so you can anchor inline comments without re-reading the entire diff.
102104
+ - ask the subagent to report findings with file paths and NEW line numbers from the diff so you can validate and anchor them. you must still read the complete diff yourself.
102110
102105
 
102111
102106
  delegation discipline:
102112
- - do NOT summarize the PR for them (biases toward a validation frame)
102107
+ - do NOT summarize the PR for them (a lossy summary biases toward a validation frame; the raw diff is the source)
102113
102108
  - do NOT hand them a curated reading list (let them discover scope)
102114
102109
  - do NOT pre-shape their output with a finding schema
102115
102110
  - do NOT mention the other lenses (independence is the point \u2014 overlapping findings are a strong signal)
102116
102111
 
102117
- 6. **aggregate & draft**: when the fan-out lands, merge findings; de-dup overlaps (two lenses catching the same issue = higher-confidence signal); trace each finding yourself before accepting it. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the PR (heuristic: if the finding's root cause lives in lines this PR added or modified, it's in scope; otherwise drop unless the PR plausibly introduced or amplified the regression), and anything not actionable. also drop **bloat-shaped findings** \u2014 proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or worse, degrades elegance to nominally improve correctness) makes the codebase worse, not better.
102112
+ 6. **aggregate & draft**: when specialist results land, merge findings; de-dup overlaps (two specialists catching the same issue = higher-confidence signal); trace each finding yourself before accepting it. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the PR (heuristic: if the finding's root cause lives in lines this PR added or modified, it's in scope; otherwise drop unless the PR plausibly introduced or amplified the regression), and anything not actionable. also drop **bloat-shaped findings** \u2014 proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or worse, degrades elegance to nominally improve correctness) makes the codebase worse, not better.
102118
102113
 
102119
102114
  **Hunt for non-anchored concerns before drafting.** After collecting your anchored findings, deliberately scan for concerns that have no specific line to point at \u2014 typically: deletion / cleanup plans for code the diff replaces or shadows; rollout sequencing (what happens to in-flight state during deploy / revert?); coverage gaps the diff implies but doesn't add; scope questions that only the human can answer (e.g. is the legacy path going away or is this a long-term dual track?); architectural risks the diff opens up that aren't a single-line bug. On substantial PRs (migrations, refactors, multi-file rewrites, version bumps that change runtime semantics), at least one such concern almost always exists; if you can't think of any, your bar is probably too high.
102120
102115
 
@@ -102148,10 +102143,10 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
102148
102143
 
102149
102144
  ${PR_SUMMARY_FORMAT}`
102150
102145
  },
102151
- // IncrementalReview shares Review's 0-or-2+ lens pattern AND its body
102152
- // format (PR_SUMMARY_FORMAT), scoped to the incremental delta against the
102153
- // prior pullfrog review. The "issues must be NEW since the last Pullfrog
102154
- // review" filter lives at aggregation time (step 8), NOT in the subagent
102146
+ // IncrementalReview shares Review's minimum hypothesis-covering specialist
102147
+ // routing and body format, scoped to the incremental delta against the
102148
+ // prior Pullfrog review. The "issues must be NEW since the last Pullfrog
102149
+ // review" filter lives at aggregation time, NOT in the subagent
102155
102150
  // prompt — pushing the filter into subagents matches the canonical anneal
102156
102151
  // anti-pattern of "list known pre-existing failures — don't flag these"
102157
102152
  // and suppresses signal on regressions the new commits amplified. A
@@ -102166,9 +102161,9 @@ ${PR_SUMMARY_FORMAT}`
102166
102161
 
102167
102162
  1. **task list**: create your task list for this run as your first action.
102168
102163
 
102169
- 2. **checkout**: call \`${t("checkout_pr")}\` \u2014 this returns PR metadata, \`diffPath\` (full diff), and \`incrementalDiffPath\` (changes since last reviewed version, if available). read the diff TOC first and use its line ranges as your coverage checklist.
102164
+ 2. **checkout**: call \`${t("checkout_pr")}\` \u2014 this returns PR metadata, \`diffPath\` (full diff), \`incrementalDiffPath\` (changes since last reviewed version, if available), and a supplemental \`impactPath\` when change-impact extraction is enabled.
102170
102165
 
102171
- 3. **incremental scope**: if \`incrementalDiffPath\` is present, read it to see what changed since the last review. this is a range-diff that isolates the net changes, filtering out base branch noise. if not present, fall back to reviewing the full PR diff and determine what changed since Pullfrog's most recent review.
102166
+ 3. **incremental scope**: if \`incrementalDiffPath\` is present, read it FIRST to see what changed since the last review. this is a range-diff that isolates the net changes, filtering out base branch noise. then read the authoritative full diff end-to-end, beginning with the TOC and using its line ranges as your coverage checklist. if no incremental diff is present, start with the full-diff TOC, determine what changed since Pullfrog's most recent review, and complete the raw-diff read. only after establishing that authoritative scope and completing raw-diff coverage, use \`impactPath\` as an explicitly incomplete list of reference leads; it never replaces raw-diff reading or establishes coverage.
102172
102167
 
102173
102168
  4. **prior feedback \u2014 read AND retire it**: fetch previous reviews via \`${t("list_pull_request_reviews")}\`, then call \`${t("get_review_comments")}\` on each prior Pullfrog review. Each thread renders as a section whose first line is a fenced tag \`comment author=<login> id=<fullDatabaseId> review=<reviewId> thread=<graphqlId>\`; section headers carry \`[RESOLVED]\` / \`[OUTDATED]\` when relevant. For every **open, Pullfrog-originated** thread, decide and act:
102174
102169
 
@@ -102182,49 +102177,48 @@ ${PR_SUMMARY_FORMAT}`
102182
102177
 
102183
102178
  5. **triage**: orient on the *incremental* changes \u2014 domain, seams, external contracts, user-facing surfaces. pull as much context as you need to render a confident review: read related files, grep for callers of changed symbols, check tests that exercise the touched paths. **you are the synthesizer.**
102184
102179
 
102185
- if the incremental changes are **genuinely trivial**, skip the fan-out entirely and jump to step 10's non-substantive path (do NOT submit a review).
102180
+ if the incremental changes are **genuinely trivial**, skip specialists entirely and jump to step 10's non-substantive path (do NOT submit a review).
102186
102181
 
102187
102182
  "Genuinely trivial" (skip): formatting/comment tweaks, import reordering, lockfile regen, mechanical rename of import paths, whitespace-only.
102188
102183
  "Looks trivial but isn't" (do NOT skip \u2014 same anti-patterns as Review mode): 1-line changes to SQL/regex/auth/billing/permissions/signature-verification code; flipping feature-flag defaults or retry/timeout constants; money/tax/HTTP-method/redirect changes; tightening or loosening a comparison operator; mixed diffs with a semantic line buried in formatting.
102189
102184
  When unsure, treat as non-trivial.
102190
102185
 
102191
- 6. **lens decision \u2014 0 or 2+, NEVER 1**.
102186
+ 6. **specialist decision \u2014 minimum hypothesis coverage**.
102192
102187
 
102193
- The default is **0 lenses**: handle the re-review yourself end-to-end. Most incremental reviews land here \u2014 especially thread-reply re-reviews where the user is asking "did you address X?" rather than "review the diff again."
102188
+ After full-diff coverage and triage, identify the load-bearing questions about the incremental changes that you still cannot resolve confidently yourself. A specialist hypothesis is load-bearing only when its answer could yield an actionable new finding that changes the review disposition and warrants independent investigation, and falsifiable only when the specialist can return evidence that supports or refutes it. Generic requests for extra confidence, polish, or "another look" do not qualify.
102194
102189
 
102195
- Dispatch **2+ \`${REVIEWER_AGENT_NAME}\` lenses in parallel** ONLY when ALL of the following are true:
102196
- - the incremental changes are substantive (>5 files changed AND >200 net new lines), OR touch a high-stakes subsystem (auth, billing, payments, schema migration, webhooks, secrets, RBAC, multi-tenant isolation, cron/scheduling)
102197
- - you can name 2+ distinct concrete failure modes the new commits plausibly introduce that warrant independent lenses
102198
- - parallel-orchestrated independent perspectives meaningfully outperform what you'd find solo
102190
+ Route the **minimum number of \`${REVIEWER_AGENT_NAME}\` specialists** needed to cover those unresolved hypotheses. Most incremental reviews need **0 or 1**, especially thread-reply re-reviews:
102191
+ - dispatch 0 when you can resolve every disposition-changing question directly
102192
+ - dispatch 1 when exactly one falsifiable, load-bearing hypothesis warrants independent investigation
102193
+ - dispatch 2+ in parallel when multiple orthogonal load-bearing hypotheses remain, or when the user explicitly requests an exhaustive or multi-angle review
102199
102194
 
102200
- **NEVER dispatch exactly one lens.** Single-lens dispatch adds wall time and cost for no orthogonality benefit. Either go multi-lens (\u22652 in parallel) or do the re-review yourself.
102195
+ **There is NO one-specialist cap or fixed maximum.** Cover every orthogonal load-bearing hypothesis that remains; do not collapse multiple real questions into one broad prompt just to reduce the count. There is also no file-count, line-count, schema, quota, or hard-budget threshold \u2014 diff size is not a proxy for review uncertainty.
102201
102196
 
102202
- Lens framing follows Review mode: themed lenses (correctness, security, etc.) and subsystem lenses (auth, billing, schema-migration, etc.) \u2014 for high-stakes domains lead with the subsystem lens.
102197
+ The primary reviewer remains responsible for reading the complete raw full diff plus the incremental diff, investigating surrounding code, validating every returned finding, and synthesizing the final review. Specialist reads supplement that work; they never replace it or satisfy the primary's diff-coverage obligation.
102203
102198
 
102204
- 7. **fan out (only if step 6 said 2+ lenses)**: dispatch every \`${REVIEWER_AGENT_NAME}\` subagent for this run **IN A SINGLE ASSISTANT TURN, AS MULTIPLE PARALLEL TASK TOOL_USE BLOCKS IN ONE MESSAGE.**
102199
+ Specialist hypotheses can draw on Review mode's themed or subsystem framings, but every dispatch must turn the framing into one falsifiable question with explicit scope boundaries.
102205
102200
 
102206
- \u26A0\uFE0F CRITICAL \u2014 PARALLELISM IS THE ONLY REASON LENSES EXIST. \u26A0\uFE0F
102207
- Default tool-call behavior is **serial dispatch**: emit one Task call, await result, emit next, await, etc. This collapses your fan-out into a sequential review where each lens adds N \xD7 (orchestrator-think-time + lens-execution-time) to wall time. **YOU MUST OVERRIDE THIS DEFAULT.** Emit ALL of your Task tool_use blocks in the SAME assistant message, BEFORE you read ANY result from ANY of them.
102201
+ 7. **dispatch specialists (only if step 6 found unresolved hypotheses)**: dispatch one \`${REVIEWER_AGENT_NAME}\` for one hypothesis. For 2+ hypotheses, emit every Task tool_use block **IN A SINGLE ASSISTANT TURN** before reading any result so the investigations run in parallel rather than serially.
102208
102202
 
102209
- \u2705 Right pattern: one assistant turn with N Task tool_use blocks \u2192 wait \u2192 N results arrive together \u2192 aggregate.
102210
- \u274C Wrong pattern: turn 1 = Task(lens A) \u2192 turn 2 (after A's result) = Task(lens B). This is the failure mode.
102203
+ \u2705 Right multi-specialist pattern: one assistant turn with N Task tool_use blocks \u2192 wait \u2192 N results arrive together \u2192 aggregate.
102204
+ \u274C Wrong multi-specialist pattern: Task(hypothesis A) \u2192 wait for A \u2192 Task(hypothesis B).
102211
102205
 
102212
102206
  You can also include your own \`read\` / \`grep\` / \`webfetch\` calls in the SAME turn as the parallel \`${REVIEWER_AGENT_NAME}\` dispatches.
102213
102207
 
102214
- if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body. each subagent gets:
102208
+ if a specialist errors out, times out, or returns nothing usable, retry it once with the same hypothesis. if it still fails, attempt to resolve the hypothesis yourself; if it remains disposition-changing and unresolved, surface the limitation and do not approve. each specialist gets:
102215
102209
  - **the absolute diff path(s) from step 2's \`${t("checkout_pr")}\` return, named verbatim in the dispatch prompt.** when \`incrementalDiffPath\` is present, name BOTH (\`incrementalDiffPath: /tmp/.../pr-NNN-SHA-incremental.diff\` then \`diffPath: /tmp/.../pr-NNN-SHA.diff\`) \u2014 the reviewer's baked-in prompt reads incremental first and uses full for context; when only \`diffPath\` exists, name it alone. the subagent \`read\`s those files; it must NOT re-derive via \`git diff\` (bare \`git diff origin/<base>\` is symmetric and pulls in the inverse of base-branch progress \u2014 pure noise, and the git tool rejects it), and paraphrasing ("review the new commits") sends it down that fallback, which also fails on shallow GHA checkouts. do NOT tell them to skip pre-existing issues \u2014 that suppresses regressions the new commits amplified; the "issues must be NEW" filter lives at aggregation time (step 8), not in the subagent prompt.
102216
- - **only one lens** \u2014 never a multi-section "review for X, Y, and Z" prompt
102217
- - **a Task \`description\` set to the lens name** \u2014 the harness reads this field to label log lines so parallel runs can be told apart.
102210
+ - **exactly one falsifiable hypothesis with explicit scope boundaries** \u2014 ask for evidence that supports or refutes it, never a broad "review for X, Y, and Z" prompt
102211
+ - **a Task \`description\` set to a short hypothesis label** \u2014 the harness reads this field to label log lines so parallel runs can be told apart.
102218
102212
  - if the lens touches external contracts, instruct the subagent to verify load-bearing claims via web search and quote source URLs.
102219
- - ask the subagent to report findings with file paths and NEW line numbers from the full PR diff so you can anchor inline comments.
102213
+ - ask the subagent to report findings with file paths and NEW line numbers from the full PR diff so you can validate and anchor them. you must still read the complete incremental and full diff scope yourself.
102220
102214
 
102221
102215
  delegation discipline:
102222
- - do NOT summarize the changes for them (biases toward validation frame)
102216
+ - do NOT summarize the changes for them (a lossy summary biases toward a validation frame; the raw diff is the source)
102223
102217
  - do NOT hand them a curated reading list (let them discover scope)
102224
102218
  - do NOT pre-shape their output with a finding schema
102225
102219
  - do NOT mention the other lenses (independence is the point)
102226
102220
 
102227
- 8. **aggregate, draft, self-critique**: merge findings (yours + any subagent output if you went multi-lens); de-dup overlaps; trace each finding yourself. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the new commits, anything not actionable, and anything that re-states prior review feedback (heuristic: if the finding's root cause lives in lines the *new commits* added or modified, it's in scope; otherwise drop). also drop **bloat-shaped findings** \u2014 proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or degrades elegance to nominally improve correctness) makes the codebase worse, not better. To compute "lines the new commits added or modified": if \`incrementalDiffPath\` from step 2 is present, use it directly. Otherwise, take the prior Pullfrog review's \`commit_id\` (returned alongside each entry from \`${t("list_pull_request_reviews")}\` in step 4) and run \`git diff <prior-review-sha>..HEAD\` to isolate the lines added since that review.
102221
+ 8. **aggregate, draft, self-critique**: merge findings (yours + output from every specialist you dispatched); de-dup overlaps; trace each finding yourself. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the new commits, anything not actionable, and anything that re-states prior review feedback (heuristic: if the finding's root cause lives in lines the *new commits* added or modified, it's in scope; otherwise drop). also drop **bloat-shaped findings** \u2014 proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or degrades elegance to nominally improve correctness) makes the codebase worse, not better. To compute "lines the new commits added or modified": if \`incrementalDiffPath\` from step 2 is present, use it directly. Otherwise, take the prior Pullfrog review's \`commit_id\` (returned alongside each entry from \`${t("list_pull_request_reviews")}\` in step 4) and run \`git diff <prior-review-sha>..HEAD\` to isolate the lines added since that review.
102228
102222
 
102229
102223
  **Hunt for non-anchored concerns before drafting.** After collecting your anchored findings, deliberately scan for concerns that have no specific line to point at \u2014 typically: deletion / cleanup plans for code the new commits replace or shadow; rollout sequencing (what happens to in-flight state during deploy / revert?); coverage gaps the new commits imply but don't add; scope questions that only the human can answer (e.g. is the legacy path going away or is this a long-term dual track?); architectural risks the new commits open up that aren't a single-line bug. On substantial incremental diffs (migrations, refactors, multi-file rewrites, version bumps that change runtime semantics), at least one such concern almost always exists; if you can't think of any, your bar is probably too high.
102230
102224
 
@@ -102321,7 +102315,7 @@ ${PR_SUMMARY_FORMAT}`
102321
102315
 
102322
102316
  1. **task list**: create your task list for this run as your first action.
102323
102317
 
102324
- 2. Analyze the task. For simple operations (labeling, commenting, answering questions, running a single command), handle directly \u2014 but your answer only reaches the user through \`${t("report_progress")}\` (step 4); raw assistant text is discarded.
102318
+ 2. Analyze the task. For simple operations (labeling, answering questions, running a single command), handle directly \u2014 but your answer only reaches the user through \`${t("report_progress")}\` (step 4); raw assistant text is discarded. If a standalone comment on the current issue/PR is the task's sole requested deliverable, create that comment directly and skip \`${t("report_progress")}\`.
102325
102319
 
102326
102320
  3. For substantial work \u2014 code changes across multiple files, multi-step investigations:
102327
102321
  - plan your approach before starting
@@ -102331,8 +102325,8 @@ ${PR_SUMMARY_FORMAT}`
102331
102325
 
102332
102326
  4. Finalize:
102333
102327
  - if code changes were made, get them onto a pull request (new or existing) using ${signedCommits ? `\`${t("commit_changes")}\`` : `\`${t("push_branch")}\``} and \`${t("create_pull_request")}\` as needed. \`git status\` must be clean before you finish (see *SYSTEM* Git rules if this fails).
102334
- - call \`${t("report_progress")}\` once with results \u2014 include exact tool errors if push or PR creation failed
102335
- - if the task involved labeling, commenting, or other GitHub operations, perform those directly`
102328
+ - call \`${t("report_progress")}\` once with results \u2014 include exact tool errors if push or PR creation failed. skip this only when a standalone comment on the current target was the task's sole requested deliverable
102329
+ - if the task involved labeling or other GitHub operations, perform those directly`
102336
102330
  }
102337
102331
  ];
102338
102332
  }
@@ -102887,7 +102881,7 @@ async function runClaude(params) {
102887
102881
  }
102888
102882
  } else if (block.type === "tool_use") {
102889
102883
  const toolName = block.name || "unknown";
102890
- if (params.onToolUse) {
102884
+ if (params.onToolUse && label === ORCHESTRATOR_LABEL) {
102891
102885
  params.onToolUse({
102892
102886
  toolName,
102893
102887
  input: block.input
@@ -103279,7 +103273,7 @@ var claude = agent({
103279
103273
  const cliPath = await installClaudeCli();
103280
103274
  const specifier = ctx.payload.proxyModel ?? ctx.resolvedModel;
103281
103275
  const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
103282
- const isBedrockRoute = specifier !== void 0 && bedrockModelId !== void 0 && bedrockModelId === specifier && isBedrockAnthropicId(specifier);
103276
+ const isBedrockRoute = specifier !== void 0 && bedrockModelId !== void 0 && bedrockModelId === specifier;
103283
103277
  const vertexModelId = process.env[VERTEX_MODEL_ID_ENV]?.trim();
103284
103278
  const isVertexRoute2 = specifier !== void 0 && vertexModelId !== void 0 && vertexModelId === specifier && isVertexAnthropicId(specifier);
103285
103279
  const model = !specifier ? void 0 : isBedrockRoute ? specifier : isVertexRoute2 ? void 0 : stripProviderPrefix(specifier);
@@ -103327,6 +103321,7 @@ var claude = agent({
103327
103321
  ...homeEnv,
103328
103322
  PWD: repoDir
103329
103323
  };
103324
+ env2.CLAUDE_CODE_AUTO_COMPACT_WINDOW ||= "500000";
103330
103325
  if (isBedrockRoute) {
103331
103326
  env2.CLAUDE_CODE_USE_BEDROCK = "1";
103332
103327
  }
@@ -142278,8 +142273,8 @@ function closeBrowserDaemon(toolState) {
142278
142273
 
142279
142274
  // mcp/checkout.ts
142280
142275
  import { createHash as createHash2 } from "node:crypto";
142281
- import { statSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7 } from "node:fs";
142282
- import { join as join12 } from "node:path";
142276
+ import { statSync, unlinkSync as unlinkSync3, writeFileSync as writeFileSync8 } from "node:fs";
142277
+ import { join as join13 } from "node:path";
142283
142278
 
142284
142279
  // node_modules/.pnpm/@ark+schema@0.56.0/node_modules/@ark/schema/out/shared/errors.js
142285
142280
  var ArkError = class _ArkError extends CastableBase {
@@ -149713,6 +149708,7 @@ function initToolState(params) {
149713
149708
  progressComment: resolved,
149714
149709
  hadProgressComment: !!resolved,
149715
149710
  prepushFailureCount: 0,
149711
+ noopReviewSubmissions: 0,
149716
149712
  backgroundProcesses: /* @__PURE__ */ new Map(),
149717
149713
  usageEntries: []
149718
149714
  };
@@ -149748,6 +149744,367 @@ function ensureRepoState(toolState, init) {
149748
149744
  return created;
149749
149745
  }
149750
149746
 
149747
+ // utils/changeImpact.ts
149748
+ import { spawn as spawn2 } from "node:child_process";
149749
+ import { writeFileSync as writeFileSync6 } from "node:fs";
149750
+ import { join as join7 } from "node:path";
149751
+ var MAX_CANDIDATES = 24;
149752
+ var MAX_ATOM_LENGTH = 128;
149753
+ var MAX_ATOMS = 12;
149754
+ var MAX_REFERENCES = 6;
149755
+ var MAX_EXCERPT = 160;
149756
+ var MAX_BYTES = 16e3;
149757
+ function isSymbolShaped(atom) {
149758
+ return atom.length >= 4 && atom.length <= MAX_ATOM_LENGTH && /[A-Z_]/.test(atom);
149759
+ }
149760
+ function getAtomConfidence(params) {
149761
+ const before = params.text.slice(Math.max(0, params.index - 64), params.index);
149762
+ const after = params.text.slice(
149763
+ params.index + params.atom.length,
149764
+ params.index + params.atom.length + 16
149765
+ );
149766
+ if (/\b(?:class|interface|type|enum|function|def|func|fn|struct|trait)\s+$/.test(before))
149767
+ return 4;
149768
+ if (/\b(?:const|let|var)\s+$/.test(before) || /^\s*\??\s*[:(]/.test(after)) return 3;
149769
+ const quoted = /['"`]$/.test(before) && /^['"`]/.test(after);
149770
+ if (quoted || before.endsWith(".") || after.startsWith(".") || params.atom.includes("_"))
149771
+ return 2;
149772
+ return 1;
149773
+ }
149774
+ function getChangedAtoms(text) {
149775
+ const atoms = /* @__PURE__ */ new Map();
149776
+ for (const match3 of text.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)) {
149777
+ const atom = match3[0];
149778
+ if (!isSymbolShaped(atom)) continue;
149779
+ const confidence = getAtomConfidence({ text, atom, index: match3.index ?? 0 });
149780
+ atoms.set(atom, Math.max(atoms.get(atom) ?? 0, confidence));
149781
+ }
149782
+ return atoms;
149783
+ }
149784
+ function recordChange(params) {
149785
+ for (const [atom, confidence] of getChangedAtoms(params.text)) {
149786
+ const change = params.changes.get(atom) ?? { atom, confidence, added: [], removed: [] };
149787
+ change.confidence = Math.max(change.confidence, confidence);
149788
+ change[params.location.kind].push(params.location);
149789
+ params.changes.set(atom, change);
149790
+ }
149791
+ }
149792
+ function locationKey(location) {
149793
+ return `${location.path}\0${location.line}`;
149794
+ }
149795
+ function collectFileChanges(collection, file2) {
149796
+ if (!file2.patch) return;
149797
+ let oldLine = 0;
149798
+ let newLine = 0;
149799
+ for (const patchLine of file2.patch.split("\n")) {
149800
+ const hunk = patchLine.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
149801
+ if (hunk) {
149802
+ oldLine = Number.parseInt(hunk[1], 10);
149803
+ newLine = Number.parseInt(hunk[2], 10);
149804
+ } else if (patchLine.startsWith("+")) {
149805
+ const location = { path: file2.filename, line: newLine++, kind: "added" };
149806
+ collection.addedLines.add(locationKey(location));
149807
+ recordChange({ changes: collection.changes, location, text: patchLine.slice(1) });
149808
+ } else if (patchLine.startsWith("-")) {
149809
+ const oldPath = file2.previous_filename ?? file2.filename;
149810
+ recordChange({
149811
+ changes: collection.changes,
149812
+ location: { path: oldPath, line: oldLine++, kind: "removed" },
149813
+ text: patchLine.slice(1)
149814
+ });
149815
+ } else if (!patchLine.startsWith("\\")) {
149816
+ oldLine++;
149817
+ newLine++;
149818
+ }
149819
+ }
149820
+ }
149821
+ function compareChanges(a, b) {
149822
+ if (a.confidence !== b.confidence) return b.confidence - a.confidence;
149823
+ const removedDelta = Number(b.removed.length > 0) - Number(a.removed.length > 0);
149824
+ if (removedDelta !== 0) return removedDelta;
149825
+ const aDelta = Math.abs(a.added.length - a.removed.length);
149826
+ const bDelta = Math.abs(b.added.length - b.removed.length);
149827
+ if (aDelta !== bDelta) return bDelta - aDelta;
149828
+ if (a.atom.length !== b.atom.length) return b.atom.length - a.atom.length;
149829
+ return a.atom < b.atom ? -1 : Number(a.atom > b.atom);
149830
+ }
149831
+ function getCandidates(files) {
149832
+ const collection = {
149833
+ changes: /* @__PURE__ */ new Map(),
149834
+ addedLines: /* @__PURE__ */ new Set()
149835
+ };
149836
+ for (const file2 of files) collectFileChanges(collection, file2);
149837
+ const candidates = [...collection.changes.values()].filter((change) => change.added.length !== change.removed.length).sort(compareChanges);
149838
+ return {
149839
+ selected: candidates.slice(0, MAX_CANDIDATES),
149840
+ total: candidates.length,
149841
+ addedLines: collection.addedLines
149842
+ };
149843
+ }
149844
+ function emptyBuckets(candidates) {
149845
+ return new Map(candidates.map((candidate) => [candidate.atom, { count: 0, samples: [] }]));
149846
+ }
149847
+ function createGrepRecordState() {
149848
+ return {
149849
+ phase: "path",
149850
+ path: "",
149851
+ line: "",
149852
+ sample: "",
149853
+ truncated: false,
149854
+ searchTail: "",
149855
+ searchStart: 0,
149856
+ matches: /* @__PURE__ */ new Set()
149857
+ };
149858
+ }
149859
+ function resetGrepRecord(state) {
149860
+ state.phase = "path";
149861
+ state.path = "";
149862
+ state.line = "";
149863
+ state.sample = "";
149864
+ state.truncated = false;
149865
+ state.searchTail = "";
149866
+ state.searchStart = 0;
149867
+ state.matches.clear();
149868
+ }
149869
+ function isWordCharacter(value2) {
149870
+ return value2 !== void 0 && /[A-Za-z0-9_]/.test(value2);
149871
+ }
149872
+ function recordCandidateMatches(params) {
149873
+ for (const candidate of params.candidates) {
149874
+ if (params.matches.has(candidate.atom)) continue;
149875
+ let index = params.text.indexOf(candidate.atom, params.minStart);
149876
+ while (index >= 0 && index < params.maxStart) {
149877
+ const before = params.text[index - 1];
149878
+ const after = params.text[index + candidate.atom.length];
149879
+ if (!isWordCharacter(before) && !isWordCharacter(after)) {
149880
+ params.matches.add(candidate.atom);
149881
+ break;
149882
+ }
149883
+ index = params.text.indexOf(candidate.atom, index + 1);
149884
+ }
149885
+ }
149886
+ }
149887
+ function consumeReferenceText(params) {
149888
+ const remaining = MAX_EXCERPT - params.state.sample.length;
149889
+ params.state.sample += params.text.slice(0, Math.max(remaining, 0));
149890
+ if (params.text.length > remaining) params.state.truncated = true;
149891
+ const searchable = params.state.searchTail + params.text;
149892
+ const consumed = params.final ? searchable.length : Math.max(0, searchable.length - params.maxAtomLength - 2);
149893
+ recordCandidateMatches({
149894
+ text: searchable,
149895
+ minStart: params.state.searchStart,
149896
+ maxStart: consumed,
149897
+ candidates: params.candidates,
149898
+ matches: params.state.matches
149899
+ });
149900
+ if (params.final) {
149901
+ params.state.searchTail = "";
149902
+ params.state.searchStart = 0;
149903
+ return;
149904
+ }
149905
+ const tailStart = Math.max(0, consumed - 1);
149906
+ params.state.searchTail = searchable.slice(tailStart);
149907
+ params.state.searchStart = consumed > 0 ? 1 : params.state.searchStart;
149908
+ }
149909
+ function recordGrepReference(params) {
149910
+ const prefix = `${params.treeish}:`;
149911
+ const path4 = params.state.path.startsWith(prefix) ? params.state.path.slice(prefix.length) : params.state.path;
149912
+ const line = Number.parseInt(params.state.line, 10);
149913
+ if (!Number.isFinite(line)) return;
149914
+ const reference2 = {
149915
+ path: path4,
149916
+ line,
149917
+ text: params.state.sample,
149918
+ truncated: params.state.truncated
149919
+ };
149920
+ if (params.addedLines.has(locationKey(reference2))) return;
149921
+ for (const atom of params.state.matches) {
149922
+ const bucket = params.buckets.get(atom);
149923
+ if (!bucket) continue;
149924
+ bucket.count++;
149925
+ if (bucket.samples.length < MAX_REFERENCES) bucket.samples.push(reference2);
149926
+ }
149927
+ }
149928
+ function consumeGrepChunk(params) {
149929
+ let offset = 0;
149930
+ while (offset < params.chunk.length) {
149931
+ if (params.state.phase !== "text") {
149932
+ const end2 = params.chunk.indexOf("\0", offset);
149933
+ const value2 = end2 < 0 ? params.chunk.slice(offset) : params.chunk.slice(offset, end2);
149934
+ params.state[params.state.phase] += value2;
149935
+ if (end2 < 0) return;
149936
+ params.state.phase = params.state.phase === "path" ? "line" : "text";
149937
+ offset = end2 + 1;
149938
+ continue;
149939
+ }
149940
+ const end = params.chunk.indexOf("\n", offset);
149941
+ const final = end >= 0;
149942
+ const text = final ? params.chunk.slice(offset, end) : params.chunk.slice(offset);
149943
+ consumeReferenceText({
149944
+ state: params.state,
149945
+ text,
149946
+ candidates: params.candidates,
149947
+ maxAtomLength: params.maxAtomLength,
149948
+ final
149949
+ });
149950
+ if (!final) return;
149951
+ recordGrepReference(params);
149952
+ resetGrepRecord(params.state);
149953
+ offset = end + 1;
149954
+ }
149955
+ }
149956
+ async function lookupReferences(params) {
149957
+ const buckets = emptyBuckets(params.candidates);
149958
+ if (params.candidates.length === 0) return { buckets, error: void 0 };
149959
+ const patterns = params.candidates.flatMap((candidate) => ["-e", candidate.atom]);
149960
+ const child = spawn2(
149961
+ "git",
149962
+ [
149963
+ "grep",
149964
+ "--no-color",
149965
+ "--full-name",
149966
+ "-n",
149967
+ "-z",
149968
+ "-I",
149969
+ "-w",
149970
+ "-F",
149971
+ ...patterns,
149972
+ params.treeish,
149973
+ "--",
149974
+ ":(top)"
149975
+ ],
149976
+ { env: resolveEnv(void 0), stdio: ["ignore", "pipe", "pipe"] }
149977
+ );
149978
+ const state = createGrepRecordState();
149979
+ const maxAtomLength = Math.max(...params.candidates.map((candidate) => candidate.atom.length));
149980
+ let spawnError;
149981
+ child.stdout.setEncoding("utf8");
149982
+ child.stdout.on("data", (chunk) => {
149983
+ consumeGrepChunk({
149984
+ chunk,
149985
+ state,
149986
+ candidates: params.candidates,
149987
+ maxAtomLength,
149988
+ buckets,
149989
+ addedLines: params.addedLines,
149990
+ treeish: params.treeish
149991
+ });
149992
+ });
149993
+ child.stderr.resume();
149994
+ const status = await new Promise((resolve3) => {
149995
+ child.once("error", (error49) => {
149996
+ spawnError = error49.message;
149997
+ resolve3(null);
149998
+ });
149999
+ child.once("close", resolve3);
150000
+ });
150001
+ if (spawnError || status !== 0 && status !== 1) {
150002
+ const error49 = spawnError ?? `git grep exit ${status ?? "unknown"}`;
150003
+ log.warning(`\xBB change impact lookup failed: ${error49}`);
150004
+ return { buckets: emptyBuckets(params.candidates), error: error49 };
150005
+ }
150006
+ if (state.phase === "text") {
150007
+ consumeReferenceText({
150008
+ state,
150009
+ text: "",
150010
+ candidates: params.candidates,
150011
+ maxAtomLength,
150012
+ final: true
150013
+ });
150014
+ recordGrepReference({ state, buckets, addedLines: params.addedLines, treeish: params.treeish });
150015
+ } else if (state.phase !== "path" || state.path) {
150016
+ const error49 = "malformed git grep output";
150017
+ log.warning(`\xBB change impact lookup failed: ${error49}`);
150018
+ return { buckets: emptyBuckets(params.candidates), error: error49 };
150019
+ }
150020
+ return { buckets, error: void 0 };
150021
+ }
150022
+ function formatReference(reference2) {
150023
+ const text = reference2.text.trim();
150024
+ const excerpt = reference2.truncated ? `${text.slice(0, MAX_EXCERPT - 1)}\u2026` : text;
150025
+ return `- ${JSON.stringify(reference2.path)}:${reference2.line} ${JSON.stringify(excerpt)}`;
150026
+ }
150027
+ function formatChangedLocations(change) {
150028
+ const selected = [change.removed[0], change.added[0]].filter(
150029
+ (location) => location !== void 0
150030
+ );
150031
+ const rendered = selected.map((location) => `${location.kind} ${JSON.stringify(location.path)}:${location.line}`).join(", ");
150032
+ const omitted = change.removed.length + change.added.length - selected.length;
150033
+ return omitted > 0 ? `${rendered}; ${omitted} more changed location(s) omitted` : rendered;
150034
+ }
150035
+ function formatEntry(entry) {
150036
+ const count = entry.bucket.count;
150037
+ const sampleNote = count > entry.bucket.samples.length ? `${count} matched line(s); first ${entry.bucket.samples.length} shown.` : `${count} matched line(s).`;
150038
+ return [
150039
+ `## \`${entry.change.atom}\``,
150040
+ "",
150041
+ `Changed at: ${formatChangedLocations(entry.change)}`,
150042
+ `Tracked references: ${sampleNote}`,
150043
+ "",
150044
+ ...entry.bucket.samples.map(formatReference)
150045
+ ].join("\n");
150046
+ }
150047
+ function formatArtifact(params) {
150048
+ const lines = [
150049
+ "# Change impact leads",
150050
+ "",
150051
+ "> Supplemental and explicitly incomplete. Read the authoritative `diffPath` TOC and raw lines first. Absence here is not evidence of no impact.",
150052
+ "",
150053
+ `Scope: base ${params.baseSha}, head ${params.headSha}; ${params.fileCount} changed file(s).`,
150054
+ "Generated from changed symbol-shaped identifiers, then one exact-word lookup pinned to the checked-out head across Git-tracked text files. Changed added lines are excluded; snippets are capped at 160 characters.",
150055
+ "",
150056
+ `Candidate atoms searched: ${params.candidateCount}/${params.totalCandidateCount}.`
150057
+ ];
150058
+ if (params.lookupError) lines.push(`Tracked-file lookup unavailable: ${params.lookupError}.`);
150059
+ let content = `${lines.join("\n")}
150060
+ `;
150061
+ let renderedAtomCount = 0;
150062
+ let referenceCount = 0;
150063
+ for (const entry of params.entries.filter((candidate) => candidate.bucket.count > 0)) {
150064
+ if (renderedAtomCount >= MAX_ATOMS) break;
150065
+ const addition = `
150066
+ ${formatEntry(entry)}
150067
+ `;
150068
+ if (Buffer.byteLength(content + addition) > MAX_BYTES) break;
150069
+ content += addition;
150070
+ renderedAtomCount++;
150071
+ referenceCount += entry.bucket.count;
150072
+ }
150073
+ if (renderedAtomCount === 0) {
150074
+ const empty = "\nNo bounded tracked references found. This is not evidence of no wider impact.\n";
150075
+ if (Buffer.byteLength(content + empty) <= MAX_BYTES) content += empty;
150076
+ }
150077
+ return { content, renderedAtomCount, referenceCount };
150078
+ }
150079
+ async function createChangeImpactArtifact(ctx, params) {
150080
+ const candidates = getCandidates(params.files);
150081
+ const lookup2 = await lookupReferences({
150082
+ candidates: candidates.selected,
150083
+ addedLines: candidates.addedLines,
150084
+ treeish: params.headSha
150085
+ });
150086
+ const artifact = formatArtifact({
150087
+ entries: candidates.selected.map((change) => {
150088
+ return { change, bucket: lookup2.buckets.get(change.atom) ?? { count: 0, samples: [] } };
150089
+ }),
150090
+ candidateCount: candidates.selected.length,
150091
+ totalCandidateCount: candidates.total,
150092
+ fileCount: params.files.length,
150093
+ baseSha: params.baseSha,
150094
+ headSha: params.headSha,
150095
+ lookupError: lookup2.error
150096
+ });
150097
+ const path4 = join7(ctx.tmpdir, `pr-${params.pullNumber}-${params.headSha.slice(0, 7)}-impact.md`);
150098
+ writeFileSync6(path4, artifact.content);
150099
+ return {
150100
+ path: path4,
150101
+ candidateCount: candidates.selected.length,
150102
+ renderedAtomCount: artifact.renderedAtomCount,
150103
+ referenceCount: artifact.referenceCount,
150104
+ bytes: Buffer.byteLength(artifact.content)
150105
+ };
150106
+ }
150107
+
149751
150108
  // utils/diffCoverage.ts
149752
150109
  import { isAbsolute, normalize as normalize2, resolve } from "node:path";
149753
150110
  function countLines(params) {
@@ -149835,27 +150192,6 @@ function getDiffCoverageBreakdown(params) {
149835
150192
  files
149836
150193
  };
149837
150194
  }
149838
- function renderDiffCoverageBreakdown(params) {
149839
- const breakdown = params.breakdown;
149840
- const lines = [];
149841
- lines.push(`diff coverage report for \`${params.diffPath}\``);
149842
- lines.push(
149843
- `overall: ${breakdown.coveredLines}/${breakdown.totalLines} lines read (${breakdown.coveragePercent}%), unread: ${breakdown.unreadLines}`
149844
- );
149845
- lines.push(`covered ranges: ${formatRanges({ ranges: breakdown.coveredRanges })}`);
149846
- lines.push(`unread ranges: ${formatRanges({ ranges: breakdown.unreadRanges })}`);
149847
- lines.push("");
149848
- lines.push("per-file TOC coverage:");
149849
- for (const file2 of breakdown.files) {
149850
- const filePercent = file2.totalLines ? Number((file2.coveredLines / file2.totalLines * 100).toFixed(2)) : 100;
149851
- lines.push(
149852
- `- ${file2.filename} (toc lines ${file2.startLine}-${file2.endLine}): ${file2.coveredLines}/${file2.totalLines} lines read (${filePercent}%)`
149853
- );
149854
- lines.push(` read: ${formatRanges({ ranges: file2.coveredRanges })}`);
149855
- lines.push(` unread: ${formatRanges({ ranges: file2.unreadRanges })}`);
149856
- }
149857
- return lines.join("\n");
149858
- }
149859
150195
  function resolveOffsetBase(params) {
149860
150196
  const lower2 = params.toolName.toLowerCase();
149861
150197
  if (lower2 === "readfile" || lower2.endsWith(".readfile")) {
@@ -149986,10 +150322,6 @@ function countLinesInRanges(params) {
149986
150322
  }
149987
150323
  return total;
149988
150324
  }
149989
- function formatRanges(params) {
149990
- if (params.ranges.length === 0) return "none";
149991
- return params.ranges.map((range2) => `${range2.startLine}-${range2.endLine}`).join(", ");
149992
- }
149993
150325
  function clampLine(params) {
149994
150326
  if (params.value < 1) return 1;
149995
150327
  if (params.value > params.totalLines) return params.totalLines;
@@ -150016,7 +150348,7 @@ function readNumber(params) {
150016
150348
  import { execSync } from "node:child_process";
150017
150349
  import { createHash } from "node:crypto";
150018
150350
  import { readFileSync as readFileSync2, realpathSync, unlinkSync } from "node:fs";
150019
- import { join as join7 } from "node:path";
150351
+ import { join as join8 } from "node:path";
150020
150352
 
150021
150353
  // utils/shell.ts
150022
150354
  import { spawnSync as spawnSync4 } from "node:child_process";
@@ -150094,7 +150426,7 @@ function resolveHooksDir(cwd, gitPath) {
150094
150426
  cwd,
150095
150427
  log: false
150096
150428
  }).trim();
150097
- const hooksDir = join7(commonDir, "hooks");
150429
+ const hooksDir = join8(commonDir, "hooks");
150098
150430
  hooksDirCache.set(cwd, hooksDir);
150099
150431
  return hooksDir;
150100
150432
  }
@@ -151242,13 +151574,13 @@ function _op(fn2, options) {
151242
151574
 
151243
151575
  // mcp/git.ts
151244
151576
  import { randomUUID as randomUUID3 } from "node:crypto";
151245
- import { writeFileSync as writeFileSync6 } from "node:fs";
151246
- import { join as join10 } from "node:path";
151577
+ import { writeFileSync as writeFileSync7 } from "node:fs";
151578
+ import { join as join11 } from "node:path";
151247
151579
 
151248
151580
  // utils/apiCommit.ts
151249
151581
  import { execFileSync as execFileSync6 } from "node:child_process";
151250
151582
  import { lstat, readlink } from "node:fs/promises";
151251
- import { join as join8 } from "node:path";
151583
+ import { join as join9 } from "node:path";
151252
151584
  var GITHUB_API = "https://api.github.com";
151253
151585
  var MAX_BLOB_BYTES = 30 * 1024 * 1024;
151254
151586
  var BLOB_UPLOAD_CONCURRENCY = 8;
@@ -151313,7 +151645,7 @@ async function assertApiCommittable(files) {
151313
151645
  const root = getRepoRoot();
151314
151646
  const attrs = $(
151315
151647
  "git",
151316
- ["check-attr", "filter", "-z", "--", ...present.map((p) => join8(root, p))],
151648
+ ["check-attr", "filter", "-z", "--", ...present.map((p) => join9(root, p))],
151317
151649
  { log: false }
151318
151650
  );
151319
151651
  const parts = attrs.split("\0");
@@ -151325,7 +151657,7 @@ async function assertApiCommittable(files) {
151325
151657
  }
151326
151658
  }
151327
151659
  for (const path4 of present) {
151328
- const stat = await lstat(join8(root, path4));
151660
+ const stat = await lstat(join9(root, path4));
151329
151661
  if (stat.isDirectory()) {
151330
151662
  throw new Error(
151331
151663
  `'${path4}' is a directory (nested repository or submodule?) \u2014 signed commits only support files and symlinks.`
@@ -151334,7 +151666,7 @@ async function assertApiCommittable(files) {
151334
151666
  }
151335
151667
  }
151336
151668
  async function createBlobEntry(params) {
151337
- const absPath = join8(params.repoRoot, params.path);
151669
+ const absPath = join9(params.repoRoot, params.path);
151338
151670
  const stat = await lstat(absPath);
151339
151671
  if (stat.size > MAX_BLOB_BYTES) {
151340
151672
  throw new Error(
@@ -151514,7 +151846,7 @@ this usually means your local branch has commits that were never pushed \u2014 s
151514
151846
  var core3 = __toESM(require_core(), 1);
151515
151847
  import { createSign } from "node:crypto";
151516
151848
  import { rename, writeFile } from "node:fs/promises";
151517
- import { dirname as dirname3, join as join9 } from "node:path";
151849
+ import { dirname as dirname3, join as join10 } from "node:path";
151518
151850
 
151519
151851
  // node_modules/.pnpm/@octokit+plugin-throttling@11.0.3_@octokit+core@7.0.5/node_modules/@octokit/plugin-throttling/dist-bundle/index.js
151520
151852
  var import_light = __toESM(require_light(), 1);
@@ -155458,7 +155790,7 @@ function getGitHubUsageSummary() {
155458
155790
  }
155459
155791
  async function writeGitHubUsageSummaryToFile(path4) {
155460
155792
  const summary2 = getGitHubUsageSummary();
155461
- const tmpPath = join9(dirname3(path4), `.usage-summary-${process.pid}.tmp`);
155793
+ const tmpPath = join10(dirname3(path4), `.usage-summary-${process.pid}.tmp`);
155462
155794
  await writeFile(tmpPath, JSON.stringify(summary2));
155463
155795
  await rename(tmpPath, path4);
155464
155796
  }
@@ -156585,8 +156917,8 @@ function countAhead(head, base, cwd) {
156585
156917
  function spillGitOutput(params) {
156586
156918
  const tempDir = process.env.PULLFROG_TEMP_DIR;
156587
156919
  if (!tempDir) throw new Error("PULLFROG_TEMP_DIR not set");
156588
- const outputPath = join10(tempDir, `git-${params.command}-${randomUUID3().slice(0, 8)}.txt`);
156589
- writeFileSync6(outputPath, params.output);
156920
+ const outputPath = join11(tempDir, `git-${params.command}-${randomUUID3().slice(0, 8)}.txt`);
156921
+ writeFileSync7(outputPath, params.output);
156590
156922
  const previewByLines = params.output.split("\n").slice(0, OVERFLOW_PREVIEW_LINES).join("\n");
156591
156923
  const preview = previewByLines.length <= OVERFLOW_PREVIEW_MAX_CHARS ? previewByLines : `${previewByLines.slice(0, OVERFLOW_PREVIEW_MAX_CHARS)}\u2026`;
156592
156924
  log.info(
@@ -156815,7 +157147,10 @@ function formatModelLabel(params) {
156815
157147
  modelAliases.find((a) => a.resolve === params.model || a.openRouterResolve === params.model);
156816
157148
  const displayName = alias?.displayName ?? params.model;
156817
157149
  if (params.oss) {
156818
- return `\`${displayName}\` (free via [Pullfrog for OSS](https://pullfrog.com/for-oss))`;
157150
+ const ossBase = `\`${displayName}\` (free via [Pullfrog for OSS](https://pullfrog.com/for-oss))`;
157151
+ if (params.clamped?.reason !== "oss") return ossBase;
157152
+ const configured = isAutoTier(params.clamped.from) ? "the intelligent tier" : `\`${resolveDisplayAlias(params.clamped.from)?.displayName ?? params.clamped.from}\``;
157153
+ return `${ossBase} (${configured} not used \u2014 the program covers this model; add its [provider key](https://docs.pullfrog.com/models) to run your pick)`;
156819
157154
  }
156820
157155
  const base = alias?.isFree ? `\`${displayName}\` (free)` : `\`${displayName}\``;
156821
157156
  if (params.fallbackFrom) {
@@ -157218,13 +157553,15 @@ function addFooter(ctx, body) {
157218
157553
  var Comment = type({
157219
157554
  issueNumber: type.number.describe("the issue number to comment on"),
157220
157555
  body: type.string.describe("the comment body content"),
157221
- type: type.enumerated("Plan", "Comment").describe("Plan: record as the plan for this run. Comment: regular comment (default).").optional()
157556
+ type: type.enumerated("Plan", "Comment").describe(
157557
+ "Plan: standalone plan comment on another target. Comment: regular comment (default)."
157558
+ ).optional()
157222
157559
  });
157223
157560
  function CreateCommentTool(ctx) {
157224
157561
  return tool({
157225
157562
  name: "create_issue_comment",
157226
157563
  mutates: true,
157227
- description: 'Create a comment on a GitHub issue or PR. Example: `create_issue_comment({ issueNumber: 1234, body: "Thanks for the report." })`. For progress/plan updates on the current run use report_progress instead \u2014 plan output (initial post AND revisions) is always posted via report_progress, never via this tool.',
157564
+ description: "Create a comment on a GitHub issue or PR. Example: `create_issue_comment({ issueNumber: 1234, body: \"Thanks for the report.\" })`. For the current run's answer, progress, or plan use report_progress instead. Use this on the current target only when the task explicitly requests a standalone comment. Skip report_progress only when that current-target comment is the task's sole requested deliverable.",
157228
157565
  parameters: Comment,
157229
157566
  execute: execute(async ({ issueNumber, body, type: commentType }) => {
157230
157567
  const bodyWithFooter = addFooter(ctx, body);
@@ -157236,9 +157573,6 @@ function CreateCommentTool(ctx) {
157236
157573
  });
157237
157574
  ctx.toolState.wasUpdated = true;
157238
157575
  log.info(`\xBB created comment ${result.data.id}`);
157239
- if (commentType !== "Plan" && issueNumber === ctx.payload.event.issue_number) {
157240
- ctx.toolState.answerCommentPosted = true;
157241
- }
157242
157576
  if (commentType === "Plan") {
157243
157577
  if (result.data.node_id) {
157244
157578
  await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
@@ -157727,12 +158061,23 @@ function duplicateReviewDecision(params) {
157727
158061
  reason: `review ${existing.id} was already submitted in this session; ignoring duplicate call (call \`checkout_pr\` again first if new commits were pushed)`
157728
158062
  };
157729
158063
  }
158064
+ var VERDICT_MARKERS = ["> \u2705", "> \u2139\uFE0F", "> [!IMPORTANT]", "> [!CAUTION]"];
158065
+ var MIN_UNMARKED_BODY_LENGTH = 20;
158066
+ var PLACEHOLDER_PATTERN = /\b(test|testing|placeholder|temp|foo|bar|asdf|dummy|sample)\b/i;
158067
+ var PLACEHOLDER_SCAN_LENGTH = 50;
158068
+ function isDegenerateReviewBody(body) {
158069
+ const trimmed = body.trim();
158070
+ if (VERDICT_MARKERS.some((marker) => trimmed.startsWith(marker))) return false;
158071
+ if (trimmed.length < MIN_UNMARKED_BODY_LENGTH) return true;
158072
+ return trimmed.length < PLACEHOLDER_SCAN_LENGTH && PLACEHOLDER_PATTERN.test(trimmed);
158073
+ }
158074
+ var MAX_CONSECUTIVE_NOOP_SUBMISSIONS = 4;
157730
158075
  function reviewSkipDecision(params) {
157731
158076
  if (params.body || params.hasComments) return null;
157732
158077
  if (!params.approved) {
157733
158078
  return {
157734
158079
  kind: "no-issues",
157735
- reason: params.requestChanges ? "request_changes with no body or comments \u2014 nothing to block on" : "no issues found \u2014 nothing to post"
158080
+ 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."
157736
158081
  };
157737
158082
  }
157738
158083
  if (!params.prApproveEnabled) {
@@ -157760,9 +158105,15 @@ function formatDroppedCommentsNote(dropped) {
157760
158105
  }
157761
158106
  var CreatePullRequestReview = type({
157762
158107
  pull_number: type.number.describe("The pull request number to review"),
158108
+ // REQUIRED on purpose, not because an empty review is invalid — pass "" for
158109
+ // that. models that emit tool calls without schema-constrained decoding drop
158110
+ // optional parameters and keep required ones; in the incident log every
158111
+ // required parameter survived and this one, when optional, vanished ~15 times
158112
+ // running. required-ness is the only pressure that empirically held.
158113
+ // see wiki/review-approval.md.
157763
158114
  body: type.string.describe(
157764
- "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."
157765
- ).optional(),
158115
+ `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.`
158116
+ ),
157766
158117
  approved: type.boolean.describe(
157767
158118
  "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."
157768
158119
  ).optional(),
@@ -157807,6 +158158,11 @@ function CreatePullRequestReviewTool(ctx) {
157807
158158
  );
157808
158159
  }
157809
158160
  if (body) body = fixDoubleEscapedString(body);
158161
+ if (body && isDegenerateReviewBody(body)) {
158162
+ throw new Error(
158163
+ `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(", ")}).`
158164
+ );
158165
+ }
157810
158166
  const primary = primaryRepoState(ctx.toolState);
157811
158167
  primary.issueNumber = pull_number;
157812
158168
  const dup = duplicateReviewDecision({
@@ -157851,9 +158207,16 @@ function CreatePullRequestReviewTool(ctx) {
157851
158207
  prApproveEnabled: ctx.prApproveEnabled && !selfAuthored
157852
158208
  });
157853
158209
  if (skip) {
158210
+ ctx.toolState.noopReviewSubmissions += 1;
157854
158211
  log.info(`skipping review submission: ${skip.reason}`);
158212
+ if (ctx.toolState.noopReviewSubmissions >= MAX_CONSECUTIVE_NOOP_SUBMISSIONS) {
158213
+ throw new Error(
158214
+ `${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.`
158215
+ );
158216
+ }
157855
158217
  return { success: true, skipped: true, reason: skip.reason };
157856
158218
  }
158219
+ ctx.toolState.noopReviewSubmissions = 0;
157857
158220
  let event = approved ? "APPROVE" : request_changes ? "REQUEST_CHANGES" : "COMMENT";
157858
158221
  if (event === "APPROVE" || event === "REQUEST_CHANGES") {
157859
158222
  if (!ctx.prApproveEnabled) {
@@ -158044,10 +158407,6 @@ function runDiffCoveragePreflight(params) {
158044
158407
  unread.push({ path: file2.filename, ranges: rangesText, unreadLines: fileUnreadLines });
158045
158408
  unreadLines += fileUnreadLines;
158046
158409
  }
158047
- coverageState.lastBreakdown = renderDiffCoverageBreakdown({
158048
- diffPath: coverageState.diffPath,
158049
- breakdown
158050
- });
158051
158410
  log.debug(
158052
158411
  `diff coverage pre-flight breakdown: coveredLines=${breakdown.coveredLines}, unreadLines=${unreadLines}`
158053
158412
  );
@@ -158063,9 +158422,7 @@ function runDiffCoveragePreflight(params) {
158063
158422
  `diff coverage pre-flight: some TOC regions were not read before review submission. this is a one-time nudge \u2014 read the ranges below from ${coverageState.diffPath} on a best-effort basis, then call create_pull_request_review again. you are NOT obligated to read generated artifacts (lockfiles like pnpm-lock.yaml / package-lock.json / yarn.lock / Cargo.lock; codegen output like *.gen.*, *.pb.go, *.generated.*; snapshot/fixture dirs like __snapshots__/; migration metadata like drizzle/meta/, prisma migration SQL). if every unread region is generated, retry immediately without reading. this pre-flight will not block again in this review session.
158064
158423
 
158065
158424
  unread TOC regions:
158066
- ${unreadText}
158067
-
158068
- ${coverageState.lastBreakdown}`
158425
+ ${unreadText}`
158069
158426
  );
158070
158427
  }
158071
158428
  async function clearStrandedPendingReview(ctx, params) {
@@ -158187,9 +158544,9 @@ async function reportReviewNodeId(ctx, params) {
158187
158544
  import { execFileSync as execFileSync7, execSync as execSync2 } from "node:child_process";
158188
158545
  import { mkdtempSync, readdirSync, realpathSync as realpathSync2, unlinkSync as unlinkSync2 } from "node:fs";
158189
158546
  import { tmpdir as tmpdir2 } from "node:os";
158190
- import { join as join11 } from "node:path";
158547
+ import { join as join12 } from "node:path";
158191
158548
  function createTempDirectory() {
158192
- const sharedTempDir = mkdtempSync(join11(tmpdir2(), "pullfrog-"));
158549
+ const sharedTempDir = mkdtempSync(join12(tmpdir2(), "pullfrog-"));
158193
158550
  process.env.PULLFROG_TEMP_DIR = sharedTempDir;
158194
158551
  log.info(`\xBB created temp dir at ${sharedTempDir}`);
158195
158552
  return sharedTempDir;
@@ -158234,13 +158591,13 @@ function wipeRunnerLeakSurface() {
158234
158591
  return [];
158235
158592
  }
158236
158593
  };
158237
- const fileCommandsDir = join11(runnerTemp, "_runner_file_commands");
158594
+ const fileCommandsDir = join12(runnerTemp, "_runner_file_commands");
158238
158595
  for (const entry of listDir(fileCommandsDir)) {
158239
- tryUnlink(join11(fileCommandsDir, entry));
158596
+ tryUnlink(join12(fileCommandsDir, entry));
158240
158597
  }
158241
158598
  for (const entry of listDir(runnerTemp)) {
158242
158599
  if (entry.endsWith(".sh") || /^git-credentials-.*\.config$/.test(entry)) {
158243
- tryUnlink(join11(runnerTemp, entry));
158600
+ tryUnlink(join12(runnerTemp, entry));
158244
158601
  }
158245
158602
  }
158246
158603
  if (wiped.length > 0) {
@@ -158746,21 +159103,23 @@ function CheckoutPrTool(ctx) {
158746
159103
  "PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
158747
159104
  );
158748
159105
  }
158749
- const headShort = primary.checkoutSha.slice(0, 7);
159106
+ const checkoutSha = primary.checkoutSha;
159107
+ if (!checkoutSha) throw new Error("checkout completed without a resolved HEAD SHA");
159108
+ const headShort = checkoutSha.slice(0, 7);
158750
159109
  let incrementalDiffPath;
158751
- if (primary.beforeSha && primary.checkoutSha) {
159110
+ if (primary.beforeSha) {
158752
159111
  const beforeShort = primary.beforeSha.slice(0, 7);
158753
159112
  const incremental = computeIncrementalDiff({
158754
159113
  baseBranch: pr.baseRef,
158755
159114
  beforeSha: primary.beforeSha,
158756
- headSha: primary.checkoutSha
159115
+ headSha: checkoutSha
158757
159116
  });
158758
159117
  if (incremental) {
158759
- incrementalDiffPath = join12(
159118
+ incrementalDiffPath = join13(
158760
159119
  tempDir,
158761
159120
  `pr-${pull_number}-${beforeShort}-${headShort}-incremental.diff`
158762
159121
  );
158763
- writeFileSync7(incrementalDiffPath, incremental);
159122
+ writeFileSync8(incrementalDiffPath, incremental);
158764
159123
  log.info(
158765
159124
  `\xBB incremental diff computed (${incremental.length} bytes) \u2192 ${incrementalDiffPath}`
158766
159125
  );
@@ -158770,8 +159129,8 @@ function CheckoutPrTool(ctx) {
158770
159129
  const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
158771
159130
  log.debug(`formatted diff preview (first 100 lines):
158772
159131
  ${diffPreview}`);
158773
- const diffPath = join12(tempDir, `pr-${pull_number}-${headShort}.diff`);
158774
- writeFileSync7(diffPath, formatResult.content);
159132
+ const diffPath = join13(tempDir, `pr-${pull_number}-${headShort}.diff`);
159133
+ writeFileSync8(diffPath, formatResult.content);
158775
159134
  log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
158776
159135
  primary.diffCoverage = createDiffCoverageState({
158777
159136
  diffPath,
@@ -158782,14 +159141,56 @@ ${diffPreview}`);
158782
159141
  log.debug(
158783
159142
  `\xBB diff coverage initialized: diffPath=${diffPath}, totalLines=${primary.diffCoverage.totalLines}, tocEntries=${primary.diffCoverage.tocEntries.length}`
158784
159143
  );
159144
+ let impactPath;
159145
+ if (process.env.PULLFROG_DISABLE_CHANGE_IMPACT === "1") {
159146
+ log.info("\xBB change impact disabled by PULLFROG_DISABLE_CHANGE_IMPACT");
159147
+ } else {
159148
+ let revisionVerified = false;
159149
+ let revisionFailure;
159150
+ try {
159151
+ const latest = await ctx.octokit.rest.pulls.get({
159152
+ owner: ctx.repo.owner,
159153
+ repo: ctx.repo.name,
159154
+ pull_number
159155
+ });
159156
+ revisionVerified = latest.data.head.sha === checkoutSha && latest.data.base.sha === prResponse.data.base.sha;
159157
+ } catch (error49) {
159158
+ revisionFailure = error49 instanceof Error ? error49.message : String(error49);
159159
+ }
159160
+ if (revisionFailure !== void 0) {
159161
+ log.warning(
159162
+ `\xBB change impact skipped: PR revision could not be verified (${revisionFailure})`
159163
+ );
159164
+ } else if (!revisionVerified) {
159165
+ log.warning("\xBB change impact skipped: PR revision changed while the diff was fetched");
159166
+ } else {
159167
+ try {
159168
+ const impact = await createChangeImpactArtifact(ctx, {
159169
+ files: formatResult.files,
159170
+ pullNumber: pull_number,
159171
+ baseSha: prResponse.data.base.sha,
159172
+ headSha: checkoutSha
159173
+ });
159174
+ impactPath = impact.path;
159175
+ log.info(
159176
+ `\xBB change impact: ${impact.candidateCount} candidate atom(s), ${impact.renderedAtomCount} detailed atom(s), ${impact.referenceCount} tracked reference(s), ${impact.bytes} bytes \u2192 ${impact.path}`
159177
+ );
159178
+ } catch (error49) {
159179
+ log.warning(
159180
+ `\xBB change impact skipped: generation failed (${error49 instanceof Error ? error49.message : String(error49)})`
159181
+ );
159182
+ }
159183
+ }
159184
+ }
158785
159185
  const cached4 = /* @__PURE__ */ new Map();
158786
159186
  for (const file2 of formatResult.files) {
158787
159187
  cached4.set(file2.filename, commentableLinesForFile(file2.patch));
158788
159188
  }
158789
159189
  primary.commentableLinesByFile = cached4;
158790
159190
  primary.commentableLinesPullNumber = pull_number;
158791
- primary.commentableLinesCheckoutSha = primary.checkoutSha;
158792
- const incrementalInstructions = incrementalDiffPath ? ` IMPORTANT: incrementalDiffPath contains ONLY the changes since the last reviewed version (computed via range-diff). you MUST read incrementalDiffPath FIRST to understand what changed, then use diffPath for full PR context. do NOT skip the incremental diff.` : "";
159191
+ primary.commentableLinesCheckoutSha = checkoutSha;
159192
+ const incrementalInstructions = incrementalDiffPath ? `IMPORTANT: incrementalDiffPath contains ONLY the changes since the last reviewed version (computed via range-diff). you MUST read incrementalDiffPath FIRST to understand what changed, then use diffPath for full PR context. do NOT skip the incremental diff. ` : "";
159193
+ const impactInstructions = impactPath ? ` impactPath contains bounded, deterministic reference leads for semantic atoms changed by the PR. read the authoritative diff TOC and relevant raw diff lines FIRST; only then use impactPath as a supplemental, explicitly incomplete lead list. impactPath never establishes review coverage and an empty artifact is not evidence of no wider impact.` : "";
158793
159194
  const COMMIT_LOG_MAX = 200;
158794
159195
  const baseRange = `origin/${pr.baseRef}..HEAD`;
158795
159196
  let commitCount = 0;
@@ -158825,6 +159226,7 @@ ${diffPreview}`);
158825
159226
  url: prResponse.data.html_url,
158826
159227
  headRepo: pr.headRepoFullName,
158827
159228
  diffPath,
159229
+ impactPath,
158828
159230
  incrementalDiffPath,
158829
159231
  toc: formatResult.toc,
158830
159232
  commitCount,
@@ -158832,7 +159234,7 @@ ${diffPreview}`);
158832
159234
  commitLogTruncated,
158833
159235
  commitLogUnavailable,
158834
159236
  hookWarning: checkoutResult.hookWarning,
158835
- instructions: `the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. use the TOC line ranges as your checklist and read specific files from the diff instead of reading the entire file. for example, if the TOC says "src/foo.ts \u2192 lines 5-42", read lines 5-42 from diffPath to see that file's changes. review files selectively based on relevance rather than reading everything sequentially. to inspect the PR's changed files, use diffPath \u2014 do NOT run \`git diff\` to re-derive what's already in diffPath. the formatted diff with line numbers is authoritative. if you ever do need a branch-vs-base diff via the git tool, use \`git diff --merge-base <base>\` (single call, includes uncommitted edits) or three-dot \`git diff <base>...HEAD\` (committed-only). bare \`<base>\` and two-dot \`<base>..HEAD\` are symmetric and pull in the inverse of every commit landed on \`<base>\` since the branch forked \u2014 the git tool will reject those forms when the divergence is detected. \`$(...)\` subshells are NOT expanded by the git tool. \`git log\` and \`git diff --stat\` are fine for commit-range overview, and \`git diff\` / \`git diff --cached\` are fine for inspecting *your own* uncommitted changes \u2014 but PR review content MUST come from diffPath. before your review is submitted, a one-time coverage pre-flight may error listing unread TOC regions. retry the same create_pull_request_review call to proceed \u2014 optionally after reading the listed ranges. the pre-flight will not block again this session. the local branch is 'localBranch' (pr-{number}), not the remote branch name. when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.` + incrementalInstructions + hookWarningInstructions + commitLogInstructions
159237
+ instructions: incrementalInstructions + `the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. use the TOC line ranges as your checklist and read specific files from the diff instead of reading the entire file. for example, if the TOC says "src/foo.ts \u2192 lines 5-42", read lines 5-42 from diffPath to see that file's changes. review files selectively based on relevance rather than reading everything sequentially. to inspect the PR's changed files, use diffPath \u2014 do NOT run \`git diff\` to re-derive what's already in diffPath. the formatted diff with line numbers is authoritative. if you ever do need a branch-vs-base diff via the git tool, use \`git diff --merge-base <base>\` (single call, includes uncommitted edits) or three-dot \`git diff <base>...HEAD\` (committed-only). bare \`<base>\` and two-dot \`<base>..HEAD\` are symmetric and pull in the inverse of every commit landed on \`<base>\` since the branch forked \u2014 the git tool will reject those forms when the divergence is detected. \`$(...)\` subshells are NOT expanded by the git tool. \`git log\` and \`git diff --stat\` are fine for commit-range overview, and \`git diff\` / \`git diff --cached\` are fine for inspecting *your own* uncommitted changes \u2014 but PR review content MUST come from diffPath. before your review is submitted, a one-time coverage pre-flight may error listing unread TOC regions. retry the same create_pull_request_review call to proceed \u2014 optionally after reading the listed ranges. the pre-flight will not block again this session. the local branch is 'localBranch' (pr-{number}), not the remote branch name. when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.` + impactInstructions + hookWarningInstructions + commitLogInstructions
158836
159238
  };
158837
159239
  };
158838
159240
  return tool({
@@ -158879,8 +159281,8 @@ ${dirty}`
158879
159281
  }
158880
159282
 
158881
159283
  // mcp/checkSuite.ts
158882
- import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync8 } from "node:fs";
158883
- import { join as join13 } from "node:path";
159284
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync9 } from "node:fs";
159285
+ import { join as join14 } from "node:path";
158884
159286
  var GetCheckSuiteLogs = type({
158885
159287
  check_suite_id: type.number.describe("the id from check_suite.id")
158886
159288
  });
@@ -158976,7 +159378,7 @@ function GetCheckSuiteLogsTool(ctx) {
158976
159378
  if (!tempDir) {
158977
159379
  throw new Error("PULLFROG_TEMP_DIR not set");
158978
159380
  }
158979
- const logsDir = join13(tempDir, "ci-logs");
159381
+ const logsDir = join14(tempDir, "ci-logs");
158980
159382
  mkdirSync7(logsDir, { recursive: true });
158981
159383
  const jobResults = [];
158982
159384
  for (const run of failedRuns) {
@@ -159003,8 +159405,8 @@ function GetCheckSuiteLogsTool(ctx) {
159003
159405
  );
159004
159406
  }
159005
159407
  const logsText = await logsResult.text();
159006
- const logPath = join13(logsDir, `job-${job.id}.log`);
159007
- writeFileSync8(logPath, logsText);
159408
+ const logPath = join14(logsDir, `job-${job.id}.log`);
159409
+ writeFileSync9(logPath, logsText);
159008
159410
  const analysis = analyzeLog(logsText, 80);
159009
159411
  const failedSteps = job.steps?.filter((s) => s.conclusion === "failure").map((s) => `Step ${s.number}: ${s.name}`) ?? [];
159010
159412
  jobResults.push({
@@ -159052,8 +159454,8 @@ function GetCheckSuiteLogsTool(ctx) {
159052
159454
  }
159053
159455
 
159054
159456
  // mcp/commitInfo.ts
159055
- import { writeFileSync as writeFileSync9 } from "node:fs";
159056
- import { join as join14 } from "node:path";
159457
+ import { writeFileSync as writeFileSync10 } from "node:fs";
159458
+ import { join as join15 } from "node:path";
159057
159459
  var CommitInfo = type({
159058
159460
  sha: type.string.describe("the commit SHA (full or abbreviated) to fetch")
159059
159461
  });
@@ -159077,8 +159479,8 @@ function CommitInfoTool(ctx) {
159077
159479
  "PULLFROG_TEMP_DIR not set - get_commit_info must run in pullfrog action context"
159078
159480
  );
159079
159481
  }
159080
- const diffFile = join14(tempDir, `commit-${sha.slice(0, 7)}.diff`);
159081
- writeFileSync9(diffFile, formatResult.content);
159482
+ const diffFile = join15(tempDir, `commit-${sha.slice(0, 7)}.diff`);
159483
+ writeFileSync10(diffFile, formatResult.content);
159082
159484
  log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`);
159083
159485
  return {
159084
159486
  sha: data.sha,
@@ -159105,7 +159507,7 @@ import { performance as performance8 } from "node:perf_hooks";
159105
159507
 
159106
159508
  // prep/installNodeDependencies.ts
159107
159509
  import { existsSync as existsSync6 } from "node:fs";
159108
- import { join as join16 } from "node:path";
159510
+ import { join as join17 } from "node:path";
159109
159511
 
159110
159512
  // node_modules/.pnpm/package-manager-detector@1.6.0/node_modules/package-manager-detector/dist/commands.mjs
159111
159513
  function dashDashArg(agent2, agentCommand) {
@@ -159404,7 +159806,7 @@ function isMetadataYarnClassic(metadataPath) {
159404
159806
  var import_semver3 = __toESM(require_semver2(), 1);
159405
159807
  import { existsSync as existsSync5 } from "node:fs";
159406
159808
  import { mkdir, readFile as readFile2 } from "node:fs/promises";
159407
- import { delimiter, join as join15 } from "node:path";
159809
+ import { delimiter, join as join16 } from "node:path";
159408
159810
  var SUPPORTED_NAMES = ["npm", "pnpm", "yarn", "bun"];
159409
159811
  var COREPACK_MANAGED = ["pnpm", "yarn"];
159410
159812
  function isSupported(name) {
@@ -159442,7 +159844,7 @@ function parseDevEnginesField(field) {
159442
159844
  };
159443
159845
  }
159444
159846
  async function resolvePackageManagerSpec(cwd) {
159445
- const pkgPath = join15(cwd, "package.json");
159847
+ const pkgPath = join16(cwd, "package.json");
159446
159848
  if (!existsSync5(pkgPath)) return null;
159447
159849
  let pkg;
159448
159850
  try {
@@ -159505,7 +159907,7 @@ async function currentVersion(name) {
159505
159907
  return result.stdout.trim();
159506
159908
  }
159507
159909
  function packageManagerBinDir(tmpdir3) {
159508
- return join15(tmpdir3, "pm-bin");
159910
+ return join16(tmpdir3, "pm-bin");
159509
159911
  }
159510
159912
  async function ensurePackageManager(params) {
159511
159913
  const spec = params.spec;
@@ -159576,7 +159978,7 @@ async function installFallback(name, installSpec) {
159576
159978
  return result.stderr || `failed to install ${name}`;
159577
159979
  }
159578
159980
  if (name === "deno") {
159579
- const denoPath = join16(process.env.HOME || "", ".deno", "bin");
159981
+ const denoPath = join17(process.env.HOME || "", ".deno", "bin");
159580
159982
  process.env.PATH = `${denoPath}:${process.env.PATH}`;
159581
159983
  }
159582
159984
  log.info(`\xBB installed ${name}`);
@@ -159585,7 +159987,7 @@ async function installFallback(name, installSpec) {
159585
159987
  var installNodeDependencies = {
159586
159988
  name: "installNodeDependencies",
159587
159989
  shouldRun: () => {
159588
- const packageJsonPath = join16(process.cwd(), "package.json");
159990
+ const packageJsonPath = join17(process.cwd(), "package.json");
159589
159991
  return existsSync6(packageJsonPath);
159590
159992
  },
159591
159993
  run: async (options) => {
@@ -159684,12 +160086,12 @@ ${errorMessage}`]
159684
160086
 
159685
160087
  // prep/installPythonDependencies.ts
159686
160088
  import { existsSync as existsSync7, readFileSync as readFileSync4 } from "node:fs";
159687
- import { join as join17 } from "node:path";
160089
+ import { join as join18 } from "node:path";
159688
160090
  function declaresBuildSystem(path4) {
159689
160091
  return /^\s*\[\s*build-system\s*\]/m.test(readFileSync4(path4, "utf8"));
159690
160092
  }
159691
160093
  function configApplies(config3, cwd) {
159692
- const path4 = join17(cwd, config3.file);
160094
+ const path4 = join18(cwd, config3.file);
159693
160095
  return existsSync7(path4) && (config3.applies?.(path4) ?? true);
159694
160096
  }
159695
160097
  var PYTHON_CONFIGS = [
@@ -160825,8 +161227,8 @@ function PullRequestInfoTool(ctx) {
160825
161227
  }
160826
161228
 
160827
161229
  // mcp/reviewComments.ts
160828
- import { writeFileSync as writeFileSync10 } from "node:fs";
160829
- import { join as join18 } from "node:path";
161230
+ import { writeFileSync as writeFileSync11 } from "node:fs";
161231
+ import { join as join19 } from "node:path";
160830
161232
  var REVIEW_THREADS_QUERY = `
160831
161233
  query ($owner: String!, $name: String!, $prNumber: Int!) {
160832
161234
  repository(owner: $owner, name: $name) {
@@ -161242,8 +161644,8 @@ function GetReviewCommentsTool(ctx) {
161242
161644
  throw new Error("PULLFROG_TEMP_DIR not set");
161243
161645
  }
161244
161646
  const filename = `review-${params.review_id}-threads.md`;
161245
- const commentsPath = join18(tempDir, filename);
161246
- writeFileSync10(commentsPath, formatted.content);
161647
+ const commentsPath = join19(tempDir, filename);
161648
+ writeFileSync11(commentsPath, formatted.content);
161247
161649
  log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
161248
161650
  return {
161249
161651
  review_id: params.review_id,
@@ -161478,11 +161880,11 @@ ${summaryAddendum}`,
161478
161880
  }
161479
161881
 
161480
161882
  // mcp/shell.ts
161481
- import { spawn as spawn2, spawnSync as spawnSync5 } from "node:child_process";
161883
+ import { spawn as spawn3, spawnSync as spawnSync5 } from "node:child_process";
161482
161884
  import { randomUUID as randomUUID4 } from "node:crypto";
161483
- import { closeSync, openSync, writeFileSync as writeFileSync11 } from "node:fs";
161885
+ import { closeSync, openSync, writeFileSync as writeFileSync12 } from "node:fs";
161484
161886
  import { userInfo as userInfo2 } from "node:os";
161485
- import { join as join19 } from "node:path";
161887
+ import { join as join20 } from "node:path";
161486
161888
  import { setTimeout as sleep2 } from "node:timers/promises";
161487
161889
  var ShellParams = type({
161488
161890
  command: "string",
@@ -161580,7 +161982,7 @@ function spawnShell(params) {
161580
161982
  const repoRoot = resolveRepoRoot();
161581
161983
  const fsMounts = buildFsMounts(repoRoot);
161582
161984
  if (sandboxMethod === "unshare") {
161583
- return spawn2(
161985
+ return spawn3(
161584
161986
  "unshare",
161585
161987
  [
161586
161988
  "--pid",
@@ -161604,7 +162006,7 @@ function spawnShell(params) {
161604
162006
  const pathRestore = 'export PATH="${SANDBOX_PATH:-$PATH}"; ';
161605
162007
  const escaped = (pathRestore + params.command).replace(/'/g, "'\\''");
161606
162008
  envArgs.push(`SANDBOX_PATH=${params.env.PATH ?? ""}`);
161607
- return spawn2(
162009
+ return spawn3(
161608
162010
  "sudo",
161609
162011
  [
161610
162012
  "env",
@@ -161620,7 +162022,7 @@ function spawnShell(params) {
161620
162022
  { ...spawnOpts, env: {} }
161621
162023
  );
161622
162024
  }
161623
- return spawn2("bash", ["-c", params.command], spawnOpts);
162025
+ return spawn3("bash", ["-c", params.command], spawnOpts);
161624
162026
  }
161625
162027
  async function killProcessGroup(proc) {
161626
162028
  if (!proc.pid) return;
@@ -161645,8 +162047,8 @@ function getTempDir() {
161645
162047
  var MAX_OUTPUT_CHARS = 5e3;
161646
162048
  function capOutput(output) {
161647
162049
  if (output.length <= MAX_OUTPUT_CHARS) return output;
161648
- const fullPath = join19(getTempDir(), `shell-${randomUUID4().slice(0, 8)}.log`);
161649
- writeFileSync11(fullPath, output);
162050
+ const fullPath = join20(getTempDir(), `shell-${randomUUID4().slice(0, 8)}.log`);
162051
+ writeFileSync12(fullPath, output);
161650
162052
  const elided = output.length - MAX_OUTPUT_CHARS;
161651
162053
  return `... [${elided} chars truncated; full output saved to ${fullPath}] ...
161652
162054
  ${output.slice(-MAX_OUTPUT_CHARS)}`;
@@ -161700,8 +162102,8 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
161700
162102
  if (params.background) {
161701
162103
  const tempDir = getTempDir();
161702
162104
  const handle = `bg-${randomUUID4().slice(0, 8)}`;
161703
- const outputPath = join19(tempDir, `${handle}.log`);
161704
- const pidPath = join19(tempDir, `${handle}.pid`);
162105
+ const outputPath = join20(tempDir, `${handle}.log`);
162106
+ const pidPath = join20(tempDir, `${handle}.pid`);
161705
162107
  const logFd = openSync(outputPath, "a");
161706
162108
  let proc2;
161707
162109
  try {
@@ -161718,7 +162120,7 @@ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead
161718
162120
  throw new Error("failed to start background process");
161719
162121
  }
161720
162122
  proc2.unref();
161721
- writeFileSync11(pidPath, `${proc2.pid}
162123
+ writeFileSync12(pidPath, `${proc2.pid}
161722
162124
  `);
161723
162125
  ctx.toolState.backgroundProcesses.set(handle, { pid: proc2.pid, outputPath, pidPath });
161724
162126
  return {
@@ -161868,7 +162270,7 @@ function UploadFileTool(ctx) {
161868
162270
 
161869
162271
  // mcp/xrepo.ts
161870
162272
  import { mkdirSync as mkdirSync8, rmSync as rmSync2 } from "node:fs";
161871
- import { join as join20 } from "node:path";
162273
+ import { join as join21 } from "node:path";
161872
162274
  function assertValidRepoName(name) {
161873
162275
  if (!/^[A-Za-z0-9._-]+$/.test(name) || name.startsWith("-") || name.includes("..")) {
161874
162276
  throw new Error(
@@ -161886,6 +162288,7 @@ var ListRepos = type({});
161886
162288
  function ListReposTool(ctx) {
161887
162289
  return tool({
161888
162290
  name: "list_repos",
162291
+ annotations: { readOnlyHint: true },
161889
162292
  description: "List the repositories available for cross-repo (`--xrepo`) work in this run, with each repo's access tier (primary, write, or read) and whether it's already checked out. Use this before `checkout_repo` to discover what you can reference or edit. Returns an empty set on single-repo runs.",
161890
162293
  parameters: ListRepos,
161891
162294
  execute: execute(async () => {
@@ -161947,7 +162350,7 @@ function CheckoutRepoTool(ctx) {
161947
162350
  return { path: existing.dir, access: existing.access, note: "already checked out." };
161948
162351
  }
161949
162352
  const access = accessFor(ctx, repo);
161950
- const dir = join20(ctx.tmpdir, "xrepo", repo);
162353
+ const dir = join21(ctx.tmpdir, "xrepo", repo);
161951
162354
  mkdirSync8(dir, { recursive: true });
161952
162355
  const state = ensureRepoState(ctx.toolState, { owner, name: repo, dir, access });
161953
162356
  const rc = resolveRepoCtx(ctx, repo);
@@ -162397,9 +162800,9 @@ function formatApiKeyErrorSummary(params) {
162397
162800
 
162398
162801
  // utils/gitAuthServer.ts
162399
162802
  import { randomUUID as randomUUID5 } from "node:crypto";
162400
- import { writeFileSync as writeFileSync12 } from "node:fs";
162803
+ import { writeFileSync as writeFileSync13 } from "node:fs";
162401
162804
  import { createServer as createServer3 } from "node:http";
162402
- import { join as join21 } from "node:path";
162805
+ import { join as join22 } from "node:path";
162403
162806
  var REVOKED_TRAP_MS = 6e4;
162404
162807
  function revokeGitHubToken(token) {
162405
162808
  fetch("https://api.github.com/installation/token", {
@@ -162468,7 +162871,7 @@ async function startGitAuthServer(tmpdir3) {
162468
162871
  function writeAskpassScript(code) {
162469
162872
  const scriptId = randomUUID5();
162470
162873
  const scriptName = `askpass-${scriptId}.js`;
162471
- const scriptPath = join21(tmpdir3, scriptName);
162874
+ const scriptPath = join22(tmpdir3, scriptName);
162472
162875
  const content = [
162473
162876
  `#!/usr/bin/env node`,
162474
162877
  `var a=process.argv[2]||"";`,
@@ -162481,7 +162884,7 @@ async function startGitAuthServer(tmpdir3) {
162481
162884
  `r.on("end",function(){process.stdout.write(d+"\\n")})`,
162482
162885
  `}).on("error",function(){process.exit(1)})}`
162483
162886
  ].join("\n");
162484
- writeFileSync12(scriptPath, content, { mode: 448 });
162887
+ writeFileSync13(scriptPath, content, { mode: 448 });
162485
162888
  return scriptPath;
162486
162889
  }
162487
162890
  async function close() {
@@ -162802,9 +163205,9 @@ When embedding images (e.g. uploaded screenshots) in comments or PR bodies, alwa
162802
163205
 
162803
163206
  **Your raw assistant messages are never delivered** \u2014 they exist only in the run logs. Anything the user is meant to see (an answer to a question, a mention reply, a result) MUST go through \`report_progress\` (or another ${pullfrogMcpName} write tool). Do not rely on returning the answer as plain text \u2014 the harness makes a best-effort attempt to recover it into the progress comment, but that is a safety net, not a substitute for calling \`report_progress\`.
162804
163207
 
162805
- **\`report_progress\`**: call this exactly once at the end of every run with a brief final summary (1-3 sentences) unless the mode guidance instructs otherwise. Never call it for intermediate status updates (e.g., "Checking for changes...", "Starting review...") \u2014 the task list handles live progress automatically. Calling \`report_progress\` replaces the task list with your summary and preserves the current task list in a collapsible section. Keep the summary concise \u2014 do not repeat what the task list already shows. Focus on the outcome (what was accomplished, links to artifacts) rather than listing individual steps. If something failed, include the tool's error text even when that makes the summary longer.
163208
+ **\`report_progress\`**: call this exactly once at the end of every run with a brief final summary (1-3 sentences) unless the mode guidance instructs otherwise or a standalone comment on the current target is the task's sole requested deliverable. Never call it for intermediate status updates (e.g., "Checking for changes...", "Starting review...") \u2014 the task list handles live progress automatically. Calling \`report_progress\` replaces the task list with your summary and preserves the current task list in a collapsible section. Keep the summary concise \u2014 do not repeat what the task list already shows. Focus on the outcome (what was accomplished, links to artifacts) rather than listing individual steps. If something failed, include the tool's error text even when that makes the summary longer.
162806
163209
 
162807
- Never use \`create_issue_comment\` for task progress \u2014 that creates duplicate comments and leaves the progress comment stuck in its initial state. \`create_issue_comment\` is only for standalone comments unrelated to your current task. Plan output (initial post AND revisions) goes through \`report_progress\` \u2014 see the Plan mode guidance for details.
163210
+ Never use \`create_issue_comment\` for task progress or for the answer that \`report_progress\` should deliver. Use it only when the task explicitly requests a separate standalone comment or a comment on a different target. Skip \`report_progress\` only when a standalone comment on the current target is the task's sole requested deliverable; successful-run cleanup then removes the progress chrome. Plan output (initial post AND revisions) goes through \`report_progress\` \u2014 see the Plan mode guidance for details.
162808
163211
 
162809
163212
  ### If you get stuck
162810
163213
 
@@ -162938,7 +163341,7 @@ function resolveInstructions(ctx) {
162938
163341
 
162939
163342
  // utils/learnings.ts
162940
163343
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
162941
- import { dirname as dirname4, join as join22 } from "node:path";
163344
+ import { dirname as dirname4, join as join23 } from "node:path";
162942
163345
 
162943
163346
  // utils/learningsTruncate.ts
162944
163347
  var MAX_LEARNINGS_LENGTH = 1e5;
@@ -162956,10 +163359,10 @@ function truncateAtLineBoundary(body, cap) {
162956
163359
  var LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
162957
163360
  var XREPO_LEARNINGS_FILE_NAME = "pullfrog-xrepo-learnings.md";
162958
163361
  function learningsFilePath(tmpdir3) {
162959
- return join22(tmpdir3, LEARNINGS_FILE_NAME);
163362
+ return join23(tmpdir3, LEARNINGS_FILE_NAME);
162960
163363
  }
162961
163364
  function xrepoLearningsFilePath(tmpdir3) {
162962
- return join22(tmpdir3, XREPO_LEARNINGS_FILE_NAME);
163365
+ return join23(tmpdir3, XREPO_LEARNINGS_FILE_NAME);
162963
163366
  }
162964
163367
  async function seedLearningsFile(params) {
162965
163368
  const path4 = learningsFilePath(params.tmpdir);
@@ -163644,8 +164047,13 @@ async function resolveProxyModel(ctx) {
163644
164047
  `\xBB no model selected \u2014 using the cost-optimized default (${ctx.proxyModel}); pick a model in your Pullfrog repo settings for stronger reviews.`
163645
164048
  );
163646
164049
  }
163647
- if (!ctx.oss && ctx.payload.model && ctx.proxyModel === DEFAULT_PROXY_MODEL && resolveOpenRouterModel(ctx.payload.model) !== DEFAULT_PROXY_MODEL) {
163648
- if (isCardGatedModel(ctx.payload.model)) {
164050
+ if (ctx.payload.model && ctx.proxyModel === DEFAULT_PROXY_MODEL && resolveOpenRouterModel(ctx.payload.model) !== DEFAULT_PROXY_MODEL) {
164051
+ if (ctx.oss) {
164052
+ ctx.toolState.modelClamped = { from: ctx.payload.model, reason: "oss" };
164053
+ log.info(
164054
+ `\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.`
164055
+ );
164056
+ } else if (isCardGatedModel(ctx.payload.model)) {
163649
164057
  ctx.toolState.modelClamped = { from: ctx.payload.model, reason: "card" };
163650
164058
  log.warning(
163651
164059
  `\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.`
@@ -163699,7 +164107,7 @@ async function runProxyResolution(ctx) {
163699
164107
 
163700
164108
  // utils/prSummary.ts
163701
164109
  import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
163702
- import { dirname as dirname5, join as join23 } from "node:path";
164110
+ import { dirname as dirname5, join as join24 } from "node:path";
163703
164111
  var SUMMARY_FILE_NAME = "pullfrog-summary.md";
163704
164112
  var SUMMARY_SCAFFOLD = `# PR summary
163705
164113
 
@@ -163709,7 +164117,7 @@ var SUMMARY_SCAFFOLD = `# PR summary
163709
164117
  var MIN_SNAPSHOT_LENGTH = 60;
163710
164118
  var MAX_SNAPSHOT_LENGTH = 32768;
163711
164119
  function summaryFilePath(tmpdir3) {
163712
- return join23(tmpdir3, SUMMARY_FILE_NAME);
164120
+ return join24(tmpdir3, SUMMARY_FILE_NAME);
163713
164121
  }
163714
164122
  async function seedSummaryFile(params) {
163715
164123
  const path4 = summaryFilePath(params.tmpdir);
@@ -164426,7 +164834,7 @@ async function finalizeSuccessRun(input) {
164426
164834
  log.debug(`failure error report failed: ${error49}`);
164427
164835
  });
164428
164836
  }
164429
- if (input.result.success && input.toolState.progressComment && input.toolState.wasUpdated && (!input.toolState.finalSummaryWritten || input.toolState.answerCommentPosted)) {
164837
+ if (input.result.success && input.toolState.progressComment && input.toolState.wasUpdated && !input.toolState.finalSummaryWritten) {
164430
164838
  await deleteProgressComment(input.toolContext).catch((error49) => {
164431
164839
  log.debug(`stranded progress comment cleanup failed: ${error49}`);
164432
164840
  });
@@ -164992,7 +165400,7 @@ ${instructions.user}` : null,
164992
165400
  log.info(instructions.full);
164993
165401
  });
164994
165402
  if (agentId === "opencode") {
164995
- const pluginDir = join24(process.cwd(), ".opencode", "plugin");
165403
+ const pluginDir = join25(process.cwd(), ".opencode", "plugin");
164996
165404
  const hasPlugins = existsSync8(pluginDir) && readdirSync2(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
164997
165405
  if (hasPlugins && toolState.dependencyInstallation?.promise) {
164998
165406
  log.info(