Compare commits

...

15 Commits

Author SHA1 Message Date
Anna Bocharova 9c99bcbbac feat: Improving the plan revisions (#465)
* feat(plans): Suggested plan for plan revisions.

* fix: add planCommentId to reduce GitHub API calls.

* Revert "fix: add planCommentId to reduce GitHub API calls."

This reverts commit ef9c24811fa291b12ac3601cc4cd3edb7c9a0fca.

* Improving plan revision: the implementation draft.

* fix schema composition order.

* fix: reusing existing retry helper (action) for reportPlanCommentToRun.

* mv: updatePlanCommentId.

* fix: higher severity for logging error.

* fix: add error handling when calling findExistingPlanCommentIdForIssue.

* feat: improving the revisit plan request detection by adding PLAN_REVISION_VERBS.

* Updating the plan with alternative non-determenistic solution.

* add more verbs to PLAN_REVISION_VERBS.

* fix: supply the previous plan in the event context as previousPlanBody, updating Plan mode instructions.

* fix: adjusting the way PLAN_REVISION_VERBS are used in sentences.

* fix: using GraphQL approach with NodeId to find commentId in findExistingPlanCommentIdForIssue.

* fix: use double word boundaries (both sides).

* fix condition in findExistingPlanCommentIdForIssue.

* fix: rm unused args from findExistingPlanCommentIdForIssue.

* bump the action version.

* fix(plan): rm everything related to approach A.

* fix(plan): No limit for progress comments.

* feat(plan): the new plan.

* Revert: changed to webhook (no longer involved).

* feat: mv plan comment lookup into a new API endpoint.

* Revert: changes to select_mode tool.

* FEAT: The new implementation.

* fix arktype issues.

* fix plan diagram.

* fix(selectMode): e2e type constraints for fetchExistingPlanComment.

* Revert "fix(selectMode): e2e type constraints for fetchExistingPlanComment."

This reverts commit 53f3b6650a9928e3080700faa9eead0052e94333.

* fix(selectMode): type constraints (copy) for fetchExistingPlanComment.

* feat: improving isHttpError helper and reusing it consistently instead of casting.

* address review: remove unconditional retry, add plan comment warning, dedupe type, remove dead guard, fix GraphQL types

* Fix: tightening the PlanEdit guidance.

* fix(select_mode): Providing the agent with existingPlanCommentId as well.

* fix(instructions): Adjusting the primary guidance to prefer Plan mode for issue-related ambiguous requests.

* fix(wiki): updating the delegation docs according to the current instructions.

* fix(instructions): rm implication to call for issue details.

* fix(select_mode): tweak for PlanEdit.

* fix(select_mode): more tweaks to PlanEdit.

* fix(select_mode): tweaks for the order of instructions and context.

* revert: to the state of 5c400efce1f1fec0a0855eeacad2bc3b721fd1bf.

* fix(select_mode): Correcting the guideline.

* rm the plan from the branch (impletemented).

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-03-10 20:34:54 +00:00
Mateusz Burzyński 6c9747585f Reject push_branch when working tree has uncommitted changes (#468) 2026-03-10 20:16:42 +00:00
Mateusz Burzyński f87073fcef Get rid of the fastmcp schema workarounds (#457)
* Get rid of the fastmcp schema workarounds

* tweak

* update lock
2026-03-06 18:12:44 +00:00
Colin McDonnell ed91fbb18d use compare API to deepen by exact divergence instead of fixed 1000 2026-03-05 23:50:22 +00:00
pullfrog[bot] 8bac460177 fix: add concurrency protection to action sync workflows (#451)
* fix: add concurrency protection to action sync workflows

* style: fix formatting in action/modes.ts

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-03-05 23:44:55 +00:00
Mateusz Burzyński 5684cbef77 Implement support for output schemas (#411)
* tweak examples

* tweak prompt

* Implement support for output schemas

* fix: add example for structured output with zod schema

* tweak

* remove redundant cast

* fix input name

* strip $schema

* hack around vendor requirement

* clarify required result output

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-03-05 23:07:03 +00:00
David Blass fafe930c77 enforce single mode selection per run (#413)
Prevents the agent from calling select_mode multiple times, which caused
it to chain modes (e.g. Plan then Build) when the user only asked for a
plan. Also removes the Plan orchestrator guidance that explicitly
encouraged switching to Build after planning.

Closes #394

Made-with: Cursor
2026-03-05 23:06:05 +00:00
Colin McDonnell 808849fcc8 make incremental reviews silent (suppress progress comments)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:11:34 +00:00
pullfrog[bot] 734e8197db tighten Plan mode guidance to prevent file creation and require full plan in progress comment (#432)
Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
2026-03-05 17:29:01 +00:00
Mateusz Burzyński a0af59b52a Fixed a wrong issue number being used for "Implement Plan" links at times (#404) 2026-03-04 16:44:46 +00:00
Colin McDonnell 887f37236d 0.0.177 2026-03-04 16:33:33 +00:00
Colin McDonnell 727e407ed3 176
Made-with: Cursor
2026-03-04 08:03:13 -08:00
Mateusz Burzyński 421eecebe3 Correctly import modes through @pullfrog/pullfrog/internal (#428)
* Corectly import `modes` through `@pullfrog/pullfrog/internal`

* add a biome rule

* fix rule
2026-03-03 13:59:37 +00:00
pullfrog[bot] cc46af0d47 Share GitHub rate limit tracking between the action and the worker (#326)
* share GitHub rate limit tracking between the action and the worker

The action now counts all GitHub API requests and captures the latest
`x-ratelimit-remaining`/`x-ratelimit-reset` headers via a global
request hook on every Octokit instance.

On exit, the usage summary is written atomically to a path specified
by `PULLFROG_USAGE_SUMMARY_PATH`. The worker sets this env var before
sandbox execution, reads the file afterward, and feeds the data into
the Durable Object's rate limit state.

This closes the visibility gap where the worker had no insight into
API calls made by the sandboxed action process.

* address review: refactor rate limit state, randomize usage summary path

* track actual rate limit cost using x-ratelimit-remaining delta

* refactor usage summary writing to use onExitSignal API

Replace the monolithic registerUsageSummaryHandler with direct use of
onExitSignal in main.ts and a writeGitHubUsageSummaryToFile utility
in github.ts. This keeps exitHandler.ts as a pure signal handler
registry (from #299) and also writes the summary on normal exit.

* tweak

* unify

* deduplicate stuff

* improve error handling

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
2026-03-03 12:45:36 +00:00
David Blass 53970308ee Add incremental re-review on new PR commits (#388)
* add incremental re-review on new PR commits

When new commits are pushed to a PR that Pullfrog has previously reviewed,
automatically perform a focused re-review on only the new changes. Includes
a supersede mechanism to abort stale in-flight reviews on rapid pushes, a
new IncrementalReview mode with incremental diff + prior-feedback awareness,
and a PRReview tracking model so re-review fires for both auto-reviewed and
manually-triggered PRs. Reviews now always submit (APPROVE when clean).

Co-authored-by: Cursor <cursoragent@cursor.com>

* simplify re-review eligibility and add summary to incremental reviews

Remove the path1/path2 distinction for re-review eligibility — now simply
requires prReReview=enabled and a prior Pullfrog review on the PR. Show
the re-review toggle regardless of prCreated setting. Add a top-level
summary body to incremental reviews for consistency with full reviews.

Co-authored-by: Cursor <cursoragent@cursor.com>

* replace superseded polling with server-side in-flight dedup and add prApproveEnabled setting

Made-with: Cursor

* add armstrong cursor command

Made-with: Cursor

* update armstrong

* feat: Pullfrogger game v1 (#378)

* Frogger game basis code (CC0 1.0 Unversal license).

* Initial React port.

* fix props for Sprite.

* fixed context issue in FroggerGame.

* Fixed format.

* Fixed lint issue in FroggerGame.

* Restoring LeapingLoader, using FroggerGame as a new fallback in Suspense.

* feat: Display toast when URL ready.

* Add props constraints on Sprite.

* feat: frog sprite.

* fix: zoom and alignment.

* fix: extract const.

* fix: mv types.

* fix: mv game into index.tsx file.

* fix: replacing deprecated event prop which with key.

* feat: Log sprite.

* feat: turtle sprite.

* Adjusting game colors.

* feat: sprites for the cars.

* rm primitive sprites.

* fix: bulldozer sprite position.

* Adjusting colors.

* Shape constraints.

* rm original.

* minor: naming, cleanup.

* fix: renderers dict.

* fix: adjusting and renaming racer.

* fix renderer binding for scored frogs.

* feat: responsive layout with gap below and maintained aspect ratio.

* grammar fix.

* feat: road lane dividers.

* feat: using AbortController to cleanup events.

* fix: cleanup and shortening.

* feat: extracting drawGameBackground.

* feat: initObstacleRows and updateAndDrawObstacles helpers.

* feat: initFroggers helper.

* feat: drawFroggers helper.

* feat: checkForCollision helper.

* feat: makeKeydownHandler helper.

* feat: listenToKeyboardEvents helper.

* mv cleanup into drawGameBackground.

* fix: cleanup.

* feat: better adjustBrightness helper.

* Polling on the page.tsx side, restoring timeout and fallbacks, dynamic link msg.

* FEAT: wait for workflow to complete and notify additionally with a big link (incl.db migration).

* Fix: larger title, shorter link.

* fix(hook): Writing completedAt from hook.workflow_run.updated_at according to suggestion.

* fix(loader): rm unused props from WorkflowRunClientProps as suggested.

* fix(loader): mv id=dev case into the page.

* fix(DNRY): mv PollStartedResult and PollCompletedResult types.

* fix(DNRY): reusing drawEllipse() helper in Sprite.

* fix(style): reordering methods by priority in Sprite.

* fix(frogger): rm empty rows from canvas, square game.

* feat: game canvas rounded corners.

* fix(DNRY): extracting SpriteShape type for faster reference.

* fix: rm target from links, use same window.

* add incremental re-review on new PR commits

When new commits are pushed to a PR that Pullfrog has previously reviewed,
automatically perform a focused re-review on only the new changes. Includes
a supersede mechanism to abort stale in-flight reviews on rapid pushes, a
new IncrementalReview mode with incremental diff + prior-feedback awareness,
and a PRReview tracking model so re-review fires for both auto-reviewed and
manually-triggered PRs. Reviews now always submit (APPROVE when clean).

Co-authored-by: Cursor <cursoragent@cursor.com>

* replace superseded polling with server-side in-flight dedup and add prApproveEnabled setting

Made-with: Cursor

* consolidate workflow_run completed handling and track completedAt

Removes the duplicate exported handleWorkflowRunCompleted in favor of
the private one, merges status + orphan resolution logic into a single
path, and sets completedAt on both normal completion and orphan cancel.

Made-with: Cursor

* fix rebase conflict resolution: restore eligibility logic, incremental review summaries, and exhaustiveness check

- replace deleted hasPullfrogReviewedPR call with WorkflowRun.findFirst
  (the utility file was removed by the dedup improvements commit)
- restore IncrementalReview summary body in modes.ts and selectMode.ts
  (lost during ca0168b conflict resolution; origin had re-added them via f98f902)
- use switch + satisfies never for workflow_run event dispatch
- lowercase comments per project conventions

Made-with: Cursor

* fix workflow-run polling architecture and improve incremental review prompts

move polling loops from server actions to client to avoid serverless timeouts
(pollForCompleted ran up to 600s in a single invocation). each server action
is now a single DB check; client drives retries. also fix misleading prompt
text about incremental diff scope and remove dead code in handleWebhook.

Made-with: Cursor

* await reportReviewNodeId to eliminate race condition and webhook sleep

- refactor reportReviewNodeId from fire-and-forget to async/awaited,
  guaranteeing the dedup signal lands before the tool returns
- remove the 5-second grace period sleep in the synchronize webhook
  handler (no longer needed with the awaited PATCH)
- update IncrementalReview guidance to use get_review_comments for
  detailed prior line-level feedback instead of just review summaries
- remove dead fallbackUrl field from CheckStartedResult type

Made-with: Cursor

* fix rebase artifacts: broken triggeringIssue reference, review formatting, and prompt wording

Made-with: Cursor

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Anna Bocharova <robin_tail@me.com>
2026-02-28 18:30:49 +00:00
60 changed files with 7601 additions and 2414 deletions
+110 -10
View File
@@ -1,5 +1,5 @@
<!-- test preview system --> <!-- test bypass 2 --> <!-- trigger preview repo creation -->
<p align="center">
<!-- test preview system -->
<p align="center">
<h1 align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/frog-white-200px.png">
@@ -24,27 +24,26 @@ Pullfrog is a GitHub bot that brings the full power of your favorite coding agen
- **Tag `@pullfrog`** — Tag `@pullfrog` in a comment anywhere in your repo. It will pull in any relevant context using the action's internal MCP server and perform the appropriate task.
- **Prompt from the web** — Trigger arbitrary tasks from the Pullfrog dashboard
- **Automated triggers** — Configure Pullfrog to trigger agent runs in response to specific events. Each of these triggers can be associated with custom prompt instructions.
- **Automated triggers** — Configure Pullfrog to trigger agent runs in response to specific events. Each of these triggers can be associated with custom prompt instructions.
- issue created
- issue labeled
- PR created
- PR review created
- PR review created
- PR review requested
- and more...
Pullfrog is the bridge between your preferred coding agents and GitHub. Use it for:
- **🤖 Coding tasks** — Tell `@pullfrog` to implement something and it'll spin up a PR. If CI fails, it'll read the logs and attempt a fix automatically. It'll automatically address any PR reviews too.
- **🔍 PR review** — Coding agents are great at reviewing PRs. Using the "PR created" trigger, you can configure Pullfrog to auto-review new PRs.
- **🤙 Issue management** — Via the "issue created" trigger, Pullfrog can automatically respond to common questions, create implementation plans, and link to related issues/PRs. Or (if you're feeling lucky) you can prompt it to immediately attempt a PR addressing new issues.
- **Literally whatever** — Want to have the agent automatically add docs to all new PRs? Cut a new release with agent-written notes on every commit to `main`? Pullfrog lets you do it.
<!-- Features
- **Agent-agnostic** — Switch between agents with the click of a radio button.
- ** -->
<!--
<!--
## Get started
Install the Pullfrog GitHub App on your personal or organization account. During installation you can choose to limit access to a specific repo or repos. After installation, you'll be redirected to the Pullfrog dashboard where you'll see an onboarding flow. This flow will create your `pullfrog.yml` workflow and prompt you to set up API keys. Once you finish those steps (2 minutes) you're ready to rock.
@@ -124,14 +123,14 @@ on:
pull_request_review:
types: [submitted]
# add other triggers as needed
jobs:
pullfrog:
# trigger conditions (e.g. only run if @pullfrog is mentioned)
if: contains(github.event.comment.body, '@pullfrog') || contains(github.event.issue.body, '@pullfrog')
permissions:
id-token: write
contents: write
@@ -148,3 +147,104 @@ jobs:
</details>
-->
## Standalone Usage
You can also use `pullfrog/pullfrog` as a step in your own workflows. The action exposes a `result` output that can be consumed by subsequent steps.
### Example: Auto-generate release notes on new tags
```yaml
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate release notes
id: notes
uses: pullfrog/pullfrog@v0
with:
prompt: |
Generate release notes for ${{ github.ref_name }}.
Compare commits between this tag and the previous tag.
Format as markdown: summary paragraph, then ### Features, ### Fixes, ### Breaking Changes sections.
Omit empty sections. Be concise.
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# write to file to avoid shell escaping issues with special characters
- name: Create GitHub release
run: |
notesfile="$RUNNER_TEMP/release-notes-$GITHUB_RUN_ID.md"
printf '%s' "$NOTES" > "$notesfile"
gh release create ${{ github.ref_name }} --title "${{ github.ref_name }}" --notes-file "$notesfile"
env:
GH_TOKEN: ${{ github.token }}
NOTES: ${{ steps.notes.outputs.result }}
```
### Example: Structured Output with Zod Schema
You can force the agent to return structured JSON output by providing a JSON schema. This allows you to reliably parse and use the agent's response in subsequent workflow steps.
You can define your JSON schema directly or uou can use any validation library that converts to JSON Schema. Here's an example using [Zod](https://zod.dev):
```yaml
name: Release Check
on:
pull_request:
types: [closed]
jobs:
check-release:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm install --no-save --no-package-lock zod @actions/core
- name: Generate Schema
id: schema
run: |
node -e '
import { z } from "zod";
import { setOutput } from "@actions/core";
const schema = z.object({
version: z.string().describe("Semantic version number (e.g. 1.0.0)"),
isBreaking: z.boolean().describe("Whether this release contains breaking changes"),
changelog: z.array(z.string()).describe("List of changes in this release"),
});
setOutput("schema", JSON.stringify(z.toJSONSchema(schema)));
'
- name: Analyze PR
id: analysis
uses: pullfrog/pullfrog@v0
with:
prompt: |
Analyze this PR and determine semantic versioning impact.
Return a JSON object matching the provided schema.
output_schema: ${{ steps.schema.outputs.schema }}
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Process Result
run: |
# Parse the JSON result using fromJSON()
echo "Version: ${{ fromJSON(steps.analysis.outputs.result).version }}"
echo "Breaking: ${{ fromJSON(steps.analysis.outputs.result).isBreaking }}"
```
+4 -1
View File
@@ -30,6 +30,9 @@ inputs:
shell:
description: "Shell permission: disabled, restricted (filters secrets from env vars), or enabled. Public repos default to restricted for security; private repos default to enabled."
required: false
output_schema:
description: "JSON Schema (draft-07) for structured output validation. When provided, the action output becomes required and must conform to this schema."
required: false
token:
description: "GitHub-provided token with job-scoped permissions. Do not set this unless you know what you are doing."
required: false
@@ -37,7 +40,7 @@ inputs:
outputs:
result:
description: "It's set when the prompt explicitly requests it. It can be used to capture an actionable output for the next step in the workflow."
description: "It's set when the prompt explicitly requests it and is required when output_schema is provided; use it to capture actionable output for the next workflow step."
runs:
using: "node24"
+6083 -1948
View File
File diff suppressed because one or more lines are too long
-4
View File
@@ -20,10 +20,6 @@ async function run(): Promise<void> {
if (!result.success) {
throw new Error(result.error || "Agent execution failed");
}
if (result.result) {
core.setOutput("result", result.result);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
core.setFailed(`Action failed: ${errorMessage}`);
+12
View File
@@ -241,6 +241,17 @@ interface ImplementPlanEvent extends BasePayloadEvent {
body: string | null;
}
interface PullRequestSynchronizeEvent extends BasePayloadEvent {
trigger: "pull_request_synchronize";
issue_number: number;
is_pr: true;
title: string;
body: string | null;
branch: string;
/** SHA before the push -- used to compute incremental diff via `git diff before_sha...HEAD` */
before_sha: string;
}
interface UnknownEvent extends BasePayloadEvent {
trigger: "unknown";
}
@@ -250,6 +261,7 @@ interface UnknownEvent extends BasePayloadEvent {
export type PayloadEvent =
| PullRequestOpenedEvent
| PullRequestReadyForReviewEvent
| PullRequestSynchronizeEvent
| PullRequestReviewRequestedEvent
| PullRequestReviewSubmittedEvent
| PullRequestReviewCommentCreatedEvent
+12 -1
View File
@@ -25699,8 +25699,8 @@ function formatJsonValue(value) {
}
// utils/github.ts
var core2 = __toESM(require_core(), 1);
import { createSign } from "node:crypto";
var core2 = __toESM(require_core(), 1);
// utils/apiUrl.ts
function isLocalUrl(url) {
@@ -25930,6 +25930,17 @@ function parseRepoContext() {
}
return { owner, name };
}
function emptyResourceUsage() {
return {
requestCount: 0,
rateLimitRemaining: null,
rateLimitResetMs: null
};
}
var usageByResource = {
core: emptyResourceUsage(),
graphql: emptyResourceUsage()
};
// utils/token.ts
async function revokeGitHubInstallationToken(token) {
+3 -1
View File
@@ -20,6 +20,8 @@ export {
Effort,
ghPullfrogMcpName,
} from "../external.ts";
export type { Mode } from "../modes.ts";
export { modes } from "../modes.ts";
export type {
AgentInfo,
BuildPullfrogFooterParams,
@@ -30,7 +32,7 @@ export {
PULLFROG_DIVIDER,
stripExistingFooter,
} from "../utils/buildPullfrogFooter.ts";
export type { ResourceUsage, UsageSummary } from "../utils/github.ts";
export {
isValidTimeString,
parseTimeString,
+47 -13
View File
@@ -1,4 +1,6 @@
// changes to tool permissions should be reflected in wiki/granular-tools.md
import * as core from "@actions/core";
import { initToolState, startMcpHttpServer, type ToolState } from "./mcp/server.ts";
import { computeModes } from "./modes.ts";
import {
@@ -12,8 +14,9 @@ import { validateAgentApiKey } from "./utils/apiKeys.ts";
import { resolveBody } from "./utils/body.ts";
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts";
import { onExitSignal } from "./utils/exitHandler.ts";
import { resolveGit } from "./utils/gitAuth.ts";
import { createOctokit } from "./utils/github.ts";
import { createOctokit, writeGitHubUsageSummaryToFile } from "./utils/github.ts";
import { resolveInstructions } from "./utils/instructions.ts";
import { executeLifecycleHook } from "./utils/lifecycle.ts";
import { normalizeEnv } from "./utils/normalizeEnv.ts";
@@ -36,6 +39,23 @@ export interface MainResult {
result?: string | undefined;
}
function resolveOutputSchema(): Record<string, unknown> | undefined {
const raw = core.getInput("output_schema");
if (!raw) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`invalid output_schema: not valid JSON`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`invalid output_schema: must be a JSON object`);
}
log.info("» structured output schema provided — output will be required");
return parsed as Record<string, unknown>;
}
async function writeJobSummary(toolState: ToolState): Promise<void> {
const usageSummary = formatUsageSummary(toolState.usageEntries);
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
@@ -48,6 +68,12 @@ export async function main(): Promise<MainResult> {
// normalize env var names to uppercase (handles case-insensitive workflow files)
normalizeEnv();
// write usage summary on SIGINT/SIGTERM so the worker can read it after sandbox.exec
const usageSummaryPath = process.env.PULLFROG_USAGE_SUMMARY_PATH;
if (usageSummaryPath) {
onExitSignal(() => writeGitHubUsageSummaryToFile(usageSummaryPath));
}
const timer = new Timer();
let activityTimeout: ActivityTimeout | null = null;
@@ -144,6 +170,8 @@ export async function main(): Promise<MainResult> {
const modes = [...computeModes(), ...runContext.repoSettings.modes];
const outputSchema = resolveOutputSchema();
// mcpServerUrl and tmpdir are set after server starts — delegate tool reads them at call time
const toolContext = {
repo: runContext.repo,
@@ -155,13 +183,14 @@ export async function main(): Promise<MainResult> {
agent,
modes,
postCheckoutScript: runContext.repoSettings.postCheckoutScript,
prApproveEnabled: runContext.repoSettings.prApproveEnabled,
toolState,
runId: runInfo.runId,
jobId: runInfo.jobId,
mcpServerUrl: "",
tmpdir,
};
await using mcpHttpServer = await startMcpHttpServer(toolContext);
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
toolContext.mcpServerUrl = mcpHttpServer.url;
log.info(`» MCP server started at ${mcpHttpServer.url}`);
timer.checkpoint("mcpServer");
@@ -170,6 +199,7 @@ export async function main(): Promise<MainResult> {
payload,
repo: runContext.repo,
modes,
outputSchema,
});
// log instructions as soon as they are fully resolved
const logParts = [
@@ -228,23 +258,24 @@ export async function main(): Promise<MainResult> {
toolState.usageEntries.push(result.usage);
}
await writeJobSummary(toolState);
// emit structured output marker for test validation
if (toolState.output) {
log.info(`::pullfrog-output::${Buffer.from(toolState.output).toString("base64")}`);
// validate this before writing job summary to avoid masking the error
if (outputSchema && !toolState.output) {
throw new Error(
"output_schema was provided but agent did not call set_output — structured output is required"
);
}
const mainResult = await handleAgentResult({
await writeJobSummary(toolState);
if (toolState.output) {
core.setOutput("result", toolState.output);
}
return await handleAgentResult({
result,
toolState,
silent: payload.event.silent ?? false,
});
return {
...mainResult,
result: toolState.output,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "unknown error occurred";
killTrackedChildren();
@@ -266,5 +297,8 @@ export async function main(): Promise<MainResult> {
};
} finally {
activityTimeout?.stop();
if (usageSummaryPath) {
await writeGitHubUsageSummaryToFile(usageSummaryPath);
}
}
}
+27 -2
View File
@@ -206,6 +206,31 @@ export async function checkoutPrBranch(
// this avoids naming conflicts and makes push config simpler
const localBranch = `pr-${pullNumber}`;
// compute deepen depth for shallow clones. actions/checkout uses depth=1
// by default, which breaks rebase/log because git can't find the merge base.
// use the GitHub compare API to fetch exactly enough history.
const isShallow =
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
let deepenArgs: string[] = [];
if (isShallow) {
let depth = 1000; // fallback
try {
const comparison = await octokit.rest.repos.compareCommits({
owner,
repo: name,
base: baseBranch,
head: `pull/${pullNumber}/head`,
});
depth = comparison.data.behind_by + 10;
log.debug(
`» PR is ${comparison.data.behind_by} commits behind ${baseBranch}, deepening by ${depth}`
);
} catch {
log.debug(`» compare API failed, falling back to --deepen=${depth}`);
}
deepenArgs = [`--deepen=${depth}`];
}
// check if we're already on the correct commit (not just branch name)
// this handles fork PRs where head branch name might match base branch name
const currentSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
@@ -216,7 +241,7 @@ export async function checkoutPrBranch(
} else {
// fetch base branch so origin/<base> exists for diff operations
log.debug(`» fetching base branch (${baseBranch})...`);
$git("fetch", ["--no-tags", "origin", baseBranch], {
$git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
token: gitToken,
restricted: shell !== "enabled",
});
@@ -241,7 +266,7 @@ export async function checkoutPrBranch(
// fetch if we skipped checkout (already on branch) - otherwise already fetched above
if (alreadyOnBranch) {
log.debug(`» fetching base branch (${baseBranch})...`);
$git("fetch", ["--no-tags", "origin", baseBranch], {
$git("fetch", [...deepenArgs, "--no-tags", "origin", baseBranch], {
token: gitToken,
restricted: shell !== "enabled",
});
+105 -7
View File
@@ -1,11 +1,43 @@
import { type } from "arktype";
import type { Agent } from "../agents/index.ts";
import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts";
import { retry } from "../utils/retry.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
/** PATCH workflow-run with plan comment node_id so plan revisions can update that comment in place. */
async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string): Promise<void> {
if (ctx.runId === undefined || !ctx.apiToken) return;
try {
await retry(
async () => {
const response = await apiFetch({
path: `/api/workflow-run/${ctx.runId}`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ planCommentNodeId }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
},
{
maxAttempts: 3,
delayMs: 2000,
label: "updatePlanCommentId",
}
);
} catch (error) {
log.warning(`updatePlanCommentId exhausted retries: ${error}`);
}
}
/**
* The prefix text for the initial "leaping into action" comment.
* This is used to identify if a comment is still in its initial state
@@ -86,15 +118,21 @@ export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string
export const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"),
type: type
.enumerated("Plan", "Comment")
.describe(
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
)
.optional(),
});
export function CreateCommentTool(ctx: ToolContext) {
return tool({
name: "create_issue_comment",
description:
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
"Create a comment on a GitHub issue. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' only when creating a standalone plan comment to record as this run's plan.",
parameters: Comment,
execute: execute(async ({ issueNumber, body }) => {
execute: execute(async ({ issueNumber, body, type: commentType }) => {
const bodyWithFooter = await addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.createComment({
@@ -104,6 +142,10 @@ export function CreateCommentTool(ctx: ToolContext) {
body: bodyWithFooter,
});
if (commentType === "Plan" && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
}
return {
success: true,
commentId: result.data.id,
@@ -147,6 +189,9 @@ export function EditCommentTool(ctx: ToolContext) {
export const ReportProgress = type({
body: type.string.describe("the progress update content to share"),
"target_plan_comment?": type("boolean").describe(
"when true, update the existing plan comment (from select_mode lookup) instead of the progress comment; use when editing an existing plan"
),
});
/**
@@ -161,13 +206,14 @@ export const ReportProgress = type({
*/
export async function reportProgress(
ctx: ToolContext,
{ body }: { body: string }
params: { body: string; target_plan_comment?: boolean }
): Promise<{
commentId?: number;
url?: string;
body: string;
action: "created" | "updated" | "skipped";
}> {
const { body, target_plan_comment } = params;
// always track the body for job summary
ctx.toolState.lastProgressBody = body;
@@ -177,10 +223,50 @@ export async function reportProgress(
return { body, action: "skipped" };
}
const existingCommentId = ctx.toolState.progressCommentId;
const issueNumber = ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber;
const isPlanMode = ctx.toolState.selectedMode === "Plan";
// when editing existing plan: update the plan comment from tool state (set by select_mode)
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId === undefined) {
log.warning("target_plan_comment requested but no existingPlanCommentId in tool state");
}
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId !== undefined) {
const commentId = ctx.toolState.existingPlanCommentId;
const customParts =
isPlanMode && issueNumber !== undefined
? [buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, commentId)]
: undefined;
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({
agent: ctx.agent,
octokit: ctx.octokit,
customParts,
});
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: commentId,
body: bodyWithFooter,
});
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
}
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body || "",
action: "updated",
};
}
const existingCommentId = ctx.toolState.progressCommentId;
// if we already have a progress comment, update it
if (existingCommentId) {
const customParts =
@@ -205,6 +291,10 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
}
return {
commentId: result.data.id,
url: result.data.html_url,
@@ -260,6 +350,10 @@ export async function reportProgress(
body: bodyWithPlanLink,
});
if (updateResult.data.node_id) {
await updatePlanCommentId(ctx, updateResult.data.node_id);
}
return {
commentId: updateResult.data.id,
url: updateResult.data.html_url,
@@ -282,8 +376,12 @@ export function ReportProgressTool(ctx: ToolContext) {
description:
"Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
parameters: ReportProgress,
execute: execute(async ({ body }) => {
const result = await reportProgress(ctx, { body });
execute: execute(async (params) => {
const reportParams: { body: string; target_plan_comment?: boolean } = { body: params.body };
if (params.target_plan_comment !== undefined) {
reportParams.target_plan_comment = params.target_plan_comment;
}
const result = await reportProgress(ctx, reportParams);
if (result.action === "skipped") {
// no-op: no comment target, but progress is still tracked for job summary
+9
View File
@@ -108,6 +108,15 @@ export function PushBranchTool(ctx: ToolContext) {
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
// reject push if working tree is dirty — forces agent to commit or discard before pushing
const status = $("git", ["status", "--porcelain"], { log: false });
if (status) {
throw new Error(
`push blocked: working tree has uncommitted changes. commit or discard them before pushing.\n\n` +
`git status:\n${status}`
);
}
// validate push destination matches expected URL
const pushUrl = ctx.toolState.pushUrl;
if (!pushUrl) {
+66 -18
View File
@@ -1,3 +1,5 @@
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec";
import { Ajv } from "ajv";
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import type { ToolContext } from "./server.ts";
@@ -7,29 +9,75 @@ export const SetOutputParams = type({
value: type.string.describe("the output value to expose as a GitHub Action output"),
});
export function SetOutputTool(ctx: ToolContext) {
type JsonSchema = Record<string, unknown>;
function jsonSchemaToStandardSchema({
$schema: _,
...jsonSchema
}: JsonSchema): StandardJSONSchemaV1<any> & StandardSchemaV1<any> {
const ajv = new Ajv();
const validate = ajv.compile(jsonSchema);
return {
"~standard": {
version: 1,
vendor: "json-schema",
jsonSchema: {
input: () => jsonSchema,
output: () => jsonSchema,
},
validate(input: unknown) {
if (validate(input)) {
return { value: input };
}
return {
issues: (validate.errors ?? []).map((err) => ({
message: `${err.instancePath || "/"}: ${err.message ?? "validation error"}`,
path: err.instancePath ? err.instancePath.split("/").filter(Boolean) : [],
})),
};
},
},
};
}
function storeOutput(ctx: ToolContext, value: string) {
const selfId = ctx.toolState.selfSubagentId;
if (selfId) {
const subagent = ctx.toolState.subagents.get(selfId);
if (subagent) {
subagent.output = value;
log.debug(`set_output: routed to subagent ${selfId} (value=${value.slice(0, 80)})`);
return { success: true, routed: "subagent" as const };
}
log.warning(
`set_output: selfSubagentId=${selfId} but subagent not found in map — routing to action output`
);
}
ctx.toolState.output = value;
return { success: true, routed: "action_output" as const };
}
export function SetOutputTool(ctx: ToolContext, outputSchema?: JsonSchema) {
if (outputSchema) {
return tool({
name: "set_output",
description:
"Set the structured action output. You MUST call this tool before finishing — the output is required. Pass the output object directly as the tool arguments (no wrapping needed).",
parameters: jsonSchemaToStandardSchema(outputSchema),
execute: execute(async (params) => {
return storeOutput(ctx, JSON.stringify(params));
}),
});
}
return tool({
name: "set_output",
description:
"Set the action output. When called by a subagent, returns a summary result to the orchestrator. When called in standalone mode, exposes the value as the 'result' GitHub Action output.",
"Set the action output. When called by a subagent, returns a summary result to the orchestrator — this is the ONLY way to pass results back. When called by the orchestrator in standalone mode (trigger: unknown), exposes the value as the 'result' GitHub Action output for downstream workflow steps. Do NOT use this for progress reporting — use report_progress instead.",
parameters: SetOutputParams,
execute: execute(async (params) => {
const selfId = ctx.toolState.selfSubagentId;
if (selfId) {
const subagent = ctx.toolState.subagents.get(selfId);
if (subagent) {
subagent.output = params.value;
log.debug(
`set_output: routed to subagent ${selfId} (value=${params.value.slice(0, 80)})`
);
return { success: true, routed: "subagent" };
}
log.warning(
`set_output: selfSubagentId=${selfId} but subagent not found in map — routing to action output`
);
}
ctx.toolState.output = params.value;
return { success: true, routed: "action_output" };
return storeOutput(ctx, params.value);
}),
});
}
+46 -1
View File
@@ -1,5 +1,6 @@
import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
@@ -73,12 +74,19 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
// set issue context (PRs are issues)
ctx.toolState.issueNumber = pull_number;
// enforce prApproveEnabled: downgrade APPROVE to COMMENT if disabled
let event: "APPROVE" | "COMMENT" = approved ? "APPROVE" : "COMMENT";
if (event === "APPROVE" && !ctx.prApproveEnabled) {
log.info("prApproveEnabled is disabled — downgrading APPROVE to COMMENT");
event = "COMMENT";
}
// compose the request
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
event: approved ? "APPROVE" : "COMMENT",
event,
};
if (body) params.body = body;
if (commit_id) {
@@ -115,12 +123,20 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
return reviewComment;
});
}
const result = await ctx.octokit.rest.pulls.createReview(params);
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id) {
throw new Error(`createReview returned invalid data: ${JSON.stringify(result.data)}`);
}
const reviewId = result.data.id;
const reviewNodeId = result.data.node_id;
// report review node ID to server so the in-flight dedup check
// in the synchronize webhook handler sees this run as "review submitted."
// awaited (not fire-and-forget) to guarantee the signal lands before
// any subsequent push's webhook checks for in-flight runs.
await reportReviewNodeId(ctx, reviewNodeId);
// build quick links footer and update the review body
// only include "Fix all" and "Fix 👍s" links if there are actual review comments
@@ -175,6 +191,35 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
});
}
async function reportReviewNodeId(ctx: ToolContext, reviewNodeId: string): Promise<void> {
for (let remaining = 2; remaining >= 0; remaining--) {
try {
const response = await apiFetch({
path: `/api/workflow-run/${ctx.runId}`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ reviewNodeId }),
signal: AbortSignal.timeout(10_000),
});
if (response.ok) return;
if (remaining > 0) {
log.debug(`reportReviewNodeId got ${response.status}, retrying (${remaining} left)`);
await new Promise((r) => setTimeout(r, 2000));
}
} catch (error) {
if (remaining > 0) {
log.debug(`reportReviewNodeId failed, retrying (${remaining} left): ${error}`);
await new Promise((r) => setTimeout(r, 2000));
} else {
log.debug(`reportReviewNodeId exhausted retries: ${error}`);
}
}
}
}
// =============================================================================
// COMMENTED OUT: Three-step review flow (start_review, add_review_comment, submit_review)
// This approach used GraphQL to add comments to a pending review one-by-one,
+137 -11
View File
@@ -1,12 +1,16 @@
import { type } from "arktype";
import { ghPullfrogMcpName } from "../external.ts";
import type { Mode } from "../modes.ts";
import { apiFetch } from "../utils/apiFetch.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const SelectModeParams = type({
mode: type.string.describe(
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews', 'Task')"
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts')"
),
"issue_number?": type("number").describe(
"optional issue number; when provided with Plan mode, used to look up an existing plan comment for this issue (edit vs create)"
),
});
@@ -46,6 +50,37 @@ For simple, well-defined tasks, a single build subagent is sufficient — skip t
Your subagent receives ONLY what you write. Include file paths, constraints, conventions, and any context from AGENTS.md or the codebase directly in the prompt. Subagents have file ops, shell, and read-only GitHub tools — but NO git/checkout, dependency, GitHub-write, or remote-mutating tools.`,
ResolveConflicts: `### Checklist
1. **Setup**:
- Call \${ghPullfrogMcpName}/checkout_pr to get the PR branch.
- Call \${ghPullfrogMcpName}/get_pull_request to identify the base branch (e.g., 'main').
- Call \${ghPullfrogMcpName}/git_fetch to fetch the base branch.
2. **Merge Attempt**:
- Run \`git merge origin/<base_branch>\` via shell.
- If it succeeds automatically: Great! Push via \${ghPullfrogMcpName}/push_branch and report success.
- If it fails (conflicts): You must resolve them.
3. **Delegation (if conflicts exist)**:
- Run \`git status\` or parse the merge output to find the list of conflicting files.
- Delegate to a subagent (or multiple in parallel if many files) to resolve the conflicts.
- **Instructions for subagent**:
- "You are resolving merge conflicts in these files: [list]."
- "For each file: read it, find the conflict markers (<<<<<<<, =======, >>>>>>>), understand the code context, and rewrite the file with the correct resolution. Remove all markers."
- "After resolving, verify the file syntax is correct."
- "Call \${ghPullfrogMcpName}/set_output with a summary of what you resolved."
- Note: Subagents cannot run git commands. They only edit the files.
4. **Finalize**:
- After subagents return:
- Run a final verification (build/test) to ensure the resolution works.
- \`git add .\`
- \`git commit -m "Resolve merge conflicts"\`
- \${ghPullfrogMcpName}/push_branch
- \${ghPullfrogMcpName}/report_progress
`,
AddressReviews: `### Checklist
1. Before delegating, checkout the PR branch yourself via \`${ghPullfrogMcpName}/checkout_pr\` — subagents have no git/checkout tools.
@@ -89,9 +124,38 @@ Each task in the \`tasks\` array should include:
After all tasks complete, consolidate into a **single** review:
- merge the \`comments\` arrays from all subagent outputs
- submit one \`${ghPullfrogMcpName}/create_pull_request_review\` with the merged comments and a unified summary body
- if subagents found actionable issues: submit one \`${ghPullfrogMcpName}/create_pull_request_review\` with \`approved: false\`, the merged comments, and a unified summary body
- if no subagent found actionable issues: submit with \`approved: true\` and a brief positive summary (no inline comments)
- call \`${ghPullfrogMcpName}/report_progress\` with the summary
Use max effort for thorough reviews.`,
IncrementalReview: `### Checklist
1. Checkout the PR via \`${ghPullfrogMcpName}/checkout_pr\` — this returns PR metadata and a \`diffPath\`. Read the diff to identify the major areas of change.
2. Generate the incremental diff using the \`before_sha\` from EVENT DATA: \`git diff <before_sha>...HEAD\`. This isolates only the new commits. If the command fails (e.g., force-push rewrote history), fall back to reviewing the full PR diff.
3. Fetch previous reviews via \`${ghPullfrogMcpName}/list_pull_request_reviews\`. For the most recent Pullfrog review, call \`${ghPullfrogMcpName}/get_review_comments\` with the review ID to retrieve specific prior line-level feedback. Include the prior review summary and comment details when crafting subagent tasks.
4. Delegate multiple subagents in a single \`${ghPullfrogMcpName}/delegate\` call, each focused on a specific area of the new changes. Provide both the full diff path and the incremental diff.
5. After all subagents return, consolidate their findings into a single review.
### Crafting each task
Each task in the \`tasks\` array should include:
- the full diff file path AND the incremental diff (so the subagent can see both new changes and full context)
- what specific area/aspect to focus on
- instruct it to prioritize reviewing code in the incremental diff while using the full diff for context and to catch any changes not covered by the incremental diff
- include the prior review comments (from step 3) so the subagent knows what feedback was already given — instruct it to avoid repeating prior issues and to note whether prior feedback was addressed by the new commits
- instruct it to actively hunt for problems: trace data flow, check boundaries, explore failure modes, verify assumptions, consider lifecycle, spot performance issues
- draft inline comments with NEW line numbers from the full PR diff — every comment must be actionable (2-3 sentences max)
- call \`${ghPullfrogMcpName}/set_output\` with a JSON object: \`{ "summary": "...", "comments": [{ "path": "file.ts", "line": 42, "body": "..." }, ...] }\`
### Post-delegation
After all tasks complete, consolidate into a **single** review:
- merge the \`comments\` arrays from all subagent outputs
- if subagents found actionable issues: submit one \`${ghPullfrogMcpName}/create_pull_request_review\` with \`approved: false\`, the merged comments, and a unified summary body
- if no subagent found actionable issues: submit with \`approved: true\` and a brief positive summary (no inline comments)
- call \`${ghPullfrogMcpName}/report_progress\` with the summary
- if no subagent found actionable issues, skip the review — just call \`report_progress\` noting the PR was reviewed
Use max effort for thorough reviews.`,
@@ -101,12 +165,28 @@ Use max effort for thorough reviews.`,
- the task to plan for
- relevant codebase context (file paths, architecture notes from AGENTS.md)
- instruct it to produce a structured, actionable plan with clear milestones
- call \`${ghPullfrogMcpName}/set_output\` with the plan (this is how results get back to you — you'll need the plan to craft the next subagent's prompt)
2. After the subagent completes, call \`${ghPullfrogMcpName}/report_progress\` with the plan.
- IMPORTANT: instruct it to return the full plan text via \`${ghPullfrogMcpName}/set_output\` as well-structured markdown — do NOT create plan files, do NOT save to disk
2. After the subagent completes, call \`${ghPullfrogMcpName}/report_progress\` with the full plan text from the subagent's output. The progress comment must contain the complete plan — not a file path or summary.
### Effort
Use mini or auto effort. After receiving the plan, you may delegate a Build subagent to implement it.`,
Use mini or auto effort.`,
PlanEdit: `### Checklist (editing existing plan)
An existing plan comment was found for this issue. Update that comment with the revised plan — do not create a new plan comment.
1. Use \`previousPlanBody\` from this response as the plan to revise; do not call \`get_issue\` or \`get_issue_comments\`.
2. When delegating, the subagent prompt must contain:
- the current plan (\`previousPlanBody\`) and the user's revision request
- relevant codebase context (file paths, architecture notes from AGENTS.md)
- instructions to produce a structured plan with clear milestones and to return the full plan via \`${ghPullfrogMcpName}/set_output\` as markdown (do not create plan files or save to disk)
3. After the subagent completes, call \`${ghPullfrogMcpName}/report_progress\` with the full plan text and \`{ target_plan_comment: true }\` so the revised plan updates the existing plan comment (not the progress comment).
4. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${ghPullfrogMcpName}/report_progress\` so it is not left as "Leaping...".
### Effort
Use mini or auto effort.`,
Fix: `### Checklist
@@ -154,8 +234,8 @@ type OrchestratorGuidance = {
orchestratorGuidance: string;
};
function buildOrchestratorGuidance(mode: Mode): OrchestratorGuidance {
const guidance = modeGuidance[mode.name] ?? "";
function buildOrchestratorGuidance(mode: Mode, overrideGuidance?: string): OrchestratorGuidance {
const guidance = overrideGuidance ?? modeGuidance[mode.name] ?? "";
return {
modeName: mode.name,
description: mode.description,
@@ -163,19 +243,49 @@ function buildOrchestratorGuidance(mode: Mode): OrchestratorGuidance {
};
}
// matches the API response for /repo/[owner]/[repo]/issue/[issueNumber]/plan-comment
export type PlanCommentResponsePayload = { error: string } | { commentId: number; body: string };
async function fetchExistingPlanComment(
ctx: ToolContext,
issueNumber: number
): Promise<Extract<PlanCommentResponsePayload, { commentId: number }> | null> {
if (!ctx.apiToken) return null;
try {
const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
method: "GET",
headers: { authorization: `Bearer ${ctx.apiToken}` },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as PlanCommentResponsePayload;
return response.ok && "commentId" in data ? data : null;
} catch {
return null;
}
}
export function SelectModeTool(ctx: ToolContext) {
return tool({
name: "select_mode",
description:
"Select a mode and receive orchestrator-level guidance on how to handle it, including suggested delegation flows and prompt-crafting tips. Call this before delegating to understand the best approach for the task.",
"Select a mode and receive orchestrator-level guidance on how to handle it, including suggested delegation flows and prompt-crafting tips. Call this ONCE before delegating. Mode selection is final — you cannot switch modes after selecting.",
parameters: SelectModeParams,
execute: execute(async (params) => {
const selectedMode = resolveMode(ctx.modes, params.mode);
if (ctx.toolState.selectedMode) {
return {
error: `mode already selected: "${ctx.toolState.selectedMode}". mode selection is final and cannot be changed. complete your current workflow within this mode.`,
};
}
const modeName = params.mode;
const selectedMode = resolveMode(ctx.modes, modeName);
if (!selectedMode) {
const availableModes = ctx.modes.map((m) => m.name).join(", ");
return {
error: `mode "${params.mode}" not found. available modes: ${availableModes}`,
error: `mode "${modeName}" not found. available modes: ${availableModes}`,
availableModes: ctx.modes.map((m) => ({
name: m.name,
description: m.description,
@@ -184,6 +294,22 @@ export function SelectModeTool(ctx: ToolContext) {
}
ctx.toolState.selectedMode = selectedMode.name;
if (selectedMode.name === "Plan") {
const issueNumber = params.issue_number ?? ctx.payload.event.issue_number;
if (issueNumber !== undefined) {
const existing = await fetchExistingPlanComment(ctx, issueNumber);
if (existing !== null) {
ctx.toolState.existingPlanCommentId = existing.commentId;
ctx.toolState.previousPlanBody = existing.body;
return {
...buildOrchestratorGuidance(selectedMode, modeGuidance.PlanEdit),
previousPlanBody: existing.body,
};
}
}
}
return buildOrchestratorGuidance(selectedMode);
}),
});
+17 -6
View File
@@ -64,6 +64,9 @@ export interface ToolState {
progressCommentId: number | null | undefined;
lastProgressBody?: string;
wasUpdated?: boolean;
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
existingPlanCommentId?: number;
previousPlanBody?: string;
output?: string;
usageEntries: AgentUsage[];
}
@@ -99,6 +102,7 @@ export interface ToolContext {
agent: Agent;
modes: Mode[];
postCheckoutScript: string | null;
prApproveEnabled: boolean;
toolState: ToolState;
runId: number | undefined;
jobId: string | undefined;
@@ -188,8 +192,10 @@ function isAddressInUse(error: unknown): boolean {
return message.includes("eaddrinuse") || message.includes("address already in use");
}
type JsonSchema = Record<string, unknown>;
// tools shared by both orchestrator and subagent servers
function buildCommonTools(ctx: ToolContext): Tool<any, any>[] {
function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any, any>[] {
const tools: Tool<any, any>[] = [
StartDependencyInstallationTool(ctx),
AwaitDependencyInstallationTool(ctx),
@@ -212,7 +218,7 @@ function buildCommonTools(ctx: ToolContext): Tool<any, any>[] {
GitTool(ctx),
GitFetchTool(ctx),
UploadFileTool(ctx),
SetOutputTool(ctx),
SetOutputTool(ctx, outputSchema),
FileReadTool(ctx),
FileWriteTool(ctx),
FileEditTool(ctx),
@@ -233,9 +239,9 @@ function buildCommonTools(ctx: ToolContext): Tool<any, any>[] {
}
// orchestrator gets common tools + delegation + remote-mutating tools
function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
function buildOrchestratorTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any, any>[] {
return [
...buildCommonTools(ctx),
...buildCommonTools(ctx, outputSchema),
ReportProgressTool(ctx),
SelectModeTool(ctx),
DelegateTool(ctx),
@@ -352,13 +358,18 @@ async function killBackgroundProcesses(toolState: ToolState): Promise<void> {
backgroundProcesses.clear();
}
type McpHttpServerOptions = {
outputSchema?: JsonSchema | undefined;
};
/**
* Start the orchestrator MCP HTTP server (has all tools including push/PR/delegation).
*/
export async function startMcpHttpServer(
ctx: ToolContext
ctx: ToolContext,
options?: McpHttpServerOptions
): Promise<{ url: string; [Symbol.asyncDispose]: () => Promise<void> }> {
const tools = buildOrchestratorTools(ctx);
const tools = buildOrchestratorTools(ctx, options?.outputSchema);
const startResult = await selectMcpPort(ctx, tools);
return {
+23 -17
View File
@@ -1,4 +1,4 @@
import type { StandardSchemaV1 } from "@standard-schema/spec";
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec";
import { encode as toonEncode } from "@toon-format/toon";
import type { FastMCP, Tool } from "fastmcp";
import { formatJsonValue, log } from "../utils/cli.ts";
@@ -141,27 +141,34 @@ function sanitizeSchema(schema: any): any {
}
/**
* Wrap a StandardSchemaV1 to intercept toJsonSchema() calls and sanitize the output
* Wrap a schema to sanitize its JSON Schema output for Gemini/OpenCode compatibility.
* xsschema calls ~standard.jsonSchema.input() for schemas that implement StandardJSONSchemaV1
* (i.e. have ~standard.jsonSchema), which includes arktype and our AJV-backed JSON schema wrapper.
* Schemas without ~standard.jsonSchema are returned unchanged (sanitization skipped).
*/
function wrapSchema(schema: StandardSchemaV1<any>): StandardSchemaV1<any> {
const originalToJsonSchema = (schema as any).toJsonSchema?.bind(schema);
function wrapSchema(
schema: StandardSchemaV1<any> & {
"~standard": Partial<StandardJSONSchemaV1<any>["~standard"]>;
}
): StandardSchemaV1<any> {
const standardProps = schema["~standard"];
if (!originalToJsonSchema) {
if (!("jsonSchema" in standardProps)) {
return schema;
}
// create a proxy that intercepts toJsonSchema calls
return new Proxy(schema, {
get(target, prop) {
if (prop === "toJsonSchema") {
return () => {
const originalSchema = originalToJsonSchema();
return sanitizeSchema(originalSchema);
};
}
return (target as any)[prop];
const jsonSchema = standardProps.jsonSchema;
const wrapped: StandardSchemaV1<any> & StandardJSONSchemaV1<any> = {
...schema,
"~standard": {
...standardProps,
jsonSchema: {
input: (options) => sanitizeSchema(jsonSchema.input(options)),
output: (options) => sanitizeSchema(jsonSchema.output(options)),
},
},
}) as StandardSchemaV1<any>;
};
return wrapped;
}
/**
@@ -172,7 +179,6 @@ function sanitizeTool<T extends Tool<any, any>>(tool: T): T {
return tool;
}
// wrap the schema object to intercept toJsonSchema() calls
const wrappedSchema = wrapSchema(tool.parameters);
// create a new tool with wrapped schema
+72 -4
View File
@@ -101,7 +101,7 @@ Keep the progress comment extremely brief. The summary should be 1-2 sentences m
name: "Review",
description:
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
prompt: `Follow these steps to review the PR. Your job is to find problems—assume they exist until you've proven otherwise. Do not submit a clean review without thorough investigation. **If you have nothing interesting to say, do NOT submit a review at all—use \`report_progress\` instead.**
prompt: `Follow these steps to review the PR. Your job is to find problems—assume they exist until you've proven otherwise. Do not submit a clean review without thorough investigation.
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number. This should give you all PR metadata you need, including a \`diffPath\`: a path to a temp file containing the PR diff.
@@ -119,15 +119,51 @@ Keep the progress comment extremely brief. The summary should be 1-2 sentences m
- **Check PR consistency**: Does the PR title/description match the actual code changes? Flag significant discrepancies.
- Do NOT stop at "this looks reasonable." Dig until you either find a problem or have concrete evidence there isn't one.
4. **DRAFT LINE-BY-LINE COMMENTS** - Every comment must be actionable: the author should need to change something in response. 2-3 sentences max. Use the NEW line number from the diff (second column: \`| OLD | NEW | TYPE | CODE\`). If no issues found, skip to step 5. NO COMPLIMENTS. NO NITPICKING ABOUT CHANGES UNRELATED TO THE MAIN CHANGE. Non-actionable comments (praise, style preferences, minor optimizatfixons, documentation nits) must not be drafted. If no comments survive and you have no significant concerns, **do not submit a review**. Use \`${ghPullfrogMcpName}/report_progress\` to note the PR was reviewed and no issues were found.
4. **DRAFT LINE-BY-LINE COMMENTS** - Every comment must be actionable: the author should need to change something in response. 2-3 sentences max. Use the NEW line number from the diff (second column: \`| OLD | NEW | TYPE | CODE\`). If no issues found, skip to step 5. NO COMPLIMENTS. NO NITPICKING ABOUT CHANGES UNRELATED TO THE MAIN CHANGE. Non-actionable comments (praise, style preferences, minor optimizatfixons, documentation nits) must not be drafted.
5. **WRITE SUMMARY** - Draft a 1-3 sentence summary for the review body. Include urgency level and any concerns about code outside the diff.
5. **WRITE SUMMARY** - Draft a 1-3 sentence summary for the review body. If issues were found, include urgency level and any concerns about code outside the diff. If no issues were found, write a brief approval summary (e.g., "Changes look good. No issues found.").
6. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review:
6. **SUBMIT** — Always submit a review via ${ghPullfrogMcpName}/create_pull_request_review:
- \`body\`: The summary from step 5
- \`comments\`: The inline comments from step 4
- \`approved\`: Set to \`true\` ONLY if the review contains no actionable feedback — neither inline comments nor actionable content in the body. An approval signals "no changes needed."
${permalinkTip}
`,
},
{
name: "IncrementalReview",
description:
"Re-review a PR after new commits are pushed; focus on new changes since the last review",
prompt: `Follow these steps to incrementally re-review the PR after new commits were pushed. Focus on what changed since the last review.
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number. This gives you the full PR diff via \`diffPath\`.
2. **INCREMENTAL DIFF** - EVENT DATA contains \`before_sha\` (the HEAD before this push). Generate the incremental diff:
\`git diff <before_sha>...HEAD\`
This shows the changes introduced by this push. Cross-reference with previous reviews (step 3) to confirm coverage of all unreviewed changes — the full PR diff fills any gaps.
**If the diff command fails** (e.g., force-push rewrote history), fall back to reviewing the full PR diff from step 1.
3. **FETCH PREVIOUS REVIEWS** - Use ${ghPullfrogMcpName}/list_pull_request_reviews to find previous Pullfrog reviews. For the most recent one, call ${ghPullfrogMcpName}/get_review_comments with the review ID to see specific line-level feedback. This lets you avoid repeating issues and assess whether prior feedback was addressed by the new commits.
4. **ANALYZE** - Read the incremental diff to understand the new changes. Use the full PR diff for surrounding context and to catch any changes not covered by the incremental diff.
- **Understand the change**: What is new or modified since the last review?
- **Evaluate the approach**: Are the new changes sound? Do they address prior feedback?
5. **INVESTIGATE** - Hunt for problems in the new code using the same techniques as a full review:
- Trace data flow, check boundaries, explore failure modes, verify assumptions, consider lifecycle, spot performance issues.
- Focus investigation on code that changed in the incremental diff, but trace its effects through the broader codebase.
- Do NOT repeat feedback already given in previous reviews unless it was not addressed.
6. **DRAFT LINE-BY-LINE COMMENTS** - Every comment must be actionable. 2-3 sentences max. Use the NEW line number from the full PR diff. NO COMPLIMENTS. NO NITPICKING.
7. **WRITE SUMMARY** - Draft a 1-3 sentence summary for the review body. Focus on what changed since the last review and whether the new changes are sound. If issues were found, include urgency level. If no issues were found, write a brief approval summary.
8. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review:
- \`body\`: The summary from step 7
- \`comments\`: The inline comments from step 6
- \`approved\`: Set to \`true\` ONLY if the review contains no actionable feedback — neither inline comments nor actionable content in the body. An approval signals "no changes needed."
${permalinkTip}
`,
},
@@ -214,6 +250,38 @@ ${permalinkTip}`,
10. **PROGRESS** - ${reportProgressInstruction}
Your job is to fix issues THIS PR introduced, not to fix all CI failures. If in doubt about causation, abort and explain rather than making speculative changes.`,
},
{
name: "ResolveConflicts",
description: "Resolve merge conflicts in a PR branch against the base branch",
prompt: `Follow these steps to resolve merge conflicts.
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch.
2. **FETCH BASE** - Identify the base branch (usually main or master) and fetch it using ${ghPullfrogMcpName}/git_fetch (e.g., ref: "main").
3. **ATTEMPT MERGE** - Use ${ghPullfrogMcpName}/shell to run \`git merge origin/<base_branch>\`.
- If the merge succeeds (exit code 0), the branch is up to date. Push it and you're done.
- If the merge fails, you have conflicts to resolve.
4. **IDENTIFY CONFLICTS** - Run \`git status\` to see which files are conflicting (modified by both).
5. **RESOLVE** - For each conflicting file:
- Read the file to see the conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`).
- Determine the correct content. You may need to keep changes from both sides, or choose one.
- Edit the file to apply the resolution and remove the markers.
6. **VERIFY** - ${dependencyInstallationStep}
- Run tests/builds to ensure the resolution is correct.
7. **COMMIT** - Once all conflicts are resolved:
- \`git add .\`
- \`git commit -m "Merge branch <base_branch> into <pr_branch>"\` (or similar).
8. **PUSH** - Call ${ghPullfrogMcpName}/push_branch.
9. **PROGRESS** - ${reportProgressInstruction}
`,
},
{
name: "Task",
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/pullfrog",
"version": "0.0.175",
"version": "0.0.178",
"type": "module",
"files": [
"index.js",
@@ -34,13 +34,14 @@
"@octokit/webhooks-types": "^7.6.1",
"@openai/codex-sdk": "0.98.0",
"@opencode-ai/sdk": "^1.0.143",
"@standard-schema/spec": "1.0.0",
"@standard-schema/spec": "1.1.0",
"@toon-format/toon": "^1.0.0",
"ajv": "^8.18.0",
"arkregex": "0.0.5",
"arktype": "2.1.29",
"arktype": "2.2.0",
"dotenv": "^17.2.3",
"execa": "^9.6.0",
"fastmcp": "^3.26.8",
"fastmcp": "^3.34.0",
"file-type": "^21.3.0",
"package-manager-detector": "^1.6.0",
"semver": "^7.7.3",
+507 -211
View File
File diff suppressed because it is too large Load Diff
+100 -47
View File
@@ -24695,7 +24695,7 @@ var require_lodash = __commonJS({
var symbolProto = Symbol2 ? Symbol2.prototype : void 0;
var symbolToString = symbolProto ? symbolProto.toString : void 0;
function baseIsRegExp(value2) {
return isObject(value2) && objectToString.call(value2) == regexpTag;
return isObject2(value2) && objectToString.call(value2) == regexpTag;
}
function baseSlice(array, start, end) {
var index = -1, length = array.length;
@@ -24729,7 +24729,7 @@ var require_lodash = __commonJS({
end = end === void 0 ? length : end;
return !start && end >= length ? array : baseSlice(array, start, end);
}
function isObject(value2) {
function isObject2(value2) {
var type2 = typeof value2;
return !!value2 && (type2 == "object" || type2 == "function");
}
@@ -24762,9 +24762,9 @@ var require_lodash = __commonJS({
if (isSymbol(value2)) {
return NAN;
}
if (isObject(value2)) {
if (isObject2(value2)) {
var other = typeof value2.valueOf == "function" ? value2.valueOf() : value2;
value2 = isObject(other) ? other + "" : other;
value2 = isObject2(other) ? other + "" : other;
}
if (typeof value2 != "string") {
return value2 === 0 ? value2 : +value2;
@@ -24778,7 +24778,7 @@ var require_lodash = __commonJS({
}
function truncate(string2, options) {
var length = DEFAULT_TRUNC_LENGTH, omission = DEFAULT_TRUNC_OMISSION;
if (isObject(options)) {
if (isObject2(options)) {
var separator2 = "separator" in options ? options.separator : separator2;
length = "length" in options ? toInteger(options.length) : length;
omission = "omission" in options ? baseToString(options.omission) : omission;
@@ -35679,7 +35679,7 @@ $ark.intrinsic = { ...intrinsic };
var regex = ((src, flags) => new RegExp(src, flags));
Object.assign(regex, { as: regex });
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/shift/operand/date.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/shift/operand/date.js
var isDateLiteral = (value2) => typeof value2 === "string" && value2[0] === "d" && (value2[1] === "'" || value2[1] === '"') && value2[value2.length - 1] === value2[1];
var isValidDate = (d) => d.toString() !== "Invalid Date";
var extractDateLiteralSource = (literal) => literal.slice(2, -1);
@@ -35698,7 +35698,7 @@ var maybeParseDate = (source, errorOnFail) => {
return errorOnFail ? throwParseError(errorOnFail === true ? writeInvalidDateMessage(source) : errorOnFail) : void 0;
};
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/shift/operand/enclosed.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/shift/operand/enclosed.js
var regexExecArray = rootSchema({
proto: "Array",
sequence: "string",
@@ -35772,12 +35772,12 @@ var enclosingCharDescriptions = {
};
var writeUnterminatedEnclosedMessage = (fragment, enclosingStart) => `${enclosingStart}${fragment} requires a closing ${enclosingCharDescriptions[enclosingTokens[enclosingStart]]}`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/ast/validate.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/ast/validate.js
var writePrefixedPrivateReferenceMessage = (name) => `Private type references should not include '#'. Use '${name}' instead.`;
var shallowOptionalMessage = "Optional definitions like 'string?' are only valid as properties in an object or tuple";
var shallowDefaultableMessage = "Defaultable definitions like 'number = 0' are only valid as properties in an object or tuple";
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/shift/tokens.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/shift/tokens.js
var terminatingChars = {
"<": 1,
">": 1,
@@ -35799,7 +35799,7 @@ var lookaheadIsFinalizing = (lookahead, unscanned) => lookahead === ">" ? unscan
unscanned[1] === "="
) : unscanned.trimStart() === "" || isKeyOf(unscanned.trimStart()[0], terminatingChars) : lookahead === "=" ? unscanned[0] !== "=" : lookahead === "," || lookahead === "?";
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/shift/operand/genericArgs.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/shift/operand/genericArgs.js
var parseGenericArgs = (name, g, s) => _parseGenericArgs(name, g, s, []);
var _parseGenericArgs = (name, g, s, argNodes) => {
const argState = s.parseUntilFinalizer();
@@ -35816,7 +35816,7 @@ var _parseGenericArgs = (name, g, s, argNodes) => {
};
var writeInvalidGenericArgCountMessage = (name, params, argDefs) => `${name}<${params.join(", ")}> requires exactly ${params.length} args (got ${argDefs.length}${argDefs.length === 0 ? "" : `: ${argDefs.join(", ")}`})`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/shift/operand/unenclosed.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/shift/operand/unenclosed.js
var parseUnenclosed = (s) => {
const token = s.scanner.shiftUntilLookahead(terminatingChars);
if (token === "keyof")
@@ -35864,10 +35864,10 @@ var writeMissingOperandMessage = (s) => {
var writeMissingRightOperandMessage = (token, unscanned = "") => `Token '${token}' requires a right operand${unscanned ? ` before '${unscanned}'` : ""}`;
var writeExpressionExpectedMessage = (unscanned) => `Expected an expression${unscanned ? ` before '${unscanned}'` : ""}`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/shift/operand/operand.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/shift/operand/operand.js
var parseOperand = (s) => s.scanner.lookahead === "" ? s.error(writeMissingOperandMessage(s)) : s.scanner.lookahead === "(" ? s.shiftedBy(1).reduceGroupOpen() : s.scanner.lookaheadIsIn(enclosingChar) ? parseEnclosed(s, s.scanner.shift()) : s.scanner.lookaheadIsIn(whitespaceChars) ? parseOperand(s.shiftedBy(1)) : s.scanner.lookahead === "d" ? s.scanner.nextLookahead in enclosingQuote ? parseEnclosed(s, `${s.scanner.shift()}${s.scanner.shift()}`) : parseUnenclosed(s) : s.scanner.lookahead === "x" ? s.scanner.nextLookahead === "/" ? s.shiftedBy(2) && parseEnclosed(s, "x/") : parseUnenclosed(s) : parseUnenclosed(s);
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/reduce/shared.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/reduce/shared.js
var minComparators = {
">": true,
">=": true
@@ -35887,7 +35887,7 @@ var writeOpenRangeMessage = (min, comparator) => `Left bounds are only valid whe
var writeUnpairableComparatorMessage = (comparator) => `Left-bounded expressions must specify their limits using < or <= (was ${comparator})`;
var writeMultipleLeftBoundsMessage = (openLimit, openComparator, limit, comparator) => `An expression may have at most one left bound (parsed ${openLimit}${invertedComparators[openComparator]}, ${limit}${invertedComparators[comparator]})`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/shift/operator/bounds.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/shift/operator/bounds.js
var parseBound = (s, start) => {
const comparator = shiftComparator(s, start);
if (s.root.hasKind("unit")) {
@@ -35958,14 +35958,14 @@ var parseRightBound = (s, comparator) => {
};
var writeInvalidLimitMessage = (comparator, limit, boundKind) => `Comparator ${boundKind === "left" ? invertedComparators[comparator] : comparator} must be ${boundKind === "left" ? "preceded" : "followed"} by a corresponding literal (was ${limit})`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/shift/operator/brand.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/shift/operator/brand.js
var parseBrand = (s) => {
s.scanner.shiftUntilNonWhitespace();
const brandName = s.scanner.shiftUntilLookahead(terminatingChars);
s.root = s.root.brand(brandName);
};
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/shift/operator/divisor.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/shift/operator/divisor.js
var parseDivisor = (s) => {
s.scanner.shiftUntilNonWhitespace();
const divisorToken = s.scanner.shiftUntilLookahead(terminatingChars);
@@ -35978,7 +35978,7 @@ var parseDivisor = (s) => {
};
var writeInvalidDivisorMessage = (divisor) => `% operator must be followed by a non-zero integer literal (was ${divisor})`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/shift/operator/operator.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/shift/operator/operator.js
var parseOperator = (s) => {
const lookahead = s.scanner.shift();
return lookahead === "" ? s.finalize("") : lookahead === "[" ? s.scanner.shift() === "]" ? s.setRoot(s.root.array()) : s.error(incompleteArrayTokenMessage) : lookahead === "|" ? s.scanner.lookahead === ">" ? s.shiftedBy(1).pushRootToBranch("|>") : s.pushRootToBranch(lookahead) : lookahead === "&" ? s.pushRootToBranch(lookahead) : lookahead === ")" ? s.finalizeGroup() : lookaheadIsFinalizing(lookahead, s.scanner.unscanned) ? s.finalize(lookahead) : isKeyOf(lookahead, comparatorStartChars) ? parseBound(s, lookahead) : lookahead === "%" ? parseDivisor(s) : lookahead === "#" ? parseBrand(s) : lookahead in whitespaceChars ? parseOperator(s) : s.error(writeUnexpectedCharacterMessage(lookahead));
@@ -35986,7 +35986,7 @@ var parseOperator = (s) => {
var writeUnexpectedCharacterMessage = (char, shouldBe = "") => `'${char}' is not allowed here${shouldBe && ` (should be ${shouldBe})`}`;
var incompleteArrayTokenMessage = `Missing expected ']'`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/shift/operator/default.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/shift/operator/default.js
var parseDefault = (s) => {
const baseNode = s.unsetRoot();
s.parseOperand();
@@ -35998,7 +35998,7 @@ var parseDefault = (s) => {
};
var writeNonLiteralDefaultMessage = (defaultDef) => `Default value '${defaultDef}' must be a literal value`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/string.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/string.js
var parseString = (def, ctx) => {
const aliasResolution = ctx.$.maybeResolveRoot(def);
if (aliasResolution)
@@ -36037,7 +36037,7 @@ var parseUntilFinalizer = (s) => {
};
var next = (s) => s.hasRoot() ? s.parseOperator() : s.parseOperand();
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/reduce/dynamic.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/reduce/dynamic.js
var RuntimeState = class _RuntimeState {
root;
branches = {
@@ -36174,7 +36174,7 @@ var RuntimeState = class _RuntimeState {
}
};
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/generic.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/generic.js
var emptyGenericParameterMessage = "An empty string is not a valid generic parameter name";
var parseGenericParamName = (scanner, result, ctx) => {
scanner.shiftUntilNonWhitespace();
@@ -36203,7 +36203,7 @@ var _parseOptionalConstraint = (scanner, name, result, ctx) => {
return parseGenericParamName(scanner, result, ctx);
};
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/fn.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/fn.js
var InternalFnParser = class extends Callable {
constructor($) {
const attach = {
@@ -36255,7 +36255,7 @@ var InternalTypedFn = class extends Callable {
var badFnReturnTypeMessage = `":" must be followed by exactly one return type e.g:
fn("string", ":", "number")(s => s.length)`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/match.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/match.js
var InternalMatchParser = class extends Callable {
$;
constructor($) {
@@ -36348,7 +36348,7 @@ var throwOnDefault = (errors) => errors.throw();
var chainedAtMessage = `A key matcher must be specified before the first case i.e. match.at('foo') or match.in<object>().at('bar')`;
var doubleAtMessage = `At most one key matcher may be specified per expression`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/property.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/property.js
var parseProperty = (def, ctx) => {
if (isArray(def)) {
if (def[1] === "=")
@@ -36361,7 +36361,7 @@ var parseProperty = (def, ctx) => {
var invalidOptionalKeyKindMessage = `Only required keys may make their values optional, e.g. { [mySymbol]: ['number', '?'] }`;
var invalidDefaultableKeyKindMessage = `Only required keys may specify default values, e.g. { value: 'number = 0' }`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/objectLiteral.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/objectLiteral.js
var parseObjectLiteral = (def, ctx) => {
let spread;
const structure = {};
@@ -36451,7 +36451,7 @@ var preparseKey = (key) => typeof key === "symbol" ? { kind: "required", normali
};
var writeInvalidSpreadTypeMessage = (def) => `Spread operand must resolve to an object literal type (was ${def})`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/tupleExpressions.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/tupleExpressions.js
var maybeParseTupleExpression = (def, ctx) => isIndexZeroExpression(def) ? indexZeroParsers[def[0]](def, ctx) : isIndexOneExpression(def) ? indexOneParsers[def[1]](def, ctx) : null;
var parseKeyOfTuple = (def, ctx) => ctx.$.parseOwnDefinitionFormat(def[1], ctx).keyof();
var parseBranchTuple = (def, ctx) => {
@@ -36514,7 +36514,7 @@ var indexZeroParsers = defineIndexZeroParsers({
var isIndexZeroExpression = (def) => indexZeroParsers[def[0]] !== void 0;
var writeInvalidConstructorMessage = (actual) => `Expected a constructor following 'instanceof' operator (was ${actual})`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/tupleLiteral.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/tupleLiteral.js
var parseTupleLiteral = (def, ctx) => {
let sequences = [{}];
let i = 0;
@@ -36616,7 +36616,7 @@ var requiredPostOptionalMessage = "A required element may not follow an optional
var optionalOrDefaultableAfterVariadicMessage = "An optional element may not follow a variadic element";
var defaultablePostOptionalMessage = "A defaultable element may not follow an optional element without a default";
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/parser/definition.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/parser/definition.js
var parseCache = {};
var parseInnerDefinition = (def, ctx) => {
if (typeof def === "string") {
@@ -36680,7 +36680,7 @@ var parseStandardSchema = (def, ctx) => ctx.$.intrinsic.unknown.pipe((v, ctx2) =
var parseTuple = (def, ctx) => maybeParseTupleExpression(def, ctx) ?? parseTupleLiteral(def, ctx);
var writeBadDefinitionTypeMessage = (actual) => `Type definitions must be strings or objects (was ${actual})`;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/type.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/type.js
var InternalTypeParser = class extends Callable {
constructor($) {
const attach = Object.assign(
@@ -36727,7 +36727,7 @@ var InternalTypeParser = class extends Callable {
}
};
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/scope.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/scope.js
var $arkTypeRegistry = $ark;
var InternalScope = class _InternalScope extends BaseScope {
get ambientAttachments() {
@@ -36822,7 +36822,7 @@ var scope = Object.assign(InternalScope.scope, {
});
var Scope = InternalScope;
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/keywords/builtins.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/keywords/builtins.js
var MergeHkt = class extends Hkt {
description = 'merge an object\'s properties onto another like `Merge(User, { isAdmin: "true" })`';
};
@@ -36832,7 +36832,7 @@ var arkBuiltins = Scope.module({
Merge
});
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/keywords/Array.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/keywords/Array.js
var liftFromHkt = class extends Hkt {
};
var liftFrom = genericNode("element")((args2) => {
@@ -36849,7 +36849,7 @@ var arkArray = Scope.module({
name: "Array"
});
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/keywords/FormData.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/keywords/FormData.js
var value = rootSchema(["string", registry.FileConstructor]);
var parsedFormDataValue = value.rawOr(value.array());
var parsed = rootSchema({
@@ -36886,7 +36886,7 @@ var arkFormData = Scope.module({
name: "FormData"
});
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/keywords/TypedArray.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/keywords/TypedArray.js
var TypedArray = Scope.module({
Int8: ["instanceof", Int8Array],
Uint8: ["instanceof", Uint8Array],
@@ -36903,7 +36903,7 @@ var TypedArray = Scope.module({
name: "TypedArray"
});
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/keywords/constructors.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/keywords/constructors.js
var omittedPrototypes = {
Boolean: 1,
Number: 1,
@@ -36916,7 +36916,7 @@ var arkPrototypes = Scope.module({
FormData: arkFormData
});
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/keywords/number.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/keywords/number.js
var epoch = rootSchema({
domain: {
domain: "number",
@@ -36959,7 +36959,7 @@ var number = Scope.module({
name: "number"
});
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/keywords/string.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/keywords/string.js
var regexStringNode = (regex4, description, jsonSchemaFormat) => {
const schema2 = {
domain: "string",
@@ -37366,7 +37366,7 @@ var string = Scope.module({
name: "string"
});
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/keywords/ts.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/keywords/ts.js
var arkTsKeywords = Scope.module({
bigint: intrinsic.bigint,
boolean: intrinsic.boolean,
@@ -37447,7 +37447,7 @@ var arkTsGenerics = Scope.module({
Required: Required2
});
// node_modules/.pnpm/arktype@2.1.29/node_modules/arktype/out/keywords/keywords.js
// node_modules/.pnpm/arktype@2.2.0/node_modules/arktype/out/keywords/keywords.js
var ark = scope({
...arkTsKeywords,
...arkTsGenerics,
@@ -41183,6 +41183,9 @@ var Octokit2 = Octokit.plugin(requestLog, legacyRestEndpointMethods, paginateRes
);
// utils/github.ts
function isObject(value2) {
return typeof value2 === "object" && value2 !== null;
}
function parseRepoContext() {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
@@ -41194,9 +41197,20 @@ function parseRepoContext() {
}
return { owner, name };
}
function emptyResourceUsage() {
return {
requestCount: 0,
rateLimitRemaining: null,
rateLimitResetMs: null
};
}
var usageByResource = {
core: emptyResourceUsage(),
graphql: emptyResourceUsage()
};
function createOctokit(token) {
const OctokitWithPlugins = Octokit2.plugin(throttling);
return new OctokitWithPlugins({
const octokit = new OctokitWithPlugins({
auth: token,
throttle: {
onRateLimit: (_retryAfter, _options, _octokit, retryCount) => {
@@ -41207,20 +41221,57 @@ function createOctokit(token) {
}
}
});
const onResponse = (response) => {
const resource = response.headers["x-ratelimit-resource"];
if (!resource) {
return response;
}
usageByResource[resource] ??= emptyResourceUsage();
const usage = usageByResource[resource];
usage.requestCount++;
const remaining = response.headers["x-ratelimit-remaining"];
const reset = response.headers["x-ratelimit-reset"];
if (remaining !== void 0) {
usage.rateLimitRemaining = Number(remaining);
}
if (reset !== void 0) {
usage.rateLimitResetMs = Number(reset) * 1e3;
}
return response;
};
octokit.hook.wrap("request", async (request2, options) => {
try {
const response = await request2(options);
onResponse(response);
return response;
} catch (error2) {
if (isObject(error2) && "response" in error2 && isObject(error2.response) && "headers" in error2.response && isObject(error2.response.headers)) {
onResponse(error2.response);
}
throw error2;
}
});
return octokit;
}
// mcp/comment.ts
var LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
var Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content")
body: type.string.describe("the comment body content"),
type: type.enumerated("Plan", "Comment").describe(
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
).optional()
});
var EditComment = type({
commentId: type.number.describe("the ID of the comment to edit"),
body: type.string.describe("the new comment body content")
});
var ReportProgress = type({
body: type.string.describe("the progress update content to share")
body: type.string.describe("the progress update content to share"),
"target_plan_comment?": type("boolean").describe(
"when true, update the existing plan comment (from select_mode lookup) instead of the progress comment; use when editing an existing plan"
)
});
var ReplyToReviewComment = type({
pull_number: type.number.describe("the pull request number"),
@@ -41267,7 +41318,7 @@ var Effort = type.enumerated("mini", "auto", "max");
// package.json
var package_default = {
name: "@pullfrog/pullfrog",
version: "0.0.175",
version: "0.0.178",
type: "module",
files: [
"index.js",
@@ -41301,13 +41352,14 @@ var package_default = {
"@octokit/webhooks-types": "^7.6.1",
"@openai/codex-sdk": "0.98.0",
"@opencode-ai/sdk": "^1.0.143",
"@standard-schema/spec": "1.0.0",
"@standard-schema/spec": "1.1.0",
"@toon-format/toon": "^1.0.0",
ajv: "^8.18.0",
arkregex: "0.0.5",
arktype: "2.1.29",
arktype: "2.2.0",
dotenv: "^17.2.3",
execa: "^9.6.0",
fastmcp: "^3.26.8",
fastmcp: "^3.34.0",
"file-type": "^21.3.0",
"package-manager-detector": "^1.6.0",
semver: "^7.7.3",
@@ -41400,7 +41452,8 @@ var Inputs = type({
"search?": ToolPermissionInput.or("undefined"),
"push?": PushPermissionInput.or("undefined"),
"shell?": ShellPermissionInput.or("undefined"),
"cwd?": type.string.or("undefined")
"cwd?": type.string.or("undefined"),
"output_schema?": type.string.or("undefined")
});
function resolvePromptInput() {
const prompt = core3.getInput("prompt", { required: true });
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* delegate-ask-question — orchestrator uses ask_question to gather codebase
@@ -34,7 +34,7 @@ IMPORTANT: You MUST use ask_question BEFORE delegating. The subagent prompt must
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* delegate-context-isolation — verifies that the subagent's "clean room"
@@ -39,7 +39,7 @@ CRITICAL: Your final output MUST contain "SECRET=${SECRET}" exactly.`,
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* delegate-error-handling — orchestrator delegates a task that will fail,
@@ -33,7 +33,7 @@ The point of this test is that you handle the error gracefully and report it —
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* delegate-file-read — orchestrator delegates a subagent to read a real file
@@ -33,7 +33,7 @@ IMPORTANT: Your subagent prompt must include the exact MCP tool names (gh_pullfr
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* delegate-synthesis — orchestrator delegates two research tasks to separate
@@ -40,7 +40,7 @@ Both pieces must come from the respective subagent results. Do NOT read the file
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* delegateTimeout test - validates that the activity timeout does NOT fire
@@ -32,7 +32,7 @@ After the delegation completes, call set_output yourself with the subagent's res
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -7
View File
@@ -1,10 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import {
defineFixture,
generateTestMarker,
getAgentOutput,
getStructuredOutput,
} from "../utils.ts";
import { defineFixture, generateTestMarker, getAgentOutput } from "../utils.ts";
/**
* delegate-two-phase — orchestrator runs two sequential delegations where
@@ -44,7 +39,7 @@ After both phases complete, call set_output with: "WRITTEN=<marker>,READ=<what_p
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const secret = marker.value;
+2 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* Adversarial sandbox escape test. Agent has no shell — only MCP file_read,
@@ -61,7 +61,7 @@ const repoSetup = [
].join(" && ");
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
// marker must be read correctly (proves file_read works inside repo)
+2 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* git config attack test - validates:
@@ -65,7 +65,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* git --exec bypass test - validates:
@@ -61,7 +61,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+3 -3
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* git flag injection test - validates:
@@ -53,7 +53,7 @@ Call the git tool with:
Call set_output with:
{
"approach1_result": "exact output or error",
"approach2_result": "exact output or error",
"approach2_result": "exact output or error",
"approach3_result": "exact output or error",
"approach4_result": "exact output or error",
"approach5_result": "exact output or error",
@@ -71,7 +71,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getStructuredOutput } from "../utils.ts";
import { defineFixture } from "../utils.ts";
/**
* .gitattributes write blocking test - validates:
@@ -59,7 +59,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
let parsed: Record<string, unknown> = {};
+2 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* comprehensive nobash escape test - validates:
@@ -80,7 +80,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, generateAgentUuids, getStructuredOutput } from "../utils.ts";
import { defineFixture, generateAgentUuids } from "../utils.ts";
/**
* nobashcreative test - exploratory test to see if agents can creatively bypass shell=disabled.
@@ -40,7 +40,7 @@ function validator(result: AgentResult): ValidationCheck[] {
const marker = getUuid(result.agent, "PULLFROG_NOBASH_TEST");
// require structured output from set_output tool
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
// shell should NOT have executed - unique marker value should NOT appear in output
+2 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* requirements.txt local path attack test - validates:
@@ -59,7 +59,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+5 -3
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* delegate test - validates core end-to-end delegation flow.
@@ -12,7 +12,9 @@ import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts"
const fixture = defineFixture(
{
prompt: `Select the Plan mode via select_mode, then delegate with mini effort. Your subagent instructions should be:
"This is a delegation test. Your only task is to call set_output with the value 'DELEGATE_BASIC_PASSED'. Do not create plans, branches, or PRs. Just call set_output."`,
"This is a delegation test. Your only task is to call set_output with the value 'DELEGATE_BASIC_PASSED'. Do not create plans, branches, or PRs. Just call set_output."
When all delegations are complete, call set_output with the final result. This makes it available as the GitHub Action output.`,
effort: "mini",
timeout: "5m",
},
@@ -20,7 +22,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+5 -3
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* delegateEffort test - validates effort selection for delegation.
@@ -16,7 +16,9 @@ const fixture = defineFixture(
{
prompt: `This is a simple task. Select the Plan mode via select_mode, then delegate with MINI effort (this is a trivial task).
Your subagent instructions should be:
"Call set_output with the value 'EFFORT_TEST_PASSED'. Do not create plans or PRs. Just call set_output."`,
"Call set_output with the value 'EFFORT_TEST_PASSED'. Do not create plans or PRs. Just call set_output."
When all delegations are complete, call set_output with the final result. This makes it available as the GitHub Action output.`,
effort: "auto",
timeout: "5m",
},
@@ -24,7 +26,7 @@ Your subagent instructions should be:
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+5 -3
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* delegateMulti test - validates multi-phase delegation with context passing.
@@ -19,7 +19,9 @@ Phase 1: Select Plan mode via select_mode, then delegate with tasks: [{ label: "
Phase 2: After Phase 1 completes, select Plan mode again and delegate with tasks: [{ label: "phase-2", instructions: "Your task is to call set_output with the value 'MULTI_DELEGATE_PASSED'. Do not create plans or PRs.", effort: "mini" }]. Include the result from Phase 1 in the instructions if you want.
Both delegations must complete successfully.`,
Both delegations must complete successfully.
When all delegations are complete, call set_output with the final result. This makes it available as the GitHub Action output.`,
effort: "mini",
timeout: "8m",
},
@@ -27,7 +29,7 @@ Both delegations must complete successfully.`,
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getStructuredOutput } from "../utils.ts";
import { defineFixture } from "../utils.ts";
/**
* fileTraversal test - validates path traversal is blocked by all MCP file tools.
@@ -31,7 +31,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
const traversalBlocked = setOutputCalled && /TRAVERSAL_BLOCKED=true/i.test(output);
// extra safety: structured output should not be exactly a hostname (leaked from /etc/hostname)
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
import { defineFixture, getAgentOutput } from "../utils.ts";
/**
* git hooks isolation test - validates:
@@ -52,7 +52,7 @@ Call set_output with:
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -7
View File
@@ -1,10 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import {
defineFixture,
generateTestMarker,
getAgentOutput,
getStructuredOutput,
} from "../utils.ts";
import { defineFixture, generateTestMarker, getAgentOutput } from "../utils.ts";
/**
* git permissions test - validates:
@@ -57,7 +52,7 @@ Call set_output with a JSON object containing:
const { value: testToken, agentEnv } = generateTestMarker("GIT_PERMS_TEST_TOKEN");
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const agentOutput = getAgentOutput(result);
const setOutputCalled = output !== null;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getStructuredOutput } from "../utils.ts";
import { defineFixture } from "../utils.ts";
/**
* package.json script injection test - validates:
@@ -56,7 +56,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
let parsed: Record<string, unknown> = {};
+2 -7
View File
@@ -1,10 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import {
defineFixture,
generateAgentUuids,
getAgentOutput,
getStructuredOutput,
} from "../utils.ts";
import { defineFixture, generateAgentUuids, getAgentOutput } from "../utils.ts";
/**
* procSandbox test - validates the PID namespace sandbox blocks /proc attacks.
@@ -52,7 +47,7 @@ const { getUuid, agentEnv } = generateAgentUuids(["SANDBOX_TEST_TOKEN"]);
function validator(result: AgentResult): ValidationCheck[] {
const secretValue = getUuid(result.agent, "SANDBOX_TEST_TOKEN");
const agentOutput = getAgentOutput(result);
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
// the secret should NOT appear anywhere in the output
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, generateAgentUuids, getStructuredOutput } from "../utils.ts";
import { defineFixture, generateAgentUuids } from "../utils.ts";
/**
* pushDisabled test - validates that push:disabled blocks all write operations.
@@ -31,7 +31,7 @@ Call set_output with a JSON object:
const { agentEnv } = generateAgentUuids(["PUSH_TEST_MARKER"]);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
let parsed: Record<string, unknown> = {};
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getStructuredOutput } from "../utils.ts";
import { defineFixture } from "../utils.ts";
/**
* push enabled test - validates full push access.
@@ -44,7 +44,7 @@ Call set_output with a JSON object containing:
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
let parsed: Record<string, unknown> = {};
+2 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getStructuredOutput } from "../utils.ts";
import { defineFixture } from "../utils.ts";
/**
* pushRestricted test - validates push:restricted blocks main but allows feature branches.
@@ -40,7 +40,7 @@ Call set_output with a JSON object:
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
let parsed: Record<string, unknown> = {};
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getStructuredOutput } from "../utils.ts";
import { defineFixture } from "../utils.ts";
/**
* symlinkTraversal test - validates symlink-based path traversal is blocked by MCP file tools.
@@ -35,7 +35,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
const symlinkBlocked = setOutputCalled && /SYMLINK_BLOCKED=true/i.test(output);
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, generateAgentUuids, getStructuredOutput } from "../utils.ts";
import { defineFixture, generateAgentUuids } from "../utils.ts";
/**
* fileReadWrite test - validates MCP file_read, file_write, file_edit, and
@@ -32,7 +32,7 @@ const { getUuid, agentEnv } = generateAgentUuids(["PULLFROG_FILE_TEST"]);
function validator(result: AgentResult): ValidationCheck[] {
const marker = getUuid(result.agent, "PULLFROG_FILE_TEST");
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
// file_edit should have replaced BEFORE: with AFTER:
const editWorked = setOutputCalled && output.includes(`AFTER:${marker}`);
+2 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getStructuredOutput } from "../utils.ts";
import { defineFixture } from "../utils.ts";
/**
* MCP merge test - validates repo-level MCP servers merge correctly with gh_pullfrog.
@@ -23,7 +23,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
const correctValue = setOutputCalled && output === secret;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, generateAgentUuids, getStructuredOutput } from "../utils.ts";
import { defineFixture, generateAgentUuids } from "../utils.ts";
/**
* noNativeFile test - validates native file read/write tools are disabled.
@@ -32,7 +32,7 @@ const fixture = defineFixture(
const { agentEnv } = generateAgentUuids(["PULLFROG_NOFILE_TEST"]);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const fullOutput = result.output;
const setOutputCalled = output !== null;
+2 -7
View File
@@ -1,10 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import {
buildShellToolPrompt,
defineFixture,
generateAgentUuids,
getStructuredOutput,
} from "../utils.ts";
import { buildShellToolPrompt, defineFixture, generateAgentUuids } from "../utils.ts";
/**
* nobash test - validates agents respect shell=disabled setting.
@@ -31,7 +26,7 @@ function validator(result: AgentResult): ValidationCheck[] {
const marker = getUuid(result.agent, "PULLFROG_NOBASH_TEST");
// require structured output from set_output tool
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
// shell should NOT have executed - unique marker value should NOT appear in output
+3 -8
View File
@@ -1,10 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import {
buildShellToolPrompt,
defineFixture,
generateAgentUuids,
getStructuredOutput,
} from "../utils.ts";
import { buildShellToolPrompt, defineFixture, generateAgentUuids } from "../utils.ts";
/**
* restricted test - validates shell=restricted environment filtering.
@@ -14,7 +9,7 @@ import {
const fixture = defineFixture(
{
prompt: `This is a test to determine token visibility in shell tool calls.
${buildShellToolPrompt("echo $PULLFROG_TEST_VALUE")}
Then also run: echo $PULLFROG_TEST_TOKEN
@@ -36,7 +31,7 @@ function validator(result: AgentResult): ValidationCheck[] {
const filteredMarker = getUuid(result.agent, "PULLFROG_TEST_TOKEN");
// require structured output from set_output tool
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
// non-sensitive env var SHOULD appear in output (agent can read it via shell)
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
import { defineFixture, getStructuredOutput } from "../utils.ts";
import { defineFixture } from "../utils.ts";
/**
* smoke test - validates agent can connect to API and call MCP tools.
@@ -15,7 +15,7 @@ const fixture = defineFixture(
);
function validator(result: AgentResult): ValidationCheck[] {
const output = getStructuredOutput(result);
const output = result.structuredOutput;
const setOutputCalled = output !== null;
const correctValue = setOutputCalled && /SMOKE TEST PASSED/i.test(output);
+20 -6
View File
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { agentsManifest } from "../external.ts";
@@ -112,6 +112,7 @@ export interface AgentResult {
agent: string;
success: boolean;
output: string;
structuredOutput: string | null;
}
// get agent output with GitHub Actions masking commands filtered out
@@ -123,12 +124,19 @@ export function getAgentOutput(result: AgentResult): string {
.join("\n");
}
// get structured output from set_output tool (via ::pullfrog-output:: marker)
// returns null if no structured output was set by the agent
export function getStructuredOutput(result: AgentResult): string | null {
const match = result.output.match(/::pullfrog-output::([A-Za-z0-9+/=]+)/);
// parse GITHUB_OUTPUT file format to extract a key's value.
// format: key<<ghadelimiter_<uuid>\n<value>\nghadelimiter_<uuid>
function parseGitHubOutputFile(filePath: string, key: string): string | null {
let content: string;
try {
content = readFileSync(filePath, "utf8");
} catch {
return null;
}
const pattern = new RegExp(`${key}<<(ghadelimiter_[\\w-]+)\\n([\\s\\S]*?)\\n\\1`);
const match = content.match(pattern);
if (!match) return null;
return Buffer.from(match[1], "base64").toString();
return match[2];
}
export interface ValidationCheck {
@@ -184,6 +192,9 @@ export async function runAgentStreaming(options: RunStreamingOptions): Promise<A
const testHome = `/tmp/home-${mcpPort}-${Date.now()}`;
mkdirSync(testHome, { recursive: true });
const githubOutputFile = join(testHome, "github-output");
writeFileSync(githubOutputFile, "");
// write file-based env vars for MCP servers that don't inherit parent env vars
// (e.g., Cursor CLI doesn't pass env vars to repo-level MCP servers).
// only explicitly opted-in vars go here -- never secrets.
@@ -203,6 +214,7 @@ export async function runAgentStreaming(options: RunStreamingOptions): Promise<A
AGENT_OVERRIDE: options.agent,
...options.env,
HOME: testHome,
GITHUB_OUTPUT: githubOutputFile,
},
stdio: "pipe",
detached: true,
@@ -217,6 +229,7 @@ export async function runAgentStreaming(options: RunStreamingOptions): Promise<A
agent: options.agent,
success: false,
output: `spawn error: ${err.message}`,
structuredOutput: null,
});
});
@@ -253,6 +266,7 @@ export async function runAgentStreaming(options: RunStreamingOptions): Promise<A
agent: options.agent,
success: code === 0,
output: Buffer.concat(chunks).toString(),
structuredOutput: parseGitHubOutputFile(githubOutputFile, "result"),
});
});
});
+1
View File
@@ -72,6 +72,7 @@ async function fetchBodyHtml(ctx: ResolveBodyContext): Promise<string | undefine
case "pull_request_opened":
case "pull_request_ready_for_review":
case "pull_request_synchronize":
case "pull_request_review_requested":
// PRs are also issues - use issues.get which returns body_html
if (!event.issue_number) return;
+1
View File
@@ -135,6 +135,7 @@ const testEnvAllowList = new Set([
"GITHUB_API_URL",
"GITHUB_SERVER_URL",
"GITHUB_GRAPHQL_URL",
"GITHUB_OUTPUT",
]);
export type EnvFilterMode = "allowlist" | "passthrough";
+99 -1
View File
@@ -1,10 +1,24 @@
import { createSign } from "node:crypto";
import { rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import * as core from "@actions/core";
import { throttling } from "@octokit/plugin-throttling";
import { Octokit } from "@octokit/rest";
import { apiFetch } from "./apiFetch.ts";
import { retry } from "./retry.ts";
function isObject(value: unknown) {
return typeof value === "object" && value !== null;
}
// we don't get access to the actual class from @octokit/rest
// it's reachable from @octokit/request-error but we'd have to add a dependency on it
// and it would pose a risk of accidentally pulling a different version of that class (node_modules dep graphs ❤️)
// so it's safer to ducktype this
interface OctokitResponseShim {
headers: Record<string, string | number | undefined>;
}
export interface InstallationToken {
token: string;
expires_at: string;
@@ -339,11 +353,55 @@ export type OctokitWithPlugins = InstanceType<
ReturnType<typeof Octokit.plugin<typeof Octokit, [typeof throttling]>>
>;
export interface ResourceUsage {
requestCount: number;
rateLimitRemaining: number | null;
rateLimitResetMs: number | null;
}
function emptyResourceUsage(): ResourceUsage {
return {
requestCount: 0,
rateLimitRemaining: null,
rateLimitResetMs: null,
};
}
const usageByResource: Record<string, ResourceUsage> = {
core: emptyResourceUsage(),
graphql: emptyResourceUsage(),
};
export interface UsageSummary {
version: 1;
github: {
core: ResourceUsage;
graphql: ResourceUsage;
};
}
function getGitHubUsageSummary(): UsageSummary {
return {
version: 1,
github: {
core: usageByResource.core,
graphql: usageByResource.graphql,
},
};
}
export async function writeGitHubUsageSummaryToFile(path: string): Promise<void> {
const summary = getGitHubUsageSummary();
const tmpPath = join(dirname(path), `.usage-summary-${process.pid}.tmp`);
await writeFile(tmpPath, JSON.stringify(summary));
await rename(tmpPath, path);
}
export function createOctokit(token: string): OctokitWithPlugins {
// `OctokitWithPlugins` initialization based on https://github.com/actions/toolkit/blob/2506e78e82fbd2f9e94d63e75f5309118c8de1b1/packages/github/src/github.ts#L15-L22
// we can't use it directly because it's stuck on `@octokit/core@v5` and we use the hottest `@octokit/core@v7`
const OctokitWithPlugins = Octokit.plugin(throttling);
return new OctokitWithPlugins({
const octokit = new OctokitWithPlugins({
auth: token,
throttle: {
onRateLimit: (_retryAfter, _options, _octokit, retryCount) => {
@@ -354,4 +412,44 @@ export function createOctokit(token: string): OctokitWithPlugins {
},
},
});
const onResponse = (response: OctokitResponseShim) => {
const resource = response.headers["x-ratelimit-resource"];
if (!resource) {
return response;
}
usageByResource[resource] ??= emptyResourceUsage();
const usage = usageByResource[resource];
usage.requestCount++;
const remaining = response.headers["x-ratelimit-remaining"];
const reset = response.headers["x-ratelimit-reset"];
if (remaining !== undefined) {
usage.rateLimitRemaining = Number(remaining);
}
if (reset !== undefined) {
usage.rateLimitResetMs = Number(reset) * 1000;
}
return response;
};
octokit.hook.wrap("request", async (request, options) => {
try {
const response = await request(options);
onResponse(response);
return response;
} catch (error) {
if (
isObject(error) &&
"response" in error &&
isObject(error.response) &&
"headers" in error.response &&
isObject(error.response.headers)
) {
onResponse(error.response as OctokitResponseShim);
}
throw error;
}
});
return octokit;
}
+14 -5
View File
@@ -10,6 +10,7 @@ interface InstructionsContext {
payload: ResolvedPayload;
repo: RunContextData["repo"];
modes: Mode[];
outputSchema?: Record<string, unknown> | undefined;
}
function buildRuntimeContext(ctx: InstructionsContext): string {
@@ -118,13 +119,21 @@ Use the \`${ghPullfrogMcpName}\` MCP file tools for all file operations. Do NOT
All file tools enforce repository-scoped access and prevent modifications to .git/.`;
}
function getStandaloneModeInstructions(trigger: string): string {
function getStandaloneModeInstructions(
trigger: string,
outputSchema?: Record<string, unknown> | undefined
): string {
if (trigger !== "unknown") {
return "";
}
const outputRequirement = outputSchema
? `**REQUIRED structured output:** You MUST call \`${ghPullfrogMcpName}/set_output\` before finishing. The tool expects a structured object matching a JSON Schema — inspect its parameter schema to see the exact shape. Omitting this call or providing non-conforming output will fail the action.`
: `When you complete your task, call \`${ghPullfrogMcpName}/set_output\` with the main result of your work (generated content, summary of changes, analysis results, etc.). This makes it available as a GitHub Action output named \`result\` for subsequent workflow steps to consume. When in doubt, prefer calling \`set_output\`—unused outputs are harmless, but missing outputs may break downstream steps.`;
return `### Standalone mode
You are running as a step in a user-defined CI workflow. When you complete your task, call \`${ghPullfrogMcpName}/set_output\` with the main result of your work (generated content, summary of changes, analysis results, etc.). This makes it available as a GitHub Action output named \`result\` for subsequent workflow steps to consume.`;
You are running as a step in a user-defined CI workflow. ${outputRequirement}`;
}
// shared system prompt body used by both orchestrator and subagent instructions.
@@ -134,6 +143,7 @@ interface SystemPromptContext {
trigger: string;
priorityOrder: string;
taskSection: string;
outputSchema?: Record<string, unknown> | undefined;
}
function buildSystemPrompt(ctx: SystemPromptContext): string {
@@ -192,7 +202,7 @@ ${getShellInstructions(ctx.shell)}
${getFileInstructions()}
${getStandaloneModeInstructions(ctx.trigger)}
${getStandaloneModeInstructions(ctx.trigger, ctx.outputSchema)}
## Workflow
@@ -384,8 +394,6 @@ To investigate questions, prefer \`${ghPullfrogMcpName}/ask_question\` over \`${
After each \`delegate\` call, you receive a \`results\` array — one entry per task with \`label\`, \`success\`, \`summary\` (from set_output), and \`stdoutFile\` (inspectable via \`${ghPullfrogMcpName}/file_read\`). Follow the post-delegation steps from the select_mode guidance.
When all delegations are complete, call \`${ghPullfrogMcpName}/set_output\` with the final result. This makes it available as the GitHub Action output.
### Subagent capabilities
Subagents have: file operations, shell (for local git, tests, builds), read-only GitHub queries, and upload_file. They do NOT have: \`git\`, \`checkout_pr\`, \`push_branch\`, \`create_pull_request\`, \`create_pull_request_review\`, \`report_progress\`, \`create_issue_comment\`, \`reply_to_review_comment\`, \`resolve_review_thread\`, \`delegate\`, \`ask_question\`, or any dependency/remote-mutating tools. All GitHub-write and state-mutating operations are your responsibility.
@@ -408,6 +416,7 @@ If the task clearly requires no work, skip delegation. Call \`${ghPullfrogMcpNam
trigger: ctx.payload.event.trigger,
priorityOrder: orchestratorPriorityOrder,
taskSection: orchestratorTaskSection,
outputSchema: ctx.outputSchema,
});
const contextSections = buildContextSections({
+2
View File
@@ -54,6 +54,7 @@ export const Inputs = type({
"push?": PushPermissionInput.or("undefined"),
"shell?": ShellPermissionInput.or("undefined"),
"cwd?": type.string.or("undefined"),
"output_schema?": type.string.or("undefined"),
});
export type Inputs = typeof Inputs.infer;
@@ -178,6 +179,7 @@ export function resolvePayload(
effort: inputs.effort ?? jsonPayload?.effort ?? "auto",
timeout: inputs.timeout ?? jsonPayload?.timeout,
cwd: resolveCwd(inputs.cwd),
progressCommentId: jsonPayload?.progressCommentId,
debug: jsonPayload?.debug,
// permissions: inputs > repoSettings > fallbacks
+2
View File
@@ -19,6 +19,7 @@ export interface RepoSettings {
search: ToolPermission;
push: PushPermission;
shell: ShellPermission;
prApproveEnabled: boolean;
}
export interface RunContext {
@@ -36,6 +37,7 @@ const defaultSettings: RepoSettings = {
search: "enabled",
push: "restricted",
shell: "restricted",
prApproveEnabled: false,
};
const defaultRunContext: RunContext = {