Files
shockbot/agents/postRun.ts
T
Colin McDonnell ddbc610569 review prompt: friendly green callouts + per-section severity emojis (#756)
* review prompt: friendly green callouts + per-section severity emojis

- Replace `[!NOTE]` informational tier and the no-callout minor-suggestions
  tier with friendly green blockquotes (`> ` / `> 💡`). The two loud
  tiers (`[!CAUTION]` / `[!IMPORTANT]`) keep their GitHub admonitions.
- Add a per-`##`-section severity-emoji rule (🚨/⚠️/💡/ℹ️) for
  cross-cutting review concerns that don't anchor to a line and would
  otherwise be buried in summary content.
- Drop the `<br/>` between summary sections — heading + blank line
  carries enough visual spacing.
- Skip the post-run learnings-reflection turn for `IncrementalReview`.
  It's the lowest-novelty mode (delta review against existing PR with
  prior summary already loaded) and almost never produces durable
  learnings — reflection there costs ~$0.50-0.80/run for nothing.
- Surface real error info on `agent-browser` skill install failures
  (exit code + stdout + stderr + spawn error). The skills CLI uses a
  TUI that prints errors to stdout, so the prior stderr-only logging
  silently swallowed every failure.

* review prompt: per-bullet severity emoji + bullets-only sections

Section headings are plain again (no leading severity emoji). Severity
moves to individual bullets so a section that mixes a 🚨 and a 💡 isn't
mislabeled by either. Section bodies are now bullets only — paragraph
prose under a heading is harder to scan and tends to bury the
actionable point.

Bullets can carry indented continuation content (sub-bullets, code
fences, blockquotes) by indenting two spaces under the parent.

* review prompt: cap section length + identifier discipline

Bound each summary section to at most 4 bullets at most 2 lines each,
and explicitly call out identifier-heavy prose as an anti-pattern. The
reader is often a manager or non-author; identifier-dense paragraphs
('foo calls bar.fetch which dispatches to baz via qux...') are
unreadable for them. Default to plain-language behavior descriptions,
name an identifier only when it's the subject of an actionable concern
or a public surface a reader would recognize, target 2-3 backtick
tokens per bullet.

Move the deep-explanation pattern from open blockquote to a default-
collapsed details/summary so depth doesn't dominate the visible body.

* review prompt: hard cap on bullet identifier density + worked rewrite example

Soft 'aim for 2-3 tokens' guidance was ignored — first big-PR e2e
showed 12 of 19 actionable bullets exceeded the target (avg 4.8 tokens,
several over 8). Promote to a hard cap of 3 backticked tokens per
bullet and pair with a concrete bad/good rewrite the agent can pattern-
match against. Also tighten the per-bullet length cap from ~240 to
~200 chars and explicitly call it 'hard cap, not target'.

* review prompt: tighten bullet length cap to 160 chars, dramatize the worked example

V2 e2e test: token discipline improved (4.8 -> 3.3 avg, 12/19 -> 6/14
violations) but length got worse (235 -> 286 chars, 13/14 over the 200
cap). The agent compensated for fewer identifiers with more prose.

Two changes: (1) tighten the cap from ~200 chars to 160 chars / 1
visual line and call out wrap-to-multiple-lines as the failure mode;
(2) rewrite the worked example so the good version is genuinely half
the length of the bad one, not just lower token count. The example was
the thing the agent pattern-matches against; making the good version
~130 chars vs the bad version's ~290 chars sets the right shape.

* review prompt: drop fixed bullet-count cap, keep length + identifier caps

Per user feedback — section length should be governed by content, not
an arbitrary count. Soft guidance ('past ~6, ask whether to split') is
fine; the hard '≤ 4 bullets per section' rule was the wrong shape.
Length cap (160c) and identifier cap (3 backtick tokens) stay; those
target the actual scanability problem.

* review prompt: drop ## subsystem sections, flat 'Issues found' list

Per-section structure forced every concern into a subsystem frame and
made the body read like a series of mini-essays. Replace with two
parts: (1) TL;DR + Key changes as the dispassionate overview, (2) flat
'### Issues found' list ordered by severity, intermixed across files
and subsystems. Per-bullet rules (≤160c, ≤3 backtick tokens, severity
emoji prefix, optional indented continuation) carry over unchanged.

* review prompt: full v6 structure — preamble + cross-cutting H3s + nitpicks

Replaces the flat 'Issues found' bullet list with the iterated v6 shape:

- Preamble is a bolded inline 'Reviewed changes' lead-in plus bullets
  plus a collapsed 'Review metadata' block (mode/files/commits/refs/
  reviewed commits list/prior pullfrog review/staleness note).
- Each cross-cutting concern gets a '### emoji Title' section. The
  visible problem write-up is human-friendly and DESCRIBES THE PROBLEM
  ONLY — no asks, no suggested fixes, no 'the right thing to do is'.
- Each section carries a collapsed 'Technical details' block wrapped
  in a 4-backtick markdown fence (so it can hold its own 3-tick code
  fences cleanly, agent-readable, one-click copyable). Standard four
  inner sections: Affected sites, Required outcome, optional Suggested
  approach, optional Open questions for the human.
- '### ℹ️ Nitpicks' at the bottom for body-only nits that don't
  inline; simple bullets, no technical-details collapse.
- Anti-paragraph-wall rule: never two successive plain paragraphs in
  visible '### ' sections; alternate prose with structure.
- Inline-vs-body discipline: anything that anchors to a single line
  goes inline, body is for cross-cutting only.
- Drops legacy '### Key changes', '### Issues found', '<b>TL;DR</b>',
  and the '<sub>Summary</sub>' line.

* model effort: bump Gemini + GPT to high effort; drop Gemini Pro→Flash subagent

E2E review eval against a substantive billing-module diff surfaced two
related quality gaps:

1. Gemini Pro at thinkingLevel=medium (#663's CI-timeout fix) reviewed
   the diff only, took the 0-lens path, and missed a catastrophic
   camelCase/snake_case service-vs-schema mismatch. Bumping back to
   high — review work is exactly the wrong shape for the medium/high
   tradeoff #663 was optimizing for; the per-turn TTFT cost is worth
   paying when reasoning IS the value.

2. GPT had no reasoningEffort override, defaulting to upstream medium.
   Same diff, similar shallow result vs Claude. Adding reasoningEffort:
   high for the curated direct-OpenAI slugs, mirroring the Gemini
   pattern (Anthropic separately uses --effort high via the Claude
   Code CLI flag in claude.ts).

3. Gemini Pro's subagentModel was 'gemini-flash' — but Google has no
   in-between tier between Pro and Flash, and Flash is a meaningful
   capability cliff for review work. Dropping the override so subagents
   inherit Pro. Cost stays reasonable since Gemini Pro is already the
   cheapest of the flagship trio.

Other providers unchanged: Anthropic opus→sonnet and OpenAI gpt→gpt-5.4
remain (each is a one-tier drop to a still-capable sibling).

* model effort: revert orchestrator override, set explicit high on reviewfrog subagent

Reshape the effort design after eval:

- Drop the explicit Gemini and GPT model-level overrides — orchestrators
  now run at upstream defaults (Gemini high, GPT-5.x medium). Gemini's
  upstream IS high, so this is a no-op there; GPT goes back to upstream
  medium for orchestrator-level routing work.
- Add explicit 'high' on the reviewfrog subagent via agent.options.
  OpenCode merge order is base ← model.options ← agent.options ← variant
  per session/llm.ts:141, so the subagent always runs at high regardless
  of which orchestrator dispatched it. Both thinkingConfig.thinkingLevel
  (Gemini) and reasoningEffort (GPT) keys included; irrelevant keys are
  ignored per provider.
- Bump providers-live timeouts (12min job / 10min step, from 8/6) to
  budget for Gemini's TTFT variance at high effort. #663's 4min timeout
  was sized for the medium-effort override that's now removed.

* model effort: restore Gemini explicit high override (no-override path breaks)

Bare 'rely on upstream default' for Gemini failed in e2e — removing the
model-level provider config produced 'Function call is missing a
thought_signature' API errors on every gemini-pro run. Even though
upstream opencode's options() returns the same thinkingLevel: high we
were explicitly setting, opencode's resolution path differs subtly
between the two cases. v2's explicit override worked; v3's removal
broke. Reproducible across two consecutive runs.

Restoring the explicit Gemini override (back to v2 design). GPT
orchestrator stays UN-overridden — at upstream default (medium) — since
removing that override didn't trigger the same failure pattern and the
reviewfrog subagent agent.options high override compensates for the
extra depth GPT loses at medium.

* diag: remove reviewfrog agent.options to isolate Gemini thought_signature failure

v3 (no Gemini orch override) failed with thought_signature error. v4
(restored Gemini orch override at v2-equivalent) ALSO failed, even
though the orchestrator config matches v2. The variable between v2
(working) and v4 (failing) is the new reviewfrog agent.options block.
Removing it to confirm — if Gemini works again, the agent.options
addition is the culprit and we need a different shape for it.

* opencode-ai: bump 1.1.56 → 1.15.0 + clean up gemini effort config

opencode-ai@1.1.56 was published 2026-02-10 (3 months old). The Google
API tightened thought_signature validation 24-48h ago (per
https://discuss.ai.google.dev/t/gemini-thought-signature-patch/122555),
and the bug class hits opencode's session→prompt serializer for MCP
tool-call parts (anomalyco/opencode#4832, #8321). Latest stable bumps
us through ~3 months of fixes; needed for Gemini-direct to stop dying
with 'thought_signature is missing' on every multi-turn run.

Companion cleanup: the gemini provider override in opencode.ts had
30-line block of comments, four unused constants, and a 6-line
Object.fromEntries map for two entries. Replaced with one source-of-
truth helper that loops modelAliases, filters provider==='google',
strips the 'google/' prefix, and returns the override map. Adding any
future Google alias to the registry now flows through automatically.

Test added: action/agents/opencode.test.ts asserts the helper covers
every direct-Google alias, strips the prefix correctly, and pins every
entry to thinkingLevel high — catches drift in helper logic without
hardcoding the API ids the test would have to update in lockstep
with the registry.

* fix(workflow): tolerate listJobsForWorkflowRun 404 in resolveRun

PR #750 (docker testing rewrite) replaced the per-call env allowlist
with full process.env passthrough into the test container. That now
leaks GITHUB_RUN_ID + GITHUB_JOB into runs whose MCP token is scoped
to a DIFFERENT repo (e.g. providers-live smoke runs the action against
pullfrog/test-repo with pullfrog/app's run ID). The unconditional
listJobsForWorkflowRun call 404s and crashes the entire run, breaking
every providers-live job on main since #750 landed.

jobId is purely cosmetic (deep-links 'View workflow run' footer to a
specific job vs the run-level URL). Wrapping the API call in try/catch
so a 404 logs a debug message and falls through to undefined jobId is
the right fix — the failure mode is exactly what graceful degradation
is for, and the alternative (filter the env vars at the docker boundary)
re-introduces the kind of allowlist #750 was getting rid of.

* opencode-ai: pin 1.14.51 instead of 1.15.0 (effect refactor breaks JSON output)

opencode 1.15.0 (May 15) ships a major architectural refactor onto
@effect — the run command boots an in-process server via
@opencode-ai/sdk/v2 and the JSON event emission path through that SDK
client doesn't surface on stdout the way our parser expects (CI run
on 1.15.0 produced 0 stdout events but the agent still completed).
Local invocation also hangs at the in-process server boot.

The Gemini thought_signature fixes (the original reason for bumping)
landed earlier in the 1.14.x line, so 1.14.51 (May 14) gets us the
upstream fix without the Effect rewrite. Defer the 1.15.x bump until
we're ready to rewire our parser/spawn around the new SDK.

* opencode-ai: revert to 1.1.56; gha: filter outer-CI workflow-run vars at the docker boundary

Two related changes for the docker testing harness's ergonomics:

1. Revert opencode-ai 1.14.51 → 1.1.56. The 1.14+ line ships an Effect
   refactor (the SDK-v2 client + in-process server architecture) that
   our --format json parser doesn't speak — even the 1.14.51 release,
   pre-dating the 1.15.0 Effect rename, produced 0 stdout events on
   our skill-invoke smoke. There's no clean pre-Effect version that
   ships the Gemini thought_signature fix; that fix needs a separate
   workstream once we're ready to rewire the parser onto SDK v2.

2. Filter outer-CI workflow-run identifiers (GITHUB_RUN_ID, GITHUB_JOB,
   GITHUB_WORKFLOW, GITHUB_ACTION, GITHUB_REF, GITHUB_SHA, etc.) from
   gha.ts's --env-file passthrough. PR #750's full-process.env design
   leaks pullfrog/app's CI run identifiers into runs that act against
   a different repo (e.g. pullfrog/test-repo); any code path inside
   the action that uses them as keys (most notably resolveRun's
   listJobsForWorkflowRun lookup) 404s. Filtering them here means
   the action sees undefined and skips the lookup, complementing the
   defensive try/catch in resolveRun (commit addc76d4). GITHUB_REPOSITORY
   and GITHUB_TOKEN are NOT filtered — those are genuinely needed.

Companion to addc76d4 (resolveRun 404 tolerance). The two together
make this class of bug 'either fix would have caught it' rather than
'silently breaks the entire test matrix'.

* fix(deps): sync pnpm-lock.yaml with opencode-ai 1.1.56 manifest revert

Forgot to refresh the lockfile after reverting the manifest in 02c6d8c1.
CI's frozen-lockfile install was failing with 'lockfile: 1.14.51,
manifest: 1.1.56' mismatch.
2026-05-16 04:58:31 +00:00

485 lines
25 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { LIFECYCLE_HOOK_TIMEOUT_MS } from "../lifecycle.ts";
import { NON_COMMITTING_MODES } from "../modes.ts";
import type { ToolState } from "../toolState.ts";
import { log } from "../utils/cli.ts";
import {
SPAWN_ACTIVITY_TIMEOUT_CODE,
SPAWN_TIMEOUT_CODE,
SpawnTimeoutError,
spawn,
} from "../utils/subprocess.ts";
import {
type AgentResult,
type AgentRunContext,
type AgentUsage,
buildCommitPrompt,
getGitStatus,
hasPostRunIssues,
MAX_POST_RUN_RETRIES,
mergeAgentUsage,
type PostRunIssues,
type StopHookFailure,
} from "./shared.ts";
/**
* derive "agent picked a review mode but never produced visible output" from
* the literal facts on `toolState`. returns the selected mode when the gate
* should fire, `null` otherwise — pure read, no side effects, safe to invoke
* after every agent attempt.
*
* the gate is anchored to `hadProgressComment` so silent runs (non-issue
* events, dispatcher skipped seeding) don't fire a nudge there's no UI for.
*
* `Review` and `IncrementalReview` have different valid exits:
* - Review: only `create_pull_request_review` counts. `report_progress` is
* not a substitute — a Review run that exits with just a summary comment
* has produced nothing reviewable on the PR. matches the hard-fail
* message at `expected = "create_pull_request_review"` below.
* - IncrementalReview: `report_progress` is a legitimate "no review
* warranted" exit, so either toolState flag short-circuits.
* splitting per mode also closes the bypass where a subagent (e.g. a
* `task`-dispatched `reviewfrog` lens) calls `report_progress` and silences
* the gate even though the orchestrator never submitted a review.
*/
export function getUnsubmittedReview(toolState: ToolState): "Review" | "IncrementalReview" | null {
const mode = toolState.selectedMode;
if (!toolState.hadProgressComment) return null;
if (mode === "Review") return toolState.review ? null : "Review";
if (mode === "IncrementalReview") {
return toolState.review || toolState.finalSummaryWritten ? null : "IncrementalReview";
}
return null;
}
/**
* hook output can flow into two size-sensitive places: the LLM resume prompt
* (context window) and AgentResult.error (surfaced in GitHub comments capped
* at 65535 chars). truncate the tail to keep both bounded; the tail is
* usually the most actionable part of a failing script's output.
*/
const MAX_HOOK_OUTPUT_CHARS = 4096;
function truncateHookOutput(raw: string): string {
if (raw.length <= MAX_HOOK_OUTPUT_CHARS) return raw;
return `...(truncated, showing last ${MAX_HOOK_OUTPUT_CHARS} chars)\n${raw.slice(-MAX_HOOK_OUTPUT_CHARS)}`;
}
/**
* run the user-configured stop hook.
*
* parallel to `executeLifecycleHook` (which soft-fails with a warning), but
* returns structured output so agent harnesses can feed the failure back into
* the session as a resume prompt.
*
* - non-zero exit → `StopHookFailure`, actionable: the output is fed to the
* agent so it can fix the underlying issue.
* - timeout / spawn error → null, treated as passed: we can't usefully ask the
* agent to fix an infrastructure problem, and retrying would risk infinite
* loops.
*/
export async function executeStopHook(script: string): Promise<StopHookFailure | null> {
log.info("» executing stop hook...");
try {
const result = await spawn({
cmd: "bash",
args: ["-c", script],
env: process.env,
timeout: LIFECYCLE_HOOK_TIMEOUT_MS,
activityTimeout: 0,
onStdout: (chunk) => process.stdout.write(chunk),
onStderr: (chunk) => process.stderr.write(chunk),
});
if (result.exitCode === 0) {
log.info("» stop hook passed");
return null;
}
// include both streams — scripts often emit a benign warning to stderr
// and the actionable error to stdout (or vice versa), and picking one
// starves the agent of the diagnostic it needs. stderr-first so stdout
// (typically longer, where truncation is more likely to bite) keeps its
// tail — summaries/totals usually live at the end.
const combined = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
const output = truncateHookOutput(combined);
log.info(`» stop hook failed with exit code ${result.exitCode}`);
return { exitCode: result.exitCode, output };
} catch (err) {
const isTimeout =
err instanceof SpawnTimeoutError &&
(err.code === SPAWN_TIMEOUT_CODE || err.code === SPAWN_ACTIVITY_TIMEOUT_CODE);
const msg = err instanceof Error ? err.message : String(err);
log.warning(
`stop hook ${isTimeout ? "timed out" : "failed to spawn"}: ${msg} — skipping retry`
);
return null;
}
}
export function buildStopHookPrompt(failure: StopHookFailure): string {
return [
`STOP HOOK FAILED — the repo-configured stop hook exited with code ${failure.exitCode}. your work is not done until the hook exits cleanly. address the issue below and push any resulting changes to a pull request.`,
"",
"```",
failure.output || "(no output)",
"```",
].join("\n");
}
/** check whether the seeded summary file is byte-identical to its seed.
* a missing or unreadable file returns false (don't nudge — the agent
* may have legitimately deleted it, or the seed step failed; the read-
* back path in main.ts handles both cases by skipping persist). */
async function isSummaryUnchanged(filePath: string, seed: string): Promise<boolean> {
try {
const current = await readFile(filePath, "utf8");
return current === seed;
} catch {
return false;
}
}
export function buildSummaryStalePrompt(filePath: string): string {
return [
`PR SUMMARY UNTOUCHED — the rolling PR summary file at \`${filePath}\` is byte-identical to its seed; this run did not edit it.`,
"",
"review the diff and update the file in place to reflect what changed in the PR. update intent, key changes, and any risks worth flagging — keep the existing section headings stable so incremental runs produce clean diffs.",
"",
"if the diff is genuinely too small or noisy to warrant rewriting (e.g. a one-line typo fix, a comment tweak, a formatting-only change), it's fine to leave the structure as-is — but at minimum confirm you considered it by appending one line to the appropriate section noting the run. silence is not an option; the snapshot is what the next review run reads as context.",
].join("\n");
}
export function buildUnsubmittedReviewPrompt(mode: "Review" | "IncrementalReview"): string {
// mode-aware: Review mode's contract is "always submit one review" — its
// mode prompt forbids `report_progress`, so the nudge here must not offer
// it as an exit. IncrementalReview legitimately allows a report_progress
// exit when there are no new issues since the last review (mode prompt
// step 8), so the nudge mirrors that contract.
if (mode === "Review") {
return [
`MISSING REVIEW OUTPUT — you selected Review mode but stopped without calling \`create_pull_request_review\`. the user has no visible signal that this run produced anything; the progress comment will be deleted on exit and no review will appear on the PR.`,
"",
"call `create_pull_request_review` now with your aggregated review (body + inline comments). pick the tier per the mode prompt — Review mode has no no-submit exit, so even informational `> ✅ No new issues found.` reviews must be submitted (with `approved: true`). the first call may error once with a diff-coverage nudge — retry the same call to proceed.",
"",
"do NOT stop again until `create_pull_request_review` has been called successfully.",
].join("\n");
}
return [
`MISSING REVIEW OUTPUT — you selected IncrementalReview mode but stopped without calling \`create_pull_request_review\` or \`report_progress\`. the user has no visible signal that this run produced anything; the progress comment will be deleted on exit and no review will appear on the PR.`,
"",
"do exactly one of:",
"- if you have findings: call `create_pull_request_review` now with your aggregated review (body + inline comments). the first call may error once with a diff-coverage nudge — retry the same call to proceed.",
"- if there are genuinely no actionable findings since the last review (e.g. only formatting / comment / lockfile changes): call `report_progress` with a 1-2 sentence summary explaining that no review was warranted.",
"",
"do NOT stop again until one of those tools has been called successfully.",
].join("\n");
}
/**
* check the post-run gates: did the stop hook pass, is the working tree
* clean, and (when applicable) did the agent touch the rolling PR summary
* snapshot or produce review output? returns everything that still needs
* nudging so the caller can render a single combined resume prompt.
*
* reads run state directly off `ctx.toolState` so each invocation sees the
* latest mutations from MCP tool calls. `skipSummaryStale` lets the loop
* suppress the summary-stale check after the one-shot nudge has been
* delivered (re-firing it would burn the retry budget on a soft gate the
* agent has already decided not to act on).
*/
export async function collectPostRunIssues(
ctx: AgentRunContext,
options: { skipSummaryStale?: boolean } = {}
): Promise<PostRunIssues> {
const issues: PostRunIssues = {};
// stop hook is disabled — production audit (May 2026) showed 8/9 configured
// scripts are foot-guns (duplicates of prepushScript, run on non-committing
// modes against unchanged trees) burning the retry budget on un-fixable
// gates. re-enable here + the dashboard block in `AgentSettings.tsx` once
// we've decided on the right semantics (mode-gating vs. HEAD-changed gating
// vs. deletion). see issue #714.
// if (ctx.stopScript) {
// const failure = await executeStopHook(ctx.stopScript);
// if (failure) issues.stopHook = failure;
// }
// dirty-tree gate fires only in modes that legitimately commit. Review /
// IncrementalReview / Plan complete via review submission or a Plan
// comment, not by touching files — any tree dirt is incidental (e.g. a
// tool-installed `node_modules/`) and the worktree is ephemeral, so
// nudging the agent to commit it would produce a spurious PR. see
// `NON_COMMITTING_MODES` in `action/modes.ts`.
const status = getGitStatus();
const mode = ctx.toolState.selectedMode;
if (status) {
if (mode && NON_COMMITTING_MODES.has(mode)) {
log.info(`» dirty-tree gate suppressed: mode \`${mode}\` does not commit`);
} else {
issues.dirtyTree = status;
}
}
const summaryFilePath = ctx.toolState.summaryFilePath;
const summarySeed = ctx.toolState.summarySeed;
if (!options.skipSummaryStale && summaryFilePath && summarySeed !== undefined) {
const stale = await isSummaryUnchanged(summaryFilePath, summarySeed);
if (stale) issues.summaryStale = { filePath: summaryFilePath };
}
const unsubmittedMode = getUnsubmittedReview(ctx.toolState);
if (unsubmittedMode) issues.unsubmittedReview = unsubmittedMode;
return issues;
}
export function buildPostRunPrompt(issues: PostRunIssues): string {
// order matches the terminal hard-fail order in `runPostRunRetryLoop` so
// the prompt's emphasis (which gate the agent should fix first) lines up
// with the user-visible failure message reported when retries exhaust.
// both hard-fail gates first (`stopHook` → `unsubmittedReview`), then the
// soft gates (`dirtyTree` → `summaryStale`).
const parts: string[] = [];
if (issues.stopHook) parts.push(buildStopHookPrompt(issues.stopHook));
if (issues.unsubmittedReview) {
parts.push(buildUnsubmittedReviewPrompt(issues.unsubmittedReview));
}
if (issues.dirtyTree) parts.push(buildCommitPrompt(issues.dirtyTree));
if (issues.summaryStale) parts.push(buildSummaryStalePrompt(issues.summaryStale.filePath));
return parts.join("\n\n---\n\n");
}
/**
* modes for which the post-run reflection turn is skipped. reflection costs a
* full resume turn (~$0.50-0.80 per run on Opus, mostly cache-write) and only
* pays for itself when the run actually produced novel, durable findings.
*
* `IncrementalReview` is the lowest-novelty mode — it's a tight delta review
* against an existing PR with the prior summary already loaded as context.
* the agent rarely discovers anything generalizable to next runs, so the
* reflection turn is dead weight. initial `Review` still touches fresh PR
* territory and benefits; `Build` / `Fix` / `AddressReviews` definitely do.
*/
const REFLECTION_SKIP_MODES: ReadonlySet<string> = new Set(["IncrementalReview"]);
export function shouldRunReflection(mode: string | undefined): boolean {
if (!mode) return true;
return !REFLECTION_SKIP_MODES.has(mode);
}
/**
* prompt for a dedicated post-run reflection turn nudging the agent to edit
* the rolling learnings file if it discovered anything worth persisting.
*
* this exists because passive "if you learned something, write it down"
* instructions baked into mode checklists are frequently ignored — the agent
* stays focused on the task and the meta-ask falls through. delivering it
* as its own resume turn, with nothing competing for attention, raises the
* fire rate substantially.
*
* the file is the single source of truth — there is no separate MCP tool
* call. the server reads the file at end-of-run and persists any edits to
* `Repo.learnings`.
*
* the prompt copy is shaped by repo-wide audits of the actual content the
* agent has been writing (issue #619 in pullfrog/app). recurring failure
* modes the framing pushes back on:
* - massive multi-paragraph "bullets" that are really mini-articles
* - PR-/review-/commit-/date-anchored facts that decay within weeks
* - rediscovery of pullfrog-tool quirks that belong in tool descriptions,
* not per-repo learnings
* - sections growing into giant flat lists with no internal structure,
* forcing future runs to read kilobytes to find one fact
*/
export function buildLearningsReflectionPrompt(filePath: string): string {
return [
`REFLECTION — before you finish, think back over this task: did you discover anything about this repo's setup, test commands, conventions, or patterns that is high-confidence and would reliably help future runs?`,
"",
`the rolling learnings file is at \`${filePath}\`. read it first if you haven't already, then edit it in place using your native file tools. the server reads this file at end-of-run and persists any changes — there is no tool to call.`,
"",
`structure:`,
`- markdown hierarchy: \`## \` for top-level themes, \`### \` and deeper for sub-themes when a section grows. there is no fixed taxonomy — choose headings that fit THIS repo (e.g. for one repo \`## Migrations\` / \`## Local dev\` may make sense; for another, \`## API quirks\` / \`## Failure modes\`).`,
`- **no section over ~300 lines.** when a section is approaching that, split it: introduce \`### \` subsections grouping related bullets, or hoist a coherent group into a new top-level \`## \` section. granular sections mean future runs read targeted line ranges instead of slurping the whole file. this is the most important hygiene rule on long-lived repos.`,
`- if you find a flat unstructured list (legacy content from before this format), restructure it: read it, group related bullets, rewrite the file with \`## \` / \`### \` headings around them. don't preserve bad structure — fix it.`,
"",
`bullet hygiene:`,
`- one fact per line starting with \`- \`. each bullet is ONE specific durable fact, not a paragraph or essay.`,
`- aim for ≤ 240 chars per bullet. longer bullets are almost always mixing multiple facts that should be split, or burying the durable claim under PR-specific context that should be cut.`,
`- only add bullets when the finding is high-confidence AND broadly useful AND will still be true in 3+ months. skip speculative, one-off, or "maybe" findings.`,
`- prune bullets that are clearly wrong, no longer relevant, or low-signal. a focused, accurate file beats a long stale one. compressing two overlapping bullets into one tighter bullet counts as progress.`,
`- deduplicate against existing entries (in any section) — if a bullet covers the same fact, update it in place instead of adding a duplicate.`,
"",
`do NOT add bullets for:`,
`- pullfrog tool quirks (e.g. "\`shell\` timeout is in milliseconds", "\`git\` args must be a JSON array", "\`create_pull_request_review\` drops out-of-hunk comments", "\`push_branch\` may report timeout when push succeeded"). these are universal across repos and belong in the tool descriptions — flag the gap rather than hoarding the workaround per-repo.`,
`- references to specific PR numbers, review IDs, commit SHAs, branch names, or person handles ("PR #595 introduced X", "flagged in review 12345", "as of commit abc123"). repo state changes; these decay into noise within weeks.`,
`- dated assertions ("as of May 2026", "currently...", "for now..."). if a fact needs a date to be true, it isn't durable enough to belong here.`,
`- play-by-play of what THIS run did. learnings are for the NEXT run, not a retrospective.`,
"",
`if you have nothing substantively new to add AND the existing entries still look healthy and well-structured, leave the file alone — just reply "done" and stop. silence is a valid outcome.`,
].join("\n");
}
/**
* shared post-run retry loop used by every agent harness.
*
* checks the post-run gates (stop hook + dirty tree), and if either is
* failing, invokes `resume` to let the agent fix and push in the same turn.
* bails at `MAX_POST_RUN_RETRIES` attempts. the `canResume` predicate is
* consulted before each retry — harnesses that can't re-enter the session
* (e.g. claude without a sessionId) return false here.
*
* an optional `reflectionPrompt` fires exactly once, after the gates first
* observe a clean state. it's a one-shot nudge (e.g. "update learnings if
* relevant"), not a gate, so it does not consume the gate-retry budget. if
* the reflection turn dirties the tree, the loop picks that up on the next
* iteration via the normal dirty-tree gate.
*
* stop hook must pass for the run to succeed; persistent hook failures are
* surfaced as `AgentResult.error`. dirty-tree-only failures preserve prior
* behavior: they're logged but don't fail the run.
*/
export async function runPostRunRetryLoop<R extends AgentResult>(params: {
ctx: AgentRunContext;
initialResult: R;
initialUsage: AgentUsage | undefined;
resume: (context: { prompt: string; previousResult: R }) => Promise<R>;
canResume?: ((result: R) => boolean) | undefined;
reflectionPrompt?: string | undefined;
}): Promise<AgentResult> {
let result = params.initialResult;
let aggregatedUsage = params.initialUsage;
let finalIssues: PostRunIssues = {};
let gateResumeCount = 0;
let pendingReflection = params.reflectionPrompt;
// nudge for an untouched summary file fires AT MOST ONCE per run. once
// delivered, subsequent collectPostRunIssues calls skip the check — the
// agent may have legitimately decided no edit is warranted, and
// re-prompting would burn the retry budget without adding signal.
let summaryStaleNudged = false;
while (gateResumeCount < MAX_POST_RUN_RETRIES) {
if (!result.success) break;
const issues = await collectPostRunIssues(params.ctx, {
skipSummaryStale: summaryStaleNudged,
});
if (issues.summaryStale) summaryStaleNudged = true;
finalIssues = issues;
if (!hasPostRunIssues(issues)) {
// gates are clean. if a reflection prompt is pending, deliver it once
// and loop back to re-check — the reflection may have touched the tree.
if (!pendingReflection) break;
if (params.canResume && !params.canResume(result)) break;
log.info("» post-run reflection: nudging agent to update learnings if relevant");
const preReflection = result;
const reflectionResult = await params.resume({
prompt: pendingReflection,
previousResult: result,
});
aggregatedUsage = mergeAgentUsage(aggregatedUsage, reflectionResult.usage);
pendingReflection = undefined;
if (!reflectionResult.success) {
// reflection is a best-effort nudge. its failure must not flip a
// successful run to failed — the gated work is already done. keep
// the pre-reflection result and exit without re-running the gates
// (which would risk a flaky false-positive hook failure right after
// it just passed).
log.warning(
`» reflection turn failed (${reflectionResult.error ?? "unknown error"}), preserving prior successful result`
);
result = preReflection;
break;
}
// reflection replies are meta-asks ("done", "updated learnings with N
// bullets") — not a task summary. keep the pre-reflection output so
// the returned AgentResult still reflects what the run accomplished,
// while inheriting reflection-specific fields the harness needs for
// any subsequent gate retry (e.g. the new sessionId claude emits per
// --resume invocation).
// use `||` (not `??`) so an empty pre-reflection output falls through
// to the reflection's reply. runs that only emit MCP tool calls and no
// plain text leave result.output = "" — keeping "" would starve the
// fallback path in handleAgentResult of anything to show.
result = {
...reflectionResult,
output: preReflection.output || reflectionResult.output,
};
continue;
}
// checks still ran even if we can't resume, so the failure gate below
// can still catch a persistent stop-hook failure.
if (params.canResume && !params.canResume(result)) {
log.info("» post-run retry skipped: cannot resume agent session");
break;
}
log.info(`» post-run retry (attempt ${gateResumeCount + 1}/${MAX_POST_RUN_RETRIES})`);
const prompt = buildPostRunPrompt(issues);
// summary-stale is a soft gate that must never flip a successful run to
// failed. when it's the only issue and the resume itself errors out,
// restore the pre-resume successful result and break — persistSummary
// detects the unchanged file via its seed comparison and skips the DB
// write on its own, so no further coordination is needed here.
const onlySummaryStale =
issues.summaryStale !== undefined &&
issues.stopHook === undefined &&
issues.dirtyTree === undefined;
const preResume = result;
result = await params.resume({ prompt, previousResult: result });
aggregatedUsage = mergeAgentUsage(aggregatedUsage, result.usage);
if (!result.success && onlySummaryStale) {
log.warning(
`» summary-stale resume turn failed (${result.error ?? "unknown error"}), preserving prior successful result`
);
result = preResume;
break;
}
gateResumeCount++;
}
// we exhausted retries without observing a clean state — finalIssues
// reflects pre-resume state, so re-check to see what the last resume
// actually did. when the subprocess failed we skip: its own error is more
// actionable than a stale "stop hook still failing" message. when the loop
// already observed a clean state we skip: re-running the hook risks flaky
// false-positive failures right after it just passed.
if (gateResumeCount > 0 && result.success && hasPostRunIssues(finalIssues)) {
// re-check the gates that can actually fail the run (stop hook /
// dirty tree / unsubmitted review). summary-stale is intentionally
// NOT re-checked here: we already delivered the one-shot nudge, and
// a still-unchanged file at this point is the agent's deliberate
// choice.
finalIssues = await collectPostRunIssues(params.ctx, { skipSummaryStale: true });
}
if (result.success && finalIssues.stopHook) {
const retryNote =
gateResumeCount > 0
? ` after ${gateResumeCount} retry ${gateResumeCount === 1 ? "attempt" : "attempts"}`
: "";
return {
...result,
success: false,
error: `stop hook failed${retryNote} (exit code ${finalIssues.stopHook.exitCode}): ${finalIssues.stopHook.output || "(no output)"}`,
usage: aggregatedUsage,
};
}
if (result.success && finalIssues.unsubmittedReview) {
const retryNote =
gateResumeCount > 0
? ` after ${gateResumeCount} retry ${gateResumeCount === 1 ? "attempt" : "attempts"}`
: "";
// mode-aware: Review's contract requires a review submission; only
// IncrementalReview accepts `report_progress` as an exit. mirroring
// the nudge prompt avoids contradicting the agent-facing copy.
const expected =
finalIssues.unsubmittedReview === "Review"
? "create_pull_request_review"
: "create_pull_request_review or report_progress";
return {
...result,
success: false,
error: `${finalIssues.unsubmittedReview} mode finished without calling ${expected}${retryNote}`,
usage: aggregatedUsage,
};
}
return { ...result, usage: aggregatedUsage };
}