majordomo now ships qwen and kimi as built-ins that ARE the openai client at their own base URL, so "qwen/qwen3.8-max" works as a GADFLY_MODELS entry once the key reaches the container. This wires up the parts that key has to pass through. Two provider switches had to learn the names, not one. resolveModel's GADFLY_BASE_URL override was the obvious one; endpointProvider's GADFLY_ENDPOINT_* parser is its sibling, and I fixed the first and missed the second on the first pass — a config that resolves one way and errors the other for no reason a user could guess. TestOpenAICompatProvidersResolveOnBothPaths now asserts both from one table so the pair fails together; break-checked in both directions. QWEN_API_KEY (and KIMI_API_KEY) are declared as workflow_call secrets and forwarded to the container, with gadfly's own stub forwarding QWEN_API_KEY so a qwen entry can join the default swarm by editing GADFLY_DEFAULT_MODELS alone — no workflow edit, no re-release. The run.sh credential pre-flight is now a provider→variable table instead of an ollama-cloud special case. Without it a forgotten key surfaces as five identical per-lens agent failures naming no variable, and the operator reads a stack trace to find out which secret they missed. Google stays out of the table on purpose: it accepts either GOOGLE_API_KEY or GEMINI_API_KEY, and a one-var entry would wrongly skip a correctly-configured run. Verified across 17 provider x key-state combinations, including that a wrong-provider key never satisfies qwen (majordomo refuses cross-provider fallback) and that unkeyed providers are never blocked. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
537 lines
34 KiB
Markdown
537 lines
34 KiB
Markdown
# 🪰 Gadfly
|
||
|
||
**An AI gadfly for your pull requests.** Gadfly is an *adversarial* code reviewer that
|
||
runs in Gitea Actions: on every PR it reads your actual repository, hunts for real
|
||
problems, verifies them against the code, and posts its findings as a comment. It does not
|
||
praise your code. A gadfly does not let things slide.
|
||
|
||
> ### 🤖 Heads up: this is a vibe-coded project
|
||
> Gadfly was built almost entirely by an AI agent (Claude Code), prompts and all — the
|
||
> reviewer's "brain" is a language model, and so was most of the author. It works and it's
|
||
> tested, but treat it accordingly: **it is advisory only, it never blocks a merge, and you
|
||
> should still review its reviews.** Issues and PRs welcome; expect the occasional
|
||
> AI-flavored rough edge.
|
||
|
||
## What makes it different
|
||
|
||
Most LLM "review my diff" bots read the diff in isolation and hallucinate problems they
|
||
can't actually see — a "missing import" that's three lines above the hunk, a "broken
|
||
caller" in a file they never opened. Gadfly is **agentic**: the model has read-only tools
|
||
over the checked-out repo and is *required* to use them before reporting anything.
|
||
|
||
- **Tools:** `read_file`, `list_dir`, `grep`, `find_files`, `get_diff`.
|
||
- **Verify-before-claiming discipline:** baked into the system prompt — open the file,
|
||
grep the symbol, or drop the finding.
|
||
- **Two passes:** a *review* pass drafts findings, then an adversarial *recheck* pass
|
||
independently re-verifies each one against the code and drops the ones it can't confirm,
|
||
recomputing the verdict. This is what kills "confident but wrong."
|
||
- **Semantic-bug hunting:** it's told not to trust a plausible-looking constant, conversion
|
||
factor, or formula — re-derive the expected value, because that's where real bugs hide.
|
||
|
||
Every review leads with a one-line verdict: **No material issues found**, **Minor issues**,
|
||
or **Blocking issues found**.
|
||
|
||
## Turn it on for a repo
|
||
|
||
Gadfly ships as a container image, so consuming repos don't build anything — they just run
|
||
it. Drop one file in your repo and set a couple of secrets/vars:
|
||
|
||
1. Copy a stub from [`examples/`](examples/) to `.gitea/workflows/adversarial-review.yml` in
|
||
your repo. Two flavors: the slim [`reusable.yml`](examples/reusable.yml) — a tiny caller of
|
||
Gadfly's **reusable workflow** (`uses: steve/gadfly/.gitea/workflows/review-reusable.yml@…`,
|
||
forwarding only the secrets the reviewer needs), whose **default swarm is set centrally via owner
|
||
variables** (see [Central config via variables](#central-config-via-variables)) and inherited by omitting `with:` — or the full self-contained
|
||
[`adversarial-review.yml`](examples/adversarial-review.yml) (Ollama Cloud default, with inline
|
||
notes for every provider / local Ollama / OpenAI-compatible / endpoint aliases). See the
|
||
[examples index](examples/README.md).
|
||
2. Add repo config:
|
||
- **secret** `OLLAMA_CLOUD_API_KEY` — your [Ollama Cloud](https://ollama.com) key (empty
|
||
⇒ Gadfly posts a harmless "not configured" notice instead of reviewing). *Not needed if
|
||
you point Gadfly at a different provider — see [Models & providers](#models--providers).*
|
||
- **var** `OLLAMA_REVIEW_MODELS` *(optional)* — comma-separated model ids
|
||
(default `qwen3-coder:480b-cloud,gpt-oss:120b-cloud`). One comment per model.
|
||
- **var** `GADFLY_ALLOWED_USERS` *(optional)* — who may re-trigger via comment; empty ⇒
|
||
any repo collaborator.
|
||
|
||
`GITEA_TOKEN` is provided automatically by Actions; comments post as the `gitea-actions`
|
||
user, scoped to that repo — no bot account needed.
|
||
|
||
## Models & providers
|
||
|
||
Gadfly is built on [majordomo](https://gitea.stevedudenhoeffer.com/steve/majordomo), so the
|
||
reviewer model is not hard-wired — it can target anything majordomo supports. Pick a provider
|
||
by setting `GADFLY_PROVIDER` (used to prefix bare model ids); point at a custom endpoint with
|
||
`GADFLY_BASE_URL`; supply a key with `GADFLY_API_KEY` or the provider's standard env var. A
|
||
`GADFLY_MODEL`/`GADFLY_MODELS` value that already contains a `provider/` prefix (or is a
|
||
majordomo failover chain / alias) is used verbatim.
|
||
|
||
| Provider | `GADFLY_PROVIDER` | Key env | Status |
|
||
|----------|-------------------|---------|--------|
|
||
| **Ollama Cloud** (default) | `ollama-cloud` | `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` | ✅ in active use |
|
||
| **Local Ollama** | `ollama` | none (`OLLAMA_HOST` or `GADFLY_BASE_URL` for a remote daemon) | ✅ tested |
|
||
| **[foreman](https://gitea.stevedudenhoeffer.com/steve/foreman)** (native-Ollama queue daemon) | `foreman` + `GADFLY_BASE_URL`, or a `GADFLY_ENDPOINT_*` / `LLM_*` `foreman://` entry | optional bearer (via the endpoint/DSN) | ✅ native-Ollama path |
|
||
| **[llama-swap](https://github.com/mostlygeek/llama-swap)** (model-swapping proxy) | `llama-swap`/`llama-swaps` (un-hyphenated `llamaswap`/`llamaswaps` also accepted) + `GADFLY_BASE_URL` or a `GADFLY_ENDPOINT_*` entry, or an `LLM_*` `llama-swap://` / `llama-swaps://` DSN | optional bearer | ⚠️ wired, **untested** |
|
||
| **OpenAI-compatible** (incl. local Ollama's `/v1`) | `openai` + `GADFLY_BASE_URL` | `OPENAI_API_KEY` (any non-empty for Ollama) | ✅ tested against Ollama |
|
||
| **OpenAI** | `openai` | `OPENAI_API_KEY` | ⚠️ wired, **untested** |
|
||
| **Qwen** (Alibaba Model Studio) | `qwen` | `QWEN_API_KEY` | ⚠️ wired, **untested** |
|
||
| **Kimi** (Moonshot) | `kimi` | `KIMI_API_KEY` | ⚠️ wired, **untested** |
|
||
| **Anthropic** | `anthropic` | `ANTHROPIC_API_KEY` | ⚠️ wired, **untested** |
|
||
| **Google (Gemini)** | `google` | `GOOGLE_API_KEY` / `GEMINI_API_KEY` | ⚠️ wired, **untested** |
|
||
|
||
Qwen and Kimi are majordomo built-ins that speak the OpenAI protocol at their own
|
||
endpoints, so `qwen/qwen3.8-max` or `kimi/kimi-k2-0711-preview` work as
|
||
`GADFLY_MODELS` entries with only the matching key set. Each reads **only** its own
|
||
variable — no cross-provider fallback — so forgetting to forward `QWEN_API_KEY`
|
||
gets you a skip notice naming it, not a mis-keyed call. Note `kimi/<model>` (Moonshot's
|
||
API, `KIMI_API_KEY`) is a different route than the `kimi-k2.6:cloud` entry in the
|
||
default swarm, which is Ollama Cloud and keyed by `OLLAMA_CLOUD_API_KEY`.
|
||
|
||
> ### 🧪 Honest status
|
||
> Only the **Ollama** paths above are actually exercised. The OpenAI / Qwen / Kimi /
|
||
> Anthropic / Google
|
||
> providers come "for free" from majordomo's abstraction and *should* work, but I haven't
|
||
> spent money verifying them — treat them as untested. The OpenAI-**compatible** path **is**
|
||
> tested, because you can point it at a local Ollama (`GADFLY_BASE_URL=http://localhost:11434/v1`)
|
||
> and exercise the exact same code an OpenAI/OpenRouter endpoint would hit, for free. If you
|
||
> try a cloud provider and it works (or doesn't), please open an issue.
|
||
|
||
### Claude Code engine (`claude-code`)
|
||
|
||
Besides the majordomo model loop, Gadfly can review through the **[Claude Code](https://claude.com/claude-code)
|
||
CLI**: for each lens it shells out to `claude -p` *inside the checked-out repo*, so Claude Code
|
||
uses its **own** read tools (Read/Grep/Glob) to verify findings against real code, then Gadfly
|
||
parses the result and runs the same verdict-parse → recheck → consolidate → emit pipeline. The
|
||
CLI is bundled in the image (Node + `@anthropic-ai/claude-code`).
|
||
|
||
Select it as a model id — bare `claude-code` (CLI default model) or `claude-code/<model>` (the
|
||
suffix becomes `--model`, e.g. `claude-code/sonnet`, `claude-code/opus`). An optional
|
||
`:<thinking>` suffix forces an extended-thinking budget for that reviewer — `:max` (the high
|
||
"ultrathink" tier) or `:<n>` for a specific token budget — so you can run the same model at two
|
||
thinking depths as separate reviewers:
|
||
|
||
```yaml
|
||
GADFLY_MODELS: "claude-code/sonnet,claude-code/opus,claude-code/opus:max"
|
||
```
|
||
|
||
The thinking budget is applied via the `MAX_THINKING_TOKENS` env on the CLI subprocess; it's
|
||
best-effort (a no-op if the installed CLI build doesn't honor it).
|
||
|
||
Auth is read from the environment: the default is a **Pro/Max subscription** via
|
||
`CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`; no `--bare`), falling back to
|
||
`ANTHROPIC_API_KEY`. Don't set both. Tuning knobs (all optional):
|
||
|
||
| Env | Default | Meaning |
|
||
|-----|---------|---------|
|
||
| `GADFLY_CLAUDE_MODEL` | *(from the spec suffix)* | overrides the `--model` value |
|
||
| `GADFLY_CLAUDE_PERMISSION_MODE` | `plan` | `--permission-mode` (read-only `plan` keeps it from editing) |
|
||
| `GADFLY_CLAUDE_ALLOWED_TOOLS` | *(unset)* | `--allowedTools` value, passed verbatim (e.g. `Read,Grep,Glob`) |
|
||
| `GADFLY_CLAUDE_EXTRA_ARGS` | *(unset)* | extra CLI args, **whitespace-split** (no shell quoting) and appended after the defaults (e.g. `--max-turns 30`) |
|
||
| `GADFLY_CLAUDE_BIN` | `claude` | CLI binary path |
|
||
|
||
> These are **operator** knobs (workflow env), not PR-author input. Because
|
||
> `GADFLY_CLAUDE_EXTRA_ARGS` is appended *after* the defaults, it can override the
|
||
> read-only `--permission-mode plan` (e.g. passing `--permission-mode acceptEdits`),
|
||
> so keep it read-only unless you mean otherwise. It's whitespace-split, so values
|
||
> can't contain spaces — use `GADFLY_CLAUDE_ALLOWED_TOOLS` / `_PERMISSION_MODE` /
|
||
> `_MODEL` for those. The subprocess runs with a **minimal environment** (its auth
|
||
> token + `PATH`/`HOME`/locale/`GADFLY_CLAUDE_*`), not the runner's full env, so the
|
||
> Gitea token and provider keys aren't handed to the CLI.
|
||
|
||
**Alternate backends (example only, not validated here).** Because the subprocess env forwards
|
||
`ANTHROPIC_*` and `CLAUDE_*`, you can point the same engine at a non-Anthropic backend by setting
|
||
`ANTHROPIC_BASE_URL` (and `ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY`) to an **Anthropic-API-compatible
|
||
proxy** — e.g. [claude-code-router](https://github.com/musistudio/claude-code-router) or LiteLLM in
|
||
front of Ollama — to run *Ollama models through Claude Code's harness* and compare it against the
|
||
native majordomo loop. Whether tool-use survives a given proxy/backend varies, so this is documented
|
||
as an example, not wired or tested here.
|
||
|
||
> **The Pro/Max path is dogfooded but otherwise lightly tested.** `claude-code/sonnet` now runs on
|
||
> gadfly's own PRs (see `.gitea/workflows/adversarial-review.yml`), but treat the engine as new —
|
||
> and note that subscription auth in automated CI is a gray area in Anthropic's terms. `auto`
|
||
> specialist selection and the `delegate_investigation` worker are majordomo-only and are skipped
|
||
> with this engine (Claude Code does its own legwork).
|
||
|
||
### OpenCode engine (`opencode`)
|
||
|
||
The same shell-out idea, but with a **freely-available** harness: Gadfly can review through the
|
||
**[OpenCode](https://opencode.ai) CLI**, which — like Claude Code — brings its own read tools and
|
||
verifies findings against the checked-out repo, but drives an **ollama-cloud** model. The point is
|
||
to benchmark gadfly's boutique executus harness against a good open harness *on the same model*:
|
||
run `ollama-cloud/glm-5.2` (majordomo loop) and `opencode/glm-5.2` (OpenCode) side by side and
|
||
compare their findings. This is the wired, no-proxy version of the "alternate backends" comparison
|
||
described above. The CLI is bundled in the image (Node + `opencode-ai`).
|
||
|
||
Select it as a model id:
|
||
|
||
| Spec | Meaning |
|
||
|------|---------|
|
||
| `opencode/glm-5.2` | serve `glm-5.2` via ollama-cloud through OpenCode |
|
||
| `open-code/glm-5.2` | accepted alias spelling (`opencode` is canonical) |
|
||
| `opencode/qwen3-coder:480b-cloud` | model ids are taken **verbatim** — colons are preserved (no `:thinking` suffix here, unlike claude-code) |
|
||
| `opencode/<provider>/<model>` | escape hatch: pass `<provider>/<model>` straight to OpenCode's own provider registry/auth (e.g. `opencode/anthropic/claude-sonnet-4-6`) |
|
||
| `opencode` | bare: OpenCode's configured default model |
|
||
|
||
```yaml
|
||
GADFLY_MODELS: "ollama-cloud/glm-5.2,opencode/glm-5.2" # the benchmark pairing
|
||
```
|
||
|
||
Auth reuses **`OLLAMA_CLOUD_API_KEY`** (the same secret the ollama-cloud path uses; it's mapped to
|
||
`OLLAMA_API_KEY`, which the generated provider references as `{env:OLLAMA_API_KEY}` — never a literal
|
||
secret in config). Tuning knobs (all optional):
|
||
|
||
| Env | Default | Meaning |
|
||
|-----|---------|---------|
|
||
| `GADFLY_OPENCODE_MODEL` | *(from the spec suffix)* | overrides the model |
|
||
| `GADFLY_OPENCODE_BASE_URL` | `https://ollama.com/v1` | ollama-cloud endpoint; point at a local Ollama (`http://localhost:11434/v1`) or any OpenAI-compatible server |
|
||
| `GADFLY_OPENCODE_EXTRA_ARGS` | *(unset)* | extra `opencode run` args, **whitespace-split**, appended before the positional task |
|
||
| `GADFLY_OPENCODE_BIN` | `opencode` | CLI binary path |
|
||
|
||
> **Read-only is enforced through config, not a flag.** OpenCode has no `--append-system-prompt`, so
|
||
> Gadfly generates a per-lens config — the lens system prompt as a `gadfly` agent's prompt, with the
|
||
> mutating and network tools (`edit`/`bash`/`webfetch`/`websearch`/`external_directory`) denied at both
|
||
> the global and agent level — and injects it via `OPENCODE_CONFIG_CONTENT`. That env var is the
|
||
> highest-precedence config source in the container, so it **outranks any `opencode.json` a reviewed
|
||
> repo ships** — a repo can't re-enable edits on the reviewer. The subprocess runs with a **reduced
|
||
> environment**: the provider keys OpenCode needs to authenticate (`OLLAMA_API_KEY` for the primary
|
||
> path, plus `ANTHROPIC_*`/`OPENAI_*`/`GOOGLE_*`/`GEMINI_*` for the `opencode/<provider>/<model>`
|
||
> pass-through) alongside `PATH`/`HOME`/locale/`OPENCODE_*`/`GADFLY_OPENCODE_*` — but **not** gadfly's
|
||
> own secrets (the Gitea token, the findings token, or the claude-code subscription token), which the
|
||
> CLI has no use for.
|
||
|
||
> **Newly wired, lightly tested.** Like the claude-code engine, `auto` specialist selection and the
|
||
> `delegate_investigation` worker are majordomo-only and are skipped here (OpenCode does its own
|
||
> legwork). Output capture reads OpenCode's default text output, so treat the engine as new and
|
||
> sanity-check a run before trusting a benchmark.
|
||
|
||
### Endpoint aliases via env vars
|
||
|
||
For multiple named backends (e.g. a couple of Ollama boxes on your LAN), register them by
|
||
name with env vars and then reference `name/model` in `GADFLY_MODEL`/`GADFLY_MODELS`:
|
||
|
||
```sh
|
||
# http-capable (Gadfly-native) — base URL used verbatim, so plaintext LAN works:
|
||
GADFLY_ENDPOINT_BIGBOX="ollama|http://192.168.1.50:11434"
|
||
GADFLY_ENDPOINT_GPU="openai|http://gpu.lan:8000/v1|sk-local"
|
||
GADFLY_ENDPOINT_M1="foreman|http://foreman-m1:8080|tok" # native-Ollama queue daemon
|
||
GADFLY_MODELS="bigbox/qwen2.5-coder:7b,gpu/llama3.1,m1/qwen3:14b"
|
||
|
||
# pure spec alias (a model, or a failover chain):
|
||
GADFLY_ALIAS_FAST="bigbox/qwen2.5-coder:7b,ollama-cloud/gpt-oss:120b-cloud"
|
||
GADFLY_MODEL="fast"
|
||
```
|
||
|
||
`<NAME>` is lowercased to form the registry name (`GADFLY_ENDPOINT_BIGBOX` → `bigbox`). This
|
||
is the same idea as majordomo's built-in **`LLM_*` env DSNs** (`LLM_BIGBOX=ollama://tok@host`,
|
||
`LLM_M1=foreman://tok@host`), which Gadfly also honors — but those are **HTTPS-only**, so for a
|
||
plaintext local Ollama or `http://` foreman use `GADFLY_ENDPOINT_*` instead.
|
||
|
||
> **Gitea Actions note:** repo `vars`/`secrets` aren't auto-exposed as env — add each alias to
|
||
> the stub workflow's `env:` block, e.g. `GADFLY_ENDPOINT_BIGBOX: ${{ vars.GADFLY_ENDPOINT_BIGBOX }}`.
|
||
|
||
## Specialists (the review swarm)
|
||
|
||
Instead of one generic reviewer, Gadfly runs a **suite of specialists** — each a focused lens
|
||
with its own review (+recheck) pass — and merges them into **one comment**, a collapsible
|
||
section per lens, led by an overall verdict (the worst across lenses; the optional
|
||
`improvements` lens never escalates it).
|
||
|
||
**Default suite** (when nothing is configured):
|
||
`security`, `correctness`, `maintainability` (code cleanliness), `performance`, `error-handling`.
|
||
|
||
**Also built in** (opt-in by name): `tests`, `docs`, `conventions`, and `improvements`
|
||
(strict & quiet — at most 1–2 high-value, non-blocking suggestions, silent otherwise).
|
||
|
||
Select which run with **`GADFLY_SPECIALISTS`** (comma-separated names, or `all`):
|
||
|
||
```yaml
|
||
GADFLY_SPECIALISTS: "security,correctness,maintainability,tests"
|
||
```
|
||
|
||
**Define your own** — two ways, which compose (env overrides file overrides built-ins):
|
||
|
||
```yaml
|
||
# 1. env: GADFLY_SPECIALIST_<NAME>="<focus>" (also overrides a built-in by reusing its name)
|
||
GADFLY_SPECIALIST_MIGRATIONS: "Review DB migrations for destructive or unindexed changes."
|
||
GADFLY_SPECIALISTS: "security,correctness,migrations"
|
||
```
|
||
|
||
```yaml
|
||
# 2. a repo .gadfly.yml at the repo root (version-controlled). See examples/.gadfly.yml:
|
||
specialists: [security, correctness, maintainability, migrations]
|
||
define:
|
||
- name: migrations
|
||
title: "🗃️ DB migrations"
|
||
focus: "Review schema migrations for destructive ops, missing indexes, table locks."
|
||
```
|
||
|
||
**Dynamic selection (`auto`):** set `GADFLY_SPECIALISTS: auto` and a selector model reads the
|
||
changed files + PR description and picks only the lenses that materially apply (and may invent
|
||
an ad-hoc one — e.g. a "migrations" lens for a schema change). The selector is
|
||
`GADFLY_SELECTOR_MODEL` if set (a cheap tier is ideal), else the review model. Capped and
|
||
de-duplicated; falls back to the default suite if selection fails.
|
||
|
||
**Worker-tier delegation:** set `GADFLY_WORKER_MODEL` (a cheap/fast model) to give every
|
||
reviewer a `delegate_investigation` tool — it offloads mechanical legwork (trace all callers,
|
||
gather every usage, check a pattern across files) to a worker sub-agent that returns a concise,
|
||
evidence-cited digest, so the expensive model reasons over summaries instead of raw file dumps.
|
||
Unset = no delegation (current behavior).
|
||
|
||
> **Cost:** each specialist is its own review+recheck, so cost ≈ *specialists × models × 2*.
|
||
> The default suite runs on a **single** model. Trim with `GADFLY_SPECIALISTS`, let `auto` pick
|
||
> only what a diff needs, and point heavy legwork at a cheap `GADFLY_WORKER_MODEL`.
|
||
|
||
### Concurrency (per-provider lanes)
|
||
|
||
With multiple models, each **provider** is its own lane and lanes run in **parallel**, so a fast
|
||
cloud provider isn't stuck behind a slow local box. There is **one throttle**: a per-provider
|
||
**lens budget** — the max number of lens passes (a lens = one specialist's review+recheck) in
|
||
flight at once for that provider. Every model in the lane runs concurrently and its lenses draw
|
||
from that single shared budget, so nothing else caps how many models run. The budget comes from
|
||
`GADFLY_PROVIDER_LENS_CONCURRENCY` (a `provider=N` map) else the `GADFLY_LENS_CONCURRENCY` scalar
|
||
(default `1`). The timeout is **per-lens** (`GADFLY_TIMEOUT_SECS`), so a slow lens can't starve
|
||
the others.
|
||
|
||
```yaml
|
||
# The local box gets 1 lens at a time (serial); the cloud lane runs up to 3 lens passes at once,
|
||
# shared across ALL its models. Both lanes run concurrently.
|
||
GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1pro=1"
|
||
GADFLY_MODELS: "m1pro/qwen3:14b,qwen3-coder:480b-cloud,gpt-oss:120b-cloud"
|
||
```
|
||
|
||
A model's provider is the spec's first segment (`m1pro/…` → `m1pro`), or `GADFLY_PROVIDER`/
|
||
`ollama-cloud` for a bare id. The budget is **shared across the provider's models**: with a
|
||
budget of 3 and two cloud models, you get 3 lens passes in flight in any mix — as one model
|
||
finishes a lens, the freed slot immediately goes to another model's next lens, so a model
|
||
winding down to its last lens never stalls the others (the pre-2026-07 design capped *models*
|
||
separately and did stall — that `GADFLY_PROVIDER_CONCURRENCY`/`GADFLY_CONCURRENCY` model cap is
|
||
**gone**; those vars are now ignored). Default (budget `1`) keeps a provider fully sequential.
|
||
|
||
> Under the hood the shared budget is a small cross-process permit pool (flock files, seeded per
|
||
> lane by `entrypoint.sh`); permits release automatically if a model process dies, so a crashed
|
||
> lens can't leak budget.
|
||
|
||
### Live status board
|
||
|
||
When several models (each with several lenses) review a PR, the individual findings land in
|
||
**one comment per model** — but while that's in flight all you'd see is a row of
|
||
`⏳ Reviewing…` placeholders. So Gadfly also upserts **one consolidated status-board comment**
|
||
that aggregates every model's per-lens progress as it happens:
|
||
|
||
```
|
||
## 🪰 Gadfly — live review status
|
||
1/3 reviewers finished · updated 2026-06-27 18:14:56Z
|
||
|
||
#### `glm-5.2:cloud` · ollama-cloud — ⏳ 2/4 lenses
|
||
- ✅ security — No material issues found
|
||
- 🔄 correctness — running
|
||
- ⏸️ performance — queued
|
||
…
|
||
```
|
||
|
||
Each model process publishes its lenses (queued → running → finished + verdict) to a small
|
||
JSON file, and a background renderer in `entrypoint.sh` re-renders + upserts the single comment
|
||
every `GADFLY_STATUS_POLL_SECS` (default 12s) until the swarm finishes. It's advisory and
|
||
best-effort — the per-model findings comments are unaffected — and entirely separate from those.
|
||
Turn it off with `GADFLY_STATUS_BOARD=0`.
|
||
|
||
### Consensus consolidation
|
||
|
||
With **two or more models**, posting one comment each means a reader faces N walls of prose that
|
||
mostly agree. Instead Gadfly consolidates: every model writes its findings to a shared file, and
|
||
after the whole swarm finishes a single pass clusters those findings by location, counts **how
|
||
many models independently flagged each one**, and posts **one consensus comment**:
|
||
|
||
```
|
||
## 🪰 Gadfly review — consensus across 7 models
|
||
|
||
**Verdict: Blocking issues found** · 9 findings (3 with multi-model agreement)
|
||
|
||
| | Finding | Where | Models | Lens |
|
||
|--|--|--|--|--|
|
||
| 🔴 | Auth bypass: token not verified | `auth/login.go:42` | 6/7 | security |
|
||
| 🟠 | Unbounded retry loop | `sync/worker.go:88` | 3/7 | error-handling |
|
||
|
||
<details><summary>4 single-model findings (lower confidence)</summary> … </details>
|
||
<details><summary>Per-model detail</summary> … each model's full review, folded … </details>
|
||
```
|
||
|
||
Cross-model agreement is the strongest real-vs-false-positive signal available, so findings are
|
||
ranked by it (a lone low-severity finding folds away; a lone *critical* still surfaces). The
|
||
per-model comments are suppressed in this mode — each model's full review is preserved, folded,
|
||
inside the consensus comment — and nothing is lost: if consolidation can't run, Gadfly falls
|
||
back to posting the per-model comments. Controlled by `GADFLY_CONSOLIDATE`: `auto` (default — on
|
||
for ≥2 models), `1` (force on), `0` (force off, one comment per model). Single-model runs are
|
||
unaffected.
|
||
|
||
**Inline PR review.** Alongside the consensus comment, Gadfly also posts a single Gitea **pull
|
||
review** (state `COMMENT` — advisory, **never** request-changes or approve, so it can't block a
|
||
merge) whose inline comments anchor each consensus finding to the exact changed line it's about.
|
||
Only findings that land on a line in the diff are anchored (Gitea rejects comments off the diff);
|
||
the rest stay in the consensus comment. A re-run replaces the previous review instead of stacking.
|
||
It's the "reviewer integrated with Gitea" without the blocking — turn it off with
|
||
`GADFLY_INLINE_REVIEW=0`.
|
||
|
||
### Triggers
|
||
|
||
1. A **new/reopened/ready** non-draft PR — automatic.
|
||
2. Commenting **`@gadfly review`** on a PR — re-review on demand (gated to allowed users).
|
||
3. **workflow_dispatch** — manual, with a `pr_number` input.
|
||
|
||
(Pushing new commits does *not* auto-re-review — comment `@gadfly review` after pushing
|
||
fixes. This keeps usage down.)
|
||
|
||
> **Comment trigger needs the workflow on your default branch.** Gitea runs `issue_comment`
|
||
> workflows from the **default branch**, so `@gadfly review` only works once this stub is
|
||
> merged to `main` (the `pull_request` auto-trigger works from the PR branch immediately).
|
||
>
|
||
> **Security:** the example stubs gate the comment trigger with a job-level
|
||
> `if: github.event_name != 'issue_comment' || github.actor == '<you>'` so an untrusted
|
||
> commenter can't start a secret-bearing run — edit it to your maintainers and keep it in
|
||
> sync with `GADFLY_ALLOWED_USERS` (the in-container check). `@gadfly review` is plain-text
|
||
> matched (configurable via `GADFLY_TRIGGER_PHRASE`), so no bot account is required; comments
|
||
> post as `gitea-actions`.
|
||
|
||
## How it's packaged
|
||
|
||
```
|
||
cmd/gadfly/ the agentic reviewer binary (majordomo + Ollama Cloud); zero deps beyond stdlib + majordomo
|
||
scripts/run.sh fetches the PR diff, runs the reviewer, upserts one labeled comment
|
||
scripts/status-board.sh renders + upserts the single live status-board comment (per-lens progress)
|
||
scripts/system-prompt.txt the reviewer persona + verification discipline
|
||
entrypoint.sh the container brains: trigger gating, clone, model loop (logic lives here, not in YAML)
|
||
Dockerfile multi-stage; build-time module creds (BuildKit secrets) never reach the final image
|
||
.gitea/workflows/build-image.yml push to main → :latest; tag v* → :<tag> + :latest
|
||
examples/ the ~15-line stub a consuming repo drops in
|
||
```
|
||
|
||
The image is published to `gitea.stevedudenhoeffer.com/steve/gadfly`. Every push to `main`
|
||
rebuilds and republishes `:latest` (plus `:sha-<short>`); pushing a `v*` tag publishes that
|
||
pinned version (plus `:latest`). Pin full-stub consumers to a `:vN` image tag for stability, or track
|
||
`:latest` to ride main. **Reusable-workflow consumers should pin the workflow ref to an immutable
|
||
`review-reusable.yml@<sha>`** — long-lived act_runners *cache the workflow file by ref*, so a moved tag
|
||
(`@v1`) or `@main` is often **not** re-fetched and silently runs a stale copy. A fresh `@<sha>` is the
|
||
only reliable way to roll out a *structural* change to the reusable.
|
||
|
||
### Central config via variables
|
||
|
||
So you don't have to re-pin every consumer just to retune the swarm, the reusable resolves its config
|
||
at **runtime** — `with:` input → owner **user/org-level variable** → image default — and variables are
|
||
injected per-run (not part of the cached file), so changing one variable propagates to every consumer
|
||
on its next review **without** a re-pin or a tag move:
|
||
|
||
| Variable (user/org scope) | Sets |
|
||
|---|---|
|
||
| `GADFLY_DEFAULT_MODELS` | `GADFLY_MODELS` (csv) |
|
||
| `GADFLY_DEFAULT_SPECIALISTS` | the lens suite |
|
||
| `GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY` | the per-provider lens budget (lens passes in flight per provider, shared across its models) |
|
||
| `GADFLY_ENDPOINT_RAGNAROS` | a named endpoint, e.g. `llamaswap\|https://host` |
|
||
|
||
Adding a *new* named endpoint still needs a one-line reusable edit (Gitea can't auto-expose arbitrary
|
||
`vars.GADFLY_ENDPOINT_*`); the values of already-wired ones are pure variables.
|
||
|
||
## Configuration (advanced)
|
||
|
||
The reviewer binary reads these (the stub/entrypoint set sane defaults):
|
||
|
||
| Env | Default | Meaning |
|
||
|-----|---------|---------|
|
||
| `GADFLY_MODEL` | — | model id, or `provider/model` spec, or majordomo alias/chain |
|
||
| `GADFLY_PROVIDER` | `ollama-cloud` | provider prefix for a bare model id |
|
||
| `GADFLY_BASE_URL` | — | override endpoint (OpenAI/Ollama-compatible servers) |
|
||
| `GADFLY_API_KEY` | — | provider key; falls back to the provider's standard env |
|
||
| `claude-code` model id | — | route a model through the bundled Claude Code CLI (`claude-code` / `claude-code/<model>`); see [Claude Code engine](#claude-code-engine-claude-code) for its `GADFLY_CLAUDE_*` knobs |
|
||
| `opencode` model id | — | route an ollama-cloud model through the bundled OpenCode CLI (`opencode/<model>`); see [OpenCode engine](#opencode-engine-opencode) for its `GADFLY_OPENCODE_*` knobs |
|
||
| `GADFLY_SPECIALISTS` | default suite | csv of lenses, `all`, or `auto` (dynamic selection) |
|
||
| `GADFLY_SELECTOR_MODEL` | review model | model that picks lenses in `auto` mode |
|
||
| `GADFLY_WORKER_MODEL` | — | cheap model for `delegate_investigation`; unset = no delegation |
|
||
| `GADFLY_WORKER_MAX_STEPS` | 8 | tool-step cap for a delegated worker run |
|
||
| `GADFLY_LENS_CONCURRENCY` | 1 | **per-provider lens budget** — lens passes in flight per provider, shared across all its models (all a provider's models run at once; this is the only throttle) |
|
||
| `GADFLY_PROVIDER_LENS_CONCURRENCY` | — | per-provider lens-budget overrides, a `provider=N` map, e.g. `ollama-cloud=3,m1=1` |
|
||
| `GADFLY_CONCURRENCY` / `GADFLY_PROVIDER_CONCURRENCY` | — | **removed** (was the per-provider models-at-once cap; now ignored — the lens budget is the single throttle) |
|
||
| `GADFLY_MAX_STEPS` | 24 | review-pass tool-step cap |
|
||
| `GADFLY_TIMEOUT_SECS` | 300 | deadline **per specialist lens** (review+recheck) |
|
||
| `GADFLY_RECHECK` | on | set `0`/`false` to skip the recheck pass |
|
||
| `GADFLY_RECHECK_MAX_STEPS` | 16 | recheck-pass step cap |
|
||
| `GADFLY_MAX_DIFF_CHARS` | 60000 | diff chars embedded in the **review** prompt (the full diff is reachable via the paginated `get_diff` tool, scoped per file with its `path` arg) |
|
||
| `GADFLY_RECHECK_DIFF_CHARS` | 20000 | diff chars embedded in the **recheck** prompt (smaller — the recheck pages `get_diff` for the hunks it verifies) |
|
||
| `GADFLY_COMPACT` | on | context compaction (via [executus](https://gitea.stevedudenhoeffer.com/steve/executus)): fold the transcript's runaway middle into a summary as it nears the model's context window, so a big diff + accumulating tool output can't balloon every step. `0` disables |
|
||
| `GADFLY_COMPACT_RATIO` | 0.45 | fraction of the model's context window at which compaction fires |
|
||
| `GADFLY_COMPACT_MODEL` | worker, else review model | cheap model the compactor uses to summarize the folded middle |
|
||
| `GADFLY_COMPACT_KEEP_RECENT` | 8 | most-recent messages kept verbatim during compaction |
|
||
| `GADFLY_COMPACT_SUMMARY_WORDS` | 200 | word cap on the compaction summary |
|
||
| `GADFLY_MODEL_CONTEXT_TOKENS` | *(auto)* | override the model's context-window size (tokens) for the compaction threshold; set it for self-hosted endpoints executus can't introspect (Ollama Cloud models resolve automatically) |
|
||
| `GADFLY_PR_TOKEN_BUDGET` | — | per-model token ceiling for this PR; once spent, remaining lenses/passes are skipped (advisory). 0 = off |
|
||
| `GADFLY_PR_TIME_BUDGET_SECS` | — | per-model wall-clock ceiling for this PR (advisory). 0 = off |
|
||
| `GADFLY_STATUS_BOARD` | on | set `0` to disable the live status-board comment |
|
||
| `GADFLY_STATUS_POLL_SECS` | 12 | how often the status board re-renders/upserts |
|
||
| `GADFLY_CONSOLIDATE` | `auto` | cross-model consensus comment: `auto` (on for ≥2 models), `1` (force on), `0` (off — one comment per model) |
|
||
| `GADFLY_INLINE_REVIEW` | on | when consolidating, also post a `COMMENT`-state PR review with inline comments on changed lines; `0` disables |
|
||
| `GADFLY_TRIGGER_PHRASE` | `@gadfly review` | comment phrase that re-triggers |
|
||
| `GADFLY_ALLOWED_USERS` | *(collaborators)* | comma-separated allow-list for comment triggers |
|
||
| `GADFLY_FINDINGS_URL` | — | gadfly-reports store base URL; set to enable findings telemetry (off when empty) |
|
||
| `GADFLY_FINDINGS_TOKEN` | — | bearer token for the gadfly-reports store (sent as `Authorization: Bearer …`) |
|
||
| `GADFLY_REPO` | *(from `GITEA_API`)* | `owner/repo` slug stamped on emitted runs/findings (set by `entrypoint.sh`) |
|
||
| `GADFLY_PR` | *(from event)* | PR number stamped on emitted runs/findings (set by `entrypoint.sh`) |
|
||
|
||
### Large-PR cost controls
|
||
|
||
A very large diff is the one thing that can blow the budget: every review step
|
||
re-sends it, multiplied across models × lenses × passes × steps (a single
|
||
~250 K-token PR can otherwise burn a whole metered usage block). Gadfly handles
|
||
big PRs in three layers, all **size-gated so small PRs are untouched**:
|
||
|
||
1. **Paginated `get_diff` + compaction** (reviewer binary, on by default) —
|
||
`get_diff` returns a paginated, optionally per-file window instead of the whole
|
||
diff, and once a transcript nears the model's context window its middle is
|
||
folded into a summary (powered by [executus](https://gitea.stevedudenhoeffer.com/steve/executus)'s
|
||
`compact`). Tune with the `GADFLY_COMPACT_*` knobs above.
|
||
2. **Downshift** (`entrypoint.sh`) — above `GADFLY_HUGE_DIFF_BYTES` the whole fleet
|
||
collapses to a single cheap model + a focused lens subset, fewer steps, and no
|
||
recheck. A finished shallow review beats a budget-nuking one, and the posted
|
||
comment says so.
|
||
3. **Hard backstop** (`entrypoint.sh`) — `GADFLY_PR_BUDGET_SECS` is a wall-clock
|
||
ceiling across the *entire* fleet; on expiry the review is stopped and whatever
|
||
was found so far is posted. Like everything else, it never fails CI.
|
||
|
||
| Env | Default | Meaning |
|
||
|-----|---------|---------|
|
||
| `GADFLY_HUGE_DIFF_BYTES` | 600000 | downshift the fleet when the PR diff exceeds this many bytes (0 = never downshift) |
|
||
| `GADFLY_HUGE_DIFF_MODELS` | first model | model(s) to run on a downshifted huge PR |
|
||
| `GADFLY_HUGE_DIFF_SPECIALISTS` | `security,correctness,error-handling` | lenses on a downshifted huge PR |
|
||
| `GADFLY_HUGE_DIFF_MAX_STEPS` | 12 | review step cap on a huge PR |
|
||
| `GADFLY_HUGE_DIFF_RECHECK_MAX_STEPS` | 8 | recheck step cap on a huge PR |
|
||
| `GADFLY_HUGE_DIFF_RECHECK` | 0 | run the recheck pass on a huge PR (off by default) |
|
||
| `GADFLY_HUGE_DIFF_MAX_DIFF_CHARS` | 20000 | embedded review-diff chars on a huge PR |
|
||
| `GADFLY_PR_BUDGET_SECS` | — | swarm-wide wall-clock backstop; stops the whole fleet when reached (0 = off) |
|
||
|
||
## Findings telemetry (optional)
|
||
|
||
Gadfly can record what it found so model quality can be tracked over time. It is
|
||
**off by default** and purely advisory: set **`GADFLY_FINDINGS_URL`** to a
|
||
[gadfly-reports](https://gitea.stevedudenhoeffer.com/steve/gadfly-reports) store base URL and,
|
||
after each review, the binary best-effort `POST`s the run (`/runs`) and the
|
||
findings it surfaced (`/reports`) to that store. Add **`GADFLY_FINDINGS_TOKEN`**
|
||
to send an `Authorization: Bearer …` header. `entrypoint.sh` supplies the run
|
||
context (`GADFLY_REPO`, `GADFLY_PR`) automatically.
|
||
|
||
Findings are extracted heuristically from each lens's markdown — a `path:line`
|
||
reference anchors a finding, titled by the nearest preceding heading / numbered
|
||
item / bold lead-in. A lens whose verdict is **"No material issues found"**
|
||
emits **no** findings: its `path:line` references are verification notes
|
||
("verified X is safe"), not problems, so extracting them would record false
|
||
positives and unfairly penalize thorough clean-pass reviewers. The emit is
|
||
strictly best-effort: a short (~10s) timeout, any error (or a non-2xx response)
|
||
is logged to stderr only, and it **never** changes the review output or the exit
|
||
code.
|
||
|
||
## Building locally
|
||
|
||
```sh
|
||
go build ./cmd/gadfly # needs read access to the private majordomo + executus modules
|
||
go test ./...
|
||
```
|
||
|
||
## License
|
||
|
||
MIT — see [LICENSE](LICENSE).
|