* add stop hook + learnings reflection to post-run loop (#515)
stop hook (#515): repo-configured script that runs after the agent
finishes. non-zero exit resumes the agent with the hook output as
guidance; persistent failure (3 attempts) marks the run failed. the
dirty-tree and stop-hook gates share a single retry loop so a fix +
push happen in one turn.
learnings reflection: per Colin, the learnings step baked into mode
checklists rarely fires — the agent stays focused on the task and the
meta-ask falls through. the post-run loop now delivers a dedicated
one-shot --continue turn asking the agent to call update_learnings if
relevant, nothing else competing for attention. reflection doesn't
consume the gate-retry budget; if it dirties the tree, the next loop
iteration catches it via the dirty-tree gate.
plumbing: Repo.stopScript column + migration, zod schema, run-context
api, AgentSettings UI. RepoSettings.stopScript threads through to
AgentRunContext and into each agent harness.
subprocess-dependent logic lives in action/agents/postRun.ts to keep
action/agents/shared.ts lean — shared.ts is reachable from
pullfrog/internal, and pulling node:child_process through it leaks
into root tsc (which uses bundler resolution, not NodeNext).
* fix: preserve successful run when reflection turn fails
The post-run reflection turn (update_learnings nudge) is a best-effort
one-shot; its failure must not flip a successful run to failed. Prior
code overwrote `result` with the reflection's return value, so a model
API error during reflection caused the whole run to be reported as
failed even though the gated work had already completed cleanly.
Now: save the pre-reflection result, and if reflection returns
`success: false`, log a warning, restore the prior success, and exit
without re-invoking the gates (re-running a freshly-green stop hook
risks a flaky false-positive failure).
Adds action/agents/postRun.test.ts covering the reflection path —
previously uncovered.
* fix: surface both stop-hook stdout and stderr to the agent
The `(stderr || stdout)` heuristic in executeStopHook dropped stdout
entirely whenever stderr had any content. Scripts that emit a benign
warning to stderr and the actionable error to stdout (common for
wrapper scripts) starved the agent of the information it needed to
fix the issue.
Now concatenate both streams (stderr first, stdout second, skipping
empty ones) before truncation. This keeps stdout's tail — usually
where summaries and totals live — intact under the 4096-char cap.
* test: lock in the core post-run retry + reflection invariants
PR #548's test plan ships four manual verification scenarios.
Convert three to vitest coverage, catching regressions on the hottest
code paths:
- persistent stop hook failure exhausts MAX_POST_RUN_RETRIES and
surfaces as AgentResult.error with both the retry count and the
verbatim hook output (so the GitHub-comment rendering stays
actionable).
- every gate retry is fed the hook output as the resume prompt.
- usage aggregates across the initial run plus every retry (billing
relies on this).
- reflection turn still fires when no stop hook is configured and the
tree is clean.
Manual item remaining is the full UI round-trip of the settings form,
which is out of scope for unit tests.
* test: cover executeStopHook soft-fail and truncation invariants
Three paths the PR documents but previously had no regression gates:
- timeout (SPAWN_TIMEOUT_CODE) and activity-timeout
(SPAWN_ACTIVITY_TIMEOUT_CODE) must return null, not a failure. a
hook that times out is an infra problem; retrying with an agent
turn risks an infinite loop.
- spawn errors (ENOENT from a typoed binary, etc.) take the same
soft-fail path for the same reason.
- oversize hook output is truncated to the last 4096 chars with a
"truncated" marker, keeping the tail (where summaries live) and
protecting the 65535-char GitHub-comment budget downstream.
Regression targets — a refactor that accidentally surfaces an infra
failure as a gate failure, or blows the comment budget, will now
fail loudly in CI.
* test: cover soft-fail, no-resume, and short-circuit invariants
Three more documented behaviors that previously had no regression
gates:
- dirty-tree-only is a soft-fail: persistent uncommitted changes log
and warn but DO NOT flip the run to failed. a regression that
started surfacing this as AgentResult.error would break every run
that leaves a test fixture untracked.
- canResume=false + stop hook failure still surfaces the hook failure
as AgentResult.error. the retry budget is zero so "N retry
attempts" is correctly omitted from the message, but the run still
reports WHY it failed rather than silently reporting success.
- initial result with success=false short-circuits the loop: no gate
checks, no reflection, no resume calls. the original agent error
flows through verbatim for clean triage.
Also reset mockedSpawn in beforeEach so test state doesn't leak
between cases.
* test: lock in the reflection-dirties-tree → dirty-tree-gate path
The PR description claims: "if the reflection turn dirties the tree,
the loop picks that up on the next iteration via the normal
dirty-tree gate." There was no regression gate on this invariant.
Without it, a refactor that moved the reflection out of the retry
loop (e.g., into a one-shot post-loop call) would silently bypass
the commit-before-you-finish contract whenever the agent misbehaves
during reflection — uncommitted changes would ship as part of the
run's "success" state.
The test sequences three getGitStatus returns (clean → dirty → clean)
and asserts two resume calls: REFLECTION first, then UNCOMMITTED
CHANGES with the dirtying file in the prompt.
* fix: preserve pre-reflection task output when reflection succeeds
the reflection turn's reply ("done" or "updated learnings with N bullets")
is a meta-ask, not a task summary. before this fix, result = reflectionResult
clobbered the original task's output on the returned AgentResult, so
downstream consumers (handleAgentResult's fallback path when toolState is
empty, programmatic callers of main()) saw the reflection's trivial reply
instead of the real summary.
spread reflectionResult to inherit fields subsequent gate retries need
(e.g. the new sessionId claude emits per --resume invocation), but keep
the pre-reflection output verbatim.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: fall back to reflection's output when pre-reflection output is empty
the prior fix used `??` which only fell through on null/undefined. runs
that communicate exclusively through MCP tools (e.g. report_progress) and
emit no plain text leave result.output = "", which `??` preserved as-is —
dropping the reflection's reply and leaving handleAgentResult's fallback
path with nothing to show. switch to `||` so empty-string pre-reflection
output yields the reflection's output instead of ""; non-empty task output
still wins as intended.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: drop reflection-failure-skips-hook test (over-specified control flow)
the test pinned the literal `break` in the post-reflection failure
branch with stopScript=null, asserting only that getGitStatus was
called once. that's not a behavior contract — a reasonable refactor
(e.g. `continue` to re-check gates with explicit flake guards) would
fail this test even though the new behavior would be fine. the
"does not flip a successful run to failed" test already covers the
only thing callers depend on.
* test: drop low-value mock-driven tests from postRun
- "fires the reflection turn when no stop hook is configured" — fully
subsumed by the output-preservation test (asserts task output
survives, which is only possible if reflection fired).
- "uses stdout alone" / "uses stderr alone" — pin format trivia
(`filter(Boolean).join`) that LLMs ignore.
- "returns empty output (not undefined) when both streams are empty"
— guards a TS-impossible case; every consumer uses `output || "(no output)"`.
- "returns null on activity-timeout" — duplicate of the timeout test;
same `return null` branch with a different constant.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
Pullfrog is a GitHub bot that brings the full power of your favorite coding agents into GitHub. It's open source and powered by GitHub Actions.
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.
issue created
issue labeled
PR 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.
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
name:Releaseon:push:tags:['v*']permissions:contents:writejobs:release:runs-on:ubuntu-lateststeps:- name:Checkoutuses:actions/checkout@v4with:fetch-depth:0- name:Generate release notesid:notesuses:pullfrog/pullfrog@v0with: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 releaserun:| 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:
name:Release Checkon:pull_request:types:[closed]jobs:check-release:if:github.event.pull_request.merged == trueruns-on:ubuntu-lateststeps:- uses:actions/checkout@v4- name:Install dependenciesrun:npm install --no-save --no-package-lock zod @actions/core- name:Generate Schemaid:schemarun:| 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 PRid:analysisuses:pullfrog/pullfrog@v0with: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 Resultrun:| # Parse the JSON result using fromJSON()
echo "Version: ${{ fromJSON(steps.analysis.outputs.result).version }}"
echo "Breaking: ${{ fromJSON(steps.analysis.outputs.result).isBreaking }}"