pullfrog 0.1.1 → 0.1.3

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
@@ -18198,7 +18198,7 @@ var require_summary = __commonJS({
18198
18198
  exports.summary = exports.markdownSummary = exports.SUMMARY_DOCS_URL = exports.SUMMARY_ENV_VAR = void 0;
18199
18199
  var os_1 = __require("os");
18200
18200
  var fs_1 = __require("fs");
18201
- var { access, appendFile, writeFile: writeFile3 } = fs_1.promises;
18201
+ var { access, appendFile, writeFile: writeFile4 } = fs_1.promises;
18202
18202
  exports.SUMMARY_ENV_VAR = "GITHUB_STEP_SUMMARY";
18203
18203
  exports.SUMMARY_DOCS_URL = "https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary";
18204
18204
  var Summary = class {
@@ -18256,7 +18256,7 @@ var require_summary = __commonJS({
18256
18256
  return __awaiter(this, void 0, void 0, function* () {
18257
18257
  const overwrite = !!(options === null || options === void 0 ? void 0 : options.overwrite);
18258
18258
  const filePath = yield this.filePath();
18259
- const writeFunc = overwrite ? writeFile3 : appendFile;
18259
+ const writeFunc = overwrite ? writeFile4 : appendFile;
18260
18260
  yield writeFunc(filePath, this._buffer, { encoding: "utf8" });
18261
18261
  return this.emptyBuffer();
18262
18262
  });
@@ -62662,8 +62662,8 @@ var require_snapshot_utils = __commonJS({
62662
62662
  var require_snapshot_recorder = __commonJS({
62663
62663
  "node_modules/.pnpm/undici@7.22.0/node_modules/undici/lib/mock/snapshot-recorder.js"(exports, module) {
62664
62664
  "use strict";
62665
- var { writeFile: writeFile3, readFile: readFile4, mkdir: mkdir2 } = __require("node:fs/promises");
62666
- var { dirname: dirname5, resolve: resolve3 } = __require("node:path");
62665
+ var { writeFile: writeFile4, readFile: readFile5, mkdir: mkdir3 } = __require("node:fs/promises");
62666
+ var { dirname: dirname6, resolve: resolve3 } = __require("node:path");
62667
62667
  var { setTimeout: setTimeout2, clearTimeout: clearTimeout2 } = __require("node:timers");
62668
62668
  var { InvalidArgumentError, UndiciError } = require_errors4();
62669
62669
  var { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = require_snapshot_utils();
@@ -62864,7 +62864,7 @@ var require_snapshot_recorder = __commonJS({
62864
62864
  throw new InvalidArgumentError("Snapshot path is required");
62865
62865
  }
62866
62866
  try {
62867
- const data = await readFile4(resolve3(path3), "utf8");
62867
+ const data = await readFile5(resolve3(path3), "utf8");
62868
62868
  const parsed2 = JSON.parse(data);
62869
62869
  if (Array.isArray(parsed2)) {
62870
62870
  this.#snapshots.clear();
@@ -62894,12 +62894,12 @@ var require_snapshot_recorder = __commonJS({
62894
62894
  throw new InvalidArgumentError("Snapshot path is required");
62895
62895
  }
62896
62896
  const resolvedPath = resolve3(path3);
62897
- await mkdir2(dirname5(resolvedPath), { recursive: true });
62897
+ await mkdir3(dirname6(resolvedPath), { recursive: true });
62898
62898
  const data = Array.from(this.#snapshots.entries()).map(([hash2, snapshot2]) => ({
62899
62899
  hash: hash2,
62900
62900
  snapshot: snapshot2
62901
62901
  }));
62902
- await writeFile3(resolvedPath, JSON.stringify(data, null, 2), { flush: true });
62902
+ await writeFile4(resolvedPath, JSON.stringify(data, null, 2), { flush: true });
62903
62903
  }
62904
62904
  /**
62905
62905
  * Clears all recorded snapshots
@@ -97475,14 +97475,14 @@ var require_turndown_cjs = __commonJS({
97475
97475
  } else if (node2.nodeType === 1) {
97476
97476
  replacement = replacementForNode.call(self2, node2);
97477
97477
  }
97478
- return join17(output, replacement);
97478
+ return join18(output, replacement);
97479
97479
  }, "");
97480
97480
  }
97481
97481
  function postProcess(output) {
97482
97482
  var self2 = this;
97483
97483
  this.rules.forEach(function(rule) {
97484
97484
  if (typeof rule.append === "function") {
97485
- output = join17(output, rule.append(self2.options));
97485
+ output = join18(output, rule.append(self2.options));
97486
97486
  }
97487
97487
  });
97488
97488
  return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, "");
@@ -97494,7 +97494,7 @@ var require_turndown_cjs = __commonJS({
97494
97494
  if (whitespace.leading || whitespace.trailing) content = content.trim();
97495
97495
  return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing;
97496
97496
  }
97497
- function join17(output, replacement) {
97497
+ function join18(output, replacement) {
97498
97498
  var s1 = trimTrailingNewlines(output);
97499
97499
  var s2 = trimLeadingNewlines(replacement);
97500
97500
  var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
@@ -98926,8 +98926,8 @@ var require_fast_content_type_parse = __commonJS({
98926
98926
  // main.ts
98927
98927
  var core6 = __toESM(require_core(), 1);
98928
98928
  import { existsSync as existsSync7, readdirSync } from "node:fs";
98929
- import { readFile as readFile3 } from "node:fs/promises";
98930
- import { join as join16 } from "node:path";
98929
+ import { readFile as readFile4 } from "node:fs/promises";
98930
+ import { join as join17 } from "node:path";
98931
98931
 
98932
98932
  // node_modules/.pnpm/@ark+util@0.56.0/node_modules/@ark/util/out/arrays.js
98933
98933
  var liftArray = (data) => Array.isArray(data) ? data : [data];
@@ -107423,7 +107423,7 @@ function buildCommitPrompt(status) {
107423
107423
  ].join("\n");
107424
107424
  }
107425
107425
  function hasPostRunIssues(issues) {
107426
- return issues.stopHook !== void 0 || issues.dirtyTree !== void 0 || issues.summaryStale !== void 0;
107426
+ return issues.stopHook !== void 0 || issues.dirtyTree !== void 0 || issues.summaryStale !== void 0 || issues.unsubmittedReview !== void 0;
107427
107427
  }
107428
107428
  var agent = (input) => {
107429
107429
  return {
@@ -107723,6 +107723,13 @@ function getApiUrl() {
107723
107723
  log.debug(`resolved API_URL: ${raw2}`);
107724
107724
  return raw2;
107725
107725
  }
107726
+ function isLocalApiUrl() {
107727
+ try {
107728
+ return isLocalUrl(new URL(getApiUrl()));
107729
+ } catch {
107730
+ return false;
107731
+ }
107732
+ }
107726
107733
 
107727
107734
  // models.ts
107728
107735
  function provider(config3) {
@@ -108961,6 +108968,7 @@ function CreateCommentTool(ctx) {
108961
108968
  body: bodyWithFooter
108962
108969
  });
108963
108970
  ctx.toolState.wasUpdated = true;
108971
+ log.info(`\xBB created comment ${result.data.id}`);
108964
108972
  if (commentType === "Plan") {
108965
108973
  if (result.data.node_id) {
108966
108974
  await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
@@ -108974,6 +108982,7 @@ function CreateCommentTool(ctx) {
108974
108982
  comment_id: result.data.id,
108975
108983
  body: bodyWithPlanLink
108976
108984
  });
108985
+ log.info(`\xBB updated comment ${updateResult.data.id}`);
108977
108986
  return {
108978
108987
  success: true,
108979
108988
  commentId: updateResult.data.id,
@@ -109007,6 +109016,7 @@ function EditCommentTool(ctx) {
109007
109016
  comment_id: commentId,
109008
109017
  body: bodyWithFooter
109009
109018
  });
109019
+ log.info(`\xBB updated comment ${result.data.id}`);
109010
109020
  return {
109011
109021
  success: true,
109012
109022
  commentId: result.data.id,
@@ -109142,6 +109152,9 @@ ${collapsible}`;
109142
109152
  message: "progress recorded (no GitHub comment created - this may occur for workflow_dispatch events or when there is no associated issue/PR)"
109143
109153
  };
109144
109154
  }
109155
+ if (result.commentId !== void 0) {
109156
+ log.info(`\xBB ${result.action} comment ${result.commentId}`);
109157
+ }
109145
109158
  if (!params.target_plan_comment) {
109146
109159
  ctx.toolState.finalSummaryWritten = true;
109147
109160
  }
@@ -109178,13 +109191,38 @@ var ReplyToReviewComment = type({
109178
109191
  "extremely brief reply (1 sentence max) explaining what was fixed, e.g. 'Fixed by renaming to X' or 'Added null check'"
109179
109192
  )
109180
109193
  });
109194
+ function duplicateReplyDecision(params) {
109195
+ const existing = params.existing;
109196
+ if (!existing) return null;
109197
+ if (existing.bodyWithFooter !== params.bodyWithFooter) return null;
109198
+ return {
109199
+ kind: "already-replied",
109200
+ commentId: existing.commentId,
109201
+ url: existing.url,
109202
+ reason: `reply ${existing.commentId} with identical body was already posted in this session; ignoring duplicate call`
109203
+ };
109204
+ }
109181
109205
  function ReplyToReviewCommentTool(ctx) {
109182
109206
  return tool({
109183
109207
  name: "reply_to_review_comment",
109184
- description: "Reply to a PR review comment thread (NOT issue comments \u2014 this only works for inline review comments on PR diffs). Call this for EACH comment you address in AddressReviews mode. Keep replies extremely brief (1 sentence max).",
109208
+ description: "Reply to a PR review comment thread (NOT issue comments \u2014 this only works for inline review comments on PR diffs). Call exactly ONCE per parent comment you address in AddressReviews mode \u2014 duplicate calls with the same body are a no-op. Keep replies extremely brief (1 sentence max).",
109185
109209
  parameters: ReplyToReviewComment,
109186
109210
  execute: execute(async ({ pull_number, comment_id, body }) => {
109187
109211
  const bodyWithFooter = addFooter(ctx, body);
109212
+ const dup = duplicateReplyDecision({
109213
+ existing: ctx.toolState.reviewReplies?.get(comment_id),
109214
+ bodyWithFooter
109215
+ });
109216
+ if (dup) {
109217
+ log.info(`skipping duplicate review reply: ${dup.reason}`);
109218
+ return {
109219
+ success: true,
109220
+ skipped: true,
109221
+ reason: dup.reason,
109222
+ commentId: dup.commentId,
109223
+ url: dup.url
109224
+ };
109225
+ }
109188
109226
  const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
109189
109227
  owner: ctx.repo.owner,
109190
109228
  repo: ctx.repo.name,
@@ -109192,7 +109230,14 @@ function ReplyToReviewCommentTool(ctx) {
109192
109230
  comment_id,
109193
109231
  body: bodyWithFooter
109194
109232
  });
109233
+ log.info(`\xBB created review comment ${result.data.id} (in reply to ${comment_id})`);
109195
109234
  ctx.toolState.wasUpdated = true;
109235
+ ctx.toolState.reviewReplies ??= /* @__PURE__ */ new Map();
109236
+ ctx.toolState.reviewReplies.set(comment_id, {
109237
+ commentId: result.data.id,
109238
+ url: result.data.html_url,
109239
+ bodyWithFooter
109240
+ });
109196
109241
  return {
109197
109242
  success: true,
109198
109243
  commentId: result.data.id,
@@ -109741,11 +109786,6 @@ async function spawn(options) {
109741
109786
  `spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
109742
109787
  );
109743
109788
  activityCheckIntervalId = setInterval(() => {
109744
- if (options.isPausedExternally?.()) {
109745
- lastActivityTime = performance3.now();
109746
- log.debug(`spawn activity check: pid=${child.pid} paused externally`);
109747
- return;
109748
- }
109749
109789
  const idleMs = performance3.now() - lastActivityTime;
109750
109790
  log.debug(
109751
109791
  `spawn activity check: pid=${child.pid} idle=${Math.round(idleMs)}ms / ${activityTimeoutMs}ms`
@@ -109944,13 +109984,13 @@ var installNodeDependencies = {
109944
109984
  };
109945
109985
  }
109946
109986
  }
109947
- const resolved = resolveCommand(agent2, "frozen", []) || resolveCommand(agent2, "install", []);
109987
+ const resolved = resolveCommand(agent2, "frozen", []);
109948
109988
  if (!resolved) {
109949
109989
  return {
109950
109990
  language: "node",
109951
109991
  packageManager,
109952
109992
  dependenciesInstalled: false,
109953
- issues: [`no install command found for ${agent2}`]
109993
+ issues: [`no frozen-install command available for ${agent2}`]
109954
109994
  };
109955
109995
  }
109956
109996
  if (options.ignoreScripts) {
@@ -142266,7 +142306,7 @@ var import_semver = __toESM(require_semver2(), 1);
142266
142306
  // package.json
142267
142307
  var package_default = {
142268
142308
  name: "pullfrog",
142269
- version: "0.1.1",
142309
+ version: "0.1.3",
142270
142310
  type: "module",
142271
142311
  bin: {
142272
142312
  pullfrog: "dist/cli.mjs",
@@ -143210,6 +143250,10 @@ ${integrateStep}
143210
143250
  if (!pushed) {
143211
143251
  throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
143212
143252
  }
143253
+ const pushedSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
143254
+ log.info(
143255
+ `\xBB pushed branch ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch} (sha ${pushedSha})`
143256
+ );
143213
143257
  return {
143214
143258
  success: true,
143215
143259
  branch,
@@ -143358,6 +143402,7 @@ function DeleteBranchTool(ctx) {
143358
143402
  await $git("push", ["origin", "--delete", `refs/heads/${params.branchName}`], {
143359
143403
  token: ctx.gitToken
143360
143404
  });
143405
+ log.info(`\xBB deleted branch ${params.branchName}`);
143361
143406
  return { success: true, deleted: params.branchName };
143362
143407
  })
143363
143408
  });
@@ -143383,6 +143428,7 @@ function PushTagsTool(ctx) {
143383
143428
  await $git("push", pushArgs, {
143384
143429
  token: ctx.gitToken
143385
143430
  });
143431
+ log.info(`\xBB pushed tag ${params.tag}`);
143386
143432
  return { success: true, tag: params.tag };
143387
143433
  })
143388
143434
  });
@@ -143537,7 +143583,7 @@ var CreatePullRequestReview = type({
143537
143583
  "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."
143538
143584
  ).optional(),
143539
143585
  approved: type.boolean.describe(
143540
- "Set to true to submit as an approval. ONLY when the review contains no actionable feedback \u2014 neither inline comments nor actionable content in the body. Defaults to false (comment-only review). Rejections are not supported."
143586
+ "Set to true to submit as an approval. Use for both 'no issues found' and informational `> [!NOTE]` 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 `> [!IMPORTANT]` (recommended changes) and `> [!CAUTION]` (critical) reviews. Defaults to false (comment-only review). Rejections are not supported."
143541
143587
  ).optional(),
143542
143588
  commit_id: type.string.describe("Optional SHA of the commit being reviewed. Defaults to latest.").optional(),
143543
143589
  comments: type({
@@ -143707,6 +143753,7 @@ function CreatePullRequestReviewTool(ctx) {
143707
143753
  }
143708
143754
  const reviewId = result.data.id;
143709
143755
  const reviewNodeId = result.data.node_id;
143756
+ log.info(`\xBB created review ${reviewId} on pull request #${pull_number}`);
143710
143757
  const actuallyReviewedSha = ctx.toolState.checkoutSha ?? params.commit_id;
143711
143758
  ctx.toolState.review = {
143712
143759
  id: reviewId,
@@ -144066,6 +144113,8 @@ async function ensureBeforeShaReachable(params) {
144066
144113
  }
144067
144114
  }
144068
144115
  var STALE_LOCK_AGE_MS = 3e4;
144116
+ var PULL_REF_RETRY_DELAYS_MS = [2e3, 5e3, 1e4];
144117
+ var PULL_REF_MISSING_PATTERN = /couldn't find remote ref pull\/\d+\/head/i;
144069
144118
  var GIT_LOCK_PATHS = [
144070
144119
  ".git/shallow.lock",
144071
144120
  ".git/index.lock",
@@ -144091,6 +144140,27 @@ function cleanupStaleGitLocks() {
144091
144140
  }
144092
144141
  }
144093
144142
  }
144143
+ async function isPullRequestStillDispatchable(args2) {
144144
+ try {
144145
+ const { data } = await args2.octokit.rest.pulls.get({
144146
+ owner: args2.owner,
144147
+ repo: args2.repo,
144148
+ pull_number: args2.pr.number
144149
+ });
144150
+ if (data.state !== "open") return false;
144151
+ if (data.head.sha !== args2.pr.headSha) return false;
144152
+ return true;
144153
+ } catch {
144154
+ return true;
144155
+ }
144156
+ }
144157
+ async function abortIfPullRequestMoved(args2) {
144158
+ const stillValid = await isPullRequestStillDispatchable(args2);
144159
+ if (stillValid) return;
144160
+ throw new Error(
144161
+ `PR #${args2.pr.number} is no longer in the state it was at dispatch (likely closed, merged, or force-pushed between webhook fire and run start). aborting checkout \u2014 re-trigger the run if this PR is still active.`
144162
+ );
144163
+ }
144094
144164
  async function checkoutPrBranch(pr, params) {
144095
144165
  const { octokit, owner, name, gitToken, toolState, beforeSha } = params;
144096
144166
  log.info(`\xBB checking out PR #${pr.number}...`);
@@ -144107,9 +144177,26 @@ async function checkoutPrBranch(pr, params) {
144107
144177
  if (!alreadyOnBranch) {
144108
144178
  $("git", ["checkout", "-B", pr.baseRef, `origin/${pr.baseRef}`], { log: false });
144109
144179
  log.debug(`\xBB fetching PR #${pr.number} (${localBranch})...`);
144110
- await $git("fetch", ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`], {
144111
- token: gitToken
144112
- });
144180
+ await retry(
144181
+ async () => {
144182
+ try {
144183
+ await $git("fetch", ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`], {
144184
+ token: gitToken
144185
+ });
144186
+ } catch (e) {
144187
+ const msg = e instanceof Error ? e.message : String(e);
144188
+ if (PULL_REF_MISSING_PATTERN.test(msg)) {
144189
+ await abortIfPullRequestMoved({ octokit, owner, repo: name, pr });
144190
+ }
144191
+ throw e;
144192
+ }
144193
+ },
144194
+ {
144195
+ delaysMs: PULL_REF_RETRY_DELAYS_MS,
144196
+ label: `pull/${pr.number}/head fetch`,
144197
+ shouldRetry: (e) => PULL_REF_MISSING_PATTERN.test(e instanceof Error ? e.message : String(e))
144198
+ }
144199
+ );
144113
144200
  $("git", ["checkout", localBranch], { log: false });
144114
144201
  log.debug(`\xBB checked out PR #${pr.number}`);
144115
144202
  toolState.checkoutSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
@@ -144571,6 +144658,7 @@ function IssueTool(ctx) {
144571
144658
  labels: params.labels ?? [],
144572
144659
  assignees: params.assignees ?? []
144573
144660
  });
144661
+ log.info(`\xBB created issue #${result.data.number} (id ${result.data.id})`);
144574
144662
  const nodeId = result.data.node_id;
144575
144663
  if (typeof nodeId === "string" && nodeId.length > 0) {
144576
144664
  await patchWorkflowRunFields(ctx, {
@@ -144762,6 +144850,7 @@ function AddLabelsTool(ctx) {
144762
144850
  issue_number,
144763
144851
  labels
144764
144852
  });
144853
+ log.info(`\xBB added labels [${labels.join(", ")}] to issue #${issue_number}`);
144765
144854
  return {
144766
144855
  success: true,
144767
144856
  labels: result.data.map((label) => label.name)
@@ -144770,40 +144859,6 @@ function AddLabelsTool(ctx) {
144770
144859
  });
144771
144860
  }
144772
144861
 
144773
- // mcp/learnings.ts
144774
- var UpdateLearningsParams = type({
144775
- learnings: type.string.describe(
144776
- "the FULL merged learnings as a flat bullet list. each line starts with `- `. one discrete, actionable fact per bullet. combine existing bullets from the prompt with your new discoveries. deduplicate \u2014 if an existing bullet covers the same fact, update it in place rather than adding a new one. drop bullets that are clearly wrong or no longer relevant to the current codebase. keep the list focused and concise."
144777
- )
144778
- });
144779
- function UpdateLearningsTool(ctx) {
144780
- return tool({
144781
- name: "update_learnings",
144782
- description: "persist operational learnings about this repository (setup steps, test commands, key conventions, patterns). ONLY call this when you have high confidence the information is correct and broadly useful for future runs \u2014 not for one-off findings or uncertain observations. format: flat bullet list (`- ` per line, one fact per bullet). pass the FULL merged list \u2014 combine existing learnings from the prompt with new discoveries. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.",
144783
- parameters: UpdateLearningsParams,
144784
- execute: execute(async (params) => {
144785
- const response = await apiFetch({
144786
- path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
144787
- method: "PATCH",
144788
- headers: {
144789
- authorization: `Bearer ${ctx.apiToken}`,
144790
- "content-type": "application/json"
144791
- },
144792
- body: JSON.stringify({
144793
- learnings: params.learnings,
144794
- model: ctx.toolState.model
144795
- }),
144796
- signal: AbortSignal.timeout(1e4)
144797
- });
144798
- if (!response.ok) {
144799
- const error49 = await response.text();
144800
- throw new Error(`failed to update learnings: ${error49}`);
144801
- }
144802
- return { success: true };
144803
- })
144804
- });
144805
- }
144806
-
144807
144862
  // mcp/output.ts
144808
144863
  var import_ajv3 = __toESM(require_ajv(), 1);
144809
144864
  var SetOutputParams = type({
@@ -144897,6 +144952,7 @@ function UpdatePullRequestBodyTool(ctx) {
144897
144952
  pull_number: params.pull_number,
144898
144953
  body: bodyWithFooter
144899
144954
  });
144955
+ log.info(`\xBB updated pull request #${result.data.number}`);
144900
144956
  ctx.toolState.wasUpdated = true;
144901
144957
  return {
144902
144958
  success: true,
@@ -144924,6 +144980,7 @@ function CreatePullRequestTool(ctx) {
144924
144980
  base: params.base,
144925
144981
  draft: params.draft ?? false
144926
144982
  });
144983
+ log.info(`\xBB created pull request #${result.data.number} (id ${result.data.id})`);
144927
144984
  const reviewer = ctx.payload.triggerer;
144928
144985
  if (reviewer) {
144929
144986
  try {
@@ -145475,7 +145532,7 @@ function ResolveReviewThreadTool(ctx) {
145475
145532
  threadId: params.thread_id
145476
145533
  });
145477
145534
  const thread = response.resolveReviewThread.thread;
145478
- log.debug(`resolved thread ${thread.id}, isResolved=${thread.isResolved}`);
145535
+ log.info(`\xBB resolved review thread ${thread.id}`);
145479
145536
  return {
145480
145537
  thread_id: thread.id,
145481
145538
  is_resolved: thread.isResolved,
@@ -145516,13 +145573,14 @@ function buildModeOverrides(t) {
145516
145573
 
145517
145574
  An existing plan comment was found for this issue. Update that comment with the revised plan \u2014 do not create a new plan comment.
145518
145575
 
145519
- 1. Use \`previousPlanBody\` from this response as the plan to revise; do not call \`get_issue\` or \`get_issue_comments\`.
145520
- 2. Revise the plan based on the user's request:
145576
+ 1. **task list**: create your task list for this run as your first action.
145577
+ 2. Use \`previousPlanBody\` from this response as the plan to revise; do not call \`get_issue\` or \`get_issue_comments\`.
145578
+ 3. Revise the plan based on the user's request:
145521
145579
  - incorporate the current plan (\`previousPlanBody\`) and the user's revision request
145522
145580
  - gather relevant codebase context (file paths, architecture notes from AGENTS.md)
145523
145581
  - produce a structured plan with clear milestones
145524
- 3. Call \`${t("report_progress")}\` with the full revised plan text and \`{ target_plan_comment: true }\` so it updates the existing plan comment (not the progress comment).
145525
- 4. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${t("report_progress")}\` so it is not left as "Leaping...".`
145582
+ 4. Call \`${t("report_progress")}\` with the full revised plan text and \`{ target_plan_comment: true }\` so it updates the existing plan comment (not the progress comment).
145583
+ 5. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${t("report_progress")}\` so it is not left as "Leaping...".`
145526
145584
  };
145527
145585
  }
145528
145586
  var modeInstructionParent = {
@@ -145947,24 +146005,13 @@ function UploadFileTool(ctx) {
145947
146005
  if (!uploadResponse.ok) {
145948
146006
  throw new Error(`failed to upload file: ${uploadResponse.statusText}`);
145949
146007
  }
146008
+ log.info(`\xBB uploaded file ${publicUrl}`);
145950
146009
  return { success: true, publicUrl, filename, contentLength, contentType };
145951
146010
  })
145952
146011
  });
145953
146012
  }
145954
146013
 
145955
146014
  // mcp/server.ts
145956
- function initToolState(params) {
145957
- const resolved = parseProgressComment(params.progressComment);
145958
- if (resolved) {
145959
- log.info(`\xBB using pre-created progress comment: ${resolved.id} (${resolved.type})`);
145960
- }
145961
- return {
145962
- progressComment: resolved,
145963
- hadProgressComment: !!resolved,
145964
- backgroundProcesses: /* @__PURE__ */ new Map(),
145965
- usageEntries: []
145966
- };
145967
- }
145968
146015
  var mcpPortStart = 3764;
145969
146016
  var mcpPortAttempts = 100;
145970
146017
  var mcpHost = "127.0.0.1";
@@ -146040,8 +146087,7 @@ function buildOrchestratorTools(ctx, outputSchema) {
146040
146087
  PushTagsTool(ctx),
146041
146088
  DeleteBranchTool(ctx),
146042
146089
  CreatePullRequestTool(ctx),
146043
- UpdatePullRequestBodyTool(ctx),
146044
- UpdateLearningsTool(ctx)
146090
+ UpdatePullRequestBodyTool(ctx)
146045
146091
  ];
146046
146092
  }
146047
146093
  async function tryStartMcpServer(ctx, tools, port) {
@@ -146198,9 +146244,6 @@ Rules:
146198
146244
  - Do NOT include a changelog section \u2014 the key changes list serves this purpose
146199
146245
  - Focus on *intent*, not *what* \u2014 the diff already shows what changed
146200
146246
  - Get the file count and commit count from the checkout_pr metadata, not by counting manually`;
146201
- function learningsStep(t, n) {
146202
- return `${n}. **learnings** (only if high confidence): if you discovered something about repo setup, test commands, conventions, or patterns that you are confident is correct and would reliably help future runs, call \`${t("update_learnings")}\` to persist it. skip this step if you are unsure or the finding is speculative/one-off. format as a flat bullet list (\`- \` per line, one fact per bullet). merge with existing learnings from the prompt \u2014 pass the FULL merged list. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`;
146203
- }
146204
146247
  function computeModes(agentId) {
146205
146248
  const t = (toolName) => formatMcpToolRef(agentId, toolName);
146206
146249
  return [
@@ -146209,18 +146252,20 @@ function computeModes(agentId) {
146209
146252
  description: "Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details",
146210
146253
  prompt: `### Checklist
146211
146254
 
146212
- 1. **plan** (optional, for complex tasks): analyze requirements, read AGENTS.md and relevant code, produce a step-by-step implementation plan.
146255
+ 1. **task list**: create your task list for this run as your first action.
146213
146256
 
146214
- 2. **setup**: checkout or create the branch:
146257
+ 2. **plan** (optional, for complex tasks): analyze requirements, read AGENTS.md and relevant code, produce a step-by-step implementation plan.
146258
+
146259
+ 3. **setup**: checkout or create the branch:
146215
146260
  - **PR event, modifying the existing PR**: call \`${t("checkout_pr")}\`
146216
146261
  - **new branch**: use \`${t("git")}\` to create a branch (\`git checkout -b pullfrog/branch-name\`)
146217
146262
 
146218
- 3. **build**: implement changes using your native file and shell tools:
146263
+ 4. **build**: implement changes using your native file and shell tools:
146219
146264
  - follow the plan (if you ran a plan phase)
146220
146265
  - plan your approach before writing code: identify which files need to change, key design decisions, and edge cases. for non-trivial changes, consider whether there's a more elegant approach.
146221
146266
  - run relevant tests/lints before committing
146222
146267
 
146223
- 4. **self-review**: judgment call \u2014 does YOUR diff warrant a fresh-eyes pass?
146268
+ 5. **self-review**: judgment call \u2014 does YOUR diff warrant a fresh-eyes pass?
146224
146269
 
146225
146270
  Skip self-review (commit directly) when the diff is **genuinely trivial**:
146226
146271
  - doc typos, comment-only edits, whitespace/format-only, import reordering
@@ -146251,13 +146296,11 @@ function computeModes(agentId) {
146251
146296
 
146252
146297
  Review the findings, address valid points, and discard nitpicks or false positives. The reviewer is fallible \u2014 it biases toward *recommending additions* (defensive checks for impossible cases, extra logging, new abstractions used once, comments restating code, tests asserting tautologies, "just-in-case" guards). For each finding, ask: would applying it leave the code more sound, correct, AND elegant? Two-out-of-three is usually a signal to look harder for a fix that gets all three before settling for one that trades elegance for correctness. Reject bloat-shaped findings without applying them, and after applying the rest re-read your diff and be discerning about what *you just changed*: if any fix turned out to be bloat in context, revert it. The goal is code that is sound and correct *while remaining elegant*; the smallest diff that fixes the real defect almost always wins. Then verify only intended changes are present, no debug artifacts or commented-out code remain, no unrelated files were modified. Commit locally via shell (\`git add . && git commit -m "..."\`).
146253
146298
 
146254
- 5. **finalize**:
146299
+ 6. **finalize**:
146255
146300
  - confirm a clean working tree, then push via \`${t("push_branch")}\` (see *SYSTEM* Git rules if this fails \u2014 prepush errors are usually the repo's tests/lint, not infra timeouts)
146256
146301
  - create a PR via \`${t("create_pull_request")}\`
146257
146302
  - call \`${t("report_progress")}\` with the PR link or the exact error if push/PR failed
146258
146303
 
146259
- ${learningsStep(t, 6)}
146260
-
146261
146304
  ### Notes
146262
146305
 
146263
146306
  For simple, well-defined tasks, skip the plan phase and go straight to build.`
@@ -146267,27 +146310,27 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`
146267
146310
  description: "Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR",
146268
146311
  prompt: `### Checklist
146269
146312
 
146270
- 1. Checkout the PR branch via \`${t("checkout_pr")}\`.
146313
+ 1. **task list**: create your task list for this run as your first action.
146271
146314
 
146272
- 2. Fetch review comments via \`${t("get_review_comments")}\`.
146315
+ 2. Checkout the PR branch via \`${t("checkout_pr")}\`.
146273
146316
 
146274
- 3. For each comment:
146317
+ 3. Fetch review comments via \`${t("get_review_comments")}\`.
146318
+
146319
+ 4. For each comment:
146275
146320
  - understand the feedback
146276
146321
  - evaluate whether applying it would leave the code more **sound, correct, AND elegant**. reviewers are fallible and bias toward *recommending additions* (defensive checks for impossible cases, extra abstractions, comments restating obvious code, tests asserting tautologies, "just-in-case" guards). if a request would add bloat \u2014 ceremony without commensurate correctness benefit \u2014 push back in your reply rather than mechanically applying it. two-out-of-three is usually a signal to look harder for a fix that gets all three before settling.
146277
146322
  - if the request stands, make the code change using your native tools; otherwise reply explaining why
146278
146323
  - record what was done (or why nothing was done)
146279
146324
 
146280
- 4. Quality check:
146325
+ 5. Quality check:
146281
146326
  - test changes, then review the diff before committing \u2014 verify only intended changes are present, no debug artifacts remain, no fix turned out to be bloat in context (revert any that did), and the changes are clean enough that a senior engineer would approve without hesitation
146282
146327
  - commit locally via shell (\`git add . && git commit -m "..."\`)
146283
146328
 
146284
- 5. Finalize:
146329
+ 6. Finalize:
146285
146330
  - confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
146286
- - reply to each comment using \`${t("reply_to_review_comment")}\`
146331
+ - reply to each comment **exactly once** using \`${t("reply_to_review_comment")}\` \u2014 do not re-emit the same call (the runtime dedupes identical bodies and the second call is wasted)
146287
146332
  - resolve addressed threads via \`${t("resolve_review_thread")}\`
146288
- - call \`${t("report_progress")}\` with a brief summary (or the exact push error if push failed)
146289
-
146290
- ${learningsStep(t, 6)}`
146333
+ - call \`${t("report_progress")}\` with a brief summary (or the exact push error if push failed)`
146291
146334
  },
146292
146335
  // Review and IncrementalReview use the multi-lens orchestrator pattern
146293
146336
  // (canonical source: .claude/commands/anneal.md). The orchestrator does
@@ -146306,11 +146349,13 @@ ${learningsStep(t, 6)}`
146306
146349
  description: "Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
146307
146350
  prompt: `### Checklist
146308
146351
 
146309
- 1. **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.
146352
+ 1. **task list**: create your task list for this run as your first action.
146310
146353
 
146311
- 2. **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). orientation only \u2014 defer specific defect-hunting to the subagents; pre-reviewing biases the lenses you pick. use \`${t("get_pull_request")}\` and other read-only GitHub tools for additional context if needed.
146354
+ 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.
146312
146355
 
146313
- if the PR is **genuinely trivial**, skip steps 3\u20134 entirely and submit a \`No new issues found.\` review per step 5. there's no value in dispatching even one lens for a typo.
146356
+ 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). orientation only \u2014 defer specific defect-hunting to the subagents; pre-reviewing biases the lenses you pick. use \`${t("get_pull_request")}\` and other read-only GitHub tools for additional context if needed.
146357
+
146358
+ if the PR is **genuinely trivial**, skip steps 4\u20135 entirely and submit a \`No new issues found.\` review per step 6. there's no value in dispatching even one lens for a typo.
146314
146359
 
146315
146360
  "Genuinely trivial" (skip):
146316
146361
  - single-word doc typo, whitespace/format-only, comment-only across any number of files
@@ -146355,7 +146400,7 @@ ${learningsStep(t, 6)}`
146355
146400
  - **holistic** \u2014 does the PR make sense as a whole? symmetric flows (delete for every create, rollback for every migration)?
146356
146401
  - **subsystem lenses** (invent as the PR demands) \u2014 auth, billing, payments, schema migration, webhooks, secrets, RBAC, multi-tenant isolation, cron/scheduling, etc.
146357
146402
 
146358
- 3. **fan out**: dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens \u2014 its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). when picking 2+ lenses, dispatch them in a **single assistant turn with multiple parallel subagent calls**; issuing one and awaiting reply before the next collapses the fan-out into a serial review. 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 step 3 entirely on a single subagent failure. each subagent gets:
146403
+ 4. **fan out**: dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens \u2014 its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). when picking 2+ lenses, dispatch them in a **single assistant turn with multiple parallel subagent calls**; issuing one and awaiting reply before the next collapses the fan-out into a serial review. 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 step 4 entirely on a single subagent failure. each subagent gets:
146359
146404
  - the diff path / target \u2014 reading the diff and the codebase is its job
146360
146405
  - **only one lens** \u2014 never a multi-section "review for X, Y, and Z" prompt
146361
146406
  - **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\`.
@@ -146370,20 +146415,33 @@ ${learningsStep(t, 6)}`
146370
146415
  - do NOT pre-shape their output with a finding schema
146371
146416
  - do NOT mention the other lenses (independence is the point \u2014 overlapping findings are a strong signal)
146372
146417
 
146373
- 4. **aggregate & draft**: 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.
146418
+ 5. **aggregate & draft**: 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.
146374
146419
 
146375
146420
  for surviving findings, draft inline comments with NEW line numbers from the diff. every comment must be actionable, 2-3 sentences max. use GitHub permalink format for code references. for impact-analysis findings (stale references after rename/remove), report them in the review body ordered by severity (runtime breakage > incorrect docs > stale comments) rather than as inline comments unless they're anchored to a specific line.
146376
146421
 
146377
- 5. **submit**: ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\`. Do NOT call \`report_progress\` \u2014 the review is the final record and the progress comment will be cleaned up automatically.
146422
+ 6. **submit**: ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\`. Do NOT call \`report_progress\` \u2014 the review is the final record and the progress comment will be cleaned up automatically.
146378
146423
 
146379
146424
  note: the first create_pull_request_review submission may error with a one-time diff-coverage nudge listing unread TOC regions. retry the same call to proceed \u2014 optionally after reading the listed ranges. the pre-flight will not block again this session.
146380
146425
 
146381
146426
  The review body is structured as: \`[optional alert blockquote]\` \u2192 \`[PR summary using the default format below]\`. Inline comments are passed via the \`comments\` parameter, not in the body.
146382
146427
 
146383
- - **critical issues** (blocks merge \u2014 bugs, security, data loss):
146428
+ GitHub alert blockquotes render at four visual intensities \u2014 the callout is what the author sees first, so pick the one that matches what you want them to do:
146429
+
146430
+ - \`[!CAUTION]\` \u2014 large red banner. Reads as "this will break something."
146431
+ - \`[!IMPORTANT]\` \u2014 large purple banner. Reads as "you need to look at this before merging."
146432
+ - \`[!NOTE]\` \u2014 small blue inline callout. Reads as "FYI, here's something worth noting."
146433
+ - no callout \u2014 plain text. Reads as routine review output.
146434
+
146435
+ Two reinforcing levers: callout intensity (above) and \`approved\` (which gates the footer Fix-button affordance \u2014 Fix renders on every non-approving review, so \`approved: true\` suppresses it). Wrapping mergeable feedback in \`[!IMPORTANT]\` trains users to click Fix on reviews that don't need fixing. Pick the tier the author's actual next action justifies.
146436
+
146437
+ - **critical issues** (blocks merge \u2014 bugs, security, data loss, broken core flows):
146384
146438
  \`approved: false\`. Body opens with \`> [!CAUTION]\\n> This PR introduces ...\`, followed by the PR summary. Include all inline comments via \`comments\`.
146385
- - **recommended changes** (non-critical):
146386
- \`approved: false\`. Body opens with \`> [!IMPORTANT]\\n> Consider ...\`, followed by the PR summary. Include all inline comments via \`comments\`.
146439
+ - **must-address non-critical findings** (real consequences if shipped \u2014 incorrect behavior in non-critical paths, missing validation on user input, regressions the author should fix before merge):
146440
+ \`approved: false\`. Body opens with \`> [!IMPORTANT]\\n> ...\`, followed by the PR summary. Reserve this tier for findings with concrete fallout \u2014 do NOT use \`[!IMPORTANT]\` for nits, style preferences, or "consider also" suggestions. Include all inline comments via \`comments\`.
146441
+ - **minor suggestions only** (single-line nits, doc/comment polish, defer-able observations, "rough edges"):
146442
+ \`approved: false\`. NO alert blockquote. Body opens directly with the PR summary. Include all inline comments via \`comments\`.
146443
+ - **informational observations** (mergeable as-is, nothing actionable \u2014 e.g. prior feedback addressed cleanly, surfacing a minor stale doc reference, calling out something noteworthy without recommending a change):
146444
+ \`approved: true\`. Body opens with \`> [!NOTE]\\n> ...\`, followed by the PR summary. Do NOT include inline \`comments\` \u2014 \`[!NOTE]\` signals "no action needed", which contradicts an actionable anchor; if a point is concrete enough to anchor to a line, downgrade the whole review to "minor suggestions only" (\`approved: false\`) instead.
146387
146445
  - **no actionable issues**:
146388
146446
  \`approved: true\`. Body opens with \`No new issues found.\` followed by the PR summary.
146389
146447
 
@@ -146392,7 +146450,7 @@ ${PR_SUMMARY_FORMAT}`
146392
146450
  // IncrementalReview shares Review's multi-lens orchestrator pattern but
146393
146451
  // scopes the target to the incremental diff. The "issues must be NEW
146394
146452
  // since the last Pullfrog review" filter lives at aggregation time
146395
- // (step 5), NOT in the subagent prompt — pushing the filter into
146453
+ // (step 6), NOT in the subagent prompt — pushing the filter into
146396
146454
  // subagents matches the canonical anneal anti-pattern of "list known
146397
146455
  // pre-existing failures — don't flag these" and suppresses signal on
146398
146456
  // regressions the new commits amplified. The review body is just
@@ -146405,15 +146463,17 @@ ${PR_SUMMARY_FORMAT}`
146405
146463
  description: "Re-review a PR after new commits are pushed; focus on new changes since the last review",
146406
146464
  prompt: `### Checklist
146407
146465
 
146408
- 1. **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.
146466
+ 1. **task list**: create your task list for this run as your first action.
146467
+
146468
+ 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.
146409
146469
 
146410
- 2. **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.
146470
+ 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.
146411
146471
 
146412
- 3. **prior feedback**: fetch previous reviews via \`${t("list_pull_request_reviews")}\`. for the most recent Pullfrog review, call \`${t("get_review_comments")}\` with the review ID to retrieve specific prior line-level feedback. you'll use this to filter your aggregation in step 5 \u2014 anything already flagged in a prior review and not changed by the new commits should not be re-raised. you do NOT need to render this in the review body; the rolling PR summary snapshot is the durable record of what's been addressed.
146472
+ 4. **prior feedback**: fetch previous reviews via \`${t("list_pull_request_reviews")}\`. for the most recent Pullfrog review, call \`${t("get_review_comments")}\` with the review ID to retrieve specific prior line-level feedback. you'll use this to filter your aggregation in step 6 \u2014 anything already flagged in a prior review and not changed by the new commits should not be re-raised. you do NOT need to render this in the review body; the rolling PR summary snapshot is the durable record of what's been addressed.
146413
146473
 
146414
- 4. **triage & fan out**: orient on the *incremental* changes \u2014 domain, seams, external contracts, user-facing surfaces.
146474
+ 5. **triage & fan out**: orient on the *incremental* changes \u2014 domain, seams, external contracts, user-facing surfaces.
146415
146475
 
146416
- if the incremental changes are **genuinely trivial**, skip the fan-out entirely and jump to step 7's non-substantive path (do NOT submit a review).
146476
+ if the incremental changes are **genuinely trivial**, skip the fan-out entirely and jump to step 8's non-substantive path (do NOT submit a review).
146417
146477
 
146418
146478
  "Genuinely trivial" (skip): formatting/comment tweaks, import reordering, lockfile regen, mechanical rename of import paths, whitespace-only.
146419
146479
  "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.
@@ -146421,8 +146481,8 @@ ${PR_SUMMARY_FORMAT}`
146421
146481
 
146422
146482
  otherwise pick lenses by where the new commits concentrate risk \u2014 **there's no fixed count**, same calibration as Review mode (1 lens for pure refactor / isolated fix; 2\u20133 for typical features; 4\u20135 for high-stakes subsystem touches; 6+ is a smell). lens framing follows Review mode: themed lenses (correctness & invariants, impact when new commits remove/rename/deprecate things, research-validated assumptions, security, user-journey, operational readiness, integration & cross-cutting, test integrity, performance, holistic) and subsystem lenses (auth, billing, schema migration, etc.) \u2014 for high-stakes domains lead with the subsystem lens rather than the generic themed equivalent.
146423
146483
 
146424
- dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens \u2014 its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). dispatch them in a **single assistant turn with multiple parallel subagent calls** (serial dispatch collapses the fan-out). 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 step 4 entirely on a single subagent failure. each subagent gets:
146425
- - the diff scope (incremental diff path if available, full diff otherwise). 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 5), not in the subagent prompt
146484
+ dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens \u2014 its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). dispatch them in a **single assistant turn with multiple parallel subagent calls** (serial dispatch collapses the fan-out). 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 step 5 entirely on a single subagent failure. each subagent gets:
146485
+ - the diff scope (incremental diff path if available, full diff otherwise). 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 6), not in the subagent prompt
146426
146486
  - **only one lens** \u2014 never a multi-section "review for X, Y, and Z" prompt
146427
146487
  - **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\`.
146428
146488
  - the read-only contract restated in your dispatch instructions so the rule is present twice (the subagent's system prompt also enforces it). The test: would this call still be a no-op if reverted? If not (PR comments, branch pushes, issue updates, set_output, label changes, dependency installs, etc.), don't make it.
@@ -146436,15 +146496,21 @@ ${PR_SUMMARY_FORMAT}`
146436
146496
  - do NOT pre-shape their output with a finding schema
146437
146497
  - do NOT mention the other lenses (independence is the point)
146438
146498
 
146439
- 5. **aggregate, draft, self-critique**: merge findings; 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 1 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 3) and run \`git diff <prior-review-sha>..HEAD\` to isolate the lines added since that review. draft inline comments with NEW line numbers from the full PR diff \u2014 every comment must be actionable, 2-3 sentences max.
146499
+ 6. **aggregate, draft, self-critique**: merge findings; 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. draft inline comments with NEW line numbers from the full PR diff \u2014 every comment must be actionable, 2-3 sentences max.
146440
146500
 
146441
- 6. **build the review body** \u2014 a single "Reviewed changes" section: summarize at the logical-change level, not per-file. each bullet starts with a past-tense verb (e.g. \`- Extracted shared CLI runtime into a single module\`, \`- Renamed package to pullfrog\`). avoid file paths unless they add clarity. if the changes can be described in one sentence, use one sentence \u2014 no bullets needed. do NOT include a separate "Prior review feedback" checklist; that's tracked in the rolling PR summary snapshot for the next agent run, and surfacing it in the user-facing body is noise (changes that addressed prior feedback are already covered by the Reviewed-changes bullets). in some cases you may receive a complete diff for the whole pull request instead of an incremental one \u2014 when this happens, you will need to determine what changes have happened since Pullfrog's most recent review.
146501
+ 7. **build the review body** \u2014 a single "Reviewed changes" section: summarize at the logical-change level, not per-file. each bullet starts with a past-tense verb (e.g. \`- Extracted shared CLI runtime into a single module\`, \`- Renamed package to pullfrog\`). avoid file paths unless they add clarity. if the changes can be described in one sentence, use one sentence \u2014 no bullets needed. do NOT include a separate "Prior review feedback" checklist; that's tracked in the rolling PR summary snapshot for the next agent run, and surfacing it in the user-facing body is noise (changes that addressed prior feedback are already covered by the Reviewed-changes bullets). in some cases you may receive a complete diff for the whole pull request instead of an incremental one \u2014 when this happens, you will need to determine what changes have happened since Pullfrog's most recent review.
146442
146502
 
146443
- 7. Submit \u2014 Do NOT call \`report_progress\` or \`create_issue_comment\` \u2014 the review is the final record and the progress comment will be cleaned up automatically. Follow these rules:
146503
+ 8. Submit \u2014 every run must end with EXACTLY ONE of \`${t("create_pull_request_review")}\` (substantive review) or \`${t("report_progress")}\` (no-review acknowledgement). do NOT call \`create_issue_comment\` for review output.
146504
+
146505
+ Same callout-intensity ladder as Review mode \u2014 \`[!CAUTION]\` (large red, "will break") \u2192 \`[!IMPORTANT]\` (large purple, "must address before merging") \u2192 \`[!NOTE]\` (small blue, "FYI") \u2192 no callout (plain text). And the same Fix-button lever: the footer renders a Fix button on every non-approving review, so \`approved: true\` suppresses it. Wrapping mergeable feedback in \`[!IMPORTANT]\` trains users to click Fix on reviews that don't need fixing \u2014 pick the tier the author's actual next action justifies.
146506
+
146507
+ Follow these rules:
146444
146508
  - note: the first create_pull_request_review submission may error with a one-time diff-coverage nudge listing unread TOC regions. retry the same call to proceed \u2014 optionally after reading the listed ranges. the pre-flight will not block again this session.
146445
- - IF NO NEW ISSUES, NON-SUBSTANTIVE CHANGES ONLY (trivial formatting, import reordering, comment tweaks): do NOT submit a review. Do NOT call \`report_progress\`. Exit \u2014 the progress comment will be cleaned up automatically.
146446
- - ELSE IF NEW CRITICAL ISSUES (blocks merge): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with a GitHub alert blockquote (e.g. \`> [!CAUTION]\\n> This PR introduces ...\`), then the Reviewed-changes summary.
146447
- - ELSE IF NEW RECOMMENDED CHANGES (non-critical): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!IMPORTANT]\\n> ...\` alert, then the Reviewed-changes summary.
146509
+ - IF NO NEW ISSUES, NON-SUBSTANTIVE CHANGES ONLY (trivial formatting, import reordering, comment tweaks): do NOT submit a review. Instead call \`${t("report_progress")}\` with a 1-2 sentence note explaining no review was warranted (e.g. "No new issues. Changes since last review are formatting-only."). this leaves a visible signal that the run completed.
146510
+ - ELSE IF NEW CRITICAL ISSUES (blocks merge \u2014 bugs, security, data loss, broken core flows): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!CAUTION]\\n> This PR introduces ...\`, then the Reviewed-changes summary.
146511
+ - ELSE IF NEW MUST-ADDRESS NON-CRITICAL FINDINGS (real consequences if shipped \u2014 incorrect behavior, missing validation, regressions the author should fix before merge): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!IMPORTANT]\\n> ...\`, then the Reviewed-changes summary. Do NOT use this tier for nits, style preferences, or "consider also" suggestions.
146512
+ - ELSE IF NEW MINOR SUGGESTIONS ONLY (single-line nits, doc/comment polish, defer-able observations, "rough edges"): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens directly with \`Reviewed the following changes:\\n\` (NO alert blockquote), then the Reviewed-changes summary.
146513
+ - ELSE IF INFORMATIONAL OBSERVATIONS (mergeable as-is, but worth surfacing \u2014 e.g. prior feedback addressed cleanly with one minor stale doc reference, or a noteworthy positive observation): call \`${t("create_pull_request_review")}\` with \`approved: true\`, NO inline comments, and the review body. body opens with \`> [!NOTE]\\n> ...\` alert, then the Reviewed-changes summary. If a point is concrete enough to anchor to a line, downgrade the whole review to "minor suggestions only" (\`approved: false\`) instead \u2014 \`[!NOTE]\` and inline comments don't mix.
146448
146514
  - ELSE IF NO NEW ISSUES, SUBSTANTIVE CHANGES (new functionality, behavior changes, or fixes to prior review feedback): call \`${t("create_pull_request_review")}\` to create a PR review. If all previous reviews have been properly addressed and no new issues were discovered, you can set \`approved: true\`. body opens with \`No new issues. Reviewed the following changes:\\n\`, then the Reviewed-changes summary.`
146449
146515
  },
146450
146516
  {
@@ -146452,61 +146518,63 @@ ${PR_SUMMARY_FORMAT}`
146452
146518
  description: "Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns",
146453
146519
  prompt: `### Checklist
146454
146520
 
146455
- 1. Analyze the task and gather context:
146521
+ 1. **task list**: create your task list for this run as your first action.
146522
+
146523
+ 2. Analyze the task and gather context:
146456
146524
  - read AGENTS.md and relevant codebase files
146457
146525
  - understand the architecture and constraints
146458
146526
 
146459
- 2. Produce a structured, actionable plan with clear milestones.
146460
-
146461
- 3. Call \`${t("report_progress")}\` with the plan.
146527
+ 3. Produce a structured, actionable plan with clear milestones.
146462
146528
 
146463
- ${learningsStep(t, 4)}`
146529
+ 4. Call \`${t("report_progress")}\` with the plan.`
146464
146530
  },
146465
146531
  {
146466
146532
  name: "Fix",
146467
146533
  description: "Fix CI failures; debug failing tests or builds; investigate and resolve check suite failures",
146468
146534
  prompt: `### Checklist
146469
146535
 
146470
- 1. Checkout the PR branch via \`${t("checkout_pr")}\`.
146536
+ 1. **task list**: create your task list for this run as your first action.
146471
146537
 
146472
- 2. Fetch check suite logs via \`${t("get_check_suite_logs")}\`.
146538
+ 2. Checkout the PR branch via \`${t("checkout_pr")}\`.
146473
146539
 
146474
- 3. **CRITICAL**: verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report.
146540
+ 3. Fetch check suite logs via \`${t("get_check_suite_logs")}\`.
146475
146541
 
146476
- 4. Diagnose and fix:
146542
+ 4. **CRITICAL**: verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report.
146543
+
146544
+ 5. Diagnose and fix:
146477
146545
  - read the workflow file, reproduce locally with the EXACT same commands CI runs
146478
146546
  - fix the issue using your native file and shell tools
146479
146547
  - verify the fix by re-running the exact CI command
146480
146548
  - review the diff before committing \u2014 verify only the fix is present, no debug artifacts, no unrelated changes. the fix should be clean enough that a senior engineer would approve without hesitation.
146481
146549
  - commit locally via shell (\`git add . && git commit -m "..."\`)
146482
146550
 
146483
- 5. Finalize:
146551
+ 6. Finalize:
146484
146552
  - confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
146485
- - call \`${t("report_progress")}\` with the diagnosis and fix summary (or the exact push error if push failed)
146486
-
146487
- ${learningsStep(t, 6)}`
146553
+ - call \`${t("report_progress")}\` with the diagnosis and fix summary (or the exact push error if push failed)`
146488
146554
  },
146489
146555
  {
146490
146556
  name: "ResolveConflicts",
146491
146557
  description: "Resolve merge conflicts in a PR branch against the base branch",
146492
146558
  prompt: `### Checklist
146493
146559
 
146494
- 1. **Setup**:
146560
+ 1. **task list**: create your task list for this run as your first action.
146561
+
146562
+ 2. **Setup**:
146495
146563
  - Call \`${t("checkout_pr")}\` to get the PR branch.
146496
146564
  - Call \`${t("get_pull_request")}\` to identify the base branch (e.g., 'main').
146497
146565
  - Call \`${t("git_fetch")}\` to fetch the base branch.
146498
146566
 
146499
- 2. **Merge Attempt**:
146567
+ 3. **Merge Attempt**:
146500
146568
  - Run \`git merge origin/<base_branch>\` via shell.
146501
- - If it succeeds automatically, confirm a clean working tree, push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*), and call \`${t("report_progress")}\` with a brief success note or the exact push error if push failed \u2014 **then stop; do not run steps 3\u20134.**
146502
- - If it fails (conflicts), resolve them manually (continue to steps 3\u20134).
146569
+ - If it succeeds automatically, confirm a clean working tree, push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*), and call \`${t("report_progress")}\` with a brief success note or the exact push error if push failed \u2014 **then stop; do not run steps 4\u20135.**
146570
+ - If it fails (conflicts), resolve them manually (continue to steps 4\u20135).
146503
146571
 
146504
- 3. **Resolve Conflicts**:
146572
+ 4. **Resolve Conflicts**:
146505
146573
  - Run \`git status\` or parse the merge output to find the list of conflicting files.
146506
146574
  - For each conflicting file: read it, find the conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`), understand the code context, and rewrite the file with the correct resolution. Remove all markers.
146507
146575
  - Verify the file syntax is correct after resolution.
146508
146576
 
146509
- 4. **Finalize**:
146577
+ 5. **Finalize**:
146510
146578
  - Run a final verification (build/test) to ensure the resolution works.
146511
146579
  - \`git add . && git commit -m "resolve merge conflicts"\`
146512
146580
  - confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
@@ -146517,24 +146585,43 @@ ${learningsStep(t, 6)}`
146517
146585
  description: "General-purpose tasks that don't fit other modes: answering questions, adding comments, labeling, running ad-hoc commands, or any direct request",
146518
146586
  prompt: `### Checklist
146519
146587
 
146520
- 1. Analyze the task. For simple operations (labeling, commenting, answering questions, running a single command), handle directly.
146588
+ 1. **task list**: create your task list for this run as your first action.
146589
+
146590
+ 2. Analyze the task. For simple operations (labeling, commenting, answering questions, running a single command), handle directly.
146521
146591
 
146522
- 2. For substantial work \u2014 code changes across multiple files, multi-step investigations:
146592
+ 3. For substantial work \u2014 code changes across multiple files, multi-step investigations:
146523
146593
  - plan your approach before starting
146524
146594
  - use native file and shell tools for local operations
146525
146595
  - use ${pullfrogMcpName} MCP tools for GitHub/git operations
146526
146596
  - if code changes are needed: review your own diff before committing \u2014 verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
146527
146597
 
146528
- 3. Finalize:
146598
+ 4. Finalize:
146529
146599
  - if code changes were made, push to a pull request (new or existing) using \`${t("push_branch")}\` and \`${t("create_pull_request")}\` as needed. \`git status\` must be clean before you finish (see *SYSTEM* Git rules if push fails).
146530
146600
  - call \`${t("report_progress")}\` once with results \u2014 include exact tool errors if push or PR creation failed
146531
- - if the task involved labeling, commenting, or other GitHub operations, perform those directly
146532
-
146533
- ${learningsStep(t, 4)}`
146601
+ - if the task involved labeling, commenting, or other GitHub operations, perform those directly`
146534
146602
  }
146535
146603
  ];
146536
146604
  }
146537
146605
  var modes = computeModes("opencode");
146606
+ var NON_COMMITTING_MODES = /* @__PURE__ */ new Set([
146607
+ "Review",
146608
+ "IncrementalReview",
146609
+ "Plan"
146610
+ ]);
146611
+
146612
+ // toolState.ts
146613
+ function initToolState(params) {
146614
+ const resolved = parseProgressComment(params.progressComment);
146615
+ if (resolved) {
146616
+ log.info(`\xBB using pre-created progress comment: ${resolved.id} (${resolved.type})`);
146617
+ }
146618
+ return {
146619
+ progressComment: resolved,
146620
+ hadProgressComment: !!resolved,
146621
+ backgroundProcesses: /* @__PURE__ */ new Map(),
146622
+ usageEntries: []
146623
+ };
146624
+ }
146538
146625
 
146539
146626
  // agents/claude.ts
146540
146627
  import { execFileSync as execFileSync3 } from "node:child_process";
@@ -146630,6 +146717,17 @@ async function installFromNpmTarball(params) {
146630
146717
  // utils/providerErrors.ts
146631
146718
  var statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`;
146632
146719
  var PROVIDER_ERROR_PATTERNS = [
146720
+ // auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error
146721
+ // payloads carry `x-ratelimit-*` response headers in the dump, and the
146722
+ // free-form rate-limit regex below would otherwise win on word-boundary
146723
+ // matches inside header names. canonical 401 messages: OpenRouter returns
146724
+ // `{"error":{"message":"User not found","code":401}}` for disabled or
146725
+ // invalid keys (https://openai.luzhipeng.com/docs/api/reference/errors-and-debugging).
146726
+ { regex: new RegExp(`${statusKey}401\\b`, "i"), label: "auth error (401)" },
146727
+ { regex: new RegExp(`${statusKey}403\\b`, "i"), label: "auth error (403)" },
146728
+ { regex: /\bUser not found\b/i, label: "auth error (invalid/disabled key)" },
146729
+ { regex: /\bInvalid authentication\b/i, label: "auth error (invalid credentials)" },
146730
+ { regex: /\bNo auth credentials found\b/i, label: "auth error (missing credentials)" },
146633
146731
  { regex: new RegExp(`${statusKey}429\\b`, "i"), label: "rate limited (429)" },
146634
146732
  { regex: new RegExp(`${statusKey}500\\b`, "i"), label: "provider 500 error" },
146635
146733
  { regex: new RegExp(`${statusKey}503\\b`, "i"), label: "provider unavailable (503)" },
@@ -146693,7 +146791,7 @@ function installBundledSkills(params) {
146693
146791
  writeFileSync6(join9(skillDir, "SKILL.md"), content);
146694
146792
  }
146695
146793
  }
146696
- log.info(`installed bundled skills: ${BUNDLED_SKILL_NAMES.join(", ")}`);
146794
+ log.success(`installed bundled skills: ${BUNDLED_SKILL_NAMES.join(", ")}`);
146697
146795
  }
146698
146796
  function addSkill(params) {
146699
146797
  const result = spawnSync5(
@@ -146718,7 +146816,7 @@ function addSkill(params) {
146718
146816
  }
146719
146817
  );
146720
146818
  if (result.status === 0) {
146721
- log.info(`installed ${params.skill} skill (${params.agent})`);
146819
+ log.success(`installed ${params.skill} skill (${params.agent})`);
146722
146820
  } else {
146723
146821
  const stderr = (result.stderr?.toString() || "").trim();
146724
146822
  const errorMsg = result.error ? result.error.message : stderr;
@@ -146770,6 +146868,13 @@ var ThinkingTimer = class {
146770
146868
 
146771
146869
  // agents/postRun.ts
146772
146870
  import { readFile } from "node:fs/promises";
146871
+ function getUnsubmittedReview(toolState) {
146872
+ const mode = toolState.selectedMode;
146873
+ if (mode !== "Review" && mode !== "IncrementalReview") return null;
146874
+ if (toolState.review || toolState.finalSummaryWritten) return null;
146875
+ if (!toolState.hadProgressComment) return null;
146876
+ return mode;
146877
+ }
146773
146878
  var MAX_HOOK_OUTPUT_CHARS = 4096;
146774
146879
  function truncateHookOutput(raw2) {
146775
146880
  if (raw2.length <= MAX_HOOK_OUTPUT_CHARS) return raw2;
@@ -146831,39 +146936,72 @@ function buildSummaryStalePrompt(filePath) {
146831
146936
  "if the diff is genuinely too small or noisy to warrant rewriting (e.g. a one-line typo fix, a comment tweak, a formatting-only change), it's fine to leave the structure as-is \u2014 but at minimum confirm you considered it by appending one line to the appropriate section noting the run. silence is not an option; the snapshot is what the next review run reads as context."
146832
146937
  ].join("\n");
146833
146938
  }
146834
- async function collectPostRunIssues(params) {
146939
+ function buildUnsubmittedReviewPrompt(mode) {
146940
+ if (mode === "Review") {
146941
+ return [
146942
+ `MISSING REVIEW OUTPUT \u2014 you selected Review mode but stopped without calling \`create_pull_request_review\`. the user has no visible signal that this run produced anything; the progress comment will be deleted on exit and no review will appear on the PR.`,
146943
+ "",
146944
+ "call `create_pull_request_review` now with your aggregated review (body + inline comments). pick the tier per the mode prompt \u2014 Review mode has no no-submit exit, so even informational `> [!NOTE]` reviews and `No new issues found.` reviews must be submitted (both use `approved: true`). the first call may error once with a diff-coverage nudge \u2014 retry the same call to proceed.",
146945
+ "",
146946
+ "do NOT stop again until `create_pull_request_review` has been called successfully."
146947
+ ].join("\n");
146948
+ }
146949
+ return [
146950
+ `MISSING REVIEW OUTPUT \u2014 you selected IncrementalReview mode but stopped without calling \`create_pull_request_review\` or \`report_progress\`. the user has no visible signal that this run produced anything; the progress comment will be deleted on exit and no review will appear on the PR.`,
146951
+ "",
146952
+ "do exactly one of:",
146953
+ "- if you have findings: call `create_pull_request_review` now with your aggregated review (body + inline comments). the first call may error once with a diff-coverage nudge \u2014 retry the same call to proceed.",
146954
+ "- if there are genuinely no actionable findings since the last review (e.g. only formatting / comment / lockfile changes): call `report_progress` with a 1-2 sentence summary explaining that no review was warranted.",
146955
+ "",
146956
+ "do NOT stop again until one of those tools has been called successfully."
146957
+ ].join("\n");
146958
+ }
146959
+ async function collectPostRunIssues(ctx, options = {}) {
146835
146960
  const issues = {};
146836
- if (params.stopScript) {
146837
- const failure = await executeStopHook(params.stopScript);
146961
+ if (ctx.stopScript) {
146962
+ const failure = await executeStopHook(ctx.stopScript);
146838
146963
  if (failure) issues.stopHook = failure;
146839
146964
  }
146840
146965
  const status = getGitStatus();
146841
- if (status) issues.dirtyTree = status;
146842
- if (params.summaryFilePath && params.summarySeed !== void 0) {
146843
- const stale = await isSummaryUnchanged(params.summaryFilePath, params.summarySeed);
146844
- if (stale) issues.summaryStale = { filePath: params.summaryFilePath };
146966
+ const mode = ctx.toolState.selectedMode;
146967
+ if (status) {
146968
+ if (mode && NON_COMMITTING_MODES.has(mode)) {
146969
+ log.info(`\xBB dirty-tree gate suppressed: mode \`${mode}\` does not commit`);
146970
+ } else {
146971
+ issues.dirtyTree = status;
146972
+ }
146845
146973
  }
146974
+ const summaryFilePath2 = ctx.toolState.summaryFilePath;
146975
+ const summarySeed = ctx.toolState.summarySeed;
146976
+ if (!options.skipSummaryStale && summaryFilePath2 && summarySeed !== void 0) {
146977
+ const stale = await isSummaryUnchanged(summaryFilePath2, summarySeed);
146978
+ if (stale) issues.summaryStale = { filePath: summaryFilePath2 };
146979
+ }
146980
+ const unsubmittedMode = getUnsubmittedReview(ctx.toolState);
146981
+ if (unsubmittedMode) issues.unsubmittedReview = unsubmittedMode;
146846
146982
  return issues;
146847
146983
  }
146848
146984
  function buildPostRunPrompt(issues) {
146849
146985
  const parts = [];
146850
146986
  if (issues.stopHook) parts.push(buildStopHookPrompt(issues.stopHook));
146987
+ if (issues.unsubmittedReview) {
146988
+ parts.push(buildUnsubmittedReviewPrompt(issues.unsubmittedReview));
146989
+ }
146851
146990
  if (issues.dirtyTree) parts.push(buildCommitPrompt(issues.dirtyTree));
146852
146991
  if (issues.summaryStale) parts.push(buildSummaryStalePrompt(issues.summaryStale.filePath));
146853
146992
  return parts.join("\n\n---\n\n");
146854
146993
  }
146855
- function buildLearningsReflectionPrompt(agentId) {
146856
- const t = (name) => formatMcpToolRef(agentId, name);
146994
+ function buildLearningsReflectionPrompt(filePath) {
146857
146995
  return [
146858
- `REFLECTION \u2014 before you finish, think back over this task: did you discover anything about this repo's setup, test commands, conventions, or patterns that you are confident is correct and would reliably help future runs?`,
146996
+ `REFLECTION \u2014 before you finish, think back over this task: did you discover anything about this repo's setup, test commands, conventions, or patterns that is high-confidence and would reliably help future runs?`,
146859
146997
  "",
146860
- `if so, call \`${t("update_learnings")}\` to persist it.`,
146998
+ `the rolling learnings file is at \`${filePath}\`. read it first if you haven't already, then edit it in place using your native file tools. the server reads this file at end-of-run and persists any changes \u2014 there is no tool to call.`,
146861
146999
  "",
146862
- `rules:`,
146863
- `- only call \`${t("update_learnings")}\` when the finding is high-confidence and broadly useful. skip if unsure, speculative, or one-off.`,
146864
- `- pass the FULL merged list: existing learnings from the original prompt + your new discoveries. one fact per bullet, lines starting with \`- \`.`,
146865
- `- deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`,
146866
- `- if you already called \`${t("update_learnings")}\` earlier in this run, or nothing new is worth capturing, just reply "done" and stop \u2014 do not edit the repo for this reflection.`
147000
+ `keep the file healthy:`,
147001
+ `- only add bullets when the finding is high-confidence AND broadly useful. skip speculative, one-off, or "maybe" findings.`,
147002
+ `- prune bullets that are clearly wrong, no longer relevant, or low-signal (rarely useful). a focused, accurate file beats a long stale one.`,
147003
+ `- format: flat bullet list, one fact per line starting with \`- \`. deduplicate against existing entries \u2014 if a bullet covers the same fact, update it in place instead of adding a duplicate.`,
147004
+ `- leave the file alone if you have nothing substantively new to add and the existing entries still look healthy. silence is a valid outcome \u2014 just reply "done" and stop.`
146867
147005
  ].join("\n");
146868
147006
  }
146869
147007
  async function runPostRunRetryLoop(params) {
@@ -146875,10 +147013,8 @@ async function runPostRunRetryLoop(params) {
146875
147013
  let summaryStaleNudged = false;
146876
147014
  while (gateResumeCount < MAX_POST_RUN_RETRIES) {
146877
147015
  if (!result.success) break;
146878
- const issues = await collectPostRunIssues({
146879
- stopScript: params.stopScript,
146880
- summaryFilePath: summaryStaleNudged ? void 0 : params.summaryFilePath,
146881
- summarySeed: summaryStaleNudged ? void 0 : params.summarySeed
147016
+ const issues = await collectPostRunIssues(params.ctx, {
147017
+ skipSummaryStale: summaryStaleNudged
146882
147018
  });
146883
147019
  if (issues.summaryStale) summaryStaleNudged = true;
146884
147020
  finalIssues = issues;
@@ -146926,7 +147062,7 @@ async function runPostRunRetryLoop(params) {
146926
147062
  gateResumeCount++;
146927
147063
  }
146928
147064
  if (gateResumeCount > 0 && result.success && hasPostRunIssues(finalIssues)) {
146929
- finalIssues = await collectPostRunIssues({ stopScript: params.stopScript });
147065
+ finalIssues = await collectPostRunIssues(params.ctx, { skipSummaryStale: true });
146930
147066
  }
146931
147067
  if (result.success && finalIssues.stopHook) {
146932
147068
  const retryNote = gateResumeCount > 0 ? ` after ${gateResumeCount} retry ${gateResumeCount === 1 ? "attempt" : "attempts"}` : "";
@@ -146937,6 +147073,16 @@ async function runPostRunRetryLoop(params) {
146937
147073
  usage: aggregatedUsage
146938
147074
  };
146939
147075
  }
147076
+ if (result.success && finalIssues.unsubmittedReview) {
147077
+ const retryNote = gateResumeCount > 0 ? ` after ${gateResumeCount} retry ${gateResumeCount === 1 ? "attempt" : "attempts"}` : "";
147078
+ const expected = finalIssues.unsubmittedReview === "Review" ? "create_pull_request_review" : "create_pull_request_review or report_progress";
147079
+ return {
147080
+ ...result,
147081
+ success: false,
147082
+ error: `${finalIssues.unsubmittedReview} mode finished without calling ${expected}${retryNote}`,
147083
+ usage: aggregatedUsage
147084
+ };
147085
+ }
146940
147086
  return { ...result, usage: aggregatedUsage };
146941
147087
  }
146942
147088
 
@@ -147053,6 +147199,12 @@ function resolveEffort(model) {
147053
147199
  if (model?.includes("opus")) return "max";
147054
147200
  return "high";
147055
147201
  }
147202
+ function tailLines(text, maxCodeUnits) {
147203
+ if (text.length <= maxCodeUnits) return text;
147204
+ const tail = text.slice(-maxCodeUnits);
147205
+ const firstNewline = tail.indexOf("\n");
147206
+ return firstNewline > 0 && firstNewline < tail.length - 1 ? tail.slice(firstNewline + 1) : tail;
147207
+ }
147056
147208
  async function runClaude(params) {
147057
147209
  const startTime = performance6.now();
147058
147210
  let eventCount = 0;
@@ -147060,6 +147212,8 @@ async function runClaude(params) {
147060
147212
  let finalOutput = "";
147061
147213
  let sessionId;
147062
147214
  let resultErrorSubtype = null;
147215
+ let lastResultError = null;
147216
+ let syntheticStopFailure = false;
147063
147217
  let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
147064
147218
  let accumulatedCostUsd = 0;
147065
147219
  let tokensLogged = false;
@@ -147142,6 +147296,16 @@ async function runClaude(params) {
147142
147296
  if (event.session_id) sessionId = event.session_id;
147143
147297
  const subtype = event.subtype || "unknown";
147144
147298
  const numTurns = event.num_turns || 0;
147299
+ if (event.is_error === true && subtype === "success") {
147300
+ const apiStatus = event.api_error_status;
147301
+ lastResultError = event.result?.trim() || `claude reported is_error=true with no result text (api_error_status=${apiStatus ?? "unknown"})`;
147302
+ resultErrorSubtype = subtype;
147303
+ syntheticStopFailure = true;
147304
+ log.info(
147305
+ `\xBB ${params.label} result error: subtype=${subtype}, api_error_status=${apiStatus ?? "unknown"}, message=${lastResultError}`
147306
+ );
147307
+ return;
147308
+ }
147145
147309
  if (subtype === "success") {
147146
147310
  const usage = event.usage;
147147
147311
  const inputTokens = usage?.input_tokens || 0;
@@ -147164,12 +147328,15 @@ async function runClaude(params) {
147164
147328
  }
147165
147329
  } else if (subtype === "error_max_turns") {
147166
147330
  resultErrorSubtype = subtype;
147331
+ lastResultError = event.errors?.join("\n").trim() || null;
147167
147332
  log.info(`\xBB ${params.label} max turns reached: ${JSON.stringify(event)}`);
147168
147333
  } else if (subtype === "error_during_execution") {
147169
147334
  resultErrorSubtype = subtype;
147335
+ lastResultError = event.errors?.join("\n").trim() || null;
147170
147336
  log.info(`\xBB ${params.label} execution error: ${JSON.stringify(event)}`);
147171
147337
  } else if (subtype.startsWith("error")) {
147172
147338
  resultErrorSubtype = subtype;
147339
+ lastResultError = event.errors?.join("\n").trim() || null;
147173
147340
  log.info(`\xBB ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
147174
147341
  } else {
147175
147342
  log.info(`\xBB ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
@@ -147277,14 +147444,15 @@ async function runClaude(params) {
147277
147444
  if (stderrContext) log.info(`\xBB last stderr output:
147278
147445
  ${stderrContext}`);
147279
147446
  }
147280
- if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0 || accumulatedTokens.cacheRead > 0 || accumulatedTokens.cacheWrite > 0)) {
147447
+ if (!tokensLogged && !syntheticStopFailure && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0 || accumulatedTokens.cacheRead > 0 || accumulatedTokens.cacheWrite > 0)) {
147281
147448
  logTokenTable({ ...accumulatedTokens, costUsd: accumulatedCostUsd });
147282
147449
  tokensLogged = true;
147283
147450
  }
147284
147451
  const usage = buildUsage();
147285
147452
  if (result.exitCode !== 0) {
147286
147453
  const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
147287
- const errorMessage = result.stderr || result.stdout || `unknown error - no output from Claude CLI${errorContext}`;
147454
+ const truncatedStdout = result.stdout ? tailLines(result.stdout, 2048) : "";
147455
+ const errorMessage = lastResultError || result.stderr || truncatedStdout || `unknown error - no output from Claude CLI${errorContext}`;
147288
147456
  log.error(
147289
147457
  `${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
147290
147458
  );
@@ -147311,7 +147479,7 @@ ${stderrContext}`);
147311
147479
  return {
147312
147480
  success: false,
147313
147481
  output: finalOutput || output,
147314
- error: `result subtype: ${resultErrorSubtype}`,
147482
+ error: lastResultError || `result subtype: ${resultErrorSubtype}`,
147315
147483
  usage,
147316
147484
  sessionId
147317
147485
  };
@@ -147441,12 +147609,10 @@ var claude = agent({
147441
147609
  args: [...baseArgs, "-p", ctx.instructions.full]
147442
147610
  });
147443
147611
  return runPostRunRetryLoop({
147612
+ ctx,
147444
147613
  initialResult: result,
147445
147614
  initialUsage: result.usage,
147446
- stopScript: ctx.stopScript,
147447
- summaryFilePath: ctx.summaryFilePath,
147448
- summarySeed: ctx.summarySeed,
147449
- reflectionPrompt: buildLearningsReflectionPrompt("claude"),
147615
+ reflectionPrompt: ctx.toolState.learningsFilePath ? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath) : void 0,
147450
147616
  canResume: (r) => Boolean(r.sessionId),
147451
147617
  resume: async (c) => {
147452
147618
  const sessionId = c.previousResult.sessionId;
@@ -147462,9 +147628,92 @@ var claude = agent({
147462
147628
 
147463
147629
  // agents/opencode.ts
147464
147630
  import { execFileSync as execFileSync4 } from "node:child_process";
147465
- import { mkdirSync as mkdirSync5 } from "node:fs";
147631
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync8 } from "node:fs";
147466
147632
  import { join as join11 } from "node:path";
147467
147633
  import { performance as performance7 } from "node:perf_hooks";
147634
+
147635
+ // agents/opencodePlugin.ts
147636
+ var PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event";
147637
+ var PULLFROG_OPENCODE_PLUGIN_FILENAME = "pullfrog-events.ts";
147638
+ var PULLFROG_OPENCODE_PLUGIN_SOURCE = `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
147639
+ // surfaces opencode subagent activity that the CLI's run-loop discards. see
147640
+ // action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
147641
+ // inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
147642
+ // the user's working tree.
147643
+
147644
+ const PULLFROG_BUS_EVENT_TYPE = ${JSON.stringify(PULLFROG_BUS_EVENT_TYPE)};
147645
+
147646
+ // the first sessionID we see on a message.part.updated event is the
147647
+ // orchestrator \u2014 opencode's run command creates exactly one top-level session
147648
+ // before any subagent is dispatched, and the user-prompt text part fires
147649
+ // before the first task tool_use. we lock that sessionID in here and use it
147650
+ // to filter: the orchestrator's events are already streamed by the CLI's
147651
+ // run-loop, so we only forward (a) all subagent events, and (b) the
147652
+ // orchestrator's task tool dispatches at status="running". the CLI only
147653
+ // emits task tool_use at status=completed (after the subagent finishes), so
147654
+ // without the early announce the parent's labeler binds subagent sessions
147655
+ // before recordTaskDispatch fires and the lens label is lost.
147656
+ let orchestratorSessionID: string | undefined;
147657
+
147658
+ function isOrchestratorTaskDispatch(part: {
147659
+ type?: string;
147660
+ tool?: string;
147661
+ state?: { status?: string };
147662
+ }): boolean {
147663
+ if (part.type !== "tool") return false;
147664
+ if (part.tool !== "task") return false;
147665
+ // only forward at status="running" (not "pending"). at pending the
147666
+ // state.input is still {} \u2014 the orchestrator has emitted the part shell
147667
+ // but the LLM hasn't filled in description/subagent_type/prompt yet. by
147668
+ // running, input is populated and recordTaskDispatch can derive the lens
147669
+ // label correctly.
147670
+ return part.state?.status === "running";
147671
+ }
147672
+
147673
+ export default async function pullfrogEventsPlugin() {
147674
+ return {
147675
+ event: async (input: {
147676
+ event: {
147677
+ type: string;
147678
+ properties?: {
147679
+ part?: {
147680
+ sessionID?: string;
147681
+ type?: string;
147682
+ tool?: string;
147683
+ state?: { status?: string };
147684
+ };
147685
+ };
147686
+ };
147687
+ }) => {
147688
+ const event = input.event;
147689
+ if (!event || typeof event !== "object") return;
147690
+ if (event.type !== "message.part.updated") return;
147691
+ const part = event.properties?.part;
147692
+ const sessionID = part?.sessionID;
147693
+ if (typeof sessionID !== "string" || sessionID.length === 0) return;
147694
+ if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
147695
+
147696
+ if (sessionID === orchestratorSessionID) {
147697
+ // skip orchestrator events EXCEPT early task dispatches.
147698
+ if (!part || !isOrchestratorTaskDispatch(part)) return;
147699
+ }
147700
+
147701
+ try {
147702
+ const line = JSON.stringify({
147703
+ type: PULLFROG_BUS_EVENT_TYPE,
147704
+ bus_event: event,
147705
+ });
147706
+ process.stdout.write(line + "\\n");
147707
+ } catch {
147708
+ // a circular reference or BigInt etc. would throw; swallow rather
147709
+ // than letting a single bad event take down the plugin.
147710
+ }
147711
+ },
147712
+ };
147713
+ }
147714
+ `;
147715
+
147716
+ // agents/opencode.ts
147468
147717
  async function installOpencodeCli() {
147469
147718
  return await installFromNpmTarball({
147470
147719
  packageName: "opencode-ai",
@@ -147474,6 +147723,8 @@ async function installOpencodeCli() {
147474
147723
  });
147475
147724
  }
147476
147725
  var PULLFROG_OPENCODE_OUTPUT_LIMIT = 5e3;
147726
+ var GEMINI_3_DIRECT_THINKING_LEVEL = "medium";
147727
+ var GEMINI_3_DIRECT_API_IDS = ["gemini-3.1-pro-preview", "gemini-3-flash-preview"];
147477
147728
  function buildSecurityConfig(ctx, model) {
147478
147729
  const config3 = {
147479
147730
  permission: {
@@ -147487,7 +147738,21 @@ function buildSecurityConfig(ctx, model) {
147487
147738
  mcp: {
147488
147739
  [pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl }
147489
147740
  },
147490
- agent: buildReviewerAgentConfig()
147741
+ agent: buildReviewerAgentConfig(),
147742
+ provider: {
147743
+ google: {
147744
+ models: Object.fromEntries(
147745
+ GEMINI_3_DIRECT_API_IDS.map((id) => [
147746
+ id,
147747
+ {
147748
+ options: {
147749
+ thinkingConfig: { thinkingLevel: GEMINI_3_DIRECT_THINKING_LEVEL }
147750
+ }
147751
+ }
147752
+ ])
147753
+ )
147754
+ }
147755
+ }
147491
147756
  };
147492
147757
  if (model) {
147493
147758
  config3.model = model;
@@ -147566,9 +147831,6 @@ async function runOpenCode(params) {
147566
147831
  const taskDispatchByCallID = /* @__PURE__ */ new Map();
147567
147832
  const pendingTaskDispatches = [];
147568
147833
  const knownNonTaskCallIDs = /* @__PURE__ */ new Set();
147569
- function isSubagentInFlight() {
147570
- return taskDispatchByCallID.size > 0 || pendingTaskDispatches.length > 0;
147571
- }
147572
147834
  function emitSubagentFinished(dispatch, status, output2, matchKind) {
147573
147835
  const subagentDuration = performance7.now() - dispatch.startedAt;
147574
147836
  const outputStr = typeof output2 === "string" ? output2 : "";
@@ -147687,18 +147949,20 @@ async function runOpenCode(params) {
147687
147949
  return;
147688
147950
  }
147689
147951
  if (toolName === "task") {
147690
- const taskInput = event.part?.state?.input ?? {};
147691
- const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
147692
- const dispatch = {
147693
- label: dispatchedLabel,
147694
- startedAt: performance7.now(),
147695
- toolUseCallID: toolId
147696
- };
147697
- taskDispatchByCallID.set(toolId, dispatch);
147698
- pendingTaskDispatches.push(dispatch);
147699
- log.info(
147700
- `\xBB dispatching subagent: ${dispatchedLabel}` + (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
147701
- );
147952
+ if (!taskDispatchByCallID.has(toolId)) {
147953
+ const taskInput = event.part?.state?.input ?? {};
147954
+ const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
147955
+ const dispatch = {
147956
+ label: dispatchedLabel,
147957
+ startedAt: performance7.now(),
147958
+ toolUseCallID: toolId
147959
+ };
147960
+ taskDispatchByCallID.set(toolId, dispatch);
147961
+ pendingTaskDispatches.push(dispatch);
147962
+ log.info(
147963
+ `\xBB dispatching subagent: ${dispatchedLabel}` + (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
147964
+ );
147965
+ }
147702
147966
  } else {
147703
147967
  knownNonTaskCallIDs.add(toolId);
147704
147968
  }
@@ -147719,6 +147983,10 @@ async function runOpenCode(params) {
147719
147983
  if (event.part?.state?.status === "completed" && event.part.state.output) {
147720
147984
  log.debug(withLabel(label, ` output: ${event.part.state.output}`));
147721
147985
  }
147986
+ if (event.part?.state?.status === "error") {
147987
+ const errorMsg = event.part.state.output ?? "(no error message)";
147988
+ log.info(withLabel(label, `\xBB tool call failed: ${errorMsg}`));
147989
+ }
147722
147990
  if (toolName.includes("report_progress") && params.todoTracker) {
147723
147991
  log.debug("\xBB report_progress detected, disabling todo tracking");
147724
147992
  params.todoTracker.cancel();
@@ -147805,6 +148073,53 @@ async function runOpenCode(params) {
147805
148073
  tokensLogged = true;
147806
148074
  }
147807
148075
  }
148076
+ },
148077
+ [PULLFROG_BUS_EVENT_TYPE]: async (event) => {
148078
+ const busEvent = event.bus_event;
148079
+ if (!busEvent || busEvent.type !== "message.part.updated") return;
148080
+ const part = busEvent.properties?.part;
148081
+ if (!part || typeof part.sessionID !== "string") return;
148082
+ const sessionID = part.sessionID;
148083
+ const partType = part.type;
148084
+ if (partType === "tool") {
148085
+ const status = part.state?.status;
148086
+ const partWithToolFields = part;
148087
+ const isOrchestratorTaskDispatch = partWithToolFields.tool === "task" && status === "running";
148088
+ if (isOrchestratorTaskDispatch) {
148089
+ const callID = partWithToolFields.callID;
148090
+ if (typeof callID === "string" && !taskDispatchByCallID.has(callID)) {
148091
+ const taskInput = partWithToolFields.state?.input ?? {};
148092
+ const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
148093
+ const dispatch = {
148094
+ label: dispatchedLabel,
148095
+ startedAt: performance7.now(),
148096
+ toolUseCallID: callID
148097
+ };
148098
+ taskDispatchByCallID.set(callID, dispatch);
148099
+ pendingTaskDispatches.push(dispatch);
148100
+ log.info(
148101
+ `\xBB dispatching subagent: ${dispatchedLabel}` + (taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
148102
+ );
148103
+ }
148104
+ return;
148105
+ }
148106
+ if (status !== "completed" && status !== "error") return;
148107
+ await handlers2.tool_use({
148108
+ type: "tool_use",
148109
+ sessionID,
148110
+ part
148111
+ });
148112
+ return;
148113
+ }
148114
+ if (partType === "step-start" || partType === "step-finish") return;
148115
+ if (partType === "text" && part.time?.end !== void 0) {
148116
+ await handlers2.text({
148117
+ type: "text",
148118
+ sessionID,
148119
+ part
148120
+ });
148121
+ return;
148122
+ }
147808
148123
  }
147809
148124
  };
147810
148125
  const recentStderr = [];
@@ -147828,13 +148143,13 @@ async function runOpenCode(params) {
147828
148143
  // never fires — producing zombie runs. detached + killGroup nukes the
147829
148144
  // whole tree.
147830
148145
  killGroup: true,
147831
- // suspend the inner activity timer while a `task` subagent is in flight.
147832
- // opencode's task tool encapsulates subagent execution in-process the
147833
- // subagent's internal events don't surface on the parent NDJSON stream,
147834
- // so without this the 5min timeout would falsely fire mid-subagent.
147835
- // suspend/resume is preferable to a heartbeat because there's no race
147836
- // between a periodic tick and a subagent finishing between ticks.
147837
- isPausedExternally: isSubagentInFlight,
148146
+ // NB: we used to pass `isPausedExternally: isSubagentInFlight` to suspend
148147
+ // the activity timer during subagent dispatches. unnecessary now that
148148
+ // our injected plugin (action/agents/opencodePlugin.ts) re-emits
148149
+ // subagent `message.part.updated` events on opencode's stdout those
148150
+ // arrive at child.stdout here, fire updateActivity(), and reset
148151
+ // lastActivityTime naturally. verified empirically in PR #634
148152
+ // (~3.3 plugin events/sec during a typical subagent run).
147838
148153
  onStdout: async (chunk) => {
147839
148154
  const text = chunk.toString();
147840
148155
  output += text;
@@ -147989,6 +148304,12 @@ var opencode = agent({
147989
148304
  XDG_CONFIG_HOME: join11(ctx.tmpdir, ".config")
147990
148305
  };
147991
148306
  mkdirSync5(join11(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true });
148307
+ const opencodePluginDir = join11(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
148308
+ mkdirSync5(opencodePluginDir, { recursive: true });
148309
+ writeFileSync8(
148310
+ join11(opencodePluginDir, PULLFROG_OPENCODE_PLUGIN_FILENAME),
148311
+ PULLFROG_OPENCODE_PLUGIN_SOURCE
148312
+ );
147992
148313
  const agentBrowserVersion = getDevDependencyVersion("agent-browser");
147993
148314
  addSkill({
147994
148315
  ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
@@ -148026,12 +148347,10 @@ var opencode = agent({
148026
148347
  args: [...baseArgs, ctx.instructions.full]
148027
148348
  });
148028
148349
  return runPostRunRetryLoop({
148350
+ ctx,
148029
148351
  initialResult: result,
148030
148352
  initialUsage: result.usage,
148031
- stopScript: ctx.stopScript,
148032
- summaryFilePath: ctx.summaryFilePath,
148033
- summarySeed: ctx.summarySeed,
148034
- reflectionPrompt: buildLearningsReflectionPrompt("opencode"),
148353
+ reflectionPrompt: ctx.toolState.learningsFilePath ? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath) : void 0,
148035
148354
  resume: async (c) => runOpenCode({
148036
148355
  ...runParams,
148037
148356
  args: [...baseArgs, "--continue", c.prompt]
@@ -151957,8 +152276,10 @@ var checkRepositoryAccess = async (token, repoOwner, repoName) => {
151957
152276
  const response = await githubRequest("/installation/repositories", {
151958
152277
  headers: { Authorization: `token ${token}` }
151959
152278
  });
152279
+ const ownerLower = repoOwner.toLowerCase();
152280
+ const nameLower = repoName.toLowerCase();
151960
152281
  return response.repositories.some(
151961
- (repo) => repo.owner.login === repoOwner && repo.name === repoName
152282
+ (repo) => repo.owner.login.toLowerCase() === ownerLower && repo.name.toLowerCase() === nameLower
151962
152283
  );
151963
152284
  } catch {
151964
152285
  return false;
@@ -152244,7 +152565,7 @@ ${ctx.error}` : ctx.error;
152244
152565
 
152245
152566
  // utils/gitAuthServer.ts
152246
152567
  import { randomUUID as randomUUID3 } from "node:crypto";
152247
- import { writeFileSync as writeFileSync8 } from "node:fs";
152568
+ import { writeFileSync as writeFileSync9 } from "node:fs";
152248
152569
  import { createServer as createServer2 } from "node:http";
152249
152570
  import { join as join13 } from "node:path";
152250
152571
  var CODE_TTL_MS = 5 * 60 * 1e3;
@@ -152333,7 +152654,7 @@ async function startGitAuthServer(tmpdir3) {
152333
152654
  `try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`,
152334
152655
  `})}).on("error",function(){process.exit(1)})}`
152335
152656
  ].join("\n");
152336
- writeFileSync8(scriptPath, content, { mode: 448 });
152657
+ writeFileSync9(scriptPath, content, { mode: 448 });
152337
152658
  return scriptPath;
152338
152659
  }
152339
152660
  async function close() {
@@ -152607,9 +152928,9 @@ function buildPromptContext(ctx) {
152607
152928
  };
152608
152929
  }
152609
152930
  function assembleFullPrompt(ctx) {
152610
- const learningsSection = ctx.learnings ? `************* LEARNINGS *************
152931
+ const learningsSection = ctx.learningsFilePath ? `************* LEARNINGS *************
152611
152932
 
152612
- ${ctx.learnings}` : "";
152933
+ Repo-level learnings accumulated by previous agent runs live at \`${ctx.learningsFilePath}\`. Read this file early and let the entries inform your approach (test commands, conventions, gotchas, etc.). The file may be empty if no learnings have been collected yet.` : "";
152613
152934
  const runtimeSection = `************* RUNTIME *************
152614
152935
 
152615
152936
  ${ctx.runtime}`;
@@ -152636,8 +152957,8 @@ function resolveInstructions(ctx) {
152636
152957
  if (eventContext)
152637
152958
  tocEntries.push({ label: "EVENT CONTEXT", description: "related PR/issue data" });
152638
152959
  tocEntries.push({ label: "SYSTEM", description: "persona, security, tools, workflow rules" });
152639
- if (pctx.learnings)
152640
- tocEntries.push({ label: "LEARNINGS", description: "repo-specific knowledge" });
152960
+ if (pctx.learningsFilePath)
152961
+ tocEntries.push({ label: "LEARNINGS", description: "repo-specific knowledge file path" });
152641
152962
  tocEntries.push({ label: "RUNTIME", description: "environment metadata" });
152642
152963
  const toc = buildToc(tocEntries);
152643
152964
  const full = assembleFullPrompt({
@@ -152646,7 +152967,7 @@ function resolveInstructions(ctx) {
152646
152967
  procedure,
152647
152968
  eventContext,
152648
152969
  system,
152649
- learnings: pctx.learnings,
152970
+ learningsFilePath: pctx.learningsFilePath,
152650
152971
  runtime: pctx.runtime
152651
152972
  });
152652
152973
  const event = [pctx.eventTitle, pctx.eventMetadata].filter(Boolean).join("\n\n---\n\n");
@@ -152660,6 +152981,32 @@ function resolveInstructions(ctx) {
152660
152981
  };
152661
152982
  }
152662
152983
 
152984
+ // utils/learnings.ts
152985
+ import { mkdir, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
152986
+ import { dirname as dirname4, join as join14 } from "node:path";
152987
+ var LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
152988
+ var MAX_LEARNINGS_LENGTH = 1e4;
152989
+ function learningsFilePath(tmpdir3) {
152990
+ return join14(tmpdir3, LEARNINGS_FILE_NAME);
152991
+ }
152992
+ async function seedLearningsFile(params) {
152993
+ const path3 = learningsFilePath(params.tmpdir);
152994
+ await mkdir(dirname4(path3), { recursive: true });
152995
+ await writeFile2(path3, params.current ?? "", "utf8");
152996
+ return path3;
152997
+ }
152998
+ async function readLearningsFile(path3) {
152999
+ let raw2;
153000
+ try {
153001
+ raw2 = await readFile2(path3, "utf8");
153002
+ } catch {
153003
+ return null;
153004
+ }
153005
+ const trimmed = raw2.trim();
153006
+ if (trimmed.length > MAX_LEARNINGS_LENGTH) return trimmed.slice(0, MAX_LEARNINGS_LENGTH);
153007
+ return trimmed;
153008
+ }
153009
+
152663
153010
  // utils/normalizeEnv.ts
152664
153011
  function maskValue(value2) {
152665
153012
  if (value2 && typeof value2 === "string" && value2.trim().length > 0) {
@@ -152835,8 +153182,8 @@ function resolvePayload(resolvedPromptInput, repoSettings) {
152835
153182
  }
152836
153183
 
152837
153184
  // utils/prSummary.ts
152838
- import { mkdir, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
152839
- import { dirname as dirname4, join as join14 } from "node:path";
153185
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
153186
+ import { dirname as dirname5, join as join15 } from "node:path";
152840
153187
  var SUMMARY_FILE_NAME = "pullfrog-summary.md";
152841
153188
  var SUMMARY_SCAFFOLD = `# PR summary
152842
153189
 
@@ -152846,19 +153193,19 @@ var SUMMARY_SCAFFOLD = `# PR summary
152846
153193
  var MIN_SNAPSHOT_LENGTH = 60;
152847
153194
  var MAX_SNAPSHOT_LENGTH = 32768;
152848
153195
  function summaryFilePath(tmpdir3) {
152849
- return join14(tmpdir3, SUMMARY_FILE_NAME);
153196
+ return join15(tmpdir3, SUMMARY_FILE_NAME);
152850
153197
  }
152851
153198
  async function seedSummaryFile(params) {
152852
153199
  const path3 = summaryFilePath(params.tmpdir);
152853
- await mkdir(dirname4(path3), { recursive: true });
153200
+ await mkdir2(dirname5(path3), { recursive: true });
152854
153201
  const seed = params.previousSnapshot && params.previousSnapshot.trim().length >= MIN_SNAPSHOT_LENGTH ? params.previousSnapshot : SUMMARY_SCAFFOLD;
152855
- await writeFile2(path3, seed, "utf8");
153202
+ await writeFile3(path3, seed, "utf8");
152856
153203
  return path3;
152857
153204
  }
152858
153205
  async function readSummaryFile(path3) {
152859
153206
  let raw2;
152860
153207
  try {
152861
- raw2 = await readFile2(path3, "utf8");
153208
+ raw2 = await readFile3(path3, "utf8");
152862
153209
  } catch {
152863
153210
  return null;
152864
153211
  }
@@ -153076,9 +153423,9 @@ async function resolveRunContextData(params) {
153076
153423
  import { execFileSync as execFileSync5, execSync as execSync3 } from "node:child_process";
153077
153424
  import { mkdtempSync } from "node:fs";
153078
153425
  import { tmpdir as tmpdir2 } from "node:os";
153079
- import { join as join15 } from "node:path";
153426
+ import { join as join16 } from "node:path";
153080
153427
  function createTempDirectory() {
153081
- const sharedTempDir = mkdtempSync(join15(tmpdir2(), "pullfrog-"));
153428
+ const sharedTempDir = mkdtempSync(join16(tmpdir2(), "pullfrog-"));
153082
153429
  process.env.PULLFROG_TEMP_DIR = sharedTempDir;
153083
153430
  log.info(`\xBB created temp dir at ${sharedTempDir}`);
153084
153431
  return sharedTempDir;
@@ -153480,15 +153827,12 @@ function formatTransientErrorSummary(error49, owner) {
153480
153827
  }
153481
153828
  async function mintProxyKey(ctx) {
153482
153829
  try {
153483
- process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
153484
- process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
153485
- const oidcToken = await core6.getIDToken("pullfrog-api");
153486
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
153487
- delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
153830
+ const headers = await buildProxyTokenHeaders(ctx);
153831
+ if (!headers) return null;
153488
153832
  const response = await apiFetch({
153489
153833
  path: "/api/proxy-token",
153490
153834
  method: "POST",
153491
- headers: { Authorization: `Bearer ${oidcToken}` }
153835
+ headers
153492
153836
  });
153493
153837
  if (response.status === 402) {
153494
153838
  const body = await response.json().catch(() => null);
@@ -153520,15 +153864,30 @@ async function mintProxyKey(ctx) {
153520
153864
  delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
153521
153865
  }
153522
153866
  }
153867
+ async function buildProxyTokenHeaders(ctx) {
153868
+ if (ctx.oidcCredentials) {
153869
+ process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
153870
+ process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
153871
+ const oidcToken = await core6.getIDToken("pullfrog-api");
153872
+ delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
153873
+ delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
153874
+ return { Authorization: `Bearer ${oidcToken}` };
153875
+ }
153876
+ if (isLocalApiUrl()) {
153877
+ log.info(`\xBB proxy: dev bypass (x-dev-repo) for ${ctx.repo.owner}/${ctx.repo.name}`);
153878
+ return { "x-dev-repo": `${ctx.repo.owner}/${ctx.repo.name}` };
153879
+ }
153880
+ return null;
153881
+ }
153523
153882
  async function resolveProxyModel(ctx) {
153524
153883
  if (process.env.PULLFROG_MODEL?.trim()) return;
153525
153884
  const needsProxy = isInfraCovered({ isOss: ctx.oss, plan: ctx.plan }) && ctx.proxyModel;
153526
153885
  if (!needsProxy) return;
153527
- if (!ctx.oidcCredentials) {
153886
+ if (!ctx.oidcCredentials && !isLocalApiUrl()) {
153528
153887
  log.warning("\xBB proxy requested but no OIDC credentials available \u2014 skipping");
153529
153888
  return;
153530
153889
  }
153531
- const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials });
153890
+ const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials, repo: ctx.repo });
153532
153891
  if (!key) return;
153533
153892
  process.env.OPENROUTER_API_KEY = key;
153534
153893
  core6.setSecret(key);
@@ -153552,6 +153911,45 @@ async function fetchPreviousSnapshot(ctx, prNumber) {
153552
153911
  return null;
153553
153912
  }
153554
153913
  }
153914
+ async function persistLearnings(ctx) {
153915
+ const filePath = ctx.toolState.learningsFilePath;
153916
+ if (!filePath) return;
153917
+ if (ctx.toolState.learningsPersistAttempted) return;
153918
+ ctx.toolState.learningsPersistAttempted = true;
153919
+ const current = await readLearningsFile(filePath);
153920
+ if (current === null) {
153921
+ log.debug(`learnings tmpfile missing or unreadable at ${filePath} \u2014 skipping persist`);
153922
+ return;
153923
+ }
153924
+ const seed = ctx.toolState.learningsSeed?.trim() ?? "";
153925
+ if (current === seed) {
153926
+ log.debug("learnings tmpfile unchanged from seed \u2014 skipping persist");
153927
+ return;
153928
+ }
153929
+ try {
153930
+ const response = await apiFetch({
153931
+ path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
153932
+ method: "PATCH",
153933
+ headers: {
153934
+ authorization: `Bearer ${ctx.apiToken}`,
153935
+ "content-type": "application/json"
153936
+ },
153937
+ body: JSON.stringify({
153938
+ learnings: current,
153939
+ model: ctx.toolState.model
153940
+ }),
153941
+ signal: AbortSignal.timeout(1e4)
153942
+ });
153943
+ if (!response.ok) {
153944
+ const error49 = await response.text().catch(() => "(no body)");
153945
+ log.debug(`learnings persist failed (${response.status}): ${error49}`);
153946
+ return;
153947
+ }
153948
+ log.info("\xBB learnings updated");
153949
+ } catch (err) {
153950
+ log.debug(`learnings persist failed: ${err instanceof Error ? err.message : String(err)}`);
153951
+ }
153952
+ }
153555
153953
  async function persistSummary(ctx) {
153556
153954
  const filePath = ctx.toolState.summaryFilePath;
153557
153955
  if (!filePath) return;
@@ -153573,9 +153971,10 @@ async function persistSummary(ctx) {
153573
153971
  log.debug(`pr summary persist failed: ${err instanceof Error ? err.message : String(err)}`);
153574
153972
  });
153575
153973
  }
153576
- async function writeJobSummary(toolState) {
153974
+ async function writeJobSummary(toolState, finalOutput) {
153577
153975
  const usageSummary = formatUsageSummary(toolState.usageEntries);
153578
- const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
153976
+ const body = toolState.lastProgressBody || finalOutput;
153977
+ const summaryParts = [body, usageSummary].filter(Boolean);
153579
153978
  if (summaryParts.length > 0) {
153580
153979
  await writeSummary(summaryParts.join("\n\n"));
153581
153980
  }
@@ -153633,7 +154032,8 @@ async function main() {
153633
154032
  oss: runContext.oss,
153634
154033
  plan: runContext.plan,
153635
154034
  proxyModel: runContext.proxyModel,
153636
- oidcCredentials
154035
+ oidcCredentials,
154036
+ repo: runContext.repo
153637
154037
  });
153638
154038
  } catch (error49) {
153639
154039
  if (error49 instanceof BillingError) {
@@ -153736,12 +154136,32 @@ async function main() {
153736
154136
  toolContext.mcpServerUrl = mcpHttpServer.url;
153737
154137
  log.info(`\xBB MCP server started at ${mcpHttpServer.url}`);
153738
154138
  timer.checkpoint("mcpServer");
154139
+ try {
154140
+ const learningsPath = await seedLearningsFile({
154141
+ tmpdir: tmpdir3,
154142
+ current: runContext.repoSettings.learnings
154143
+ });
154144
+ toolState.learningsFilePath = learningsPath;
154145
+ try {
154146
+ toolState.learningsSeed = await readFile4(learningsPath, "utf8");
154147
+ } catch {
154148
+ }
154149
+ log.info(
154150
+ `\xBB learnings seeded at ${learningsPath} (existing=${runContext.repoSettings.learnings ? "yes" : "no"})`
154151
+ );
154152
+ const ctxForExit = toolContext;
154153
+ onExitSignal(() => persistLearnings(ctxForExit));
154154
+ } catch (err) {
154155
+ log.warning(
154156
+ `\xBB learnings seed failed: ${err instanceof Error ? err.message : String(err)} \u2014 continuing without learnings file`
154157
+ );
154158
+ }
153739
154159
  if (payload.generateSummary && payload.event.is_pr && payload.event.issue_number) {
153740
154160
  const previousSnapshot = await fetchPreviousSnapshot(toolContext, payload.event.issue_number);
153741
154161
  const filePath = await seedSummaryFile({ tmpdir: tmpdir3, previousSnapshot });
153742
154162
  toolState.summaryFilePath = filePath;
153743
154163
  try {
153744
- toolState.summarySeed = await readFile3(filePath, "utf8");
154164
+ toolState.summarySeed = await readFile4(filePath, "utf8");
153745
154165
  } catch {
153746
154166
  }
153747
154167
  log.info(
@@ -153765,7 +154185,7 @@ async function main() {
153765
154185
  modes: modes2,
153766
154186
  agentId,
153767
154187
  outputSchema,
153768
- learnings: runContext.repoSettings.learnings
154188
+ learningsFilePath: toolState.learningsFilePath ?? null
153769
154189
  });
153770
154190
  const logParts = [
153771
154191
  instructions.eventInstructions ? `EVENT-LEVEL INSTRUCTIONS:
@@ -153781,7 +154201,7 @@ ${instructions.user}` : null,
153781
154201
  log.info(instructions.full);
153782
154202
  });
153783
154203
  if (agentId === "opencode") {
153784
- const pluginDir = join16(process.cwd(), ".opencode", "plugin");
154204
+ const pluginDir = join17(process.cwd(), ".opencode", "plugin");
153785
154205
  const hasPlugins = existsSync7(pluginDir) && readdirSync(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
153786
154206
  if (hasPlugins && toolState.dependencyInstallation?.promise) {
153787
154207
  log.info(
@@ -153840,8 +154260,7 @@ ${instructions.user}` : null,
153840
154260
  instructions,
153841
154261
  todoTracker,
153842
154262
  stopScript: runContext.repoSettings.stopScript,
153843
- summaryFilePath: toolState.summaryFilePath,
153844
- summarySeed: toolState.summarySeed,
154263
+ toolState,
153845
154264
  onActivityTimeout: onInnerActivityTimeout,
153846
154265
  onToolUse: (event) => {
153847
154266
  const wasTracked = recordDiffReadFromToolUse({
@@ -153899,12 +154318,27 @@ ${instructions.user}` : null,
153899
154318
  if (toolContext) {
153900
154319
  await persistSummary(toolContext);
153901
154320
  }
153902
- if (toolContext && toolState.progressComment && !toolState.finalSummaryWritten) {
154321
+ if (toolContext) {
154322
+ await persistLearnings(toolContext);
154323
+ }
154324
+ if (!result.success && toolContext && toolState.progressComment) {
154325
+ await reportErrorToComment({
154326
+ toolState,
154327
+ error: result.error || "agent run failed"
154328
+ }).catch((error49) => {
154329
+ log.debug(`failure error report failed: ${error49}`);
154330
+ });
154331
+ }
154332
+ if (toolContext && result.success && toolState.progressComment && !toolState.finalSummaryWritten) {
153903
154333
  await deleteProgressComment(toolContext).catch((error49) => {
153904
154334
  log.debug(`stranded progress comment cleanup failed: ${error49}`);
153905
154335
  });
153906
154336
  }
153907
- await writeJobSummary(toolState);
154337
+ try {
154338
+ await writeJobSummary(toolState, result.output);
154339
+ } catch (error49) {
154340
+ log.debug(`job summary write failed: ${error49}`);
154341
+ }
153908
154342
  if (toolState.output) {
153909
154343
  log.info(`::pullfrog-output::${Buffer.from(toolState.output).toString("base64")}`);
153910
154344
  core6.setOutput("result", toolState.output);
@@ -153951,6 +154385,9 @@ ${errorMessage}
153951
154385
  if (toolContext) {
153952
154386
  await persistSummary(toolContext);
153953
154387
  }
154388
+ if (toolContext) {
154389
+ await persistLearnings(toolContext);
154390
+ }
153954
154391
  return {
153955
154392
  success: false,
153956
154393
  error: errorMessage