Compare commits

...

33 Commits

Author SHA1 Message Date
steve 7bc3c982fa feat(reusable): runtime-variable swarm config (cache-immune, no more re-pinning to retune) (#14)
Build & push image / build-and-push (push) Successful in 5s
2026-06-28 06:00:18 +00:00
steve 95a9ec546a feat(reusable): add the 4090 Ti (qwen3.6-27b via llama-swap) to the default swarm (#13)
Build & push image / build-and-push (push) Successful in 7s
2026-06-28 05:01:50 +00:00
steve 8f69e71311 docs: recommend the @v1 release tag for reusable-workflow consumers (#12)
Build & push image / build-and-push (push) Successful in 6s
2026-06-28 04:17:19 +00:00
steve 0d80ae73d8 tune(reusable): claude-code=3 models × 5 lenses (claude was the bottleneck) (#11)
Build & push image / build-and-push (push) Successful in 8s
2026-06-28 04:02:17 +00:00
steve b02b11d691 feat(reusable): ship the curated swarm as the default config consumers inherit (#10)
Build & push image / build-and-push (push) Successful in 8s
2026-06-28 02:23:40 +00:00
steve 20a5c431f2 Merge pull request 'security: scope reusable-workflow secrets (least privilege) over secrets: inherit' (#9) from sec/scope-secrets into main
Build & push image / build-and-push (push) Successful in 6s
Reviewed-on: #9
2026-06-28 01:17:16 +00:00
Steve Dudenhoeffer daff6d08a1 docs: drop stale 'secrets: inherit' mentions (reusable comment + CLAUDE.md)
Build & push image / build-and-push (pull_request) Successful in 6s
Self-review on PR #9 flagged two doc-drift spots left over from the
explicit-secret-forwarding switch. Cosmetic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:00:40 -04:00
Steve Dudenhoeffer 18de9b8ebc fix: source GITEA_TOKEN from github.token (auto) under explicit secret forwarding
Build & push image / build-and-push (pull_request) Successful in 7s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m2s
The first attempt failed at entrypoint.sh:61 'GITEA_TOKEN required' — with
explicit secrets (no `inherit`), secrets.GITEA_TOKEN resolves empty in the
reusable job. github.token comes from the github context (not a forwarded
secret), so it's present regardless. The forwarded provider/findings secrets
arrived correctly; only the auto-token sourcing was wrong.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:53:00 -04:00
Steve Dudenhoeffer f06fe5ef72 security: scope reusable-workflow secrets (least privilege) over secrets: inherit
Adversarial Review (Gadfly) / review (pull_request) Failing after 2s
Build & push image / build-and-push (pull_request) Successful in 6s
The swarm (reviewing the mort/executus rollout PRs) correctly flagged that
`secrets: inherit` forwards EVERY caller secret to the reusable review
workflow — registry/deploy/db creds the reviewer never touches. Fix:

- review-reusable.yml: declare workflow_call.secrets (all optional) so a
  caller can forward only what the reviewer needs.
- adversarial-review.yml (gadfly's own caller) + examples/reusable.yml:
  replace `secrets: inherit` with an explicit forward of just
  OLLAMA_CLOUD_API_KEY / CLAUDE_CODE_OAUTH_TOKEN / findings tokens.
  GITEA_TOKEN stays automatic.
- Docs (README, examples) updated; also advise pinning consumers to an
  immutable @<sha> instead of @main (supply-chain, the other finding).

gadfly's own review on this PR exercises the explicit-secrets path (local
reusable ref) — validating it on the act_runner before mort/executus adopt it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:45:18 -04:00
steve 5f86062a5a feat: Phase 4 — reusable "subscribe" workflow (+ dogfood it) (#8)
Build & push image / build-and-push (push) Successful in 9s
Centralizes the consumer stub into a reusable Gitea workflow
(.gitea/workflows/review-reusable.yml, workflow_call + defaulted inputs +
secrets: inherit); gadfly's own dogfood is now a thin caller of it, which
proved end-to-end that github.event context propagates into the reusable
on this act_runner. Adds the slim examples/reusable.yml stub + docs.

Folded in the swarm's findings: timeout_minutes default 30->45, map
GADFLY_API_KEY, explicit permissions block, drop the dead specialist_suite
input, and harden the example's actor gate. ~70 findings graded.

Completes the gadfly-games build (Phases 1-4 + quality fixes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
Co-committed-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
2026-06-27 23:42:01 +00:00
steve b23eeb8cbf feat: bump majordomo + support llama-swap(s) provider spellings (#7)
Build & push image / build-and-push (push) Successful in 7s
Bump majordomo to the latest build and accept every llama-swap spelling
(llama-swap/llama-swaps + un-hyphenated llamaswap/llamaswaps) in gadfly's
endpoint switches; the LLM_* llama-swap(s):// DSN path already worked via
majordomo.Parse. README + error messages + endpointProvider alias tests.

Swarm review: 8/9 clean; qwen3-coder's "Blocking" was a false positive
(claimed llamaswap was untested — it has dedicated test cases). Folded in
its one fair nit (README now lists the un-hyphenated aliases).

gofmt clean, go vet quiet, go test -race green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
Co-committed-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
2026-06-27 23:18:56 +00:00
steve a4cdc905c9 ci: enable claude-code/opus:max (max-thinking) reviewer (#6)
Build & push image / build-and-push (push) Successful in 6s
Adds claude-code/opus:max to the dogfood swarm and pins to :sha-c342bdb
(which has the :thinking parse). Claude Code lineup is now sonnet + opus +
opus:max. All three ran end-to-end on this PR's own review; 0 findings
(clean PR + the telemetry fix suppressing phantom clean-verification
findings — working as intended).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
Co-committed-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
2026-06-27 22:49:49 +00:00
steve c342bdb905 feat: add claude-code/opus reviewer + max-thinking spec support (#5)
Build & push image / build-and-push (push) Successful in 15s
Adds claude-code/opus to gadfly's dogfood swarm (both sonnet and opus run
end-to-end), bumps the image pin to :sha-80d8f53 so the clean-lens
telemetry fix is live, and adds engine support for a
"claude-code/<model>:max" extended-thinking spec (MAX_THINKING_TOKENS,
best-effort). Validated: only 13 findings on this clean PR vs 43 on the
comparable #4 — the telemetry fix works.

Folded in the swarm's two real findings: a runPass env-injection test and
keeping MAX_THINKING_TOKENS in claudeEnv. Follow-up enables
claude-code/opus:max once this image builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
Co-committed-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
2026-06-27 22:39:14 +00:00
steve 80d8f53f63 fix: clean-lens findings + trim the dogfood swarm to strong reviewers (#4)
Build & push image / build-and-push (push) Successful in 9s
emit() now skips findings extraction for a "No material issues found"
lens (its path:line refs are verification notes, not problems), fixing
the FP inflation that penalized thorough clean-pass reviewers. Also trims
the dogfood swarm to the strong reviewers: drops m5/qwen3.6 (last local
lane), gemma4, gpt-oss:120b, and kimi-k2.7-code — leaving 6 cloud +
claude-code/sonnet.

Fittingly, PR #4's own 11-model review produced 43 findings that were ALL
clean-verification bullets (zero real) — a live demonstration of the bug
this fixes. gofmt clean, go vet quiet, go test -race green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
Co-committed-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
2026-06-27 22:14:07 +00:00
steve 82f7ef78d5 feat: claude-code backends + llamaswap provider + dogfood the CC engine (#3)
Build & push image / build-and-push (push) Successful in 10s
Phase 2: bump majordomo to latest and wire its new llamaswap provider
into gadfly's endpoint switches; add claude-code/sonnet to gadfly's own
dogfood swarm (pin :sha-86f12c1, map CLAUDE_CODE_OAUTH_TOKEN) so the
Phase-1 engine runs as a live competitor; document the Ollama-through-CC
ANTHROPIC_BASE_URL proxy path as example-only.

The 11-model swarm (incl. claude-code/sonnet) reviewed it; 52 findings
graded via the MCP. Folded in the two real ones: a llamaswap
endpointProvider test (caught by claude-code/sonnet, citing CLAUDE.md)
and adding "openai-compatible" to the provider error messages (gpt-oss).

gofmt clean, go vet quiet, go build + go test -race green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
Co-committed-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
2026-06-27 21:53:41 +00:00
steve 86f12c126f feat: claude-code reviewer engine (#2)
Build & push image / build-and-push (push) Successful in 28s
Phase 1: a second review engine alongside the majordomo agent loop. For
each lens, shell out to the Claude Code CLI (`claude -p --output-format
json`) inside the checked-out repo so it verifies findings with its own
read tools, then reuse gadfly's verdict-parse + recheck + consolidate +
emit pipeline. Select via GADFLY_MODELS `claude-code`/`claude-code/<model>`;
auth via CLAUDE_CODE_OAUTH_TOKEN (no --bare) else ANTHROPIC_API_KEY;
read-only by default; GADFLY_CLAUDE_* knobs. Dockerfile bundles Node +
@anthropic-ai/claude-code. Also bumped the dogfood pin to the status-board
image (PR #2 was the first dogfood with the live board + full fleet).

Folded in the swarm's own review findings: minimal subprocess env (no
GITEA_TOKEN leak to the CLI), runPass robustness (ctx/empty-result/runErr),
process-group cleanup on timeout, rune-safe error truncation, and
engine-neutral prompts (also de-mort-ified the recheck prompt). 66 findings
graded via the gadfly MCP.

gofmt clean, go vet quiet, go build + go test -race green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
Co-committed-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
2026-06-27 20:40:41 +00:00
steve c3d09d3bd4 feat: live status-board comment + full-fleet dogfood (#1)
Build & push image / build-and-push (push) Successful in 6s
Phase 3: one consolidated, live-updating PR comment aggregating every
model's per-lens progress (queued -> running -> finished + verdict), so
the swarm's progress is visible at a glance and a watcher can tell when
it's done. Opt-in statusWriter in the binary (atomic writes) + a
background status-board.sh renderer wired through entrypoint.sh; default
on, GADFLY_STATUS_BOARD=0 to disable.

Also restores gadfly's dogfood swarm to the full cloud fleet (9 cloud +
M5; M1 dropped as too slow) matching mort, and folds in the 3 real bugs
the swarm found on its own PR (skip-binary stuck-waiting, panic-stuck
lens, busy-loop on bad poll interval). All 36 findings graded via the
gadfly MCP (18 real / 18 false-positive).

gofmt clean, go vet quiet, go build + go test -race green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
Co-committed-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
2026-06-27 19:00:12 +00:00
steve 0ad5b66170 ci: dogfood — gadfly reviews its own PRs (mort's full-fleet setup)
Build & push image / build-and-push (push) Successful in 14s
Adds the adversarial-review workflow to gadfly itself (copied from mort: 3 cloud + m1/m5 via foreman, findings telemetry, sha-d7f364d). Future gadfly PRs get reviewed by the swarm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:26:37 -04:00
steve d7f364d803 feat: optional findings telemetry — emit runs+findings to a gadfly-reports store
Build & push image / build-and-push (push) Successful in 8s
After each review the binary POSTs the run + its heuristically-extracted findings to GADFLY_FINDINGS_URL (off unless set). Advisory: any error only goes to stderr — never touches stdout, the exit code, or the review. stdlib net/http only (no new deps). entrypoint.sh derives GADFLY_REPO/GADFLY_PR and passes through GADFLY_FINDINGS_URL/GADFLY_FINDINGS_TOKEN. Also renames store references from the old 'docket' name to 'gadfly-reports'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 09:09:51 -04:00
steve d0de034726 feat: configurable lens fan-out, per-provider like model concurrency
Build & push image / build-and-push (push) Successful in 9s
Specialist lenses ran strictly sequentially within a model. Add a
GADFLY_LENS_CONCURRENCY knob (default 1 = unchanged) that overlaps the
independent per-lens review+recheck passes, so a model posts its
consolidated comment as soon as its lenses finish.

Per-provider configurable, mirroring GADFLY_PROVIDER_CONCURRENCY:
GADFLY_PROVIDER_LENS_CONCURRENCY takes a "provider=N,..." map keyed by
the same provider lanes (modelProvider() mirrors entrypoint's provider_of;
providerOverride() mirrors provider_cap). The override wins for the model's
lane, else the scalar default.

runSpecialists fans out via a bounded worker pool, order-preserving
(results written by index) and keeping each lens's own timeout/recheck.
repoFS is immutable + fresh-toolbox-per-pass, so lenses share no mutable
state (verified under -race). Docs/examples updated; dropped a duplicate
GADFLY_TIMEOUT_SECS README row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:53:27 -04:00
steve 6e3a83c437 feat: add foreman provider type for endpoint overrides
Build & push image / build-and-push (push) Successful in 7s
Accept "foreman" in both resolveModel (GADFLY_BASE_URL) and endpointProvider
(GADFLY_ENDPOINT_*) switches, mapping to majordomo's ollama.Foreman() preset
(handles foreman's non-streaming/long-poll quirks). Unlike the HTTPS-only
LLM_* foreman:// DSN, the base URL is verbatim, so a plaintext http:// foreman
queue works. Tests + README provider table + endpoint-aliases example updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:13:47 -04:00
Steve Dudenhoeffer a1e9d109e5 security: add job-level if-guard to example stubs (gate comment trigger by actor)
Build & push image / build-and-push (push) Successful in 5s
Per a Gadfly self-review finding (kimi-k2.7-code): an issue_comment can start a
secret-bearing run before the in-container allowed-users check. Add a workflow
if: that only lets trusted actors trigger via comment (PR/dispatch already
trusted); keep GADFLY_ALLOWED_USERS as the belt-and-suspenders layer. README
documents it + the default-branch caveat for comment triggers. (Docs/examples
only — paths-ignored, no image rebuild.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
2026-06-25 21:49:23 -04:00
Steve Dudenhoeffer b409dff4ed fix: parseVerdict matches leniently + earliest phrase wins
Build & push image / build-and-push (push) Successful in 8s
A section that led with '**Blocking issues**' (no 'found') fell through to
unknown, so the consolidated header wrongly read 'No material issues found'
(seen live on gpt-oss). Now matches 'blocking issue'/'minor issue'/'no material
issue' and picks the earliest-appearing phrase (the lead verdict). + tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:28:19 -04:00
Steve Dudenhoeffer 92bf22a1be fix: correctness lens emoji -> 🎯 ( read like 'no issues')
Build & push image / build-and-push (push) Successful in 8s
A green check on a section reporting blocking issues was misleading; 🎯 signals
accuracy/on-target. Section verdict text already conveys pass/fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:50:39 -04:00
Steve Dudenhoeffer 9e582bfaca feat: per-provider concurrency lanes (cloud parallel while local churns)
Build & push image / build-and-push (push) Successful in 7s
entrypoint.sh groups models by provider into lanes that run in PARALLEL; within
a lane at most `cap` models run at once. cap = GADFLY_PROVIDER_CONCURRENCY map
("ollama-cloud=3,m1pro=1") else GADFLY_CONCURRENCY (default 1). So a single
local box stays serial (1 at a time) while cloud models run several at once and
both lanes progress simultaneously. Portable bash (no associative arrays).
Default cap 1 keeps a single-provider pool sequential as before. Pairs with the
per-lens timeout so a slow lane can't starve others. Docs: README Concurrency
section + config table; CLAUDE.md lessons incl. the docker://:latest cache gotcha.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:29:08 -04:00
Steve Dudenhoeffer 49f3623204 fix: per-lens timeout, errored-verdict honesty, accurate provider label, tighter lens focus, run timing
Build & push image / build-and-push (push) Successful in 8s
Five fixes, several surfaced by the live bake-off:

- PER-LENS TIMEOUT (critical): GADFLY_TIMEOUT_SECS now applies to EACH specialist
  (own context), not shared across the suite. A slow model (e.g. a 35B local MLX)
  was exhausting the whole 600s budget on lens 1, leaving the rest "step 0:
  context deadline exceeded". Default lowered to 300s (per-lens). cmd/gadfly/main.go.
- ERRORED VERDICT: a lens whose review pass failed no longer counts as "clean".
  Header shows "· ⚠️ N/M lens(es) errored" (or "Review incomplete — all lenses
  errored"); the section reads "⚠️ could not complete". consolidate.go.
- PROVIDER LABEL: the comment header now shows the model's ACTUAL backend from the
  spec ("m1pro/qwen3.6:35b-mlx" -> m1pro), not the global GADFLY_PROVIDER default
  (was wrongly "ollama-cloud" for local models). scripts/run.sh.
- LENS FOCUS: base prompt no longer licenses "report anything serious"; each lens
  stays in its lane, says "nothing in my area" rather than re-reporting another
  lens's bug, with a one-line "Outside my lens:" escape hatch. The re-derive-
  constants discipline is now lane-scoped, not "every lens". system-prompt.txt + specialists.go.
- RUN TIMING: run.sh posts a " Reviewing…" placeholder at model start and updates
  it with "⏱️ reviewed in 1m 23s" on finish, for per-model comparison.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:15:40 -04:00
Steve Dudenhoeffer 4b8f9aa39b feat: dynamic auto specialist selection + worker-tier delegation
Build & push image / build-and-push (push) Successful in 33s
Two Phase-2 swarm upgrades:

- auto.go: GADFLY_SPECIALISTS=auto routes the review — a selector model
  (GADFLY_SELECTOR_MODEL, else the review model) reads the changed files + PR
  description and picks the smallest relevant lens set from the catalog, and may
  propose ad-hoc lenses for gaps (e.g. migrations). Structured output via
  majordomo.Generate[T]; capped + de-duped; falls back to the default suite.
- delegate.go: GADFLY_WORKER_MODEL adds a delegate_investigation tool so the
  reviewer offloads mechanical legwork (trace callers, gather usages) to a cheap
  worker sub-agent that returns an evidence-cited digest — the top model reasons
  over summaries, not raw file dumps. Workers get an fs-only toolbox (no
  sub-delegation). Unset = off.

resolveSpecialists now also returns the registry + an auto flag. Docs (README
Specialists + config table, CLAUDE.md, main.go header) + tests updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:35:59 -04:00
Steve Dudenhoeffer 7809d1b93d feat: specialist suite — configurable + custom review lenses (one consolidated comment)
Build & push image / build-and-push (push) Successful in 8s
Replace the single generic review with a suite of focused specialists, each its
own review+recheck pass, merged into ONE comment (a collapsible section per lens,
led by the worst verdict; the optional `improvements` lens never escalates it).

- cmd/gadfly/specialists.go: built-in lenses + default suite (security, correctness,
  maintainability, performance, error-handling) + opt-in (tests, docs, conventions,
  improvements). Selection via GADFLY_SPECIALISTS (csv/"all"); custom defs via
  GADFLY_SPECIALIST_<NAME> env and a repo .gadfly.yml (specialists + define).
  Precedence: built-ins < file < env. Unknown names error but don't sink the run.
- cmd/gadfly/consolidate.go: verdict parse + one-comment render.
- main.go: loop specialists; per-lens failure is an inline notice, never fatal.
  Default timeout bumped to 600s (suite runs sequentially).
- base system prompt trimmed to persona+tools+discipline+output; lens-specific
  focus is appended per specialist (semantic re-derivation discipline kept in base).
- entrypoint default models -> single model (suite already gives breadth; cost ~=
  specialists × models × 2). Adds gopkg.in/yaml.v3.
- docs/examples: README "Specialists" section, examples/.gadfly.yml, stub var,
  CLAUDE.md architecture/config. Dynamic `auto` selection is the planned next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:23:05 -04:00
Steve Dudenhoeffer 676c9d4f07 ci: skip image rebuild on docs/example-only changes (paths-ignore)
Build & push image / build-and-push (push) Successful in 5s
Tag pushes (v*) bypass path filters, so releases always build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:10:24 -04:00
Steve Dudenhoeffer 04cd260ff9 docs: add CLAUDE.md + provider example configs
Build & push image / build-and-push (push) Successful in 6s
- CLAUDE.md: project goals (advisory-only, real-bugs-not-nits, easy-to-enable,
  provider-agnostic, portable), architecture map, build/test/release, and
  maintenance rules — incl. "keep README + examples/ current with any env/flag/
  provider/trigger change" and the advisory-only invariant.
- examples/: local-ollama.yml, openai-compatible.yml, endpoint-aliases.yml +
  an examples/README index; README setup step points at them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:06:08 -04:00
Steve Dudenhoeffer bd76aa8286 feat: env-defined endpoint aliases (http-capable, local Ollama friendly)
Build & push image / build-and-push (push) Successful in 9s
majordomo's built-in LLM_* env DSNs are HTTPS-only (DSN.BaseURL forces https),
so they can't express a plaintext local Ollama. Add Gadfly-native env families
that register named providers/aliases with majordomo before resolution:

  GADFLY_ENDPOINT_<NAME>="<provider>|<base-url>[|<key>]"  # base URL verbatim (http ok)
  GADFLY_ALIAS_<NAME>="<majordomo spec>"                  # plain alias / failover chain

Then reference them as "<name>/<model>" (or the bare alias) in GADFLY_MODEL(S).
<NAME> lowercases to the registry name, matching majordomo's LLM_* convention.
LLM_* DSNs still work (and are documented) for HTTPS endpoints. + unit tests,
README "Endpoint aliases via env vars", stub example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:01:07 -04:00
Steve Dudenhoeffer d9405f4f69 feat: multi-provider model support via majordomo (local Ollama, OpenAI-compatible, etc.)
Build & push image / build-and-push (push) Successful in 18s
Replace the hardcoded ollama.Cloud binding with majordomo's provider registry,
so Gadfly can target any backend majordomo supports without code changes.

- cmd/gadfly/model.go: resolveModel() — GADFLY_PROVIDER (default ollama-cloud)
  prefixes bare model ids; GADFLY_MODEL may be a full provider/model spec, alias,
  or failover chain (verbatim). GADFLY_BASE_URL constructs openai/ollama/anthropic/
  google directly at a custom endpoint (OpenAI-compatible + local/remote Ollama).
  GADFLY_API_KEY else the provider's standard env var. + buildSpec unit tests.
- run.sh: provider-aware key gate (local Ollama needs none); maps OLLAMA_CLOUD_API_KEY
  -> OLLAMA_API_KEY; provider/base-url/key inherited by the binary. Gadfly-branded comment.
- entrypoint.sh: GADFLY_MODELS alias for OLLAMA_REVIEW_MODELS; provider passthrough.
- examples + README: Models & providers section. Upfront: only the Ollama paths
  (local + OpenAI-compatible-against-Ollama) are tested; OpenAI/Anthropic/Google
  are wired via majordomo but UNTESTED (no spend).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:58:00 -04:00
Steve Dudenhoeffer 6123604595 ci: auto build & push image on main (:latest) + v* tags
Build & push image / build-and-push (push) Successful in 58s
Mirror mort-ci.yml's build-and-push: BuildKit secrets (REGISTRY_USER/
REGISTRY_PASSWORD) for private majordomo access instead of build-args, and the
LAN --add-host so the builder can reach the registry. push main -> :latest +
:sha-<short>; tag v* -> :<tag> + :latest; other branches -> :branch-<safe>;
PRs build-only (no push). Optional DISCORD_WEBHOOK_URL notifications.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:45:48 -04:00
37 changed files with 4646 additions and 153 deletions
+55
View File
@@ -0,0 +1,55 @@
# Gadfly reviewing its OWN PRs — a thin CALLER of the reusable workflow
# (.gitea/workflows/review-reusable.yml), dogfooding the "subscribe" path. The
# reusable holds the image pin, env plumbing, AND the default swarm; this file
# holds only the triggers, the actor gate, secret forwarding, and allow-list.
#
# Advisory only — never blocks a merge. It inherits the default swarm: 3 cloud
# models + Claude Code (sonnet, opus, opus:max), 5-lens suite (claude models run
# one at a time, each with all 5 lenses at once).
name: Adversarial Review (Gadfly)
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number:
description: "PR number to review"
required: true
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: gadfly-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.pr_number }}
cancel-in-progress: true
jobs:
review:
# Security: only trusted users may trigger a secret-bearing run via a PR
# comment (pull_request + workflow_dispatch are already trusted). Mirrors
# the allowed_users input below, the in-container belt-and-suspenders check.
if: >-
github.event_name != 'issue_comment'
|| (github.event.issue.pull_request
&& (github.actor == 'steve'
|| github.actor == 'fizi'
|| github.actor == 'dazed'))
uses: ./.gitea/workflows/review-reusable.yml
# Least privilege: forward ONLY the secrets this swarm uses (cloud + Claude
# Code + findings telemetry), not `secrets: inherit`. GITEA_TOKEN is auto.
secrets:
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }}
GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
with:
# Inherit the default swarm (3 cloud + Claude Code sonnet/opus/opus:max,
# 5-lens suite) from review-reusable.yml. Only the consumer-specific
# allow-list is set here.
allowed_users: "steve,fizi,dazed"
+99 -19
View File
@@ -1,47 +1,127 @@
name: Build & push image name: Build & push image
# Builds the Gadfly reviewer container and pushes it to the Gitea container # Builds the Gadfly reviewer container and pushes it to the Gitea container
# registry. Tag a release (v1, v1.2.0, …) to publish that version + :latest. # registry. Mirrors mort-ci.yml's build-and-push (BuildKit secrets for private
# module access + the LAN --add-host so the builder can reach the registry).
#
# push to main -> :latest + :sha-<short>
# push tag v* -> :<tag> + :latest
# other branch push -> :branch-<safe> + :sha-<short>
# pull_request -> build only (no push), as a sanity check
# #
# Required repo secrets: # Required repo secrets:
# REGISTRY_USER / REGISTRY_PASSWORD Gitea creds with registry push + read # REGISTRY_USER / REGISTRY_PASSWORD Gitea creds with registry push + read
# access to the private majordomo module. # access to the private majordomo module.
# Optional:
# DISCORD_WEBHOOK_URL build notifications (unset => silent).
on: on:
push: push:
branches: [main]
tags: ["v*"] tags: ["v*"]
# Docs/example-only changes don't change the image — skip the rebuild.
# (Path filters are not applied to tag pushes, so `v*` releases always build.)
paths-ignore:
- "**.md"
- "examples/**"
- "LICENSE"
- ".gitignore"
- ".dockerignore"
pull_request:
types: [opened, synchronize, reopened]
paths-ignore:
- "**.md"
- "examples/**"
- "LICENSE"
- ".gitignore"
- ".dockerignore"
workflow_dispatch: {} workflow_dispatch: {}
concurrency:
group: gadfly-image-${{ github.ref }}
cancel-in-progress: true
env: env:
IMAGE: gitea.stevedudenhoeffer.com/steve/gadfly IMAGE_NAME: gitea.stevedudenhoeffer.com/steve/gadfly
jobs: jobs:
image: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Docker Buildx
run: docker buildx create --use --name gadfly-builder --driver docker-container 2>/dev/null || docker buildx use gadfly-builder
- name: Log in to the registry - name: Log in to the registry
run: | if: github.event_name != 'pull_request'
echo "${{ secrets.REGISTRY_PASSWORD }}" \ env:
| docker login gitea.stevedudenhoeffer.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "${REGISTRY_PASSWORD}" | docker login gitea.stevedudenhoeffer.com -u "${REGISTRY_USER}" --password-stdin
- name: Resolve tags - name: Compute tags
id: tags id: meta
run: | run: |
if [ "${{ github.ref_type }}" = "tag" ]; then SHA_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" PUSH=true
if [ "${{ github.event_name }}" = "pull_request" ]; then
# Build-only sanity check; nothing published.
TAGS="${IMAGE_NAME}:pr-${{ github.event.pull_request.number }}"
PUSH=false
elif [ "${{ github.ref_type }}" = "tag" ]; then
TAGS="${IMAGE_NAME}:${GITHUB_REF_NAME},${IMAGE_NAME}:latest"
elif [ "${GITHUB_REF_NAME}" = "main" ]; then
TAGS="${IMAGE_NAME}:latest,${IMAGE_NAME}:sha-${SHA_SHORT}"
else else
echo "version=dev-$(echo ${{ github.sha }} | cut -c1-8)" >> "$GITHUB_OUTPUT" BRANCH_SAFE=$(echo "${GITHUB_REF_NAME}" | sed 's/[^a-zA-Z0-9._-]/-/g; s/--*/-/g; s/^-//; s/-$//')
TAGS="${IMAGE_NAME}:branch-${BRANCH_SAFE},${IMAGE_NAME}:sha-${SHA_SHORT}"
fi fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "push=${PUSH}" >> "$GITHUB_OUTPUT"
echo "Tags: ${TAGS} (push=${PUSH})"
- name: Build & push - name: Notify Discord (started)
if: github.event_name != 'pull_request'
env:
WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: | run: |
docker build \ [ -z "$WEBHOOK_URL" ] && exit 0
--build-arg GIT_USER="${{ secrets.REGISTRY_USER }}" \ MSG="🪰 Gadfly image build #${{ github.run_number }} started on \`${{ github.ref_name }}\` (${{ github.sha }})."
--build-arg GIT_TOKEN="${{ secrets.REGISTRY_PASSWORD }}" \ curl -sS -H 'Content-Type: application/json' -d "{\"content\": \"$MSG\"}" "$WEBHOOK_URL" || true
-t "${IMAGE}:${{ steps.tags.outputs.version }}" \
-t "${IMAGE}:latest" \ - name: Build and push
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
TAG_FLAGS=""
IFS=',' read -ra TAG_ARRAY <<< "${{ steps.meta.outputs.tags }}"
for tag in "${TAG_ARRAY[@]}"; do TAG_FLAGS="$TAG_FLAGS --tag $tag"; done
PUSH_FLAG="--push"
[ "${{ steps.meta.outputs.push }}" = "false" ] && PUSH_FLAG="--output=type=cacheonly"
docker buildx build \
$PUSH_FLAG \
--platform linux/amd64 \
$TAG_FLAGS \
--add-host gitea.stevedudenhoeffer.com:192.168.0.134 \
--secret id=REGISTRY_USER,env=REGISTRY_USER \
--secret id=REGISTRY_PASSWORD,env=REGISTRY_PASSWORD \
--file ./Dockerfile \
. .
docker push "${IMAGE}:${{ steps.tags.outputs.version }}"
docker push "${IMAGE}:latest" - name: Notify Discord (result)
if: always() && github.event_name != 'pull_request'
env:
WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: |
[ -z "$WEBHOOK_URL" ] && exit 0
if [ "${{ job.status }}" = "success" ]; then
MSG="✅ Gadfly image build #${{ github.run_number }} succeeded. Tags: \`${{ steps.meta.outputs.tags }}\`."
else
MSG="❌ Gadfly image build #${{ github.run_number }} failed. Check Actions logs."
fi
curl -sS -H 'Content-Type: application/json' -d "{\"content\": \"$MSG\"}" "$WEBHOOK_URL" || true
+145
View File
@@ -0,0 +1,145 @@
# Gadfly — REUSABLE adversarial-review workflow (Gitea `workflow_call`).
#
# Centralizes the ~90-line consumer stub so a repo can subscribe to Gadfly with
# a tiny caller. A consumer workflow does:
#
# jobs:
# review:
# if: ... # actor gate for the comment trigger
# uses: steve/gadfly/.gitea/workflows/review-reusable.yml@v1
# secrets: # forward ONLY what the reviewer needs
# OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
# CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# with: { allowed_users: "..." } # config inputs are optional (see below)
#
# The swarm config (models, specialists, concurrency) is resolved at RUNTIME from,
# in order: a consumer's `with:` input → the owner's user-scope GADFLY_DEFAULT_*
# variable → the image's built-in default. Because variables are injected per-run
# (not part of this cached file), the owner retunes the whole fleet by editing ONE
# variable — see the inputs block and README "Central config via variables".
# Secrets are DECLARED below (workflow_call.secrets) so a
# caller forwards only the credentials the reviewer actually uses — least
# privilege — rather than `secrets: inherit`, which leaks every caller secret
# (registry/deploy/db creds) into this workflow. `secrets: inherit` still works
# if you accept that exposure; the explicit form is recommended. GITEA_TOKEN is
# the automatic job token (no need to forward it).
#
# Advisory only — never blocks a merge. The image is pinned to an immutable
# :sha- tag here (act_runner caches :latest); bump it per Gadfly release.
# Consumers should pin `uses: ...@v1` — a curated release tag moved on deliberate
# releases, so central tuning here propagates without per-consumer edits — or a
# full `@<sha>` for an immutable pin. Avoid `@main` (moves on every push).
name: Gadfly review (reusable)
on:
workflow_call:
# The swarm config (models, specialists, concurrency) is resolved at RUNTIME,
# in priority order: a consumer's explicit `with:` input → the owner's
# user/org-level variable (GADFLY_DEFAULT_*) → the image's built-in default.
# Variables are injected per-run by Gitea (not baked into this file), so the
# owner can retune the whole fleet by editing ONE variable — it propagates even
# though long-lived act_runners CACHE this workflow file by ref (a moved tag is
# NOT re-fetched; only a runtime value or a fresh @<sha> bypasses the cache).
#
# Owner-set user-scope variables (see README "Central config via variables"):
# GADFLY_DEFAULT_MODELS, GADFLY_DEFAULT_SPECIALISTS,
# GADFLY_DEFAULT_PROVIDER_CONCURRENCY, GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY,
# GADFLY_ENDPOINT_RAGNAROS (the 4090 Ti endpoint).
# An unset variable + no input → the image default (one model, default suite),
# so a public consumer with neither still gets a sane minimal review.
inputs:
models: { type: string, default: "" } # GADFLY_MODELS — empty falls back to user var GADFLY_DEFAULT_MODELS, then the image default
specialists: { type: string, default: "" } # GADFLY_SPECIALISTS — empty falls back to user var GADFLY_DEFAULT_SPECIALISTS
provider: { type: string, default: "" } # GADFLY_PROVIDER
base_url: { type: string, default: "" } # GADFLY_BASE_URL
provider_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_CONCURRENCY — empty falls back to user var GADFLY_DEFAULT_PROVIDER_CONCURRENCY
provider_lens_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_LENS_CONCURRENCY — empty falls back to user var GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY
timeout_secs: { type: string, default: "600" } # GADFLY_TIMEOUT_SECS (per lens)
max_steps: { type: string, default: "14" } # GADFLY_MAX_STEPS
worker_model: { type: string, default: "" } # GADFLY_WORKER_MODEL
allowed_users: { type: string, default: "" } # GADFLY_ALLOWED_USERS (consumer-specific; set in your stub)
trigger_phrase: { type: string, default: "" } # GADFLY_TRIGGER_PHRASE
# Job wall-clock cap. 90 as a default: the 5-lens suite across a slow lane
# (claude-code with extended thinking) over two passes can run long.
timeout_minutes: { type: number, default: 90 }
# Declared so callers can forward ONLY the secrets the reviewer needs
# (least privilege) instead of `secrets: inherit`, which would hand this
# workflow every secret in the caller's repo (registry/deploy/db creds the
# review never touches). All optional — an unset/unpassed secret resolves to
# empty, harmless for the providers a given consumer doesn't use. GITEA_TOKEN
# is the automatic job token and need not be declared/forwarded. Named
# endpoints (GADFLY_ENDPOINT_*) come from user/org VARS now, not secrets.
secrets:
OLLAMA_CLOUD_API_KEY: { required: false }
OPENAI_API_KEY: { required: false }
ANTHROPIC_API_KEY: { required: false }
GOOGLE_API_KEY: { required: false }
GADFLY_API_KEY: { required: false }
CLAUDE_CODE_OAUTH_TOKEN: { required: false }
GADFLY_FINDINGS_URL: { required: false }
GADFLY_FINDINGS_TOKEN: { required: false }
# The reusable job posts the review comment, so it needs issues/PR write. Gitea
# caps these by the caller's granted permissions; declaring them here is explicit.
permissions:
contents: read
issues: write
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout_minutes }}
steps:
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-c342bdb
env:
# --- event context (from the CALLER's github.*) -------------------
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
# github.token is the auto job token from the github CONTEXT (not a
# secret), so it's present even without `secrets: inherit`. Using
# secrets.GITEA_TOKEN here would be empty under explicit secret
# forwarding, since the auto token isn't a forwarded workflow_call secret.
GITEA_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
PR_BRANCH: ${{ github.head_ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
ACTOR: ${{ github.actor }}
# --- provider auth (forwarded workflow_call secrets; empty if the caller doesn't forward it) -
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GADFLY_API_KEY: ${{ secrets.GADFLY_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Named LAN endpoints, defined in user/org vars (format
# "<provider>|<base-url>[|<key>]"). Adding a NEW name still needs a line
# here — a reusable workflow can't enumerate arbitrary vars.GADFLY_ENDPOINT_*.
# NB: vars are NOT masked like secrets — if an endpoint embeds an auth
# token, keep that one a secret instead.
GADFLY_ENDPOINT_M1: ${{ vars.GADFLY_ENDPOINT_M1 }}
GADFLY_ENDPOINT_M5: ${{ vars.GADFLY_ENDPOINT_M5 }}
# ragnaros = the 4090 Ti via its llama-swap proxy. Defined in the user
# var GADFLY_ENDPOINT_RAGNAROS (format "<provider>|<base-url>[|<key>]")
# so the URL can change without editing this file; the matching model is
# ragnaros/qwen3.6-27b in GADFLY_DEFAULT_MODELS. NB: use the un-hyphenated
# `llamaswap` provider spelling in the var — the pinned image needs it.
GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}
# --- findings telemetry (optional) --------------------------------
GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }}
GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
# --- config (from inputs; empty => image default) -----------------
GADFLY_MODELS: ${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }}
GADFLY_SPECIALISTS: ${{ inputs.specialists || vars.GADFLY_DEFAULT_SPECIALISTS }}
GADFLY_PROVIDER: ${{ inputs.provider }}
GADFLY_BASE_URL: ${{ inputs.base_url }}
GADFLY_PROVIDER_CONCURRENCY: ${{ inputs.provider_concurrency || vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY }}
GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ inputs.provider_lens_concurrency || vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY }}
GADFLY_TIMEOUT_SECS: ${{ inputs.timeout_secs }}
GADFLY_MAX_STEPS: ${{ inputs.max_steps }}
GADFLY_WORKER_MODEL: ${{ inputs.worker_model }}
GADFLY_ALLOWED_USERS: ${{ inputs.allowed_users }}
GADFLY_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
+151
View File
@@ -0,0 +1,151 @@
# Gadfly — Developer Guide
Gadfly (🪰) is an **agentic adversarial code reviewer** that runs in Gitea Actions. On a pull
request it reads the *checked-out repository* with read-only tools, hunts for real problems,
verifies each one against the actual code, and posts its findings as a comment. It is
**advisory only** — it never blocks a merge.
> This is a public, **vibe-coded** project (built largely by an AI agent). Keep that framing
> honest in the README; don't oversell it.
## Project goals (keep changes aligned to these)
1. **Find *real* problems, not nits.** The whole point of the agentic tools + two-pass
recheck is to kill diff-only false positives. Anything that raises the false-positive rate
(or removes verification) works against the project.
2. **Advisory, never blocking.** Gadfly must never fail a CI job for review *content*, never
merge, never deploy. Non-zero exit only on usage/config errors; even then run.sh posts a
notice rather than failing. Do not add it to branch-protection required checks.
3. **Easy to turn on for any repo.** Consumers should need only a ~15-line stub workflow + a
couple of secrets/vars. All real logic lives in the image (`entrypoint.sh`), not in the
consumer's YAML (Gitea's act_runner has weak YAML expression support).
4. **Provider-agnostic.** Powered by [majordomo](https://gitea.stevedudenhoeffer.com/steve/majordomo),
so it can target Ollama (local/cloud), OpenAI, Anthropic, Google, or any
OpenAI/Ollama-compatible endpoint. Don't re-hardcode a single provider.
5. **Portable & self-contained.** `cmd/gadfly` depends only on the Go stdlib + majordomo. Keep
it that way — no heavyweight deps, no coupling to any one consumer repo (e.g. mort).
## Architecture
```
cmd/gadfly/ the reviewer binary — pure producer of review markdown (stdout)
main.go orchestration: loop specialists, each a review pass + adversarial recheck
engine.go reviewEngine abstraction: majordomo agent loop vs claude-code CLI shell-out
specialists.go specialist lenses: built-ins, default suite, env + .gadfly.yml resolution
auto.go dynamic `auto` selection: a selector model picks lenses per-diff (may invent)
delegate.go worker-tier delegate_investigation tool (cheap sub-agent does legwork)
consolidate.go verdict parsing + one-comment consolidation (a section per specialist)
model.go provider/model + selector + worker resolution (majordomo.Parse) + endpoint aliases
tools.go the 5 read-only repo tools (read_file/list_dir/grep/find_files/get_diff)
recheck.go second-pass verification prompt + verdict recompute
*_test.go sandbox, recheck, wrap-up, spec/endpoint-parse, specialist-resolution tests
scripts/run.sh fetch PR diff+meta, run the binary, upsert ONE labeled PR comment
scripts/status-board.sh render+upsert ONE live status-board comment (per-model/per-lens progress)
scripts/system-prompt.txt the reviewer persona + verification discipline (generic, not repo-specific)
entrypoint.sh container brains: trigger gating, PR clone, model loop (the logic that
used to live in workflow YAML)
Dockerfile multi-stage; private-module creds via BuildKit secrets never reach the final image
.gitea/workflows/build-image.yml push main → :latest; tag v* → :<tag>+:latest; PR → build-only
.gitea/workflows/review-reusable.yml reusable (workflow_call) review job; resolves swarm config at
RUNTIME: consumer `with:` input → owner user-scope var (GADFLY_DEFAULT_MODELS /
_SPECIALISTS / _PROVIDER_CONCURRENCY / _PROVIDER_LENS_CONCURRENCY, +
GADFLY_ENDPOINT_RAGNAROS) → image default. Vars are injected per-run, so editing
one var retunes the whole fleet even though long-lived act_runners CACHE this file
by ref (a moved tag is NOT re-fetched — only a runtime value or a fresh @<sha>
bypasses the cache). Consumers subscribe with an ~8-line caller forwarding only the
secrets the reviewer needs and pinned to an immutable @<sha> (Phase 4);
gadfly's own adversarial-review.yml is a thin caller of it (dogfoods the path).
examples/ copy-paste consumer stub workflows for different providers
```
**Data flow:** consumer stub workflow → container `entrypoint.sh` (gate + clone) →
`scripts/run.sh` (per model) → `cmd/gadfly` binary (agentic review) → markdown → run.sh
upserts a PR comment as `gitea-actions`.
**Two passes:** a *review* pass drafts findings; an adversarial *recheck* pass independently
re-verifies each finding against the code and drops the unconfirmed ones, recomputing the
verdict. Verdict is one of: `No material issues found` / `Minor issues` / `Blocking issues found`.
## Build / test
```sh
go build ./cmd/gadfly # needs read access to the private majordomo module
go test ./...
gofmt -l cmd/ # must be clean
docker build -t gadfly:dev --secret id=REGISTRY_USER,env=REGISTRY_USER --secret id=REGISTRY_PASSWORD,env=REGISTRY_PASSWORD .
```
Run it locally against a real diff without CI:
```sh
git -C /path/to/repo diff main > /tmp/x.diff
GADFLY_PROVIDER=ollama GADFLY_MODEL=qwen2.5-coder:7b \
GADFLY_REPO_DIR=/path/to/repo GADFLY_DIFF_FILE=/tmp/x.diff \
GADFLY_SYSTEM_FILE=scripts/system-prompt.txt ./gadfly
```
## Release / deploy
- **Push to `main`** → CI builds and pushes `:latest` (+ `:sha-<short>`).
- **Tag `v*`** → publishes `:<tag>` (+ `:latest`). Pin consumers to `:vN` for stability.
- Required CI secrets: `REGISTRY_USER` / `REGISTRY_PASSWORD` (registry push + read access to the
private majordomo module). Optional `DISCORD_WEBHOOK_URL`.
## Configuration
The full env reference lives in the **README** (`Specialists`, `Models & providers`,
`Configuration`). Provider selection: `GADFLY_PROVIDER` (default `ollama-cloud`),
`GADFLY_MODEL`/`GADFLY_MODELS`, `GADFLY_BASE_URL`, `GADFLY_API_KEY`. Named endpoint aliases via
`GADFLY_ENDPOINT_<NAME>` / `GADFLY_ALIAS_<NAME>` (http-capable) and majordomo `LLM_*` DSNs
(HTTPS-only).
**Specialists (the swarm):** the reviewer runs a suite of focused lenses, one consolidated
comment with a section each. Default suite = security/correctness/maintainability/performance/
error-handling; opt-in built-ins = tests/docs/conventions/improvements. Select via
`GADFLY_SPECIALISTS` (csv or `all`); define/override via `GADFLY_SPECIALIST_<NAME>` env or a repo
`.gadfly.yml` (`specialists:` + `define:`). See `cmd/gadfly/specialists.go`. Cost ≈
specialists × models × 2 passes — the **image/entrypoint** default stays minimal (one model) for
that reason; the **reusable workflow** (`review-reusable.yml`) deliberately ships a heavier
opinionated default swarm (3 cloud + Claude Code, 5 lenses) for steve's own fleet, which consumers
inherit or override per-input.
**Dynamic `auto`** (`GADFLY_SPECIALISTS=auto`): a selector (`GADFLY_SELECTOR_MODEL` or the review
model) picks lenses per-diff and may invent ad-hoc ones (`cmd/gadfly/auto.go`). **Worker-tier**
(`GADFLY_WORKER_MODEL`): a `delegate_investigation` tool offloads grep/read legwork to a cheap
sub-agent (`cmd/gadfly/delegate.go`).
**Tested vs untested:** only the Ollama paths (local + OpenAI-compatible pointed at Ollama)
are actually exercised. OpenAI/Anthropic/Google come from majordomo's abstraction and are
**untested** (no spend). Keep the README honest about this; update it if that changes.
## When making changes — maintenance rules
- **Keep the README and `examples/` current.** Any change to env vars, flags, defaults,
triggers, provider support, or the consumer stub MUST be reflected in `README.md` and the
relevant files under `examples/` in the *same* change. The README's `Configuration` table,
the `Models & providers` table, and the example workflows are the contract users rely on —
stale docs are a bug.
- **Preserve the advisory-only invariant** (goal #2). If you touch exit codes or the workflow,
re-confirm a review can never fail/block a consumer's CI.
- **Don't add mort-specific (or any single-consumer) assumptions** to the binary or system
prompt. The system prompt is intentionally generic; repo-specific conventions should be
discovered by the agent at runtime (it can read the repo's own CONTRIBUTING/CLAUDE.md), not
hardcoded here.
- **Keep secrets out of image layers.** Private-module creds flow via BuildKit `--mount=type=secret`
in the build stage only; never bake them into the final image or commit them.
- Add a test when you add logic (see the `*_test.go` patterns). Keep `gofmt` clean and `go vet` quiet.
## Lessons
- majordomo's `LLM_*` env DSNs are **HTTPS-only** (`DSN.BaseURL()` forces `https://`), so they
can't express a plaintext local Ollama. That's why Gadfly adds the http-capable
`GADFLY_ENDPOINT_<NAME>="provider|base-url[|key]"` mechanism (see `cmd/gadfly/model.go`).
- Gitea `vars`/`secrets` are **not** auto-exposed as env in a job — the consumer stub must map
each one explicitly in its `env:` block (dynamic alias names can't be auto-enumerated).
- **`uses: docker://…:latest` is CACHED by act_runner** — a freshly-pushed `:latest` is often
NOT re-pulled, so the job silently runs the previous image. For a run that must use a specific
build (e.g. validating a just-pushed fix), pin the consumer stub to the immutable
`:sha-<short>` tag the build publishes, not `:latest`.
- **Concurrency is per-provider** (`entrypoint.sh`): each provider is a lane, lanes run in
parallel, `cap` (from `GADFLY_PROVIDER_CONCURRENCY` else `GADFLY_CONCURRENCY`, default 1) bounds
models-at-once within a lane. The review timeout (`GADFLY_TIMEOUT_SECS`) is **per-lens**, not
shared across the suite — a slow model can't starve later lenses (the original timeout bug).
+20 -13
View File
@@ -1,30 +1,37 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# #
# Multi-stage so the private-module access token used to fetch the majordomo # Multi-stage so the private-module credentials (used to fetch the majordomo
# dependency lives ONLY in the build stage and never lands in the final image. # dependency) live ONLY in the build stage via BuildKit secrets and never land
# in the final image. Mirrors mort's Dockerfile secret idiom.
FROM golang:1.26 AS build FROM golang:1.26 AS build
ARG GIT_HOST=gitea.stevedudenhoeffer.com ARG GIT_HOST=gitea.stevedudenhoeffer.com
ARG GIT_USER=
ARG GIT_TOKEN=
ENV CGO_ENABLED=0 \ ENV CGO_ENABLED=0 \
GOFLAGS=-mod=mod \ GOFLAGS=-mod=mod \
GOSUMDB=off GOSUMDB=off \
GOTOOLCHAIN=auto
ENV GOPRIVATE=${GIT_HOST}/* GONOSUMDB=${GIT_HOST}/* ENV GOPRIVATE=${GIT_HOST}/* GONOSUMDB=${GIT_HOST}/*
WORKDIR /src WORKDIR /src
# Private Go module access (majordomo). Token is confined to this stage.
RUN if [ -n "$GIT_TOKEN" ]; then \
git config --global url."https://${GIT_USER}:${GIT_TOKEN}@${GIT_HOST}/".insteadOf "https://${GIT_HOST}/"; \
fi
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN --mount=type=secret,id=REGISTRY_USER \
--mount=type=secret,id=REGISTRY_PASSWORD \
--mount=type=cache,target=/go/pkg/mod \
git config --global url."https://$(cat /run/secrets/REGISTRY_USER):$(cat /run/secrets/REGISTRY_PASSWORD)@${GIT_HOST}/".insteadOf "https://${GIT_HOST}/" \
&& go mod download
COPY . . COPY . .
RUN go build -trimpath -ldflags="-s -w" -o /out/gadfly ./cmd/gadfly RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags="-s -w" -o /out/gadfly ./cmd/gadfly
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache bash git curl jq ca-certificates RUN apk add --no-cache bash git curl jq ca-certificates nodejs npm
# Bundle the Claude Code CLI so the `claude-code` review engine works out of the
# box (GADFLY_MODELS=claude-code or claude-code/<model>). This adds Node + the
# CLI to the image (notably larger); ollama-only users pay the size but nothing
# else. Auth is provided at runtime via CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY.
RUN npm install -g @anthropic-ai/claude-code && npm cache clean --force
COPY --from=build /out/gadfly /usr/local/bin/gadfly COPY --from=build /out/gadfly /usr/local/bin/gadfly
COPY scripts /app/scripts COPY scripts /app/scripts
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh /app/scripts/run.sh /usr/local/bin/gadfly RUN chmod +x /entrypoint.sh /app/scripts/run.sh /app/scripts/status-board.sh /usr/local/bin/gadfly
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+310 -10
View File
@@ -36,11 +36,18 @@ or **Blocking issues found**.
Gadfly ships as a container image, so consuming repos don't build anything — they just run 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: it. Drop one file in your repo and set a couple of secrets/vars:
1. Copy [`examples/adversarial-review.yml`](examples/adversarial-review.yml) to 1. Copy a stub from [`examples/`](examples/) to `.gitea/workflows/adversarial-review.yml` in
`.gitea/workflows/adversarial-review.yml` in your repo. 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: 2. Add repo config:
- **secret** `OLLAMA_CLOUD_API_KEY` — your [Ollama Cloud](https://ollama.com) key (empty - **secret** `OLLAMA_CLOUD_API_KEY` — your [Ollama Cloud](https://ollama.com) key (empty
⇒ Gadfly posts a harmless "not configured" notice instead of reviewing). ⇒ 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 - **var** `OLLAMA_REVIEW_MODELS` *(optional)* — comma-separated model ids
(default `qwen3-coder:480b-cloud,gpt-oss:120b-cloud`). One comment per model. (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 ⇒ - **var** `GADFLY_ALLOWED_USERS` *(optional)* — who may re-trigger via comment; empty ⇒
@@ -49,6 +56,227 @@ it. Drop one file in your repo and set a couple of secrets/vars:
`GITEA_TOKEN` is provided automatically by Actions; comments post as the `gitea-actions` `GITEA_TOKEN` is provided automatically by Actions; comments post as the `gitea-actions`
user, scoped to that repo — no bot account needed. 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** |
| **Anthropic** | `anthropic` | `ANTHROPIC_API_KEY` | ⚠️ wired, **untested** |
| **Google (Gemini)** | `google` | `GOOGLE_API_KEY` / `GEMINI_API_KEY` | ⚠️ wired, **untested** |
> ### 🧪 Honest status
> Only the **Ollama** paths above are actually exercised. The OpenAI / 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).
### 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 12 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. Within a lane, at most `cap` models run at
once — `cap` comes from `GADFLY_PROVIDER_CONCURRENCY` (a `provider=N` map) else `GADFLY_CONCURRENCY`
(default `1`). The timeout is **per-lens** (`GADFLY_TIMEOUT_SECS`), so a slow model on one lens
can't starve the others.
```yaml
# One local box (serial — it serves one model at a time) + 3 cloud reviews at once,
# both lanes running concurrently:
GADFLY_PROVIDER_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. Default (`cap 1`) keeps a single-provider pool fully sequential.
**Lens fan-out (within a model).** By default the specialist lenses run **sequentially** inside
each model (`GADFLY_LENS_CONCURRENCY=1`). Raise it to overlap the independent per-lens
review+recheck passes — the model then posts its consolidated comment as soon as its lenses
finish (so with sequential models, results stream in per model and per-model timings stay
clean). Like the model cap, it's **per-provider configurable**: `GADFLY_PROVIDER_LENS_CONCURRENCY`
takes a `provider=N` map keyed by the **same provider lanes** as `GADFLY_PROVIDER_CONCURRENCY`,
falling back to the `GADFLY_LENS_CONCURRENCY` scalar (default `1`). **It multiplies with the
model cap:** total in-flight requests ≈ *models-at-once × lenses-at-once*, so to fan lenses out
without oversubscribing a backend, keep its model cap low and raise its lens cap:
```yaml
# Per provider: cloud runs one model at a time but fans its 3 lenses out (3 concurrent requests);
# the slow local box stays fully serial. Both provider lanes still run in parallel.
GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=1,m1=1"
GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1=1"
GADFLY_SPECIALISTS: "security,correctness,error-handling"
```
### 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`.
### Triggers ### Triggers
1. A **new/reopened/ready** non-draft PR — automatic. 1. A **new/reopened/ready** non-draft PR — automatic.
@@ -58,20 +286,55 @@ user, scoped to that repo — no bot account needed.
(Pushing new commits does *not* auto-re-review — comment `@gadfly review` after pushing (Pushing new commits does *not* auto-re-review — comment `@gadfly review` after pushing
fixes. This keeps usage down.) 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 ## How it's packaged
``` ```
cmd/gadfly/ the agentic reviewer binary (majordomo + Ollama Cloud); zero deps beyond stdlib + majordomo 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/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 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) entrypoint.sh the container brains: trigger gating, clone, model loop (logic lives here, not in YAML)
Dockerfile multi-stage; the build-time module token never reaches the final image Dockerfile multi-stage; build-time module creds (BuildKit secrets) never reach the final image
.gitea/workflows/build-image.yml tags v* → build & push the image .gitea/workflows/build-image.yml push to main → :latest; tag v* → :<tag> + :latest
examples/ the ~15-line stub a consuming repo drops in examples/ the ~15-line stub a consuming repo drops in
``` ```
The image is published to `gitea.stevedudenhoeffer.com/steve/gadfly`. Push a `v*` tag to The image is published to `gitea.stevedudenhoeffer.com/steve/gadfly`. Every push to `main`
build and publish a new version (and `:latest`). 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_CONCURRENCY` | models-at-once per provider |
| `GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY` | lenses-at-once per provider |
| `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) ## Configuration (advanced)
@@ -79,15 +342,52 @@ The reviewer binary reads these (the stub/entrypoint set sane defaults):
| Env | Default | Meaning | | Env | Default | Meaning |
|-----|---------|---------| |-----|---------|---------|
| `OLLAMA_API_KEY` | — | Ollama Cloud bearer key (required for real reviews) | | `GADFLY_MODEL` | — | model id, or `provider/model` spec, or majordomo alias/chain |
| `GADFLY_MODEL` | — | model id | | `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 |
| `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_CONCURRENCY` | 1 | default max models run at once **per provider** |
| `GADFLY_PROVIDER_CONCURRENCY` | — | per-provider overrides, e.g. `ollama-cloud=3,m1pro=1` |
| `GADFLY_LENS_CONCURRENCY` | 1 | specialist lenses run at once **within a model** (× model cap = total in-flight) |
| `GADFLY_PROVIDER_LENS_CONCURRENCY` | — | per-provider lens overrides, same lanes as `GADFLY_PROVIDER_CONCURRENCY`, e.g. `ollama-cloud=3,m1=1` |
| `GADFLY_MAX_STEPS` | 24 | review-pass tool-step cap | | `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` | on | set `0`/`false` to skip the recheck pass |
| `GADFLY_RECHECK_MAX_STEPS` | 16 | recheck-pass step cap | | `GADFLY_RECHECK_MAX_STEPS` | 16 | recheck-pass step cap |
| `GADFLY_TIMEOUT_SECS` | 300 | overall deadline (both passes) |
| `GADFLY_MAX_DIFF_CHARS` | 60000 | diff chars embedded in the prompt (full diff via `get_diff`) | | `GADFLY_MAX_DIFF_CHARS` | 60000 | diff chars embedded in the prompt (full diff via `get_diff`) |
| `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_TRIGGER_PHRASE` | `@gadfly review` | comment phrase that re-triggers | | `GADFLY_TRIGGER_PHRASE` | `@gadfly review` | comment phrase that re-triggers |
| `GADFLY_ALLOWED_USERS` | *(collaborators)* | comma-separated allow-list for comment 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`) |
## 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 ## Building locally
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// maxAutoSpecialists bounds how many lenses dynamic selection may run, so a
// runaway selector can't blow up cost.
const maxAutoSpecialists = 6
// autoCustom is an ad-hoc specialist the selector may propose when the change
// needs a lens the catalog lacks (e.g. DB migrations, i18n).
type autoCustom struct {
Name string `json:"name" description:"short lowercase id, e.g. migrations"`
Title string `json:"title" description:"human label for the section, e.g. 🗃️ DB migrations"`
Focus string `json:"focus" description:"one or two sentences telling the reviewer what to look for"`
}
// autoSelection is the structured result the selector returns.
type autoSelection struct {
Specialists []string `json:"specialists" description:"names of EXISTING catalog specialists that materially apply to this change"`
Custom []autoCustom `json:"custom" description:"ad-hoc specialists to add ONLY when the catalog lacks a clearly-needed lens; usually empty"`
}
// resolveSelectorModel returns the model that picks specialists in auto mode:
// GADFLY_SELECTOR_MODEL (a cheap tier is ideal) if set, else the review model.
func resolveSelectorModel(fallback llm.Model) (llm.Model, error) {
spec := strings.TrimSpace(os.Getenv("GADFLY_SELECTOR_MODEL"))
if spec == "" {
return fallback, nil
}
provider := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER"))
if provider == "" {
provider = defaultProvider
}
return majordomo.Parse(buildSpec(provider, spec))
}
// autoSelectSpecialists asks the selector model which lenses a given diff needs.
// It picks from the registry and may invent a few ad-hoc lenses, capped and
// de-duplicated. On any failure it returns an error and the caller falls back
// to the default suite.
func autoSelectSpecialists(ctx context.Context, selector llm.Model, title, body, diff string, registry map[string]Specialist) ([]Specialist, error) {
system := "You are the router for an adversarial code review. Given a pull request's " +
"changed files and description plus a catalog of available review specialists (lenses), " +
"choose the SMALLEST set that materially applies to THIS change — usually 2 to 4. Skip " +
"lenses the diff doesn't touch (e.g. no 'performance' for a docs-only change). Strongly " +
"prefer existing catalog lenses; only propose a custom one when the change clearly needs a " +
"lens the catalog lacks (e.g. database migrations, i18n, build/CI). Be conservative."
user := fmt.Sprintf("PR title: %s\n\nPR description:\n%s\n\nChanged files:\n%s\n\nAvailable specialists (name — focus):\n%s\n\nSelect the lenses to run.",
strings.TrimSpace(title), strings.TrimSpace(body), changedFilesList(diff), catalog(registry))
sel, err := majordomo.Generate[autoSelection](ctx, selector, majordomo.Request{
System: system,
Messages: []llm.Message{llm.UserText(user)},
})
if err != nil {
return nil, fmt.Errorf("auto-select: %w", err)
}
var out []Specialist
seen := map[string]bool{}
add := func(sp Specialist) {
if sp.Name == "" || seen[sp.Name] || len(out) >= maxAutoSpecialists {
return
}
seen[sp.Name] = true
out = append(out, sp)
}
for _, name := range sel.Specialists {
if sp, ok := registry[strings.ToLower(strings.TrimSpace(name))]; ok {
add(sp)
}
}
for _, c := range sel.Custom {
name := strings.ToLower(strings.TrimSpace(c.Name))
if name == "" || strings.TrimSpace(c.Focus) == "" {
continue
}
if sp, ok := registry[name]; ok { // prefer the catalog definition if it exists
add(sp)
continue
}
add(Specialist{Name: name, Title: titleOr(c.Title, name), Focus: c.Focus})
}
if len(out) == 0 {
return nil, fmt.Errorf("auto-select returned no usable specialists")
}
return out, nil
}
// catalog renders the registry as a compact name — focus list for the selector.
func catalog(registry map[string]Specialist) string {
names := make([]string, 0, len(registry))
for k := range registry {
names = append(names, k)
}
sort.Strings(names)
var b strings.Builder
for _, n := range names {
fmt.Fprintf(&b, "- %s — %s\n", n, firstSentence(registry[n].Focus))
}
return b.String()
}
// changedFilesList extracts the changed file paths from a unified diff.
func changedFilesList(diff string) string {
var files []string
seen := map[string]bool{}
for _, line := range strings.Split(diff, "\n") {
if !strings.HasPrefix(line, "+++ ") && !strings.HasPrefix(line, "--- ") {
continue
}
p := strings.TrimSpace(line[4:])
if p == "/dev/null" {
continue
}
p = strings.TrimPrefix(strings.TrimPrefix(p, "a/"), "b/")
if p != "" && !seen[p] {
seen[p] = true
files = append(files, p)
}
}
if len(files) == 0 {
return "(could not parse file list; review the whole diff)"
}
return "- " + strings.Join(files, "\n- ")
}
func firstSentence(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '.'); i > 0 && i < 140 {
return s[:i]
}
if len(s) > 140 {
return s[:140] + "…"
}
return s
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"fmt"
"strings"
)
// verdict is the severity a specialist (or the whole review) lands on.
type verdict int
const (
verdictUnknown verdict = iota // couldn't parse; treated below Minor for display, not blocking
verdictClean // "No material issues found"
verdictMinor // "Minor issues"
verdictBlocking // "Blocking issues found"
)
func (v verdict) label() string {
switch v {
case verdictClean:
return "No material issues found"
case verdictMinor:
return "Minor issues"
case verdictBlocking:
return "Blocking issues found"
default:
return "Reviewed"
}
}
// parseVerdict extracts a specialist's self-reported verdict from its output.
// The base prompt tells each lens to lead with one of the three phrases.
func parseVerdict(out string) verdict {
l := strings.ToLower(out)
// Match the EARLIEST-appearing verdict phrase (the lead verdict), and match
// leniently — models write "Blocking issues found", "**Blocking issues**",
// "blocking issue", etc. (A strict "blocking issues found" check let a
// gpt-oss section that said "**Blocking issues**" fall through to unknown,
// so the overall header wrongly read "No material issues found".)
best, bestIdx := verdictUnknown, -1
for _, c := range []struct {
phrase string
v verdict
}{
{"blocking issue", verdictBlocking},
{"minor issue", verdictMinor},
{"no material issue", verdictClean},
} {
if i := strings.Index(l, c.phrase); i >= 0 && (bestIdx == -1 || i < bestIdx) {
best, bestIdx = c.v, i
}
}
return best
}
// specialistResult pairs a specialist with its rendered review and verdict.
type specialistResult struct {
spec Specialist
out string
verdict verdict
errored bool // the review pass failed (timeout/model error) — not a clean result
}
// worstVerdict returns the most severe verdict across results. The optional
// "improvements" lens never escalates the overall verdict (it's advisory).
func worstVerdict(results []specialistResult) verdict {
worst := verdictClean
for _, r := range results {
if r.spec.Name == "improvements" {
continue
}
if r.verdict > worst {
worst = r.verdict
}
}
return worst
}
// renderConsolidated assembles the single comment body: an overall verdict line
// followed by one verbatim section per specialist. run.sh wraps this with the
// "🪰 Gadfly review — <model>" header and the advisory footer.
func renderConsolidated(results []specialistResult) string {
errored := 0
for _, r := range results {
if r.errored {
errored++
}
}
headline := "Verdict: " + worstVerdict(results).label()
if len(results) > 0 && errored == len(results) {
// Every lens errored — do NOT report this as "clean".
headline = "Review incomplete — all lenses errored"
} else if errored > 0 {
headline += fmt.Sprintf(" · ⚠️ %d/%d lens(es) errored", errored, len(results))
}
var b strings.Builder
fmt.Fprintf(&b, "**%s** — %d reviewers: %s\n",
headline, len(results), strings.Join(specialistNames(results), ", "))
for _, r := range results {
body := strings.TrimSpace(r.out)
if body == "" {
body = "_(no output)_"
}
summary := r.verdict.label()
if r.errored {
summary = "⚠️ could not complete"
}
fmt.Fprintf(&b, "\n<details><summary><b>%s</b> — %s</summary>\n\n%s\n\n</details>\n",
r.spec.Title, summary, body)
}
return strings.TrimRight(b.String(), "\n")
}
func specialistNames(results []specialistResult) []string {
names := make([]string, 0, len(results))
for _, r := range results {
names = append(names, r.spec.Name)
}
return names
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"context"
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// defaultWorkerMaxSteps bounds a delegated worker run.
const defaultWorkerMaxSteps = 8
// workerSystemPrompt drives a delegated investigation sub-agent. It does the
// grunt work (grep/read) and returns a tight, evidence-cited digest so the
// top reviewer reasons over a summary instead of raw file dumps.
const workerSystemPrompt = "You are a fast code investigator. Use the read-only tools " +
"(read_file, grep, find_files, list_dir, get_diff) to answer the SPECIFIC question you are " +
"given about this repository — nothing more. Be precise and concise: report concrete findings " +
"with `path:line` evidence, quote only the few lines that matter, and state plainly when " +
"something is absent. Do NOT speculate or pad. When you have the answer, stop and report it."
type delegateArgs struct {
Task string `json:"task" description:"A specific investigation to carry out by reading/grepping the repo, e.g. 'find every caller of parseAmount and list which ones don't check the error'. Ask for a concrete answer, not an opinion."`
}
// delegateTool lets the reviewer offload mechanical investigation to a (cheaper)
// worker model. Present only when GADFLY_WORKER_MODEL is configured.
func (r *repoFS) delegateTool() llm.Tool {
return llm.DefineTool[delegateArgs](
"delegate_investigation",
"Delegate focused legwork to a fast worker agent that reads/greps this repo and reports back "+
"a concise, evidence-cited digest. Use it to offload mechanical investigation (tracing all "+
"callers, gathering every usage of a symbol, checking a pattern across many files) so you can "+
"reason over the summary instead of pulling raw files into your own context.",
func(ctx context.Context, args delegateArgs) (any, error) {
task := strings.TrimSpace(args.Task)
if task == "" {
return nil, fmt.Errorf("task is required")
}
box, err := r.workerToolbox()
if err != nil {
return nil, err
}
w := agent.New(r.worker, workerSystemPrompt,
agent.WithToolbox(box),
agent.WithMaxSteps(envInt("GADFLY_WORKER_MAX_STEPS", defaultWorkerMaxSteps)),
agent.WithToolErrorLimits(3, 3),
)
res, runErr := w.Run(ctx, task)
out := ""
if res != nil {
out = strings.TrimSpace(res.Output)
}
if out == "" {
if runErr != nil {
return nil, fmt.Errorf("worker investigation failed: %w", runErr)
}
return "worker returned no findings", nil
}
return out, nil
},
)
}
+394
View File
@@ -0,0 +1,394 @@
package main
// Optional findings telemetry. After a review, Gadfly can POST the run and the
// findings it surfaced to a "gadfly-reports" store (gitea.stevedudenhoeffer.com-style
// HTTP API) so model quality can be tracked over time and graded later.
//
// This is strictly ADVISORY plumbing and must never affect the review itself:
// - It is OFF unless GADFLY_FINDINGS_URL is set.
// - It writes ONLY to stderr on any error; it never touches stdout (stdout is
// the review markdown consumed by run.sh) and never changes the exit code.
// - It depends only on the Go stdlib (net/http).
//
// Findings are extracted heuristically from each lens's markdown: a "path:line"
// reference (e.g. run/executor.go:166) anchors a finding, whose title is the
// nearest preceding markdown heading / numbered item / bold lead-in (else the
// first sentence of the finding's paragraph). This is best-effort signal for the
// store to aggregate — not a structured contract the reviewer guarantees.
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
)
const (
// findingsHTTPTimeout bounds each telemetry POST. Short on purpose — a slow
// or dead store must never delay the review's stdout (already printed) or the
// process exit.
findingsHTTPTimeout = 10 * time.Second
// maxFindingsPerLens caps how many findings a single lens contributes, so a
// pathological lens output can't blow up the /reports payload.
maxFindingsPerLens = 20
)
// pathLineRe matches a source-path-with-extension followed by ":<line>", e.g.
// run/executor.go:166 or compact/compactor.go:225. The path may contain
// directories, dots, dashes and underscores; backtracking lets the final ".ext"
// be split off the greedy path run. Surrounding backticks/parens are ignored
// because they are not in the character class.
var pathLineRe = regexp.MustCompile(`([A-Za-z0-9_./-]+\.[A-Za-z0-9]+):(\d+)`)
// numberedRe matches a numbered list/heading lead-in: "1. Title" or "1) Title".
var numberedRe = regexp.MustCompile(`^\d+[.)]\s+(.+)$`)
// finding is one extracted issue: where it points and a derived human title.
type finding struct {
file string
line int
title string
detail string
}
// runPayload is the POST /runs body. Field names match the gadfly-reports API EXACTLY.
// Token/cost fields are pointers so they marshal to JSON null when unknown
// (Gadfly does not currently meter tokens or cost).
type runPayload struct {
RunID string `json:"run_id"`
Repo string `json:"repo"`
PR int `json:"pr"`
Model string `json:"model"`
Provider string `json:"provider"`
Lenses int `json:"lenses"`
DurationSecs float64 `json:"duration_secs"`
InputTokens *int `json:"input_tokens"`
OutputTokens *int `json:"output_tokens"`
CostUSD *float64 `json:"cost_usd"`
}
// reportPayload is one element of the POST /reports JSON array. Field names match
// the gadfly-reports API EXACTLY.
type reportPayload struct {
Repo string `json:"repo"`
PR int `json:"pr"`
Lens string `json:"lens"`
File string `json:"file"`
Line int `json:"line"`
Title string `json:"title"`
Model string `json:"model"`
Provider string `json:"provider"`
RunID string `json:"run_id"`
RawSeverity string `json:"raw_severity"`
Detail string `json:"detail"`
}
// emit posts the run and its findings to the gadfly-reports store. It is a no-op unless
// GADFLY_FINDINGS_URL is set. Best-effort: any error is logged to stderr and
// otherwise ignored. It NEVER returns an error, never calls os.Exit, and never
// writes to stdout.
func emit(results []specialistResult, elapsed time.Duration) {
base := strings.TrimRight(strings.TrimSpace(os.Getenv("GADFLY_FINDINGS_URL")), "/")
if base == "" {
return // telemetry disabled
}
repo := strings.TrimSpace(os.Getenv("GADFLY_REPO"))
pr := envAtoi("GADFLY_PR")
model := strings.TrimSpace(os.Getenv("GADFLY_MODEL"))
provider := modelProvider()
runID := fmt.Sprintf("%s#%d:%s", repo, pr, model)
run := runPayload{
RunID: runID,
Repo: repo,
PR: pr,
Model: model,
Provider: provider,
Lenses: len(results),
DurationSecs: elapsed.Seconds(),
// InputTokens/OutputTokens/CostUSD stay nil -> JSON null (not metered).
}
var reports []reportPayload
for _, r := range results {
if r.errored {
continue // a failed lens contributes no findings
}
// A lens that reports "No material issues found" has nothing to flag —
// its path:line references are verification notes ("verified X at
// file:line is safe"), not problems. Extracting them pollutes the
// findings store with false positives and unfairly penalizes thorough
// reviewers that do clean passes, so a clean lens emits no findings.
if r.verdict == verdictClean {
continue
}
sev := r.verdict.label()
for _, f := range parseFindings(r.spec, r.out) {
reports = append(reports, reportPayload{
Repo: repo,
PR: pr,
Lens: r.spec.Name,
File: f.file,
Line: f.line,
Title: f.title,
Model: model,
Provider: provider,
RunID: runID,
RawSeverity: sev,
Detail: f.detail,
})
}
}
client := &http.Client{Timeout: findingsHTTPTimeout}
token := strings.TrimSpace(os.Getenv("GADFLY_FINDINGS_TOKEN"))
if err := postJSON(client, base+"/runs", token, run); err != nil {
fmt.Fprintln(os.Stderr, "gadfly: findings emit (/runs) failed:", err)
}
if len(reports) > 0 {
if err := postJSON(client, base+"/reports", token, reports); err != nil {
fmt.Fprintln(os.Stderr, "gadfly: findings emit (/reports) failed:", err)
}
}
}
// parseFindings extracts findings from one lens's markdown output. A lens whose
// result errored (its output is the inline failure notice) yields nothing.
// Findings are anchored on "path:line" references, deduped by (file,line) keeping
// the first occurrence, and capped at maxFindingsPerLens.
func parseFindings(sp Specialist, out string) []finding {
_ = sp // signature kept symmetric with the caller; lens identity isn't needed here
trimmed := strings.TrimSpace(out)
if trimmed == "" || strings.HasPrefix(trimmed, "⚠️") {
return nil // empty or a "reviewer failed" notice
}
lines := strings.Split(out, "\n")
var findings []finding
seen := map[string]bool{}
for _, loc := range pathLineRe.FindAllStringSubmatchIndex(out, -1) {
file := out[loc[2]:loc[3]]
lineStr := out[loc[4]:loc[5]]
ln, err := strconv.Atoi(lineStr)
if err != nil {
continue
}
key := file + ":" + lineStr
if seen[key] {
continue
}
seen[key] = true
li := strings.Count(out[:loc[0]], "\n") // line index of the match
findings = append(findings, finding{
file: file,
line: ln,
title: deriveTitle(lines, li),
detail: paragraphAt(lines, li),
})
if len(findings) >= maxFindingsPerLens {
break
}
}
return findings
}
// deriveTitle finds the nearest preceding markdown heading / numbered item /
// bold lead-in at or above line li; failing that, the first sentence of the
// paragraph containing li.
func deriveTitle(lines []string, li int) string {
for i := li; i >= 0 && i < len(lines); i-- {
if t, ok := headingTitle(lines[i]); ok {
return t
}
}
return paragraphTitle(lines, li)
}
// headingTitle returns the title carried by a markdown heading-like line:
// an ATX "#"/"##"/"###" heading, a numbered "1." lead-in, or a "**bold**"
// lead-in (optionally behind a list bullet). ok is false for ordinary lines.
func headingTitle(line string) (string, bool) {
t := strings.TrimSpace(line)
if t == "" {
return "", false
}
if strings.HasPrefix(t, "#") {
h := strings.TrimSpace(strings.TrimLeft(t, "#"))
if h != "" {
return cleanHeading(h), true
}
return "", false
}
if m := numberedRe.FindStringSubmatch(t); m != nil {
if rest := strings.TrimSpace(m[1]); rest != "" {
return cleanHeading(rest), true
}
return "", false
}
// Bold lead-in, possibly behind a "- "/"* "/"+ " bullet. Strip only the
// bullet marker (NOT the bold asterisks) before testing for "**".
b := t
for _, pfx := range []string{"- ", "* ", "+ "} {
if strings.HasPrefix(b, pfx) {
b = strings.TrimSpace(b[len(pfx):])
break
}
}
if strings.HasPrefix(b, "**") {
if title := firstBold(b); title != "" {
return title, true
}
}
return "", false
}
// cleanHeading normalizes an extracted heading into a concise title: prefer a
// leading "**bold**" span, otherwise trim a trailing colon and truncate.
func cleanHeading(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "**") {
if b := firstBold(s); b != "" {
return b
}
}
s = strings.TrimRight(s, ": ")
return truncate(s, 120)
}
// paragraphTitle falls back to the first sentence/line of the paragraph (the
// contiguous block of non-blank lines) containing line li, stripped of markdown
// list/emphasis markers.
func paragraphTitle(lines []string, li int) string {
if li < 0 || li >= len(lines) {
return ""
}
start := li
for start > 0 && strings.TrimSpace(lines[start-1]) != "" {
start--
}
first := leadingSentence(stripMarkers(lines[start]))
return truncate(strings.TrimSpace(first), 120)
}
// leadingSentence returns text up to the first sentence terminator that is
// followed by a space (". " / "! " / "? "), so a dot inside a filename like
// "util.go" does not prematurely cut the sentence. With no internal terminator
// it returns the line minus any trailing terminal punctuation. (auto.go's
// firstSentence splits on any ".", which would mis-cut a "path:line" sentence.)
func leadingSentence(s string) string {
best := -1
for _, sep := range []string{". ", "! ", "? "} {
if i := strings.Index(s, sep); i >= 0 && (best == -1 || i < best) {
best = i
}
}
if best >= 0 {
return s[:best]
}
return strings.TrimRight(s, ".!?")
}
// paragraphAt returns the paragraph (contiguous non-blank lines) containing li,
// joined onto one line and truncated — used as the finding's detail context.
func paragraphAt(lines []string, li int) string {
if li < 0 || li >= len(lines) {
return ""
}
start, end := li, li
for start > 0 && strings.TrimSpace(lines[start-1]) != "" {
start--
}
for end < len(lines)-1 && strings.TrimSpace(lines[end+1]) != "" {
end++
}
var parts []string
for i := start; i <= end; i++ {
if t := strings.TrimSpace(lines[i]); t != "" {
parts = append(parts, t)
}
}
return truncate(strings.Join(parts, " "), 500)
}
// stripMarkers removes a leading list bullet / numbered marker and surrounding
// emphasis/backticks from a line, leaving plain text.
func stripMarkers(s string) string {
s = strings.TrimSpace(s)
for _, pfx := range []string{"- ", "* ", "+ "} {
if strings.HasPrefix(s, pfx) {
s = strings.TrimSpace(s[len(pfx):])
break
}
}
if m := numberedRe.FindStringSubmatch(s); m != nil {
s = strings.TrimSpace(m[1])
}
return strings.Trim(s, "*`# ")
}
// firstBold returns the text inside the first "**...**" span of s, or "".
func firstBold(s string) string {
i := strings.Index(s, "**")
if i < 0 {
return ""
}
rest := s[i+2:]
j := strings.Index(rest, "**")
if j < 0 {
return ""
}
return strings.TrimSpace(rest[:j])
}
// truncate shortens s to at most n runes, appending an ellipsis when cut.
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimSpace(string(r[:n])) + "…"
}
// postJSON marshals payload and POSTs it as application/json, attaching a Bearer
// token when non-empty. A non-2xx response is an error. The body is drained and
// closed so the connection can be reused.
func postJSON(client *http.Client, url, token string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s: status %d", url, resp.StatusCode)
}
return nil
}
// envAtoi reads an integer env var, returning 0 when unset or unparseable.
func envAtoi(name string) int {
n, _ := strconv.Atoi(strings.TrimSpace(os.Getenv(name)))
return n
}
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// sampleLensMarkdown is a realistic lens output: a bold verdict lead-in, an ATX
// "###" heading anchoring one finding, a numbered + bold lead-in anchoring
// another, plus a duplicate "path:line" reference that must be deduped away.
const sampleLensMarkdown = "" +
"**Blocking issues found**\n" +
"\n" +
"### Swallowed error\n" +
"In `run/executor.go:166` the returned error is ignored, so a failed step is\n" +
"reported as success.\n" +
"\n" +
"1. **Missing nil check** — `compact/compactor.go:225` dereferences `cfg`\n" +
" which can be nil when the caller passes an empty config.\n" +
"\n" +
"See again run/executor.go:166 for the same root cause.\n"
func TestParseFindings(t *testing.T) {
got := parseFindings(Specialist{Name: "security"}, sampleLensMarkdown)
if len(got) != 2 {
t.Fatalf("parseFindings returned %d findings, want 2 (deduped): %+v", len(got), got)
}
if got[0].file != "run/executor.go" || got[0].line != 166 {
t.Errorf("finding[0] location = %s:%d, want run/executor.go:166", got[0].file, got[0].line)
}
if got[0].title != "Swallowed error" {
t.Errorf("finding[0] title = %q, want %q (nearest ### heading)", got[0].title, "Swallowed error")
}
if got[1].file != "compact/compactor.go" || got[1].line != 225 {
t.Errorf("finding[1] location = %s:%d, want compact/compactor.go:225", got[1].file, got[1].line)
}
if got[1].title != "Missing nil check" {
t.Errorf("finding[1] title = %q, want %q (numbered bold lead-in)", got[1].title, "Missing nil check")
}
if got[1].detail == "" {
t.Error("finding[1] detail should carry the paragraph context, got empty")
}
}
func TestParseFindings_ErroredLensYieldsNone(t *testing.T) {
notice := "⚠️ This reviewer failed to complete: context deadline exceeded (file.go:10)"
if f := parseFindings(Specialist{Name: "security"}, notice); len(f) != 0 {
t.Errorf("a failure-notice lens should yield no findings, got %+v", f)
}
if f := parseFindings(Specialist{Name: "security"}, " "); len(f) != 0 {
t.Errorf("empty output should yield no findings, got %+v", f)
}
}
func TestParseFindings_FallbackParagraphTitle(t *testing.T) {
md := "No material issues found.\n\nThe helper in src/util.go:42 returns the wrong code. Investigate.\n"
got := parseFindings(Specialist{Name: "correctness"}, md)
if len(got) != 1 {
t.Fatalf("want 1 finding, got %d: %+v", len(got), got)
}
if got[0].title != "The helper in src/util.go:42 returns the wrong code" {
t.Errorf("fallback title = %q, want first sentence of the paragraph", got[0].title)
}
}
func TestEmit_PostsRunsAndReports(t *testing.T) {
var runs, reports int32
var runBody map[string]any
var reportBody []map[string]any
var sawAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if a := r.Header.Get("Authorization"); a != "" {
sawAuth = a
}
data, _ := io.ReadAll(r.Body)
switch r.URL.Path {
case "/runs":
atomic.AddInt32(&runs, 1)
_ = json.Unmarshal(data, &runBody)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"run_id":"x"}`))
case "/reports":
atomic.AddInt32(&reports, 1)
_ = json.Unmarshal(data, &reportBody)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"finding_ids":[1,2]}`))
default:
http.Error(w, "not found", http.StatusNotFound)
}
}))
defer srv.Close()
t.Setenv("GADFLY_FINDINGS_URL", srv.URL)
t.Setenv("GADFLY_FINDINGS_TOKEN", "secret-token")
t.Setenv("GADFLY_REPO", "owner/repo")
t.Setenv("GADFLY_PR", "7")
t.Setenv("GADFLY_MODEL", "ollama-cloud/qwen3")
t.Setenv("GADFLY_PROVIDER", "")
results := []specialistResult{
{spec: Specialist{Name: "security", Title: "🔒 Security"}, out: sampleLensMarkdown, verdict: verdictBlocking},
}
emit(results, 3*time.Second)
if got := atomic.LoadInt32(&runs); got != 1 {
t.Fatalf("/runs received %d times, want exactly 1", got)
}
if got := atomic.LoadInt32(&reports); got != 1 {
t.Fatalf("/reports received %d times, want exactly 1", got)
}
if sawAuth != "Bearer secret-token" {
t.Errorf("Authorization header = %q, want %q", sawAuth, "Bearer secret-token")
}
// --- /runs body: exact field names + values ---
for _, k := range []string{"run_id", "repo", "pr", "model", "provider", "lenses", "duration_secs", "input_tokens", "output_tokens", "cost_usd"} {
if _, ok := runBody[k]; !ok {
t.Errorf("/runs body missing field %q (got keys %v)", k, keysOf(runBody))
}
}
if runBody["run_id"] != "owner/repo#7:ollama-cloud/qwen3" {
t.Errorf("run_id = %v, want owner/repo#7:ollama-cloud/qwen3", runBody["run_id"])
}
if runBody["repo"] != "owner/repo" {
t.Errorf("repo = %v, want owner/repo", runBody["repo"])
}
if runBody["pr"] != float64(7) {
t.Errorf("pr = %v, want 7", runBody["pr"])
}
if runBody["model"] != "ollama-cloud/qwen3" {
t.Errorf("model = %v, want ollama-cloud/qwen3", runBody["model"])
}
if runBody["provider"] != "ollama-cloud" {
t.Errorf("provider = %v, want ollama-cloud", runBody["provider"])
}
if runBody["lenses"] != float64(1) {
t.Errorf("lenses = %v, want 1", runBody["lenses"])
}
if runBody["duration_secs"] != float64(3) {
t.Errorf("duration_secs = %v, want 3", runBody["duration_secs"])
}
if v, ok := runBody["input_tokens"]; !ok || v != nil {
t.Errorf("input_tokens = %v, want JSON null", v)
}
// --- /reports body: exact field names + values ---
if len(reportBody) != 2 {
t.Fatalf("/reports array length = %d, want 2", len(reportBody))
}
for _, k := range []string{"repo", "pr", "lens", "file", "line", "title", "model", "provider", "run_id", "raw_severity", "detail"} {
if _, ok := reportBody[0][k]; !ok {
t.Errorf("/reports[0] missing field %q (got keys %v)", k, keysOf(reportBody[0]))
}
}
if reportBody[0]["lens"] != "security" {
t.Errorf("reports[0].lens = %v, want security", reportBody[0]["lens"])
}
if reportBody[0]["file"] != "run/executor.go" {
t.Errorf("reports[0].file = %v, want run/executor.go", reportBody[0]["file"])
}
if reportBody[0]["line"] != float64(166) {
t.Errorf("reports[0].line = %v, want 166", reportBody[0]["line"])
}
if reportBody[0]["raw_severity"] != "Blocking issues found" {
t.Errorf("reports[0].raw_severity = %v, want 'Blocking issues found'", reportBody[0]["raw_severity"])
}
if reportBody[0]["run_id"] != "owner/repo#7:ollama-cloud/qwen3" {
t.Errorf("reports[0].run_id = %v, want owner/repo#7:ollama-cloud/qwen3", reportBody[0]["run_id"])
}
}
// A clean "No material issues found" lens must NOT emit findings, even though
// its markdown contains path:line references (those are verification notes, not
// problems). /runs is still posted; /reports is not.
func TestEmit_SkipsCleanVerdictLens(t *testing.T) {
var runs, reports int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/runs":
atomic.AddInt32(&runs, 1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"run_id":"x"}`))
case "/reports":
atomic.AddInt32(&reports, 1)
w.WriteHeader(http.StatusOK)
default:
http.Error(w, "not found", http.StatusNotFound)
}
}))
defer srv.Close()
t.Setenv("GADFLY_FINDINGS_URL", srv.URL)
t.Setenv("GADFLY_REPO", "owner/repo")
t.Setenv("GADFLY_PR", "7")
t.Setenv("GADFLY_MODEL", "ollama-cloud/qwen3")
// Clean verdict, but the markdown is full of path:line "verified X" notes.
cleanMarkdown := "No material issues found.\n\nVerified `run/executor.go:166` handles the error.\n"
results := []specialistResult{
{spec: Specialist{Name: "security"}, out: cleanMarkdown, verdict: verdictClean},
}
emit(results, time.Second)
if got := atomic.LoadInt32(&runs); got != 1 {
t.Fatalf("/runs received %d times, want 1 (the run is still recorded)", got)
}
if got := atomic.LoadInt32(&reports); got != 0 {
t.Fatalf("/reports received %d times, want 0 (clean lens emits no findings)", got)
}
}
func TestEmit_DisabledMakesNoRequests(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
t.Setenv("GADFLY_FINDINGS_URL", "") // disabled
t.Setenv("GADFLY_REPO", "owner/repo")
t.Setenv("GADFLY_PR", "7")
t.Setenv("GADFLY_MODEL", "ollama-cloud/qwen3")
results := []specialistResult{
{spec: Specialist{Name: "security"}, out: sampleLensMarkdown, verdict: verdictBlocking},
}
// Must not panic and must not hit the server.
emit(results, time.Second)
emit(nil, 0)
if got := atomic.LoadInt32(&hits); got != 0 {
t.Fatalf("telemetry disabled but server received %d requests, want 0", got)
}
}
func keysOf(m map[string]any) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
return ks
}
+264
View File
@@ -0,0 +1,264 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"unicode/utf8"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// reviewEngine runs a single agent pass against the checked-out repo and returns
// the model's text answer. It is the one primitive both review passes use — the
// draft review and the adversarial recheck — so the rest of the pipeline
// (specialist composition, recheck orchestration, consolidation, emit) is
// engine-agnostic. Two implementations:
//
// - majordomoEngine: the original path — a majordomo tool-using agent loop
// (read_file/grep/… over a sandboxed repoFS).
// - claudeCodeEngine: shells out to the `claude` CLI in print mode, which
// brings its OWN repo tools; gadfly just feeds it the prompt and reads back
// the final text.
//
// maxSteps is the tool-step budget for engines that have one (majordomo); the
// claude-code engine manages its own loop and ignores it.
type reviewEngine interface {
runPass(ctx context.Context, system, task string, maxSteps int) (string, error)
}
// majordomoEngine drives the in-process majordomo agent over the repo sandbox.
type majordomoEngine struct {
mdl llm.Model
fsTools *repoFS
}
func (e *majordomoEngine) runPass(ctx context.Context, system, task string, maxSteps int) (string, error) {
return runAgent(ctx, e.mdl, e.fsTools, system, task, maxSteps)
}
// claudeCodeEngine reviews by shelling out to the `claude` CLI (Claude Code) in
// non-interactive print mode. Claude Code reads the checked-out tree with its
// own read tools (so it verifies findings against real code, like the agentic
// majordomo path), and we parse its final answer out of `--output-format json`.
//
// Auth is inherited from the environment: the default backend is a Pro/Max
// subscription via CLAUDE_CODE_OAUTH_TOKEN (no `--bare`). See README.
type claudeCodeEngine struct {
bin string // CLI binary (GADFLY_CLAUDE_BIN, default "claude")
model string // --model value ("" = CLI default)
repoDir string // cwd for the CLI, so its tools read the checked-out tree
permissionMode string // --permission-mode (default "plan": read-only, no edits)
allowedTools string // --allowedTools value, passed verbatim ("" = omit)
extraArgs []string // appended verbatim (GADFLY_CLAUDE_EXTRA_ARGS)
thinkingTokens int // MAX_THINKING_TOKENS for the subprocess; 0 = leave default
}
// maxThinkingTokens is the extended-thinking budget used for a "claude-code/<model>:max"
// spec — Claude Code's high "ultrathink" tier. Set as MAX_THINKING_TOKENS on the
// subprocess; harmless (a no-op) if the CLI build doesn't honor it.
const maxThinkingTokens = 31999
// isClaudeCodeSpec reports whether a GADFLY_MODEL spec selects the claude-code
// engine: the bare id "claude-code" or a "claude-code/<model>" form.
func isClaudeCodeSpec(model string) bool {
m := strings.TrimSpace(model)
return m == "claude-code" || strings.HasPrefix(m, "claude-code/")
}
// newClaudeCodeEngine builds the engine from the GADFLY_MODEL spec and the
// optional GADFLY_CLAUDE_* overrides. The part after the slash in
// "claude-code/<model>[:<thinking>]" becomes --model (e.g. "claude-code/sonnet"
// → "sonnet"), with an optional thinking suffix: ":max" forces the high
// extended-thinking budget and ":<n>" sets a specific MAX_THINKING_TOKENS.
// GADFLY_CLAUDE_MODEL overrides the model id (the thinking suffix still applies).
// It does not verify the CLI is installed — a missing binary surfaces as a normal
// pass error (advisory, never fatal).
func newClaudeCodeEngine(spec, repoDir string) *claudeCodeEngine {
var model string
thinking := 0
if _, after, ok := strings.Cut(strings.TrimSpace(spec), "/"); ok {
after = strings.TrimSpace(after)
// Optional ":<thinking>" suffix. Claude model aliases/ids contain no ":",
// so a colon unambiguously separates the thinking tier here.
if m, t, hasColon := strings.Cut(after, ":"); hasColon {
model = strings.TrimSpace(m)
thinking = parseThinking(strings.TrimSpace(t))
} else {
model = after
}
}
if env := strings.TrimSpace(os.Getenv("GADFLY_CLAUDE_MODEL")); env != "" {
model = env
}
return &claudeCodeEngine{
bin: envOr("GADFLY_CLAUDE_BIN", "claude"),
model: model,
repoDir: repoDir,
permissionMode: envOr("GADFLY_CLAUDE_PERMISSION_MODE", "plan"),
allowedTools: strings.TrimSpace(os.Getenv("GADFLY_CLAUDE_ALLOWED_TOOLS")),
extraArgs: strings.Fields(os.Getenv("GADFLY_CLAUDE_EXTRA_ARGS")),
thinkingTokens: thinking,
}
}
// parseThinking maps a spec thinking suffix to a MAX_THINKING_TOKENS budget:
// "max" → maxThinkingTokens, a positive integer → itself, anything else → 0 (off).
func parseThinking(s string) int {
if strings.EqualFold(s, "max") {
return maxThinkingTokens
}
if n, err := strconv.Atoi(s); err == nil && n > 0 {
return n
}
return 0
}
// args assembles the `claude` argv for one pass. Factored out (and pure) so it
// can be unit-tested without invoking the CLI. The system prompt is layered on
// top of Claude Code's own via --append-system-prompt; the task is the -p
// prompt.
func (e *claudeCodeEngine) args(system, task string) []string {
a := []string{"-p", task, "--output-format", "json", "--append-system-prompt", system}
if e.model != "" {
a = append(a, "--model", e.model)
}
if e.permissionMode != "" {
a = append(a, "--permission-mode", e.permissionMode)
}
if e.allowedTools != "" {
a = append(a, "--allowedTools", e.allowedTools)
}
return append(a, e.extraArgs...)
}
// claudeResult is the subset of `claude --output-format json` we read.
type claudeResult struct {
Result string `json:"result"`
IsError bool `json:"is_error"`
Subtype string `json:"subtype"`
}
func (e *claudeCodeEngine) runPass(ctx context.Context, system, task string, _ int) (string, error) {
cmd := exec.CommandContext(ctx, e.bin, e.args(system, task)...)
cmd.Dir = e.repoDir
cmd.Env = claudeEnv() // minimal env — don't hand GITEA_TOKEN et al. to the CLI
if e.thinkingTokens > 0 {
// Force an extended-thinking budget for this run (a "...:max" spec).
cmd.Env = append(cmd.Env, "MAX_THINKING_TOKENS="+strconv.Itoa(e.thinkingTokens))
}
// Put the CLI and the Node children it spawns in their own process group and
// kill the WHOLE group on context cancel, so a timed-out lens can't leave
// orphaned claude/node processes behind in the container.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
// A cancelled/timed-out run must surface as an error, never as whatever
// partial bytes the CLI flushed before it was killed.
if ctx.Err() != nil {
return "", fmt.Errorf("claude -p %v", ctx.Err())
}
var res claudeResult
parsed := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &res) == nil
// Clean exit: trust the parsed JSON answer, and ONLY it — never fall back to
// the raw JSON envelope when the result is empty.
if runErr == nil && parsed {
if res.IsError {
return "", fmt.Errorf("claude reported error (%s): %s", res.Subtype, truncateForErr(res.Result))
}
if out := strings.TrimSpace(res.Result); out != "" {
return out, nil
}
return "", fmt.Errorf("claude -p returned an empty result")
}
if runErr != nil {
// Prefer the CLI's own structured error message when it gave one.
if parsed && res.IsError && strings.TrimSpace(res.Result) != "" {
return "", fmt.Errorf("claude reported error (%s): %s", res.Subtype, truncateForErr(res.Result))
}
detail := truncateForErr(stderr.String())
if detail == "" {
detail = truncateForErr(stdout.String())
}
if detail != "" {
return "", fmt.Errorf("claude -p failed: %v: %s", runErr, detail)
}
return "", fmt.Errorf("claude -p failed: %v", runErr)
}
// Clean exit but stdout wasn't the expected JSON envelope: degrade to the raw
// text so a CLI format change still yields a review instead of nothing.
if raw := strings.TrimSpace(stdout.String()); raw != "" {
return raw, nil
}
return "", fmt.Errorf("claude -p produced no parseable output")
}
// claudeEnv builds a minimal environment for the `claude` subprocess: only what
// the CLI needs (PATH/HOME, its auth tokens, locale, Node/XDG/GADFLY_CLAUDE_*
// knobs), deliberately dropping the rest of the runner's secrets — GITEA_TOKEN,
// GADFLY_FINDINGS_TOKEN, provider keys — so they never reach the third-party
// CLI. Defense in depth: the parent already holds them, but the CLI has no need.
func claudeEnv() []string {
keep := func(k string) bool {
switch k {
case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "MAX_THINKING_TOKENS":
return true
}
return strings.HasPrefix(k, "LC_") ||
strings.HasPrefix(k, "CLAUDE_") ||
strings.HasPrefix(k, "ANTHROPIC_") ||
strings.HasPrefix(k, "GADFLY_CLAUDE_") ||
strings.HasPrefix(k, "NODE_") ||
strings.HasPrefix(k, "XDG_")
}
var env []string
for _, kv := range os.Environ() {
if k, _, ok := strings.Cut(kv, "="); ok && keep(k) {
env = append(env, kv)
}
}
return env
}
// truncateForErr caps CLI error detail so a stderr dump can't bloat the comment,
// cutting on a rune boundary so it never emits invalid UTF-8.
func truncateForErr(s string) string {
s = strings.TrimSpace(s)
const max = 800
if len(s) <= max {
return s
}
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "…"
}
// envOr returns the env var value or a default when unset/blank.
func envOr(name, def string) string {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
return v
}
return def
}
+281
View File
@@ -0,0 +1,281 @@
package main
import (
"context"
"os"
"slices"
"strings"
"testing"
"unicode/utf8"
)
func TestIsClaudeCodeSpec(t *testing.T) {
cases := map[string]bool{
"claude-code": true,
"claude-code/sonnet": true,
"claude-code/opus": true,
"claude-code/claude-opus-4-8": true,
" claude-code ": true, // trimmed
"qwen3-coder:480b-cloud": false,
"claude-code-extra": false, // not the bare id, not a "/" form
"sonnet": false,
"": false,
}
for spec, want := range cases {
if got := isClaudeCodeSpec(spec); got != want {
t.Errorf("isClaudeCodeSpec(%q) = %v, want %v", spec, got, want)
}
}
}
func TestNewClaudeCodeEngineModel(t *testing.T) {
// model derived from the spec's "/<model>" suffix
t.Setenv("GADFLY_CLAUDE_MODEL", "")
if e := newClaudeCodeEngine("claude-code/sonnet", "/repo"); e.model != "sonnet" {
t.Errorf("model = %q, want sonnet", e.model)
}
// bare spec → CLI default (no --model)
if e := newClaudeCodeEngine("claude-code", "/repo"); e.model != "" {
t.Errorf("model = %q, want empty for bare spec", e.model)
}
// GADFLY_CLAUDE_MODEL overrides the spec suffix
t.Setenv("GADFLY_CLAUDE_MODEL", "opus")
if e := newClaudeCodeEngine("claude-code/sonnet", "/repo"); e.model != "opus" {
t.Errorf("model = %q, want opus (env override)", e.model)
}
}
func TestClaudeCodeEngineDefaults(t *testing.T) {
t.Setenv("GADFLY_CLAUDE_BIN", "")
t.Setenv("GADFLY_CLAUDE_PERMISSION_MODE", "")
t.Setenv("GADFLY_CLAUDE_ALLOWED_TOOLS", "")
t.Setenv("GADFLY_CLAUDE_EXTRA_ARGS", "")
e := newClaudeCodeEngine("claude-code", "/repo")
if e.bin != "claude" {
t.Errorf("bin = %q, want claude", e.bin)
}
if e.permissionMode != "plan" {
t.Errorf("permissionMode = %q, want plan", e.permissionMode)
}
if e.repoDir != "/repo" {
t.Errorf("repoDir = %q, want /repo", e.repoDir)
}
}
// argAfter returns the value following flag in args, or "" if absent.
func argAfter(args []string, flag string) string {
if i := slices.Index(args, flag); i >= 0 && i+1 < len(args) {
return args[i+1]
}
return ""
}
func TestClaudeCodeArgs(t *testing.T) {
t.Setenv("GADFLY_CLAUDE_MODEL", "")
t.Setenv("GADFLY_CLAUDE_PERMISSION_MODE", "")
t.Setenv("GADFLY_CLAUDE_ALLOWED_TOOLS", "Read,Grep,Glob")
t.Setenv("GADFLY_CLAUDE_EXTRA_ARGS", "--max-turns 30")
e := newClaudeCodeEngine("claude-code/sonnet", "/repo")
args := e.args("SYS-PROMPT", "TASK-PROMPT")
// task is the -p value; json output; system appended; model + policy present.
if argAfter(args, "-p") != "TASK-PROMPT" {
t.Errorf("-p = %q, want TASK-PROMPT", argAfter(args, "-p"))
}
if argAfter(args, "--output-format") != "json" {
t.Errorf("--output-format = %q, want json", argAfter(args, "--output-format"))
}
if argAfter(args, "--append-system-prompt") != "SYS-PROMPT" {
t.Errorf("--append-system-prompt = %q, want SYS-PROMPT", argAfter(args, "--append-system-prompt"))
}
if argAfter(args, "--model") != "sonnet" {
t.Errorf("--model = %q, want sonnet", argAfter(args, "--model"))
}
if argAfter(args, "--permission-mode") != "plan" {
t.Errorf("--permission-mode = %q, want plan", argAfter(args, "--permission-mode"))
}
if argAfter(args, "--allowedTools") != "Read,Grep,Glob" {
t.Errorf("--allowedTools = %q, want Read,Grep,Glob", argAfter(args, "--allowedTools"))
}
// extra args appended verbatim (split on whitespace)
if !strings.Contains(strings.Join(args, " "), "--max-turns 30") {
t.Errorf("extra args not appended: %v", args)
}
}
func TestClaudeCodeArgsBareModelOmitsFlag(t *testing.T) {
t.Setenv("GADFLY_CLAUDE_MODEL", "")
t.Setenv("GADFLY_CLAUDE_ALLOWED_TOOLS", "") // omit when blank
t.Setenv("GADFLY_CLAUDE_EXTRA_ARGS", "")
e := newClaudeCodeEngine("claude-code", "/repo")
args := e.args("s", "t")
if slices.Contains(args, "--model") {
t.Errorf("--model should be omitted for a bare claude-code spec: %v", args)
}
if slices.Contains(args, "--allowedTools") {
t.Errorf("--allowedTools should be omitted when blank: %v", args)
}
}
func TestClaudeEnvFilters(t *testing.T) {
t.Setenv("GITEA_TOKEN", "secret-gitea")
t.Setenv("OLLAMA_API_KEY", "secret-ollama")
t.Setenv("GADFLY_API_KEY", "secret-gadfly")
t.Setenv("GADFLY_FINDINGS_TOKEN", "secret-findings")
t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "keep-claude")
t.Setenv("ANTHROPIC_API_KEY", "keep-anthropic")
t.Setenv("GADFLY_CLAUDE_MODEL", "keep-knob")
env := claudeEnv()
has := func(k string) bool {
for _, kv := range env {
if strings.HasPrefix(kv, k+"=") {
return true
}
}
return false
}
// kept: the CLI's auth + its own knobs + PATH
for _, k := range []string{"CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "GADFLY_CLAUDE_MODEL", "PATH"} {
if !has(k) {
t.Errorf("claudeEnv dropped %s, but it should be kept", k)
}
}
// dropped: the runner's secrets the CLI doesn't need
for _, k := range []string{"GITEA_TOKEN", "OLLAMA_API_KEY", "GADFLY_API_KEY", "GADFLY_FINDINGS_TOKEN"} {
if has(k) {
t.Errorf("claudeEnv leaked %s into the subprocess env", k)
}
}
}
func TestTruncateForErrRuneSafe(t *testing.T) {
// 900 multibyte runes (3 bytes each) -> well over the 800-byte cap; the cut
// must land on a rune boundary so the result stays valid UTF-8.
s := strings.Repeat("€", 900)
got := truncateForErr(s)
if !utf8.ValidString(got) {
t.Fatalf("truncateForErr produced invalid UTF-8")
}
if !strings.HasSuffix(got, "…") {
t.Fatalf("truncateForErr should append an ellipsis when truncating")
}
// short strings pass through untouched
if truncateForErr(" hi ") != "hi" {
t.Fatalf("truncateForErr should trim and pass short strings through")
}
}
// stubClaude writes an executable shell stub that prints body and exits code,
// and returns an engine pointed at it.
func stubClaude(t *testing.T, body string, code int) *claudeCodeEngine {
t.Helper()
dir := t.TempDir()
path := dir + "/claude-stub.sh"
script := "#!/bin/sh\nprintf '%s' " + shSingleQuote(body) + "\nexit " + itoa(code) + "\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return &claudeCodeEngine{bin: path, repoDir: dir}
}
func shSingleQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
func itoa(i int) string { return string(rune('0' + i)) } // single-digit exit codes only
func TestRunPassCleanResult(t *testing.T) {
e := stubClaude(t, `{"result":"REVIEW TEXT","is_error":false}`, 0)
out, err := e.runPass(context.Background(), "sys", "task", 0)
if err != nil || out != "REVIEW TEXT" {
t.Fatalf("clean result: got (%q, %v), want (REVIEW TEXT, nil)", out, err)
}
}
func TestRunPassEmptyResultIsError(t *testing.T) {
// JSON parses, exit 0, but result empty: must NOT return the raw JSON blob.
e := stubClaude(t, `{"result":"","is_error":false}`, 0)
out, err := e.runPass(context.Background(), "sys", "task", 0)
if err == nil {
t.Fatalf("empty result should be an error, got out=%q", out)
}
if strings.Contains(out, "{") {
t.Fatalf("empty result must not leak raw JSON, got %q", out)
}
}
func TestRunPassIsErrorFlag(t *testing.T) {
e := stubClaude(t, `{"result":"boom","is_error":true,"subtype":"error_max_turns"}`, 0)
_, err := e.runPass(context.Background(), "sys", "task", 0)
if err == nil || !strings.Contains(err.Error(), "claude reported error") {
t.Fatalf("is_error should surface as an error, got %v", err)
}
}
func TestRunPassNonZeroNoJSON(t *testing.T) {
e := stubClaude(t, "fatal: auth failed", 1)
_, err := e.runPass(context.Background(), "sys", "task", 0)
if err == nil || !strings.Contains(err.Error(), "claude -p failed") {
t.Fatalf("non-zero exit should error with detail, got %v", err)
}
}
func TestClaudeCodeThinking(t *testing.T) {
t.Setenv("GADFLY_CLAUDE_MODEL", "")
cases := []struct {
spec string
wantModel string
wantThink int
}{
{"claude-code/opus", "opus", 0},
{"claude-code/opus:max", "opus", maxThinkingTokens},
{"claude-code/sonnet:20000", "sonnet", 20000},
{"claude-code/opus:bogus", "opus", 0}, // unrecognized suffix -> off
{"claude-code", "", 0},
}
for _, c := range cases {
e := newClaudeCodeEngine(c.spec, "/repo")
if e.model != c.wantModel || e.thinkingTokens != c.wantThink {
t.Errorf("newClaudeCodeEngine(%q) = (model %q, think %d), want (%q, %d)",
c.spec, e.model, e.thinkingTokens, c.wantModel, c.wantThink)
}
}
}
func TestClaudeCodeThinkingEnvOverrideKeepsSuffix(t *testing.T) {
// GADFLY_CLAUDE_MODEL overrides the model id, but the :max thinking from the
// spec still applies.
t.Setenv("GADFLY_CLAUDE_MODEL", "claude-opus-4-8")
e := newClaudeCodeEngine("claude-code/opus:max", "/repo")
if e.model != "claude-opus-4-8" {
t.Errorf("model = %q, want claude-opus-4-8 (env override)", e.model)
}
if e.thinkingTokens != maxThinkingTokens {
t.Errorf("thinkingTokens = %d, want %d (suffix still applies)", e.thinkingTokens, maxThinkingTokens)
}
}
// TestRunPassInjectsThinkingTokens verifies the engine actually puts
// MAX_THINKING_TOKENS into the subprocess env for a ":max" spec (and not for a
// plain spec). The stub echoes the value it received back as the result.
func TestRunPassInjectsThinkingTokens(t *testing.T) {
t.Setenv("GADFLY_CLAUDE_MODEL", "")
t.Setenv("MAX_THINKING_TOKENS", "") // not in the test's own env
dir := t.TempDir()
stub := dir + "/claude-stub.sh"
// Report the env var the CLI was launched with.
script := "#!/bin/sh\nprintf '{\"result\":\"MTT=%s\",\"is_error\":false}' \"${MAX_THINKING_TOKENS:-unset}\"\n"
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
maxEng := newClaudeCodeEngine("claude-code/opus:max", dir)
maxEng.bin = stub
if out, err := maxEng.runPass(context.Background(), "s", "t", 0); err != nil || out != "MTT=31999" {
t.Fatalf(":max run: got (%q, %v), want (MTT=31999, nil)", out, err)
}
plainEng := newClaudeCodeEngine("claude-code/opus", dir)
plainEng.bin = stub
if out, err := plainEng.runPass(context.Background(), "s", "t", 0); err != nil || out != "MTT=unset" {
t.Fatalf("plain run: got (%q, %v), want (MTT=unset, nil)", out, err)
}
}
+189
View File
@@ -0,0 +1,189 @@
package main
import (
"context"
"sync"
"testing"
"time"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
)
// trackingModel wraps a real llm.Model and records the maximum number of
// concurrent Generate calls, holding each call open briefly so overlap is
// observable. The concurrency tracking + sleep happen OUTSIDE the wrapped
// model's call, so (unlike the fake provider, which holds its mutex while
// invoking its default func) this measures genuine caller-side concurrency.
type trackingModel struct {
inner llm.Model
onEnter func()
onExit func()
}
func (m *trackingModel) Generate(ctx context.Context, req llm.Request, opts ...llm.Option) (*llm.Response, error) {
m.onEnter()
defer m.onExit()
time.Sleep(40 * time.Millisecond) // overlap window: concurrent lenses pile up here
return m.inner.Generate(ctx, req, opts...)
}
func (m *trackingModel) Stream(ctx context.Context, req llm.Request, opts ...llm.Option) (llm.Stream, error) {
return m.inner.Stream(ctx, req, opts...)
}
func (m *trackingModel) Capabilities() llm.Capabilities { return m.inner.Capabilities() }
// peakTrackingModel returns a model that replies with a clean verdict (so the
// recheck pass is skipped — one Generate per lens) and a getter for the peak
// number of concurrent Generate calls observed.
func peakTrackingModel(t *testing.T) (llm.Model, func() int) {
t.Helper()
p := fake.New("fake", fake.WithDefault(func(_ string, _ llm.Request) fake.Step {
return fake.Reply("No material issues found.")
}))
inner, err := p.Model("mock")
if err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var inFlight, peak int
enter := func() {
mu.Lock()
defer mu.Unlock()
inFlight++
if inFlight > peak {
peak = inFlight
}
}
exit := func() {
mu.Lock()
defer mu.Unlock()
inFlight--
}
return &trackingModel{inner: inner, onEnter: enter, onExit: exit},
func() int { mu.Lock(); defer mu.Unlock(); return peak }
}
func threeLenses() []Specialist {
return []Specialist{
{Name: "security", Title: "🔒 Security", Focus: "x"},
{Name: "correctness", Title: "🎯 Correctness", Focus: "y"},
{Name: "error-handling", Title: "🧯 Errors", Focus: "z"},
}
}
func assertOrderClean(t *testing.T, specs []Specialist, results []specialistResult) {
t.Helper()
if len(results) != len(specs) {
t.Fatalf("got %d results, want %d", len(results), len(specs))
}
for i, r := range results {
if r.spec.Name != specs[i].Name {
t.Errorf("result %d is %q, want %q (order must follow the specialist list, not finish order)", i, r.spec.Name, specs[i].Name)
}
if r.errored {
t.Errorf("lens %q unexpectedly errored", r.spec.Name)
}
if r.verdict != verdictClean {
t.Errorf("lens %q verdict = %v, want clean", r.spec.Name, r.verdict)
}
}
}
// TestRunSpecialists_FansOut: with GADFLY_LENS_CONCURRENCY=3 all three lenses run
// at once (peak == 3), and results still come back in specialist order.
func TestRunSpecialists_FansOut(t *testing.T) {
t.Setenv("GADFLY_LENS_CONCURRENCY", "3")
t.Setenv("GADFLY_PROVIDER_LENS_CONCURRENCY", "")
mdl, peak := peakTrackingModel(t)
fs, err := newRepoFS(t.TempDir(), "diff --git a/x b/x\n+y\n")
if err != nil {
t.Fatal(err)
}
specs := threeLenses()
results := runSpecialists(&majordomoEngine{mdl: mdl, fsTools: fs}, "sys", specs, "task", "diff")
if got := peak(); got != 3 {
t.Errorf("peak concurrent lenses = %d, want 3", got)
}
assertOrderClean(t, specs, results)
}
// TestRunSpecialists_SequentialByDefault: an unset GADFLY_LENS_CONCURRENCY keeps
// the suite strictly serial (peak == 1) — the historical behavior.
func TestRunSpecialists_SequentialByDefault(t *testing.T) {
t.Setenv("GADFLY_LENS_CONCURRENCY", "")
t.Setenv("GADFLY_PROVIDER_LENS_CONCURRENCY", "")
mdl, peak := peakTrackingModel(t)
fs, err := newRepoFS(t.TempDir(), "diff --git a/x b/x\n+y\n")
if err != nil {
t.Fatal(err)
}
specs := threeLenses()
results := runSpecialists(&majordomoEngine{mdl: mdl, fsTools: fs}, "sys", specs, "task", "diff")
if got := peak(); got != 1 {
t.Errorf("peak concurrent lenses = %d, want 1 (sequential by default)", got)
}
assertOrderClean(t, specs, results)
}
// TestRunSpecialists_PerProviderFanOut: a per-provider override fans this model's
// lenses out even when the scalar default is serial — the model's lane (from
// GADFLY_MODEL's prefix) is matched against GADFLY_PROVIDER_LENS_CONCURRENCY.
func TestRunSpecialists_PerProviderFanOut(t *testing.T) {
t.Setenv("GADFLY_MODEL", "m1/qwen3:14b")
t.Setenv("GADFLY_LENS_CONCURRENCY", "1") // scalar default: serial
t.Setenv("GADFLY_PROVIDER_LENS_CONCURRENCY", "m1=3") // but the m1 lane fans out
mdl, peak := peakTrackingModel(t)
fs, err := newRepoFS(t.TempDir(), "diff --git a/x b/x\n+y\n")
if err != nil {
t.Fatal(err)
}
specs := threeLenses()
results := runSpecialists(&majordomoEngine{mdl: mdl, fsTools: fs}, "sys", specs, "task", "diff")
if got := peak(); got != 3 {
t.Errorf("peak concurrent lenses = %d, want 3 (m1 per-provider override)", got)
}
assertOrderClean(t, specs, results)
}
// TestLensConcurrency covers the resolution matrix: scalar default, scalar
// override, and per-provider override keyed by the model's resolved lane (same
// lane rule entrypoint.sh uses for GADFLY_PROVIDER_CONCURRENCY).
func TestLensConcurrency(t *testing.T) {
tests := []struct {
name string
model string
provider string
scalar string
provMap string
want int
}{
{"default", "", "", "", "", 1},
{"scalar", "", "", "4", "", 4},
{"scalar invalid falls back to default", "", "", "nope", "", 1},
{"bare id uses GADFLY_PROVIDER lane", "qwen3-coder:480b-cloud", "ollama-cloud", "1", "ollama-cloud=3", 3},
{"bare id with no provider defaults to ollama-cloud lane", "qwen3-coder:480b-cloud", "", "1", "ollama-cloud=7", 7},
{"prefixed spec uses its prefix lane", "m1/qwen3:14b", "", "1", "m1=2,ollama-cloud=3", 2},
{"no lane match falls back to scalar", "m5/qwen3.6:35b-mlx", "", "5", "m1=2", 5},
{"override wins over scalar", "ollama-cloud/x", "", "9", "ollama-cloud=2", 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("GADFLY_MODEL", tt.model)
t.Setenv("GADFLY_PROVIDER", tt.provider)
t.Setenv("GADFLY_LENS_CONCURRENCY", tt.scalar)
t.Setenv("GADFLY_PROVIDER_LENS_CONCURRENCY", tt.provMap)
if got := lensConcurrency(); got != tt.want {
t.Errorf("lensConcurrency() = %d, want %d", got, tt.want)
}
})
}
}
+200 -27
View File
@@ -20,8 +20,19 @@
// //
// Inputs (env): // Inputs (env):
// //
// OLLAMA_API_KEY Ollama Cloud bearer key (required). // GADFLY_MODEL model id, or a full "provider/model" spec / majordomo
// GADFLY_MODEL model id, e.g. "qwen3-coder:480b-cloud" (required). // alias / failover chain (required). A bare id is
// prefixed with GADFLY_PROVIDER.
// GADFLY_PROVIDER provider for bare model ids (default "ollama-cloud";
// e.g. "ollama" for a local daemon, "openai", …).
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
// servers, remote Ollama, gateways). See model.go.
// GADFLY_API_KEY provider key; optional — falls back to the provider's
// standard env (OLLAMA_API_KEY / OPENAI_API_KEY /
// ANTHROPIC_API_KEY / GOOGLE_API_KEY|GEMINI_API_KEY).
// GADFLY_SPECIALISTS csv of review lenses, "all", or "auto" (see specialists.go).
// GADFLY_SELECTOR_MODEL model that picks lenses in "auto" mode (default: review model).
// GADFLY_WORKER_MODEL cheap model for the delegate_investigation tool (unset = off).
// GADFLY_REPO_DIR path to the checked-out repo (required; the FS sandbox root). // GADFLY_REPO_DIR path to the checked-out repo (required; the FS sandbox root).
// GADFLY_DIFF_FILE path to a file holding the full unified diff (required). // GADFLY_DIFF_FILE path to a file holding the full unified diff (required).
// GADFLY_SYSTEM_FILE path to the reviewer system prompt (required). // GADFLY_SYSTEM_FILE path to the reviewer system prompt (required).
@@ -35,6 +46,15 @@
// GADFLY_RECHECK set to 0/false to skip the recheck pass (optional, default on). // GADFLY_RECHECK set to 0/false to skip the recheck pass (optional, default on).
// GADFLY_RECHECK_MAX_STEPS recheck-pass step cap (optional, default 16). // GADFLY_RECHECK_MAX_STEPS recheck-pass step cap (optional, default 16).
// GADFLY_TIMEOUT_SECS overall deadline in seconds, shared by both passes (optional, default 300). // GADFLY_TIMEOUT_SECS overall deadline in seconds, shared by both passes (optional, default 300).
// GADFLY_LENS_CONCURRENCY how many specialist lenses run concurrently within this
// model (optional, default 1 = sequential). Total in-flight
// model requests ≈ this × entrypoint.sh's per-provider model
// concurrency, so keep the product within the backend's budget.
// GADFLY_PROVIDER_LENS_CONCURRENCY per-provider override for the above, as a
// "provider=N,provider=N" map keyed by the SAME provider
// lanes as GADFLY_PROVIDER_CONCURRENCY (e.g.
// "ollama-cloud=3,m1=1"). Wins over GADFLY_LENS_CONCURRENCY
// for the model's provider; falls back to it otherwise.
// GADFLY_MAX_DIFF_CHARS diff chars embedded in the prompt (optional, default 60000; // GADFLY_MAX_DIFF_CHARS diff chars embedded in the prompt (optional, default 60000;
// the full diff is always available via the get_diff tool). // the full diff is always available via the get_diff tool).
// //
@@ -50,23 +70,33 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"gitea.stevedudenhoeffer.com/steve/majordomo/agent" "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm" llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
) )
const ( const (
defaultMaxSteps = 24 defaultMaxSteps = 24
// defaultTimeoutSecs is the deadline for EACH specialist's passes (review +
// recheck). It is per-lens, not shared across the suite, so one slow lens
// (e.g. a big local model) can't starve the others. Slow local models may
// need this raised (and a higher job timeout to match the suite total).
defaultTimeoutSecs = 300 defaultTimeoutSecs = 300
defaultMaxDiffChars = 60000 defaultMaxDiffChars = 60000
// autoSelectTimeout bounds the dynamic specialist-selection call.
autoSelectTimeout = 120 * time.Second
// defaultWrapUpReserve is how many steps before the cap the agent is told // defaultWrapUpReserve is how many steps before the cap the agent is told
// to stop investigating and write its final answer. Reserving a margin is // to stop investigating and write its final answer. Reserving a margin is
// what keeps a thorough reviewer from spending its whole budget on tool // what keeps a thorough reviewer from spending its whole budget on tool
// calls and then hard-failing with "max steps reached without a final // calls and then hard-failing with "max steps reached without a final
// answer" — it always has a few steps left to wrap up. // answer" — it always has a few steps left to wrap up.
defaultWrapUpReserve = 4 defaultWrapUpReserve = 4
// defaultLensConcurrency is how many specialist lenses run at once within a
// single model. 1 keeps the suite sequential (the historical behavior);
// higher values overlap the independent per-lens passes. See runSpecialists.
defaultLensConcurrency = 1
) )
// wrapUpInstruction is steered into a running agent once it comes within the // wrapUpInstruction is steered into a running agent once it comes within the
@@ -92,16 +122,12 @@ func main() {
} }
func run() error { func run() error {
apiKey := os.Getenv("OLLAMA_API_KEY") start := time.Now()
if apiKey == "" {
return errors.New("OLLAMA_API_KEY is required")
}
model := os.Getenv("GADFLY_MODEL")
repoDir := os.Getenv("GADFLY_REPO_DIR") repoDir := os.Getenv("GADFLY_REPO_DIR")
diffFile := os.Getenv("GADFLY_DIFF_FILE") diffFile := os.Getenv("GADFLY_DIFF_FILE")
systemFile := os.Getenv("GADFLY_SYSTEM_FILE") systemFile := os.Getenv("GADFLY_SYSTEM_FILE")
if model == "" || repoDir == "" || diffFile == "" || systemFile == "" { if repoDir == "" || diffFile == "" || systemFile == "" {
return errors.New("GADFLY_MODEL, GADFLY_REPO_DIR, GADFLY_DIFF_FILE and GADFLY_SYSTEM_FILE are all required") return errors.New("GADFLY_REPO_DIR, GADFLY_DIFF_FILE and GADFLY_SYSTEM_FILE are all required")
} }
diffBytes, err := os.ReadFile(diffFile) diffBytes, err := os.ReadFile(diffFile)
@@ -123,39 +149,186 @@ func run() error {
return err return err
} }
mdl, err := ollama.Cloud(ollama.WithToken(apiKey)).Model(model) // Resolve the review engine. The claude-code engine shells out to the
if err != nil { // `claude` CLI (its own repo tools); every other spec is a majordomo model.
return fmt.Errorf("build model %q: %w", model, err) // auto-selection and the delegate worker are majordomo-only — with
// claude-code they're skipped (Claude Code does its own legwork).
ccSpec := isClaudeCodeSpec(os.Getenv("GADFLY_MODEL"))
var eng reviewEngine
if ccSpec {
eng = newClaudeCodeEngine(os.Getenv("GADFLY_MODEL"), fsTools.root)
} else {
mdl, merr := resolveModel()
if merr != nil {
return fmt.Errorf("resolve model: %w", merr)
}
// Optional cheap worker for delegate_investigation. Non-fatal: a bad
// worker spec just disables delegation rather than sinking the review.
if worker, werr := resolveWorkerModel(); werr != nil {
fmt.Fprintln(os.Stderr, "gadfly: worker model disabled:", werr)
} else if worker != nil {
fsTools.worker = worker
}
eng = &majordomoEngine{mdl: mdl, fsTools: fsTools}
} }
specialists, registry, auto, serrs := resolveSpecialists(repoDir)
for _, e := range serrs {
fmt.Fprintln(os.Stderr, "gadfly:", e)
}
// Dynamic selection: a (cheap) model picks the lenses this diff needs.
// Majordomo-only — the selector is an llm.Model.
if auto {
if ccSpec {
fmt.Fprintln(os.Stderr, "gadfly: auto-select is not supported with the claude-code engine; using the default suite")
specialists = suiteFromRegistry(registry, defaultSuite)
} else {
selector, serr := resolveSelectorModel(eng.(*majordomoEngine).mdl)
if serr != nil {
return fmt.Errorf("resolve selector model: %w", serr)
}
selCtx, cancel := context.WithTimeout(context.Background(), autoSelectTimeout)
picked, aerr := autoSelectSpecialists(selCtx, selector, os.Getenv("GADFLY_TITLE"), os.Getenv("GADFLY_BODY"), diff, registry)
cancel()
if aerr != nil {
fmt.Fprintln(os.Stderr, "gadfly: auto-select failed; falling back to the default suite:", aerr)
specialists = suiteFromRegistry(registry, defaultSuite)
} else {
specialists = picked
fmt.Fprintln(os.Stderr, "gadfly: auto-selected specialists:", specialistNamesOf(specialists))
}
}
}
if len(specialists) == 0 {
return errors.New("no specialists resolved to run")
}
base := string(systemBytes)
task := buildTask(diff)
results := runSpecialists(eng, base, specialists, task, diff)
fmt.Println(renderConsolidated(results))
// Optional, best-effort telemetry. OFF unless GADFLY_FINDINGS_URL is set;
// any failure is logged to stderr and never affects stdout or the exit code.
emit(results, time.Since(start))
return nil
}
// runSpecialists reviews the diff through each lens and returns the results in
// the SAME order as specialists, regardless of finish order. Up to
// GADFLY_LENS_CONCURRENCY lenses run concurrently; the default of 1 keeps the
// suite sequential, exactly as before. Each lens already runs under its own
// per-lens timeout (reviewWithSpecialist), so concurrency simply overlaps those
// independent passes — and because reviewWithSpecialist builds a fresh toolbox
// per pass and the lenses only read the immutable repoFS, they share no mutable
// state. Results are stored by index so the consolidated comment keeps the
// configured lens order.
//
// Caution: this fans out WITHIN one model. It multiplies with entrypoint.sh's
// per-provider model concurrency, so total concurrent backend requests ≈
// (models at once) × (lenses at once). To fan lenses out without oversubscribing
// the backend, run models one at a time (provider lane cap 1) and raise this.
func runSpecialists(eng reviewEngine, base string, specialists []Specialist, task, diff string) []specialistResult {
results := make([]specialistResult, len(specialists))
// Optional live status board: publishes this model's per-lens progress to a
// file the entrypoint board renders. Inert (no-op) unless GADFLY_STATUS_FILE
// is set, so plain runs are unaffected.
sw := newStatusWriter(os.Getenv("GADFLY_MODEL"), modelProvider(), specialists)
conc := min(lensConcurrency(), len(specialists))
sem := make(chan struct{}, conc)
var wg sync.WaitGroup
for i, sp := range specialists {
wg.Add(1)
sem <- struct{}{} // blocks once `conc` lenses are already in flight
go func(i int, sp Specialist) {
defer wg.Done()
defer func() { <-sem }()
// A panic in one lens must not crash the whole binary (which would
// kill every other lens's output) or leave this lens stuck at
// "running" on the status board. Recover, record it as an errored
// result, and mark the lens finished so the board can complete.
defer func() {
if r := recover(); r != nil {
results[i] = specialistResult{spec: sp, out: fmt.Sprintf("⚠️ This reviewer panicked: %v", r), verdict: verdictUnknown, errored: true}
sw.set(sp.Name, lensFinished, "", true)
}
}()
sw.set(sp.Name, lensRunning, "", false)
out, errored := reviewWithSpecialist(eng, base, sp, task, diff)
v := parseVerdict(out)
results[i] = specialistResult{spec: sp, out: out, verdict: v, errored: errored}
sw.set(sp.Name, lensFinished, v.label(), errored)
}(i, sp)
}
wg.Wait()
return results
}
// lensConcurrency resolves how many specialist lenses run at once for THIS run's
// model. It mirrors entrypoint.sh's per-provider MODEL concurrency: a
// per-provider override in GADFLY_PROVIDER_LENS_CONCURRENCY ("provider=N,...")
// wins for the model's provider, otherwise the GADFLY_LENS_CONCURRENCY scalar
// (default 1). The provider is resolved by modelProvider() — the SAME lane rule
// entrypoint uses for GADFLY_PROVIDER_CONCURRENCY — so e.g.
// "ollama-cloud=3,m1=1" fans cloud lenses out while keeping a slow local box
// serial, exactly the way the model map does for whole models.
func lensConcurrency() int {
if n, ok := providerOverride("GADFLY_PROVIDER_LENS_CONCURRENCY", modelProvider()); ok {
return n
}
return envInt("GADFLY_LENS_CONCURRENCY", defaultLensConcurrency)
}
// providerOverride parses a "provider=N,provider=N" env map and returns the
// value for provider when present and valid (>0). Mirrors entrypoint.sh's
// provider_cap lookup so the two concurrency maps share one syntax.
func providerOverride(envName, provider string) (int, bool) {
for _, item := range strings.Split(os.Getenv(envName), ",") {
k, v, ok := strings.Cut(item, "=")
if !ok || strings.TrimSpace(k) != provider {
continue
}
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
return n, true
}
}
return 0, false
}
// reviewWithSpecialist runs one lens end-to-end under its OWN timeout, so a slow
// model on one lens can't starve the others: a review pass under the
// specialist's composed prompt, then the shared adversarial recheck pass. The
// returned bool is true when the review pass failed (rendered as an inline
// notice — advisory; one lens failing never sinks the others or the job).
func reviewWithSpecialist(eng reviewEngine, base string, sp Specialist, task, diff string) (string, bool) {
timeout := time.Duration(envInt("GADFLY_TIMEOUT_SECS", defaultTimeoutSecs)) * time.Second timeout := time.Duration(envInt("GADFLY_TIMEOUT_SECS", defaultTimeoutSecs)) * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
// Pass 1 — review: produce the draft. draft, err := eng.runPass(ctx, composeSpecialistPrompt(base, sp), task,
draft, err := runAgent(ctx, mdl, fsTools, string(systemBytes), buildTask(diff),
envInt("GADFLY_MAX_STEPS", defaultMaxSteps)) envInt("GADFLY_MAX_STEPS", defaultMaxSteps))
if err != nil { if err != nil {
return fmt.Errorf("review pass: %w", err) fmt.Fprintf(os.Stderr, "gadfly: specialist %q review pass failed: %v\n", sp.Name, err)
return fmt.Sprintf("⚠️ This reviewer failed to complete: %v", err), true
} }
// Pass 2 — recheck: adversarially re-verify the draft's findings and drop
// the unconfirmed ones. Skipped for a clean draft (nothing to verify) or
// when disabled. A recheck failure is non-fatal — we emit the unverified
// draft rather than losing the review entirely.
final := draft final := draft
if shouldRecheck(draft) { if shouldRecheck(draft) {
rechecked, rerr := runAgent(ctx, mdl, fsTools, recheckSystemPrompt, buildRecheckTask(draft, diff), rechecked, rerr := eng.runPass(ctx, recheckSystemPrompt, buildRecheckTask(draft, diff),
envInt("GADFLY_RECHECK_MAX_STEPS", defaultRecheckMaxSteps)) envInt("GADFLY_RECHECK_MAX_STEPS", defaultRecheckMaxSteps))
if rerr != nil { if rerr != nil {
fmt.Fprintln(os.Stderr, "gadfly: recheck pass failed; emitting unverified draft:", rerr) fmt.Fprintf(os.Stderr, "gadfly: specialist %q recheck failed; emitting unverified draft: %v\n", sp.Name, rerr)
} else { } else {
final = rechecked final = rechecked
} }
} }
return final, false
fmt.Println(final)
return nil
} }
// runAgent runs one agent pass (its own fresh toolbox over the sandbox) and // runAgent runs one agent pass (its own fresh toolbox over the sandbox) and
@@ -258,7 +431,7 @@ func buildTask(diff string) string {
truncNote := "" truncNote := ""
if maxDiff > 0 && len(diff) > maxDiff { if maxDiff > 0 && len(diff) > maxDiff {
diff = diff[:maxDiff] diff = diff[:maxDiff]
truncNote = fmt.Sprintf("\n\n[NOTE: diff truncated to %d chars in this message; call get_diff for the full text.]", maxDiff) truncNote = fmt.Sprintf("\n\n[NOTE: diff truncated to %d chars in this message; read the changed files (or call get_diff, if available) for the full text.]", maxDiff)
} }
var b strings.Builder var b strings.Builder
@@ -268,7 +441,7 @@ func buildTask(diff string) string {
if strings.TrimSpace(body) != "" { if strings.TrimSpace(body) != "" {
fmt.Fprintf(&b, "PR description:\n%s\n\n", body) fmt.Fprintf(&b, "PR description:\n%s\n\n", body)
} }
b.WriteString("Review the following unified diff. Before reporting any cross-file or compile-correctness issue, use your tools (read_file, grep, find_files) to verify it against the actual checked-out code — do not rely on the diff alone.\n\n") b.WriteString("Review the following unified diff. Before reporting any cross-file or compile-correctness issue, use your repository read tools to verify it against the actual checked-out code — do not rely on the diff alone.\n\n")
fmt.Fprintf(&b, "```diff\n%s\n```%s", diff, truncNote) fmt.Fprintf(&b, "```diff\n%s\n```%s", diff, truncNote)
return b.String() return b.String()
} }
+261
View File
@@ -0,0 +1,261 @@
package main
import (
"fmt"
"os"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/anthropic"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/google"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/llamaswap"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/openai"
)
// defaultProvider is the provider used when GADFLY_MODEL is a bare model id
// (no "provider/" prefix). It keeps existing Ollama Cloud configs — where the
// model list is just ids like "qwen3-coder:480b-cloud" — working unchanged.
const defaultProvider = "ollama-cloud"
// resolveModel builds the review model from the environment. Gadfly is powered
// by majordomo, so it can target any provider majordomo supports — Ollama
// (local or cloud), OpenAI, Anthropic, Google, or any OpenAI/Ollama-compatible
// endpoint — without code changes.
//
// Env:
//
// GADFLY_MODEL model id, or a full "provider/model" spec, or a
// majordomo failover chain / alias (required).
// GADFLY_PROVIDER provider prefix applied when GADFLY_MODEL has no "/"
// (default "ollama-cloud"). e.g. "ollama" for a local daemon.
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
// servers, a remote Ollama, an OpenRouter-style gateway…).
// When set, the provider is constructed directly at that URL.
// GADFLY_API_KEY bearer/API key for the chosen provider. Optional; when
// unset the provider falls back to its standard env var
// (OLLAMA_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY /
// GOOGLE_API_KEY|GEMINI_API_KEY). Local Ollama needs none.
//
// With GADFLY_BASE_URL unset, resolution goes through majordomo's registry, so
// LLM_* env DSNs and registered aliases/tiers work too.
func resolveModel() (llm.Model, error) {
// Register any env-defined endpoints/aliases first so they're resolvable as
// "<name>/<model>" specs below. Best-effort: a malformed entry is logged and
// skipped rather than failing the whole review.
for _, err := range registerEnvProviders() {
fmt.Fprintln(os.Stderr, "gadfly: ignoring bad endpoint/alias:", err)
}
model := strings.TrimSpace(os.Getenv("GADFLY_MODEL"))
if model == "" {
return nil, fmt.Errorf("GADFLY_MODEL is required")
}
provider := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER"))
if provider == "" {
provider = defaultProvider
}
baseURL := strings.TrimSpace(os.Getenv("GADFLY_BASE_URL"))
apiKey := os.Getenv("GADFLY_API_KEY")
// No endpoint override: let majordomo's registry resolve the spec. This
// path supports built-in providers (reading their standard key envs),
// LLM_* env DSNs, and aliases/failover chains.
if baseURL == "" {
return majordomo.Parse(buildSpec(provider, model))
}
// Endpoint override: construct the provider directly at the given URL.
switch provider {
case "openai", "openai-compatible":
opts := []openai.Option{openai.WithBaseURL(baseURL)}
if apiKey != "" {
opts = append(opts, openai.WithAPIKey(apiKey))
}
return openai.New(opts...).Model(model)
case "ollama", "ollama-cloud":
opts := []ollama.Option{ollama.WithBaseURL(baseURL)}
if apiKey != "" {
opts = append(opts, ollama.WithToken(apiKey))
}
return ollama.New(opts...).Model(model)
case "llamaswap", "llamaswaps", "llama-swap", "llama-swaps":
// llama-swap (model-swapping proxy). Accept every spelling: hyphenated
// ("llama-swap"/"llama-swaps") mirrors majordomo's DSN schemes (http vs
// https), and the un-hyphenated forms are accepted too. With an explicit
// GADFLY_BASE_URL the scheme is whatever the URL says, so all behave the same.
opts := []llamaswap.Option{llamaswap.WithBaseURL(baseURL)}
if apiKey != "" {
opts = append(opts, llamaswap.WithToken(apiKey))
}
return llamaswap.New(opts...).Model(model)
case "foreman":
// foreman (gitea.stevedudenhoeffer.com/steve/foreman) is a native-Ollama
// queue daemon; the preset also smooths its non-streaming/long-poll
// quirks. Base URL is used verbatim, so a plaintext http:// foreman works.
return ollama.Foreman(baseURL, apiKey).Model(model)
case "anthropic":
opts := []anthropic.Option{anthropic.WithBaseURL(baseURL)}
if apiKey != "" {
opts = append(opts, anthropic.WithAPIKey(apiKey))
}
return anthropic.New(opts...).Model(model)
case "google", "gemini":
opts := []google.Option{google.WithBaseURL(baseURL)}
if apiKey != "" {
opts = append(opts, google.WithAPIKey(apiKey))
}
return google.New(opts...).Model(model)
default:
return nil, fmt.Errorf("GADFLY_BASE_URL is set but GADFLY_PROVIDER %q has no endpoint-override support (use openai/openai-compatible/ollama/llama-swap/foreman/anthropic/google, or unset GADFLY_BASE_URL to resolve via majordomo)", provider)
}
}
// resolveWorkerModel builds the optional worker model for delegate_investigation
// from GADFLY_WORKER_MODEL (a cheap tier is ideal). Returns (nil, nil) when
// unset — delegation is simply off. Honors GADFLY_PROVIDER for a bare id.
func resolveWorkerModel() (llm.Model, error) {
spec := strings.TrimSpace(os.Getenv("GADFLY_WORKER_MODEL"))
if spec == "" {
return nil, nil
}
provider := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER"))
if provider == "" {
provider = defaultProvider
}
return majordomo.Parse(buildSpec(provider, spec))
}
// buildSpec turns (provider, model) into a majordomo spec. A model id that
// already carries a "provider/" prefix (or is a multi-element failover chain)
// is passed through verbatim; a bare id is prefixed with the provider.
func buildSpec(provider, model string) string {
if strings.Contains(model, "/") || strings.Contains(model, ",") {
return model
}
return provider + "/" + model
}
// modelProvider returns the provider "lane" for THIS run's model, mirroring
// entrypoint.sh's provider_of: the segment before the first "/" in GADFLY_MODEL,
// else GADFLY_PROVIDER, else the default (ollama-cloud). The binary reviews one
// model per invocation, so this is that model's provider — used to resolve
// per-provider policy (e.g. lens concurrency) against the SAME provider keys
// entrypoint uses for GADFLY_PROVIDER_CONCURRENCY.
func modelProvider() string {
model := strings.TrimSpace(os.Getenv("GADFLY_MODEL"))
if pfx, _, ok := strings.Cut(model, "/"); ok {
return strings.TrimSpace(pfx)
}
if p := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER")); p != "" {
return p
}
return defaultProvider
}
// registerEnvProviders reads named endpoints and aliases from the environment
// and registers them with majordomo's default registry, so they can be used as
// "<name>/<model>" specs (or bare aliases) in GADFLY_MODEL.
//
// Two env families (NAME is lowercased to form the registry name, like
// majordomo's own LLM_* convention — GADFLY_ENDPOINT_BIGBOX → "bigbox"):
//
// GADFLY_ENDPOINT_<NAME> = "<provider>|<base-url>[|<key>]"
// Registers a provider at an explicit endpoint. Unlike majordomo's LLM_*
// DSNs (which are HTTPS-only), the base URL is used verbatim, so a
// plaintext local Ollama (or foreman queue) works:
// GADFLY_ENDPOINT_BIGBOX="ollama|http://192.168.1.50:11434"
// GADFLY_MODEL=bigbox/qwen2.5-coder:7b
// provider is one of ollama/llama-swap(s)/foreman/openai/anthropic/google; "foreman"
// targets a foreman daemon (native Ollama on the wire):
// GADFLY_ENDPOINT_M1="foreman|http://foreman-m1:8080|tok"
//
// GADFLY_ALIAS_<NAME> = "<majordomo spec>"
// Registers a plain alias that expands inline (a model, or a failover
// chain): GADFLY_ALIAS_FAST="bigbox/qwen2.5-coder:7b,ollama-cloud/gpt-oss:120b-cloud".
//
// Returns one error per malformed entry; valid entries still register.
func registerEnvProviders() []error {
var errs []error
for _, kv := range os.Environ() {
key, val, _ := strings.Cut(kv, "=")
switch {
case strings.HasPrefix(key, "GADFLY_ENDPOINT_") && len(key) > len("GADFLY_ENDPOINT_"):
name := strings.ToLower(strings.TrimPrefix(key, "GADFLY_ENDPOINT_"))
p, err := endpointProvider(name, val)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", key, err))
continue
}
majordomo.RegisterProvider(p)
case strings.HasPrefix(key, "GADFLY_ALIAS_") && len(key) > len("GADFLY_ALIAS_"):
name := strings.ToLower(strings.TrimPrefix(key, "GADFLY_ALIAS_"))
spec := strings.TrimSpace(val)
if spec == "" {
errs = append(errs, fmt.Errorf("%s: empty alias spec", key))
continue
}
majordomo.RegisterAlias(name, spec)
}
}
return errs
}
// endpointProvider builds a named provider from a "provider|base-url[|key]"
// value. The base URL is honored verbatim (http or https).
func endpointProvider(name, raw string) (llm.Provider, error) {
parts := strings.SplitN(raw, "|", 3)
if len(parts) < 2 {
return nil, fmt.Errorf("want \"<provider>|<base-url>[|<key>]\", got %q", raw)
}
provider := strings.TrimSpace(parts[0])
baseURL := strings.TrimSpace(parts[1])
key := ""
if len(parts) == 3 {
key = strings.TrimSpace(parts[2])
}
if baseURL == "" {
return nil, fmt.Errorf("missing base URL in %q", raw)
}
switch provider {
case "ollama", "ollama-cloud":
opts := []ollama.Option{ollama.WithName(name), ollama.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, ollama.WithToken(key))
}
return ollama.New(opts...), nil
case "llamaswap", "llamaswaps", "llama-swap", "llama-swaps":
opts := []llamaswap.Option{llamaswap.WithName(name), llamaswap.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, llamaswap.WithToken(key))
}
return llamaswap.New(opts...), nil
case "foreman":
// foreman is native-Ollama on the wire; the preset additionally handles
// its non-streaming degradation. Unlike the HTTPS-only LLM_* foreman://
// DSN, the base URL here is verbatim, so a plaintext http:// foreman works.
return ollama.Foreman(baseURL, key, ollama.WithName(name)), nil
case "openai", "openai-compatible":
opts := []openai.Option{openai.WithName(name), openai.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, openai.WithAPIKey(key))
}
return openai.New(opts...), nil
case "anthropic":
opts := []anthropic.Option{anthropic.WithName(name), anthropic.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, anthropic.WithAPIKey(key))
}
return anthropic.New(opts...), nil
case "google", "gemini":
opts := []google.Option{google.WithName(name), google.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, google.WithAPIKey(key))
}
return google.New(opts...), nil
default:
return nil, fmt.Errorf("unknown provider %q (use ollama/llama-swap(s)/foreman/openai/openai-compatible/anthropic/google)", provider)
}
}
+91
View File
@@ -0,0 +1,91 @@
package main
import "testing"
func TestEndpointProvider(t *testing.T) {
t.Run("ollama http endpoint registers under its name", func(t *testing.T) {
p, err := endpointProvider("bigbox", "ollama|http://192.168.1.50:11434")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Name() != "bigbox" {
t.Errorf("Name() = %q, want %q", p.Name(), "bigbox")
}
})
t.Run("openai compatible with key", func(t *testing.T) {
if _, err := endpointProvider("gpu", "openai|http://gpu.lan:8000/v1|sk-x"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("foreman queue registers under its name", func(t *testing.T) {
p, err := endpointProvider("m1", "foreman|http://foreman-m1:8080|tok")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// WithName(name) must win over the Foreman preset's default "foreman".
if p.Name() != "m1" {
t.Errorf("Name() = %q, want %q", p.Name(), "m1")
}
})
t.Run("foreman without token", func(t *testing.T) {
if _, err := endpointProvider("m5", "foreman|http://foreman-m5:8080"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("llamaswap registers under its name", func(t *testing.T) {
p, err := endpointProvider("ls", "llamaswap|http://swap.lan:8080|tok")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Name() != "ls" {
t.Errorf("Name() = %q, want %q", p.Name(), "ls")
}
})
t.Run("llamaswap without token", func(t *testing.T) {
if _, err := endpointProvider("ls2", "llamaswap|http://swap.lan:8080"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
// All llama-swap spellings (hyphenated/TLS variants mirror majordomo's DSN
// schemes) must resolve to the llamaswap provider.
for _, name := range []string{"llama-swap", "llama-swaps", "llamaswaps"} {
t.Run(name+" alias", func(t *testing.T) {
p, err := endpointProvider("ls", name+"|https://swap.lan:8080|tok")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Name() != "ls" {
t.Errorf("Name() = %q, want %q", p.Name(), "ls")
}
})
}
for _, bad := range []string{"", "ollama", "noprovider-no-pipe", "mystery|http://x"} {
t.Run("rejects "+bad, func(t *testing.T) {
if _, err := endpointProvider("n", bad); err == nil {
t.Errorf("endpointProvider(%q) = nil error, want error", bad)
}
})
}
}
func TestBuildSpec(t *testing.T) {
tests := []struct {
name string
provider string
model string
want string
}{
{"bare id gets provider prefix", "ollama-cloud", "qwen3-coder:480b-cloud", "ollama-cloud/qwen3-coder:480b-cloud"},
{"bare id local ollama", "ollama", "llama3.1", "ollama/llama3.1"},
{"already has provider passes through", "ollama-cloud", "openai/gpt-4o", "openai/gpt-4o"},
{"slashed model name passes through verbatim", "openai", "openai/meta-llama/Llama-3.1", "openai/meta-llama/Llama-3.1"},
{"failover chain passes through", "ollama-cloud", "anthropic/opus-4.8,ollama-cloud/qwen3-coder:480b-cloud", "anthropic/opus-4.8,ollama-cloud/qwen3-coder:480b-cloud"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildSpec(tt.provider, tt.model); got != tt.want {
t.Errorf("buildSpec(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want)
}
})
}
}
+8 -9
View File
@@ -16,15 +16,14 @@ const defaultRecheckMaxSteps = 16
// against the real code before letting it survive — the antidote to a // against the real code before letting it survive — the antidote to a
// single-pass reviewer that reads a couple of files, mis-connects them, and // single-pass reviewer that reads a couple of files, mis-connects them, and
// posts a confident but wrong "blocking" verdict. // posts a confident but wrong "blocking" verdict.
const recheckSystemPrompt = `You are a VERIFICATION GATE for an automated adversarial code review of the const recheckSystemPrompt = `You are a VERIFICATION GATE for an automated adversarial code review. You are
"mort" project (a large Go Discord bot). You are given a DRAFT review produced given a DRAFT review produced by another model. Your job is NOT to write a new
by another model. Your job is NOT to write a new review — it is to confirm or review — it is to confirm or reject each finding in the draft against the ACTUAL
reject each finding in the draft against the ACTUAL code, then output the code, then output the corrected review.
corrected review.
You have the same read-only repository tools as the original reviewer: You have read-only access to the checked-out repository — use your tools to read
- read_file(path[, start_line, limit]), list_dir([path]), grep(pattern[, path, files and search the code to independently verify each finding against the real
max_results]), find_files(name[, max_results]), get_diff(). source.
For EVERY finding in the draft: For EVERY finding in the draft:
1. Independently reproduce the reasoning by reading the actual files with your 1. Independently reproduce the reasoning by reading the actual files with your
@@ -84,7 +83,7 @@ func buildRecheckTask(draft, diff string) string {
truncNote := "" truncNote := ""
if maxDiff > 0 && len(diff) > maxDiff { if maxDiff > 0 && len(diff) > maxDiff {
diff = diff[:maxDiff] diff = diff[:maxDiff]
truncNote = fmt.Sprintf("\n\n[NOTE: diff truncated to %d chars here; call get_diff for the full text.]", maxDiff) truncNote = fmt.Sprintf("\n\n[NOTE: diff truncated to %d chars here; read the changed files (or call get_diff, if available) for the full text.]", maxDiff)
} }
var b strings.Builder var b strings.Builder
+252
View File
@@ -0,0 +1,252 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// Specialist is one review lens: a focused instruction appended to the base
// system prompt, run as its own review (+recheck) pass and rendered as its own
// section in the consolidated comment.
type Specialist struct {
Name string // lowercase id, used in GADFLY_SPECIALISTS and section anchors
Title string // human label for the section header
Focus string // lens-specific instruction appended to the base prompt
}
// builtinSpecialists are the lenses Gadfly ships with. The default suite (below)
// is the "biggies"; the rest are opt-in by name via GADFLY_SPECIALISTS, a
// .gadfly.yml `specialists:` list, or `all`.
var builtinSpecialists = map[string]Specialist{
"security": {"security", "🔒 Security", "" +
"Authn/authz gaps, injection (SQL/command/path/template), SSRF, unsafe deserialization, " +
"secret/credential leakage, missing input validation, unsafe handling of untrusted data, " +
"and insecure defaults. Trace tainted input to where it's used."},
"correctness": {"correctness", "🎯 Correctness", "" +
"Logic bugs and incorrect behavior introduced by the change. Pay special attention to " +
"SEMANTIC / domain correctness: do NOT trust a constant, conversion factor, formula, unit, " +
"or threshold because it looks reasonable — independently RE-DERIVE the expected value from " +
"first principles (units, dimensions, boundary values) and compare. A plausible-looking magic " +
"number is exactly where real bugs hide."},
"maintainability": {"maintainability", "🧹 Code cleanliness & maintainability", "" +
"Readability and structure: dead/duplicated code, confusing names, overly long or deeply " +
"nested functions, leaky abstractions, copy-paste that should be shared, and changes that " +
"don't follow the patterns the surrounding code already uses. Prefer concrete, low-churn fixes."},
"performance": {"performance", "⚡ Performance", "" +
"Efficiency regressions: N+1 queries, unnecessary allocations or copies, work inside hot loops, " +
"unbounded growth, missing pagination/limits, blocking calls on hot paths, and avoidable " +
"quadratic behavior. Only flag impact you can justify, not micro-optimizations."},
"error-handling": {"error-handling", "🧯 Error handling & edge cases", "" +
"Ignored or swallowed errors, missing cleanup/rollback/`defer`, panics on bad input, and " +
"unhandled edge cases: nil/null, empty collections, zero/negative, integer overflow, and " +
"boundary (off-by-one) conditions. Check the unhappy paths the diff introduces."},
// --- opt-in (not in the default suite) ---
"tests": {"tests", "🧪 Tests", "" +
"Whether the change is adequately tested: new logic without tests, changed behavior whose " +
"tests weren't updated, missing edge-case/error-path coverage, and brittle or assertion-free tests."},
"docs": {"docs", "📝 Docs", "" +
"Documentation drift: public APIs/flags/config changed without updating doc comments, README, " +
"or examples; stale comments now contradicting the code; missing docs for new exported surface."},
"conventions": {"conventions", "📐 Conventions", "" +
"Adherence to THIS repo's own conventions. Discover them — read any README / CONTRIBUTING / " +
"CLAUDE.md / AGENTS.md / lint config the repo ships, and hold the change to the documented and " +
"surrounding-code patterns."},
"improvements": {"improvements", "💡 Improvements (optional)", "" +
"OPTIONAL, non-blocking suggestions only. Surface at most 12 genuinely high-value blind spots " +
"or simplifications the author likely wants to know about. Be quiet: if nothing rises to that " +
"bar, reply exactly 'No suggestions.' Never nitpick, never pad. These never affect the verdict."},
}
// defaultSuite is used when nothing selects specialists explicitly.
var defaultSuite = []string{"security", "correctness", "maintainability", "performance", "error-handling"}
// fileConfig is the optional .gadfly.yml schema.
type fileConfig struct {
// Specialists selects which run (names). Empty => the default suite.
Specialists []string `yaml:"specialists"`
// Define adds or overrides specialist definitions.
Define []struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
Focus string `yaml:"focus"`
} `yaml:"define"`
}
// resolveSpecialists builds the active specialist list from (in increasing
// precedence) the built-ins, a repo .gadfly.yml, and GADFLY_SPECIALIST_<NAME>
// env definitions. The active SELECTION is GADFLY_SPECIALISTS (csv, "all", or
// "auto"), else the file's `specialists:`, else the default suite. Unknown
// names are reported as errors and skipped; valid ones still run.
//
// It also returns the full registry (the catalog of every defined specialist)
// and an auto flag: when the selection is "auto", specs is nil and the caller
// runs the dynamic selector (see auto.go) against the registry instead.
func resolveSpecialists(repoDir string) (specs []Specialist, registry map[string]Specialist, auto bool, errs []error) {
// 1. registry seeded with built-ins.
registry = make(map[string]Specialist, len(builtinSpecialists))
for k, v := range builtinSpecialists {
registry[k] = v
}
// 2. config file: definitions + (maybe) the selection.
var fileSelection []string
if cfg, ok, err := loadFileConfig(repoDir); err != nil {
errs = append(errs, err)
} else if ok {
for _, d := range cfg.Define {
name := strings.ToLower(strings.TrimSpace(d.Name))
if name == "" || strings.TrimSpace(d.Focus) == "" {
errs = append(errs, fmt.Errorf(".gadfly.yml: specialist define needs non-empty name and focus"))
continue
}
registry[name] = Specialist{Name: name, Title: titleOr(d.Title, name), Focus: d.Focus}
}
fileSelection = cfg.Specialists
}
// 3. env definitions: GADFLY_SPECIALIST_<NAME>="focus".
for _, kv := range os.Environ() {
key, val, _ := strings.Cut(kv, "=")
if !strings.HasPrefix(key, "GADFLY_SPECIALIST_") || len(key) == len("GADFLY_SPECIALIST_") {
continue
}
name := strings.ToLower(strings.TrimPrefix(key, "GADFLY_SPECIALIST_"))
if strings.TrimSpace(val) == "" {
errs = append(errs, fmt.Errorf("%s: empty specialist focus", key))
continue
}
existing, ok := registry[name]
title := titleOr("", name)
if ok {
title = existing.Title
}
registry[name] = Specialist{Name: name, Title: title, Focus: val}
}
// 4. selection: env > file > default suite.
var names []string
switch {
case strings.TrimSpace(os.Getenv("GADFLY_SPECIALISTS")) != "":
names = splitCSV(os.Getenv("GADFLY_SPECIALISTS"))
case len(fileSelection) > 0:
names = fileSelection
default:
names = defaultSuite
}
if len(names) == 1 && strings.EqualFold(names[0], "auto") {
// Dynamic selection: caller runs the selector against the registry.
return nil, registry, true, errs
}
if len(names) == 1 && strings.EqualFold(names[0], "all") {
names = sortedKeys(registry)
}
// 5. resolve names -> specialists.
var out []Specialist
seen := map[string]bool{}
for _, raw := range names {
name := strings.ToLower(strings.TrimSpace(raw))
if name == "" || seen[name] {
continue
}
seen[name] = true
sp, ok := registry[name]
if !ok {
errs = append(errs, fmt.Errorf("unknown specialist %q (built-ins: %s; or define it via .gadfly.yml / GADFLY_SPECIALIST_%s)",
name, strings.Join(sortedKeys(builtinSpecialists), ", "), strings.ToUpper(name)))
continue
}
out = append(out, sp)
}
return out, registry, false, errs
}
// suiteFromRegistry resolves names against the registry, skipping any missing.
// Used as the auto-mode fallback (the default suite always exists in builtins).
func suiteFromRegistry(registry map[string]Specialist, names []string) []Specialist {
var out []Specialist
for _, n := range names {
if sp, ok := registry[n]; ok {
out = append(out, sp)
}
}
return out
}
// specialistNamesOf returns the names of a specialist slice (for logging).
func specialistNamesOf(specs []Specialist) []string {
names := make([]string, len(specs))
for i, s := range specs {
names[i] = s.Name
}
return names
}
// composeSpecialistPrompt appends a specialist's lens to the base system prompt.
func composeSpecialistPrompt(base string, sp Specialist) string {
return strings.TrimRight(base, "\n") +
"\n\n## Your assigned lens — " + sp.Title + "\n" +
"Review the change specifically and ONLY through this lens. Scrutinize it for:\n" +
sp.Focus +
"\n\nStay in this lane: other lenses (correctness, security, performance, etc.) are reviewed " +
"separately, so don't duplicate their findings here. If nothing in your lens is materially " +
"wrong, reply with the \"No material issues found\" verdict for this lens — do not reach for " +
"another lens's issue just to have something to say."
}
func loadFileConfig(repoDir string) (fileConfig, bool, error) {
candidates := []string{}
if p := strings.TrimSpace(os.Getenv("GADFLY_CONFIG")); p != "" {
if !filepath.IsAbs(p) {
p = filepath.Join(repoDir, p)
}
candidates = append(candidates, p)
} else {
candidates = append(candidates, filepath.Join(repoDir, ".gadfly.yml"), filepath.Join(repoDir, ".gadfly.yaml"))
}
for _, p := range candidates {
data, err := os.ReadFile(p)
if err != nil {
continue // not found / unreadable: try next
}
var cfg fileConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fileConfig{}, false, fmt.Errorf("parse %s: %w", filepath.Base(p), err)
}
return cfg, true, nil
}
return fileConfig{}, false, nil
}
func titleOr(title, name string) string {
if strings.TrimSpace(title) != "" {
return title
}
return name
}
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
func sortedKeys(m map[string]Specialist) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+138
View File
@@ -0,0 +1,138 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func names(specs []Specialist) []string {
out := make([]string, len(specs))
for i, s := range specs {
out[i] = s.Name
}
return out
}
func eq(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestResolveSpecialists_DefaultSuite(t *testing.T) {
t.Setenv("GADFLY_SPECIALISTS", "")
specs, _, _, errs := resolveSpecialists(t.TempDir())
if len(errs) != 0 {
t.Fatalf("unexpected errors: %v", errs)
}
if !eq(names(specs), defaultSuite) {
t.Errorf("default = %v, want %v", names(specs), defaultSuite)
}
}
func TestResolveSpecialists_EnvSelection(t *testing.T) {
t.Setenv("GADFLY_SPECIALISTS", "security, tests")
specs, _, _, errs := resolveSpecialists(t.TempDir())
if len(errs) != 0 {
t.Fatalf("unexpected errors: %v", errs)
}
if !eq(names(specs), []string{"security", "tests"}) {
t.Errorf("got %v", names(specs))
}
}
func TestResolveSpecialists_AutoFlag(t *testing.T) {
t.Setenv("GADFLY_SPECIALISTS", "auto")
specs, registry, auto, errs := resolveSpecialists(t.TempDir())
if len(errs) != 0 {
t.Fatalf("unexpected errors: %v", errs)
}
if !auto {
t.Error("expected auto=true for GADFLY_SPECIALISTS=auto")
}
if specs != nil {
t.Errorf("auto mode should return nil specs, got %v", names(specs))
}
if len(registry) == 0 {
t.Error("auto mode should still return the registry catalog")
}
}
func TestResolveSpecialists_UnknownNameErrors(t *testing.T) {
t.Setenv("GADFLY_SPECIALISTS", "security,bogus")
specs, _, _, errs := resolveSpecialists(t.TempDir())
if len(errs) == 0 {
t.Fatal("expected an error for unknown specialist")
}
if !eq(names(specs), []string{"security"}) {
t.Errorf("valid ones should still resolve, got %v", names(specs))
}
}
func TestResolveSpecialists_EnvCustomDefinition(t *testing.T) {
t.Setenv("GADFLY_SPECIALIST_MIGRATIONS", "Review DB migrations for destructive ops.")
t.Setenv("GADFLY_SPECIALISTS", "migrations")
specs, _, _, errs := resolveSpecialists(t.TempDir())
if len(errs) != 0 {
t.Fatalf("unexpected errors: %v", errs)
}
if len(specs) != 1 || specs[0].Name != "migrations" || specs[0].Focus == "" {
t.Fatalf("custom specialist not registered: %+v", specs)
}
}
func TestResolveSpecialists_FileConfig(t *testing.T) {
dir := t.TempDir()
cfg := `specialists: [security, migrations]
define:
- name: migrations
title: "DB migrations"
focus: "Review schema migrations for destructive or unindexed changes."
`
if err := os.WriteFile(filepath.Join(dir, ".gadfly.yml"), []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("GADFLY_SPECIALISTS", "") // let the file drive selection
specs, _, _, errs := resolveSpecialists(dir)
if len(errs) != 0 {
t.Fatalf("unexpected errors: %v", errs)
}
if !eq(names(specs), []string{"security", "migrations"}) {
t.Errorf("got %v", names(specs))
}
}
func TestParseVerdictAndWorst(t *testing.T) {
cases := map[string]verdict{
"VERDICT: No material issues found.": verdictClean,
"Minor issues\n- nit": verdictMinor,
"**Blocking issues found**": verdictBlocking,
"**Blocking issues**\n- bug": verdictBlocking, // no "found" suffix
"VERDICT: Blocking issue\n- one": verdictBlocking, // singular
"No material issues found. There are no blocking issues.": verdictClean, // earliest phrase wins
"something unparseable": verdictUnknown,
}
for in, want := range cases {
if got := parseVerdict(in); got != want {
t.Errorf("parseVerdict(%q) = %v, want %v", in, got, want)
}
}
results := []specialistResult{
{spec: Specialist{Name: "security"}, verdict: verdictMinor},
{spec: Specialist{Name: "correctness"}, verdict: verdictBlocking},
{spec: Specialist{Name: "improvements"}, verdict: verdictBlocking}, // must not count
}
if w := worstVerdict(results[:2]); w != verdictBlocking {
t.Errorf("worst = %v, want blocking", w)
}
if w := worstVerdict([]specialistResult{results[0], results[2]}); w != verdictMinor {
t.Errorf("improvements should not escalate; worst = %v, want minor", w)
}
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Lens states for the live status board. A lens starts queued, becomes running
// when its pass begins, and ends finished (with a verdict, or errored).
const (
lensQueued = "queued"
lensRunning = "running"
lensFinished = "finished"
)
// lensStatus is one specialist lens's progress, as rendered by the entrypoint
// status board (scripts/status-board.sh).
type lensStatus struct {
Name string `json:"name"`
State string `json:"state"` // queued | running | finished
Verdict string `json:"verdict,omitempty"` // set when finished (the lens's label)
Errored bool `json:"errored,omitempty"` // the lens failed to complete
}
// modelStatus is the on-disk shape one model process publishes for the live
// status board: a snapshot of this model's lenses as they progress. The board
// reads every model's file and renders a single consolidated PR comment.
type modelStatus struct {
Model string `json:"model"`
Provider string `json:"provider"`
Started int64 `json:"started"` // unix seconds
Updated int64 `json:"updated"` // unix seconds, bumped on every change
Done bool `json:"done"` // all lenses finished
Lenses []lensStatus `json:"lenses"`
}
// statusWriter maintains a model's status file as its lenses progress. It is
// purely opt-in: when GADFLY_STATUS_FILE is unset the writer's path is empty and
// every method is a no-op, so a plain run (and the unit tests) never touch the
// filesystem and behave exactly as before. Writes are atomic (temp file +
// rename within the same dir) so the board never reads a half-written file even
// though lenses can finish concurrently.
type statusWriter struct {
path string
mu sync.Mutex
st modelStatus
}
// newStatusWriter seeds a writer with every lens queued and flushes the initial
// snapshot. model/provider are echoed into the file so the board can render
// them without re-deriving from the filename (which is sanitized). The status
// file path comes from GADFLY_STATUS_FILE (set by run.sh per model); when empty
// the writer is inert.
func newStatusWriter(model, provider string, specialists []Specialist) *statusWriter {
w := &statusWriter{path: strings.TrimSpace(os.Getenv("GADFLY_STATUS_FILE"))}
w.st = modelStatus{
Model: model,
Provider: provider,
Started: time.Now().Unix(),
}
for _, sp := range specialists {
w.st.Lenses = append(w.st.Lenses, lensStatus{Name: sp.Name, State: lensQueued})
}
w.flush()
return w
}
// set transitions a lens to a new state (and verdict/errored when finished),
// recomputes the overall done flag, and atomically rewrites the file. Unknown
// lens names are ignored. Safe for concurrent callers (one goroutine per lens).
func (w *statusWriter) set(name, state, verdict string, errored bool) {
if w == nil || w.path == "" {
return
}
w.mu.Lock()
defer w.mu.Unlock()
for i := range w.st.Lenses {
if w.st.Lenses[i].Name == name {
w.st.Lenses[i].State = state
w.st.Lenses[i].Verdict = verdict
w.st.Lenses[i].Errored = errored
break
}
}
done := true
for _, l := range w.st.Lenses {
if l.State != lensFinished {
done = false
break
}
}
w.st.Done = done
w.flush()
}
// flush writes the current snapshot atomically. Best-effort: any error is
// swallowed (the status board is advisory and must never affect the review).
func (w *statusWriter) flush() {
if w.path == "" {
return
}
w.st.Updated = time.Now().Unix()
data, err := json.MarshalIndent(&w.st, "", " ")
if err != nil {
return
}
dir := filepath.Dir(w.path)
tmp, err := os.CreateTemp(dir, ".status-*.tmp")
if err != nil {
return
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
return
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return
}
// Rename is atomic within the same filesystem, so the board reader sees
// either the old complete file or the new complete file — never a partial.
if err := os.Rename(tmpName, w.path); err != nil {
os.Remove(tmpName)
}
}
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// readStatus loads a modelStatus written by the statusWriter.
func readStatus(t *testing.T, path string) modelStatus {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read status file: %v", err)
}
var st modelStatus
if err := json.Unmarshal(data, &st); err != nil {
t.Fatalf("unmarshal status: %v", err)
}
return st
}
func TestStatusWriterLifecycle(t *testing.T) {
path := filepath.Join(t.TempDir(), "glm.json")
t.Setenv("GADFLY_STATUS_FILE", path)
specs := []Specialist{
{Name: "security", Title: "Security"},
{Name: "correctness", Title: "Correctness"},
}
w := newStatusWriter("glm-5.2:cloud", "ollama-cloud", specs)
// Initial snapshot: both lenses queued, model not done, metadata echoed.
st := readStatus(t, path)
if st.Model != "glm-5.2:cloud" || st.Provider != "ollama-cloud" {
t.Fatalf("model/provider not echoed: %+v", st)
}
if len(st.Lenses) != 2 {
t.Fatalf("want 2 lenses, got %d", len(st.Lenses))
}
for _, l := range st.Lenses {
if l.State != lensQueued {
t.Fatalf("lens %q want queued, got %q", l.Name, l.State)
}
}
if st.Done {
t.Fatal("model marked done while lenses still queued")
}
if st.Started == 0 {
t.Fatal("started timestamp not set")
}
// Transition one lens through running -> finished; model not yet done.
w.set("security", lensRunning, "", false)
if got := readStatus(t, path); got.Lenses[0].State != lensRunning {
t.Fatalf("security want running, got %q", got.Lenses[0].State)
}
w.set("security", lensFinished, "No material issues found", false)
st = readStatus(t, path)
if st.Lenses[0].State != lensFinished || st.Lenses[0].Verdict != "No material issues found" {
t.Fatalf("security finish not recorded: %+v", st.Lenses[0])
}
if st.Done {
t.Fatal("model marked done with one lens still queued")
}
// Finish the second lens (errored) -> model done.
w.set("correctness", lensFinished, "Reviewed", true)
st = readStatus(t, path)
if !st.Done {
t.Fatal("model should be done after all lenses finished")
}
if !st.Lenses[1].Errored {
t.Fatal("errored flag not recorded for correctness")
}
if st.Updated < st.Started {
t.Fatalf("updated (%d) should be >= started (%d)", st.Updated, st.Started)
}
}
// With GADFLY_STATUS_FILE unset the writer is inert: no file, no panic.
func TestStatusWriterDisabled(t *testing.T) {
t.Setenv("GADFLY_STATUS_FILE", "")
w := newStatusWriter("m", "p", []Specialist{{Name: "security"}})
w.set("security", lensFinished, "Minor issues", false)
// Nothing to assert beyond "did not panic / did not write" — a nil-safe set
// on the disabled writer is the contract.
if w.path != "" {
t.Fatalf("expected empty path when disabled, got %q", w.path)
}
}
// set must ignore unknown lens names rather than panic or append.
func TestStatusWriterUnknownLens(t *testing.T) {
path := filepath.Join(t.TempDir(), "s.json")
t.Setenv("GADFLY_STATUS_FILE", path)
w := newStatusWriter("m", "p", []Specialist{{Name: "security"}})
w.set("does-not-exist", lensRunning, "", false)
if st := readStatus(t, path); len(st.Lenses) != 1 || st.Lenses[0].State != lensQueued {
t.Fatalf("unknown lens mutated state: %+v", st.Lenses)
}
}
+26 -4
View File
@@ -39,6 +39,7 @@ var skipDirs = map[string]bool{
type repoFS struct { type repoFS struct {
root string // absolute, symlink-resolved repo root root string // absolute, symlink-resolved repo root
diff string // the full PR unified diff (served by get_diff) diff string // the full PR unified diff (served by get_diff)
worker llm.Model // optional cheap model for delegate_investigation; nil = no delegation
} }
// newRepoFS resolves root to an absolute, symlink-free path. // newRepoFS resolves root to an absolute, symlink-free path.
@@ -96,16 +97,25 @@ func (r *repoFS) contains(abs string) error {
return nil return nil
} }
// toolbox builds the read-only review toolbox over this sandbox. // fsTools is the set of read-only repository tools.
func (r *repoFS) toolbox() (*llm.Toolbox, error) { func (r *repoFS) fsTools() []llm.Tool {
box := llm.NewToolbox("gadfly") return []llm.Tool{
tools := []llm.Tool{
r.readFileTool(), r.readFileTool(),
r.listDirTool(), r.listDirTool(),
r.grepTool(), r.grepTool(),
r.findFilesTool(), r.findFilesTool(),
r.getDiffTool(), r.getDiffTool(),
} }
}
// toolbox builds the reviewer's toolbox: the read-only repo tools, plus the
// delegate_investigation tool when a worker model is configured.
func (r *repoFS) toolbox() (*llm.Toolbox, error) {
box := llm.NewToolbox("gadfly")
tools := r.fsTools()
if r.worker != nil {
tools = append(tools, r.delegateTool())
}
for _, t := range tools { for _, t := range tools {
if err := box.Add(t); err != nil { if err := box.Add(t); err != nil {
return nil, fmt.Errorf("add tool %q: %w", t.Name, err) return nil, fmt.Errorf("add tool %q: %w", t.Name, err)
@@ -114,6 +124,18 @@ func (r *repoFS) toolbox() (*llm.Toolbox, error) {
return box, nil return box, nil
} }
// workerToolbox is the toolbox handed to a delegated worker sub-agent: the
// read-only repo tools only (no delegate tool — workers don't sub-delegate).
func (r *repoFS) workerToolbox() (*llm.Toolbox, error) {
box := llm.NewToolbox("gadfly-worker")
for _, t := range r.fsTools() {
if err := box.Add(t); err != nil {
return nil, fmt.Errorf("add tool %q: %w", t.Name, err)
}
}
return box, nil
}
type readFileArgs struct { type readFileArgs struct {
Path string `json:"path" description:"Repository-relative path of the file to read, e.g. pkg/logic/agentexec/pipeline.go"` Path string `json:"path" description:"Repository-relative path of the file to read, e.g. pkg/logic/agentexec/pipeline.go"`
StartLine int `json:"start_line,omitempty" description:"Optional 1-based line to start from (default 1)."` StartLine int `json:"start_line,omitempty" description:"Optional 1-based line to start from (default 1)."`
+140 -15
View File
@@ -31,13 +31,25 @@
# COMMENT_ID comment id, for the 👀 reaction (issue_comment only) # COMMENT_ID comment id, for the 👀 reaction (issue_comment only)
# ACTOR github.actor (the user who triggered) # ACTOR github.actor (the user who triggered)
# Optional config: # Optional config:
# OLLAMA_REVIEW_MODELS comma-separated model ids (default below) # GADFLY_MODELS comma-separated model ids/specs (alias: OLLAMA_REVIEW_MODELS)
# GADFLY_PROVIDER majordomo provider for bare model ids (default ollama-cloud;
# e.g. "ollama" local, "openai", "anthropic", "google")
# GADFLY_BASE_URL override backend endpoint (OpenAI/Ollama-compatible servers)
# GADFLY_API_KEY provider key (else provider's standard env: OPENAI_API_KEY, …)
# CLAUDE_CODE_OAUTH_TOKEN auth for the claude-code engine (GADFLY_MODELS entry
# "claude-code"/"claude-code/<model>"); Pro/Max subscription
# token from `claude setup-token`. Else ANTHROPIC_API_KEY.
# GADFLY_TRIGGER_PHRASE comment phrase that triggers a re-review (default "@gadfly review") # GADFLY_TRIGGER_PHRASE comment phrase that triggers a re-review (default "@gadfly review")
# GADFLY_ALLOWED_USERS comma-separated usernames allowed to comment-trigger; # GADFLY_ALLOWED_USERS comma-separated usernames allowed to comment-trigger;
# empty => fall back to "is a repo collaborator" # empty => fall back to "is a repo collaborator"
# GADFLY_FINDINGS_URL optional gadfly-reports store base URL; set to POST the run +
# findings for model-quality tracking (off when empty)
# GADFLY_FINDINGS_TOKEN optional bearer token for the gadfly-reports store
set -uo pipefail set -uo pipefail
DEFAULT_MODELS="qwen3-coder:480b-cloud,gpt-oss:120b-cloud" # One model by default: the specialist suite already provides breadth, so a
# multi-model default would multiply cost (models × specialists × 2 passes).
DEFAULT_MODELS="qwen3-coder:480b-cloud"
TRIGGER_PHRASE="${GADFLY_TRIGGER_PHRASE:-@gadfly review}" TRIGGER_PHRASE="${GADFLY_TRIGGER_PHRASE:-@gadfly review}"
SCRIPTS_DIR="/app/scripts" SCRIPTS_DIR="/app/scripts"
WORKDIR="${WORKDIR:-/tmp/gadfly}" WORKDIR="${WORKDIR:-/tmp/gadfly}"
@@ -118,18 +130,131 @@ log "cloning ${REPO_PATH} @ ${BRANCH}"
git clone --depth=1 --branch "$BRANCH" "$CLONE_URL" "$REPO_DIR" 2>/dev/null \ git clone --depth=1 --branch "$BRANCH" "$CLONE_URL" "$REPO_DIR" 2>/dev/null \
|| die "clone of ${REPO_PATH}@${BRANCH} failed" || die "clone of ${REPO_PATH}@${BRANCH} failed"
# --- review once per model ------------------------------------------------- # --- findings telemetry (optional) -----------------------------------------
MODELS="${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}" # Plumb the run context to the binary (inherited through run.sh and the gadfly
log "models: ${MODELS}" # process env). The binary's emit is OFF unless GADFLY_FINDINGS_URL is set; when
IFS=',' read -ra ARR <<< "$MODELS" || true # set it POSTs the run + its findings to a gadfly-reports store. GADFLY_FINDINGS_URL /
for raw in "${ARR[@]}"; do # GADFLY_FINDINGS_TOKEN come from the consumer's stub env and are re-exported so
m="$(echo "$raw" | tr -d '[:space:]')" # they reach the binary even if unset (empty => disabled).
[ -z "$m" ] && continue export GADFLY_REPO="$REPO_PATH"
log "::: reviewing with ${m}" export GADFLY_PR="$PR"
PROVIDER=ollama \ export GADFLY_FINDINGS_URL="${GADFLY_FINDINGS_URL:-}"
MODEL="$m" \ export GADFLY_FINDINGS_TOKEN="${GADFLY_FINDINGS_TOKEN:-}"
GADFLY_BIN="/usr/local/bin/gadfly" \
GADFLY_REPO_DIR="$REPO_DIR" \ # --- review once per model, with per-provider concurrency -------------------
bash "${SCRIPTS_DIR}/run.sh" || log "model ${m} failed (continuing)" # GADFLY_MODELS is the provider-agnostic name; OLLAMA_REVIEW_MODELS is a
# back-compat alias. GADFLY_PROVIDER / GADFLY_BASE_URL / GADFLY_API_KEY and any
# provider key envs (OPENAI_API_KEY, …) are inherited by run.sh and the binary.
#
# Concurrency: each PROVIDER is its own lane and lanes run in PARALLEL, so a fast
# cloud provider isn't stuck behind a slow local box. Within a lane, at most
# `cap` models run at once. cap = GADFLY_PROVIDER_CONCURRENCY's "provider=N"
# entry, else GADFLY_CONCURRENCY (default 1). A model's provider is the spec's
# first path segment ("m1pro/qwen3.6:35b-mlx" -> m1pro), or GADFLY_PROVIDER /
# ollama-cloud for a bare id. Default (cap 1) keeps a single-provider pool fully
# sequential, exactly as before.
MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}"
DEFAULT_CONC="${GADFLY_CONCURRENCY:-1}"
provider_of() { case "$1" in */*) echo "${1%%/*}";; *) echo "${GADFLY_PROVIDER:-ollama-cloud}";; esac; }
# Per-model status file path for the live board. The model id can contain '/'
# and ':' (e.g. m1/qwen3:14b), so sanitize to a flat filename; the JSON inside
# carries the real model/provider, so this just needs to be unique per model.
STATUS_DIR="${WORKDIR}/status"
status_file_for() { echo "${STATUS_DIR}/$(echo "$1" | tr -c '[:alnum:]._-' '_').json"; }
provider_cap() { # provider -> concurrency (override map "p=N,...", else default)
local p="$1" item k v
IFS=',' read -ra _caps <<< "${GADFLY_PROVIDER_CONCURRENCY:-}"
for item in "${_caps[@]}"; do
k="$(echo "${item%%=*}" | tr -d '[:space:]')"
v="$(echo "${item#*=}" | tr -d '[:space:]')"
if [ "$k" = "$p" ] && [ -n "$v" ]; then echo "$v"; return; fi
done
echo "$DEFAULT_CONC"
}
review_one() {
local sf=""
[ "${GADFLY_STATUS_BOARD:-1}" != "0" ] && sf="$(status_file_for "$1")"
PROVIDER=ollama MODEL="$1" GADFLY_BIN="/usr/local/bin/gadfly" GADFLY_REPO_DIR="$REPO_DIR" \
GADFLY_STATUS_FILE="$sf" \
bash "${SCRIPTS_DIR}/run.sh" || log "model $1 failed (continuing)"
# If the binary never wrote real status (run.sh skipped it: empty diff, no key,
# binary missing), the pre-seed stays {started:0, done:false} and the board
# would show this model "waiting to start" forever and never reach N/N. Mark
# such a never-started file done so the board can complete. The binary stamps a
# nonzero `started`, so that reliably distinguishes "ran" from "skipped".
if [ -n "$sf" ] && [ -f "$sf" ] && [ "$(jq -r '.started // 0' "$sf" 2>/dev/null)" = "0" ]; then
tmp="$(jq '.done = true' "$sf" 2>/dev/null)" && printf '%s' "$tmp" > "$sf"
fi
}
# Normalize the model list (trim, drop blanks) into MODEL_LIST.
IFS=',' read -ra _raw <<< "$MODELS" || true
MODEL_LIST=()
for raw in "${_raw[@]}"; do m="$(echo "$raw" | tr -d '[:space:]')"; [ -n "$m" ] && MODEL_LIST+=("$m"); done
# Distinct providers, in first-seen order (no associative arrays — portable).
PROVIDERS=""
for m in "${MODEL_LIST[@]}"; do
p="$(provider_of "$m")"
case " $PROVIDERS " in *" $p "*) ;; *) PROVIDERS="${PROVIDERS}${PROVIDERS:+ }$p" ;; esac
done done
run_lane() { # $1=provider: run its models, at most `cap` at a time
local p="$1" cap inflight=0 m
cap="$(provider_cap "$p")"; [ "$cap" -ge 1 ] 2>/dev/null || cap=1
local mine=()
for m in "${MODEL_LIST[@]}"; do [ "$(provider_of "$m")" = "$p" ] && mine+=("$m"); done
log "lane ${p}: cap ${cap}; models: ${mine[*]}"
for m in "${mine[@]}"; do
review_one "$m" &
inflight=$((inflight+1))
if [ "$inflight" -ge "$cap" ]; then wait -n 2>/dev/null || wait; inflight=$((inflight-1)); fi
done
wait
}
# --- live status board (optional, default on) ------------------------------
# Each model process publishes per-lens progress to STATUS_DIR/<model>.json; a
# background renderer (status-board.sh) upserts ONE consolidated PR comment so
# progress across all models/lenses is visible at a glance — and a watcher can
# tell when the whole swarm is finished. Advisory/best-effort; the per-model
# findings still land in each model's own comment. Disable with
# GADFLY_STATUS_BOARD=0.
BOARD_PID=""
if [ "${GADFLY_STATUS_BOARD:-1}" != "0" ]; then
rm -rf "$STATUS_DIR"; mkdir -p "$STATUS_DIR"
# Pre-seed every model as queued so the board shows the full swarm from t=0,
# even models still waiting on their provider lane's concurrency cap. Each
# binary overwrites its own file with real per-lens detail once it starts.
for m in "${MODEL_LIST[@]}"; do
jq -n --arg model "$m" --arg provider "$(provider_of "$m")" \
'{model:$model, provider:$provider, started:0, updated:0, done:false, lenses:[]}' \
> "$(status_file_for "$m")" 2>/dev/null || true
done
GITEA_API="$GITEA_API" GITEA_TOKEN="$GITEA_TOKEN" PR="$PR" GADFLY_STATUS_DIR="$STATUS_DIR" \
bash "${SCRIPTS_DIR}/status-board.sh" &
BOARD_PID=$!
log "status board started (pid ${BOARD_PID})"
fi
log "providers: ${PROVIDERS:-none}"
# Each provider lane runs in parallel; cap is enforced within each lane. Track
# the lane PIDs so we wait ONLY for the review work — not the status board,
# which intentionally runs until we signal it below.
LANE_PIDS=()
for p in $PROVIDERS; do
run_lane "$p" &
LANE_PIDS+=("$!")
done
[ "${#LANE_PIDS[@]}" -gt 0 ] && wait "${LANE_PIDS[@]}"
# Reviews are done: signal the board to render the final state once and exit.
if [ -n "$BOARD_PID" ]; then
touch "${STATUS_DIR}/.done" 2>/dev/null || true
wait "$BOARD_PID" 2>/dev/null || true
fi
log "done" log "done"
+34
View File
@@ -0,0 +1,34 @@
# Optional per-repo Gadfly config. Place at the repo ROOT as `.gadfly.yml`
# (or point GADFLY_CONFIG at it). It's read from the PR's checked-out tree, so
# it's version-controlled and reviewed like any other file.
#
# Precedence for specialist DEFINITIONS: built-ins < this file < GADFLY_SPECIALIST_* env.
# Precedence for SELECTION: GADFLY_SPECIALISTS env > this file's `specialists:` > default suite.
# Which specialists run. Omit this key to use the default suite
# (security, correctness, maintainability, performance, error-handling).
# Built-in opt-ins you can add: tests, docs, conventions, improvements. Or "all".
specialists:
- security
- correctness
- maintainability
- tests
- migrations # a custom one, defined below
# Add new specialists, or override a built-in by reusing its name (e.g. give
# `security` a repo-specific focus). `focus` is appended to the base reviewer
# prompt as that lens's instruction.
define:
- name: migrations
title: "🗃️ DB migrations"
focus: >
Review database schema migrations for destructive operations (DROP/ALTER
that loses data), missing or unindexed foreign keys, non-idempotent steps,
and changes that would lock a large table on deploy. Check that any new
column added to a domain struct is wired through every storage layer.
- name: security
title: "🔒 Security (house rules)"
focus: >
In addition to the usual security review, this repo requires: all web
routes use the auth middleware, no secrets in logs, and all external HTTP
calls set a timeout. Flag any violation.
+27
View File
@@ -0,0 +1,27 @@
# Example consumer workflows
Each file here is a complete, copy-paste **stub workflow**. Pick the one that matches your
setup, copy it to `.gitea/workflows/adversarial-review.yml` in the repo you want reviewed, and
set the secrets/vars it references. Gadfly is advisory only — it never blocks a merge.
| File | Backend | Needs |
|------|---------|-------|
| [`reusable.yml`](reusable.yml) | **slimmest stub** — calls Gadfly's reusable workflow and inherits its **default swarm** (3 cloud + Claude Code, 5-lens suite), forwarding only the secrets it needs (least privilege, not `secrets: inherit`); the stub keeps a cloud-only `models:` override so it runs with just the Ollama key (drop it + add the Claude token to get the full default) | secret `OLLAMA_CLOUD_API_KEY` |
| [`adversarial-review.yml`](adversarial-review.yml) | **Ollama Cloud** (default) + inline notes for every provider; full self-contained stub | secret `OLLAMA_CLOUD_API_KEY` |
| [`local-ollama.yml`](local-ollama.yml) | a **local/LAN Ollama** daemon | nothing (or `GADFLY_BASE_URL` for a remote host) |
| [`openai-compatible.yml`](openai-compatible.yml) | any **OpenAI-compatible** endpoint (local Ollama `/v1`, gateway, vLLM, OpenRouter…) | `GADFLY_BASE_URL` (+ a key for most gateways) |
| [`endpoint-aliases.yml`](endpoint-aliases.yml) | **several named backends** at once (one comment each) | repo vars `GADFLY_ENDPOINT_<NAME>` |
| [`claude-code.yml`](claude-code.yml) | the bundled **Claude Code CLI** engine (`claude-code/<model>`) | secret `CLAUDE_CODE_OAUTH_TOKEN` (or `ANTHROPIC_API_KEY`) |
| [`.gadfly.yml`](.gadfly.yml) | **per-repo specialist config** (not a workflow — goes at your repo root) | — |
Common to all:
- **Triggers:** new/reopened/ready non-draft PR (auto), `@gadfly review` comment (allowed users),
or manual `workflow_dispatch` with a `pr_number`.
- `GITEA_TOKEN` is provided automatically; comments post as `gitea-actions`.
- Tested backends are the **Ollama** ones; OpenAI/Anthropic/Google are wired via majordomo but
untested. See the repo [README](../README.md#models--providers) for the full config reference
and the honest tested/untested status.
> **Gitea note:** repo `vars`/`secrets` are not auto-exposed as env — anything you reference
> via `${{ vars.X }}` / `${{ secrets.X }}` must appear in the step's `env:` block (already wired
> in these examples).
+59
View File
@@ -33,6 +33,13 @@ concurrency:
jobs: jobs:
review: review:
# Security: only trusted users may trigger a secret-bearing run via a PR
# comment (pull_request + workflow_dispatch are already trusted). Replace the
# username(s) below with your maintainers — keep them in sync with
# GADFLY_ALLOWED_USERS (the in-container belt-and-suspenders check).
if: >-
github.event_name != 'issue_comment'
|| github.actor == 'your-username'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
@@ -43,6 +50,58 @@ jobs:
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }} OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
OLLAMA_REVIEW_MODELS: ${{ vars.OLLAMA_REVIEW_MODELS }} OLLAMA_REVIEW_MODELS: ${{ vars.OLLAMA_REVIEW_MODELS }}
GADFLY_ALLOWED_USERS: ${{ vars.GADFLY_ALLOWED_USERS }} GADFLY_ALLOWED_USERS: ${{ vars.GADFLY_ALLOWED_USERS }}
# Specialist suite (optional). Empty = default suite
# (security,correctness,maintainability,performance,error-handling).
# csv to choose; "all" for everything; or define custom ones via a repo
# .gadfly.yml / GADFLY_SPECIALIST_<NAME>. See README "Specialists".
GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS }}
# Lens fan-out (optional; default 1 = lenses run sequentially within a
# model). Raise it to run a model's lenses concurrently so each model
# posts its comment sooner. Total in-flight requests = (models at once)
# × (lenses at once), so to fan out without oversubscribing a backend,
# keep its model cap low and raise its lens cap. Per-provider configurable
# via GADFLY_PROVIDER_LENS_CONCURRENCY (same lanes as the model map):
# GADFLY_PROVIDER_CONCURRENCY: "ollama-cloud=1,m1=1"
# GADFLY_PROVIDER_LENS_CONCURRENCY: "ollama-cloud=3,m1=1"
# GADFLY_LENS_CONCURRENCY: ${{ vars.GADFLY_LENS_CONCURRENCY }}
# GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ vars.GADFLY_PROVIDER_LENS_CONCURRENCY }}
# Live status board (optional; ON by default): one consolidated comment
# showing every model's per-lens progress as it runs. Disable with
# GADFLY_STATUS_BOARD=0; tune the refresh with GADFLY_STATUS_POLL_SECS.
# GADFLY_STATUS_BOARD: ${{ vars.GADFLY_STATUS_BOARD }}
# --- Models & providers (optional; default = Ollama Cloud) ----------
# Gadfly is majordomo-powered, so it can target other backends. Set a
# provider for bare model ids; point at a different endpoint with a
# base URL; supply a key (or the provider's standard env var). Examples:
#
# Local Ollama daemon (no key):
# GADFLY_PROVIDER: ollama
# GADFLY_MODELS: qwen2.5-coder:7b
# # GADFLY_BASE_URL: http://my-ollama-host:11434 # if not localhost
#
# OpenAI-compatible endpoint (incl. local Ollama's /v1):
# GADFLY_PROVIDER: openai
# GADFLY_BASE_URL: http://localhost:11434/v1
# GADFLY_MODELS: qwen2.5-coder:7b
#
# OpenAI / Anthropic / Google (supported via majordomo, UNTESTED — see README):
# GADFLY_PROVIDER: openai # then set OPENAI_API_KEY below
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
#
# Named endpoint aliases (reference as name/model in GADFLY_MODELS).
# vars/secrets aren't auto-exposed, so map each one explicitly:
# GADFLY_ENDPOINT_BIGBOX: ${{ vars.GADFLY_ENDPOINT_BIGBOX }} # "ollama|http://192.168.1.50:11434"
# GADFLY_MODELS: bigbox/qwen2.5-coder:7b
GADFLY_PROVIDER: ${{ vars.GADFLY_PROVIDER }}
GADFLY_BASE_URL: ${{ vars.GADFLY_BASE_URL }}
GADFLY_MODELS: ${{ vars.GADFLY_MODELS }}
# --- Findings telemetry (optional; OFF by default) ------------------
# Set GADFLY_FINDINGS_URL to a gadfly-reports store base URL to POST each run +
# its findings for model-quality tracking. Advisory only: failures are
# logged to stderr and never affect the review. Add a bearer token if
# the store requires auth. (GADFLY_REPO / GADFLY_PR are derived for you.)
# GADFLY_FINDINGS_URL: ${{ vars.GADFLY_FINDINGS_URL }}
# GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
EVENT_NAME: ${{ github.event_name }} EVENT_NAME: ${{ github.event_name }}
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
PR_BRANCH: ${{ github.head_ref }} PR_BRANCH: ${{ github.head_ref }}
+78
View File
@@ -0,0 +1,78 @@
# Gadfly reviewing via the Claude Code CLI engine.
# Copy to .gitea/workflows/adversarial-review.yml in your repo.
#
# Instead of a majordomo model, each lens shells out to the bundled `claude` CLI
# inside the checked-out repo (it uses its own Read/Grep/Glob tools to verify
# findings), then Gadfly runs its usual verdict + recheck + consolidate pipeline.
#
# Auth: a Pro/Max subscription token from `claude setup-token` (no --bare),
# stored as the CLAUDE_CODE_OAUTH_TOKEN secret. Falls back to ANTHROPIC_API_KEY
# if you'd rather pay per-token — set only ONE.
#
# Heads-up: this engine is wired but not yet validated end-to-end here, and using
# subscription auth in automated CI is a gray area in Anthropic's terms — read
# the README's "Claude Code engine" note before relying on it.
name: Adversarial Review (Gadfly)
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number: { description: "PR number to review", required: true }
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: gadfly-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.pr_number }}
cancel-in-progress: true
jobs:
review:
# Security: only trusted users may trigger a secret-bearing run via a PR
# comment. Replace the username(s) below with your maintainers — keep them in
# sync with GADFLY_ALLOWED_USERS (the in-container belt-and-suspenders check).
if: >-
github.event_name != 'issue_comment'
|| github.actor == 'your-username'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:latest
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
# --- Claude Code engine ---
# Pro/Max subscription token (preferred). Or set ANTHROPIC_API_KEY
# instead for per-token billing — but never both.
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# bare "claude-code" uses the CLI default model; "claude-code/<model>"
# sets --model (sonnet/opus/haiku, or a full id). One comment per entry.
GADFLY_MODELS: "claude-code/sonnet"
# Optional CLI tuning (defaults are read-only-safe):
# GADFLY_CLAUDE_PERMISSION_MODE: plan # read-only; never edits
# GADFLY_CLAUDE_ALLOWED_TOOLS: "Read,Grep,Glob"
# GADFLY_CLAUDE_EXTRA_ARGS: "--max-turns 30"
# Alternate backend (EXAMPLE ONLY, not validated): point Claude Code at
# an Anthropic-API-compatible proxy (e.g. claude-code-router / LiteLLM in
# front of Ollama) to run Ollama models THROUGH the CC harness. The
# subprocess env forwards ANTHROPIC_*, so just set these instead of the
# token above. Tool-use support depends on the proxy/backend.
# ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }}
# ANTHROPIC_AUTH_TOKEN: ${{ secrets.ANTHROPIC_AUTH_TOKEN }}
GADFLY_ALLOWED_USERS: "your-username"
# --- event context (leave as-is) ---
EVENT_NAME: ${{ github.event_name }}
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
PR_BRANCH: ${{ github.head_ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
ACTOR: ${{ github.actor }}
+67
View File
@@ -0,0 +1,67 @@
# Gadfly with named ENDPOINT ALIASES — review with several backends at once,
# each posting its own comment. Copy to .gitea/workflows/adversarial-review.yml.
#
# GADFLY_ENDPOINT_<NAME>="<provider>|<base-url>[|<key>]" registers a provider you
# can then reference as "<name>/<model>" (NAME lowercases: BIGBOX -> bigbox).
# The base URL is used verbatim, so plaintext http LAN endpoints work.
#
# provider is ollama / foreman / openai / anthropic / google. "foreman" targets a
# foreman queue daemon (https://gitea.stevedudenhoeffer.com/steve/foreman) — native
# Ollama on the wire, so just give it the daemon's URL (and optional bearer token).
#
# Gitea note: vars/secrets aren't auto-exposed as env, so map each alias here.
# Suggested repo vars (and a secret when the value carries a token):
# GADFLY_ENDPOINT_BIGBOX = ollama|http://192.168.1.50:11434
# GADFLY_ENDPOINT_GPU = openai|http://gpu.lan:8000/v1
# GADFLY_ENDPOINT_M1 = foreman|http://foreman-m1:8080|<token> (use a secret)
name: Adversarial Review (Gadfly)
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number: { description: "PR number to review", required: true }
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: gadfly-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.pr_number }}
cancel-in-progress: true
jobs:
review:
# Security: only trusted users may trigger a secret-bearing run via a PR
# comment (pull_request + workflow_dispatch are already trusted). Replace the
# username(s) below with your maintainers — keep them in sync with
# GADFLY_ALLOWED_USERS (the in-container belt-and-suspenders check).
if: >-
github.event_name != 'issue_comment'
|| github.actor == 'your-username'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:latest
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
# --- named endpoints (mapped from repo vars) ---
GADFLY_ENDPOINT_BIGBOX: ${{ vars.GADFLY_ENDPOINT_BIGBOX }} # "ollama|http://192.168.1.50:11434"
GADFLY_ENDPOINT_GPU: ${{ vars.GADFLY_ENDPOINT_GPU }} # "openai|http://gpu.lan:8000/v1"
GADFLY_ENDPOINT_M1: ${{ secrets.GADFLY_ENDPOINT_M1 }} # "foreman|http://foreman-m1:8080|<token>"
# one reviewer (one comment) per model, across the aliased endpoints:
GADFLY_MODELS: "bigbox/qwen2.5-coder:7b,gpu/llama3.1,m1/qwen3:14b"
# --- event context (leave as-is) ---
EVENT_NAME: ${{ github.event_name }}
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
PR_BRANCH: ${{ github.head_ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
ACTOR: ${{ github.actor }}
+56
View File
@@ -0,0 +1,56 @@
# Gadfly using a LOCAL Ollama daemon (no API key needed).
# Copy to .gitea/workflows/adversarial-review.yml in your repo.
#
# The runner must be able to reach the Ollama host. For localhost on the runner,
# leave GADFLY_BASE_URL unset; for a LAN box set it to http://<host>:11434.
#
# Pick a model you've pulled into Ollama (e.g. `ollama pull qwen2.5-coder:7b`).
name: Adversarial Review (Gadfly)
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number: { description: "PR number to review", required: true }
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: gadfly-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.pr_number }}
cancel-in-progress: true
jobs:
review:
# Security: only trusted users may trigger a secret-bearing run via a PR
# comment (pull_request + workflow_dispatch are already trusted). Replace the
# username(s) below with your maintainers — keep them in sync with
# GADFLY_ALLOWED_USERS (the in-container belt-and-suspenders check).
if: >-
github.event_name != 'issue_comment'
|| github.actor == 'your-username'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:latest
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
# --- local Ollama ---
GADFLY_PROVIDER: ollama
GADFLY_MODELS: qwen2.5-coder:7b
# GADFLY_BASE_URL: http://192.168.1.50:11434 # uncomment for a remote/LAN daemon
# --- event context (leave as-is) ---
EVENT_NAME: ${{ github.event_name }}
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
PR_BRANCH: ${{ github.head_ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
ACTOR: ${{ github.actor }}
+59
View File
@@ -0,0 +1,59 @@
# Gadfly against an OpenAI-COMPATIBLE endpoint.
# Copy to .gitea/workflows/adversarial-review.yml in your repo.
#
# Works for: a local Ollama's OpenAI bridge (http://localhost:11434/v1), an
# in-house gateway, OpenRouter, vLLM, LM Studio, etc. This is the same code path
# the real OpenAI API uses, so it's a free way to exercise the OpenAI provider.
#
# Set GADFLY_API_KEY (or OPENAI_API_KEY) — Ollama ignores it, but most gateways
# require some value.
name: Adversarial Review (Gadfly)
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number: { description: "PR number to review", required: true }
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: gadfly-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.pr_number }}
cancel-in-progress: true
jobs:
review:
# Security: only trusted users may trigger a secret-bearing run via a PR
# comment (pull_request + workflow_dispatch are already trusted). Replace the
# username(s) below with your maintainers — keep them in sync with
# GADFLY_ALLOWED_USERS (the in-container belt-and-suspenders check).
if: >-
github.event_name != 'issue_comment'
|| github.actor == 'your-username'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:latest
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
# --- OpenAI-compatible endpoint ---
GADFLY_PROVIDER: openai
GADFLY_BASE_URL: http://localhost:11434/v1 # e.g. local Ollama /v1, or your gateway
GADFLY_API_KEY: ${{ secrets.OPENAI_API_KEY }} # any non-empty value for Ollama
GADFLY_MODELS: qwen2.5-coder:7b
# --- event context (leave as-is) ---
EVENT_NAME: ${{ github.event_name }}
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}
PR_BRANCH: ${{ github.head_ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
ACTOR: ${{ github.actor }}
+70
View File
@@ -0,0 +1,70 @@
# Gadfly — SLIM consumer stub via the reusable workflow.
# Copy to .gitea/workflows/adversarial-review.yml in your repo.
#
# This is the shortest way to subscribe: it calls Gadfly's centralized reusable
# workflow, which holds the image pin + all the env plumbing. You only declare
# the triggers, the comment-trigger actor gate, and any overrides you want.
#
# The reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine
# (sonnet/opus/opus:max), 5-lens suite. That default needs BOTH
# OLLAMA_CLOUD_API_KEY and CLAUDE_CODE_OAUTH_TOKEN. This example overrides
# `models:` to a cloud-only set so it works with just OLLAMA_CLOUD_API_KEY —
# delete that override (and forward the Claude token) to inherit the full default.
#
# Forward ONLY the secrets the reviewer uses (least privilege) — see the
# `secrets:` block below. GITEA_TOKEN is automatic. `secrets: inherit` also works
# but hands the reusable EVERY secret in your repo (registry/deploy/db creds the
# review never touches), so prefer the explicit form. Pin @<ref>: use the @v1
# release tag (a curated pointer moved on deliberate releases) for auto-updating
# stability, or a full @<sha> for an immutable pin. Avoid @main — it moves on
# every push and would change what runs with your forwarded secrets.
#
# For custom named endpoints (GADFLY_ENDPOINT_<NAME>) or a provider the reusable
# doesn't map, use the full stub in adversarial-review.yml instead.
name: Adversarial Review (Gadfly)
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number: { description: "PR number to review", required: true }
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: gadfly-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.pr_number }}
cancel-in-progress: true
jobs:
review:
# Only let your maintainers re-trigger via a PR comment (keep in sync with
# the allowed_users override below).
if: >-
github.event_name != 'issue_comment'
|| (github.event.issue.pull_request && github.actor == 'your-username')
# @v1 = curated release tag (auto-updates on releases); swap for a full @<sha>
# if you want an immutable pin. Don't use @main (moves on every push).
uses: steve/gadfly/.gitea/workflows/review-reusable.yml@v1
# Forward ONLY what the reviewer needs. Add provider keys you use
# (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, GADFLY_API_KEY) and/or
# GADFLY_ENDPOINT_M1/M5; drop the findings ones if you don't run telemetry.
secrets:
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
# CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }}
# GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
with:
# Cloud-only override so this works with just OLLAMA_CLOUD_API_KEY. Delete
# this line (and forward CLAUDE_CODE_OAUTH_TOKEN above) to inherit the full
# default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5 lenses).
models: "minimax-m3:cloud,glm-5.2:cloud,deepseek-v4-pro:cloud"
# Other inputs inherit the default (5-lens suite, concurrency, 90-min cap);
# override any of them here (specialists, provider, base_url, timeout_secs…).
allowed_users: "your-username"
+33 -1
View File
@@ -2,4 +2,36 @@ module gitea.stevedudenhoeffer.com/steve/gadfly
go 1.26.2 go 1.26.2
require gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260610113006-0147a79d187b require (
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462
gopkg.in/yaml.v3 v3.0.1
)
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/api v0.286.0 // indirect
google.golang.org/genai v1.62.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+88 -2
View File
@@ -1,2 +1,88 @@
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260610113006-0147a79d187b h1:/pglCqQW02kV2p9tKyQpIJoXZK2p7LKLeDCZL/V26MM= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260610113006-0147a79d187b/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462 h1:1crjE1YkWHLZ91tUDOxN/Y5cuOnJ56e0U9UADoFfEPY=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI=
google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
google.golang.org/genai v1.62.0 h1:PaBju84orf4Vbcc6OfHe4vxhxhjwulKTgOpEc3iIc00=
google.golang.org/genai v1.62.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+73 -34
View File
@@ -23,8 +23,16 @@
# GADFLY_REPO_DIR (checked-out repo; default: this script's repo) # GADFLY_REPO_DIR (checked-out repo; default: this script's repo)
# antigravity: `agy` on PATH with credentials already seeded (~/.gemini) # antigravity: `agy` on PATH with credentials already seeded (~/.gemini)
# #
# claude-code engine: when MODEL is "claude-code" or "claude-code/<model>" the
# binary shells out to the bundled `claude` CLI instead of a majordomo model.
# Its auth (CLAUDE_CODE_OAUTH_TOKEN, else ANTHROPIC_API_KEY) and GADFLY_CLAUDE_*
# tuning are read straight from the inherited environment — same as the other
# provider keys (OPENAI_API_KEY, …) — so no extra wiring is needed here.
#
# Optional: # Optional:
# MAX_DIFF_CHARS diff truncation cap for the prompt (default 60000) # MAX_DIFF_CHARS diff truncation cap for the prompt (default 60000)
# GADFLY_STATUS_FILE per-model JSON path for the live status board (set by
# entrypoint.sh; empty/unset disables status publishing)
# #
# This script is advisory: it never fails the job for review content. It exits # This script is advisory: it never fails the job for review content. It exits
# non-zero only on a usage/configuration error. # non-zero only on a usage/configuration error.
@@ -42,6 +50,14 @@ MAX_DIFF_CHARS="${MAX_DIFF_CHARS:-60000}"
MARKER="<!-- gadfly-review:${PROVIDER}:${MODEL} -->" MARKER="<!-- gadfly-review:${PROVIDER}:${MODEL} -->"
say() { echo "[gadfly-review:${PROVIDER}:${MODEL}] $*" >&2; } say() { echo "[gadfly-review:${PROVIDER}:${MODEL}] $*" >&2; }
# Display the model's ACTUAL backend: the provider segment of the spec
# ("m1pro/qwen3.6:35b-mlx" -> "m1pro"); a bare id uses GADFLY_PROVIDER (default
# ollama-cloud). This is what the comment header shows, not the run.sh lane.
case "$MODEL" in
*/*) MODEL_PROVIDER="${MODEL%%/*}" ;;
*) MODEL_PROVIDER="${GADFLY_PROVIDER:-ollama-cloud}" ;;
esac
# jq is required for payload building / response parsing; install if missing. # jq is required for payload building / response parsing; install if missing.
if ! command -v jq >/dev/null 2>&1; then if ! command -v jq >/dev/null 2>&1; then
say "jq not found; attempting install" say "jq not found; attempting install"
@@ -55,6 +71,32 @@ fi
# binary / agy, not here.) # binary / agy, not here.)
API_TIMEOUT="--connect-timeout 20 --max-time 30" API_TIMEOUT="--connect-timeout 20 --max-time 30"
# upsert_comment BODY — create or update (by MARKER) this model's single comment.
upsert_comment() {
local body="$1" post_body existing_id page=1 cmts
post_body="$(jq -n --arg b "$body" '{body:$b}')"
existing_id=""
while [ "$page" -le 10 ]; do
cmts="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API}/issues/${PR}/comments?limit=50&page=${page}" || echo '[]')"
[ "$(echo "$cmts" | jq 'length')" = "0" ] && break
existing_id="$(echo "$cmts" | jq -r --arg m "$MARKER" \
'.[] | select(.body != null and (.body | startswith($m))) | .id' | head -n1)"
[ -n "$existing_id" ] && break
page=$((page+1))
done
if [ -n "$existing_id" ]; then
curl $API_TIMEOUT -sS -X PATCH -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
"${GITEA_API}/issues/comments/${existing_id}" -d "$post_body" >/dev/null
else
curl $API_TIMEOUT -sS -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
"${GITEA_API}/issues/${PR}/comments" -d "$post_body" >/dev/null
fi
}
# fmt_duration SECONDS -> "1m 23s" / "45s"
fmt_duration() { if [ "$1" -ge 60 ]; then echo "$(($1/60))m $(($1%60))s"; else echo "$1s"; fi; }
# --- fetch PR context ------------------------------------------------------- # --- fetch PR context -------------------------------------------------------
say "fetching PR #${PR} context" say "fetching PR #${PR} context"
DIFF="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_API}/pulls/${PR}.diff" || true)" DIFF="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_API}/pulls/${PR}.diff" || true)"
@@ -81,17 +123,33 @@ SYS="$(cat "${SCRIPT_DIR}/system-prompt.txt")"
USR="$(printf 'PR #%s: %s\n\nDescription:\n%s\n\nUnified diff to review:\n```diff\n%s\n```%s' \ USR="$(printf 'PR #%s: %s\n\nDescription:\n%s\n\nUnified diff to review:\n```diff\n%s\n```%s' \
"$PR" "$TITLE" "$BODY" "$DIFF" "$TRUNC_NOTE")" "$PR" "$TITLE" "$BODY" "$DIFF" "$TRUNC_NOTE")"
# --- announce start (placeholder comment) -----------------------------------
START_TS="$(date +%s)"
say "starting review with ${MODEL}"
upsert_comment "$(printf '%s\n### 🪰 Gadfly review — `%s` (%s)\n\n⏳ Reviewing… this comment will update with findings and run time.' \
"$MARKER" "$MODEL" "$MODEL_PROVIDER")"
# --- call the model --------------------------------------------------------- # --- call the model ---------------------------------------------------------
REVIEW="" REVIEW=""
case "$PROVIDER" in case "$PROVIDER" in
ollama) ollama)
# Agentic lane: hand off to the cmd/gadfly binary, which runs a tool-using # Agentic lane: hand off to the cmd/gadfly binary, which runs a tool-using
# agent over the checked-out repo so it can verify findings instead of # agent over the checked-out repo so it can verify findings instead of
# guessing from the diff. The workflow builds the binary and exports # guessing from the diff. The reviewer is majordomo-powered, so GADFLY_PROVIDER
# GADFLY_BIN + GADFLY_REPO_DIR; we fall back to sane defaults for a # selects the backend (default ollama-cloud); local Ollama, OpenAI, Anthropic,
# local run. # Google and OpenAI/Ollama-compatible endpoints all work — see the README.
if [ -z "${OLLAMA_CLOUD_API_KEY:-}" ]; then
REVIEW="⚠️ \`OLLAMA_CLOUD_API_KEY\` is not configured; this reviewer was skipped." # Back-compat: map the consumer's OLLAMA_CLOUD_API_KEY secret onto the
# OLLAMA_API_KEY env the ollama-cloud provider reads.
if [ -n "${OLLAMA_CLOUD_API_KEY:-}" ] && [ -z "${OLLAMA_API_KEY:-}" ]; then
export OLLAMA_API_KEY="$OLLAMA_CLOUD_API_KEY"
fi
GADFLY_PROVIDER_EFF="$MODEL_PROVIDER"
# Only the default cloud provider strictly needs a key up front; local Ollama
# and other providers either need none or read their own standard env var.
if [ "$GADFLY_PROVIDER_EFF" = "ollama-cloud" ] && [ -z "${OLLAMA_API_KEY:-}" ] && [ -z "${GADFLY_API_KEY:-}" ]; then
REVIEW="⚠️ No Ollama Cloud key configured (set \`OLLAMA_CLOUD_API_KEY\`) and \`GADFLY_PROVIDER\` is the default \`ollama-cloud\`; this reviewer was skipped."
else else
BIN="${GADFLY_BIN:-gadfly}" BIN="${GADFLY_BIN:-gadfly}"
if ! command -v "$BIN" >/dev/null 2>&1 && [ ! -x "$BIN" ]; then if ! command -v "$BIN" >/dev/null 2>&1 && [ ! -x "$BIN" ]; then
@@ -101,8 +159,9 @@ case "$PROVIDER" in
DIFF_FILE="$(mktemp)" DIFF_FILE="$(mktemp)"
ERR_FILE="${DIFF_FILE}.err" ERR_FILE="${DIFF_FILE}.err"
printf '%s' "$FULL_DIFF" > "$DIFF_FILE" printf '%s' "$FULL_DIFF" > "$DIFF_FILE"
# GADFLY_PROVIDER / GADFLY_BASE_URL / GADFLY_API_KEY and provider key
# envs (OPENAI_API_KEY, …) are inherited from the process environment.
REVIEW="$( REVIEW="$(
OLLAMA_API_KEY="$OLLAMA_CLOUD_API_KEY" \
GADFLY_MODEL="$MODEL" \ GADFLY_MODEL="$MODEL" \
GADFLY_REPO_DIR="$REPO_DIR" \ GADFLY_REPO_DIR="$REPO_DIR" \
GADFLY_DIFF_FILE="$DIFF_FILE" \ GADFLY_DIFF_FILE="$DIFF_FILE" \
@@ -110,6 +169,7 @@ case "$PROVIDER" in
GADFLY_TITLE="$TITLE" \ GADFLY_TITLE="$TITLE" \
GADFLY_BODY="$BODY" \ GADFLY_BODY="$BODY" \
GADFLY_MAX_DIFF_CHARS="$MAX_DIFF_CHARS" \ GADFLY_MAX_DIFF_CHARS="$MAX_DIFF_CHARS" \
GADFLY_STATUS_FILE="${GADFLY_STATUS_FILE:-}" \
"$BIN" 2>"$ERR_FILE" "$BIN" 2>"$ERR_FILE"
)" )"
rc=$? rc=$?
@@ -141,31 +201,10 @@ $(tail -c 1500 agy.err 2>/dev/null)
say "unknown provider: ${PROVIDER}"; exit 1 ;; say "unknown provider: ${PROVIDER}"; exit 1 ;;
esac esac
# --- assemble comment ------------------------------------------------------- # --- assemble + post final comment (with run time) --------------------------
COMMENT="$(printf '%s\n### 🔭 Adversarial review — `%s` (%s)\n\n%s\n\n<sub>Automated adversarial review. Advisory only — does not block merge.</sub>' \ ELAPSED="$(( $(date +%s) - START_TS ))"
"$MARKER" "$MODEL" "$PROVIDER" "$REVIEW")" DUR="$(fmt_duration "$ELAPSED")"
POST_BODY="$(jq -n --arg b "$COMMENT" '{body:$b}')" COMMENT="$(printf '%s\n### 🪰 Gadfly review — `%s` (%s)\n\n%s\n\n<sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in %s</sub>' \
"$MARKER" "$MODEL" "$MODEL_PROVIDER" "$REVIEW" "$DUR")"
# --- upsert by marker ------------------------------------------------------- upsert_comment "$COMMENT"
EXISTING_ID="" say "done in ${DUR}"
page=1
while [ "$page" -le 10 ]; do
CMTS="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API}/issues/${PR}/comments?limit=50&page=${page}" || echo '[]')"
[ "$(echo "$CMTS" | jq 'length')" = "0" ] && break
EXISTING_ID="$(echo "$CMTS" | jq -r --arg m "$MARKER" \
'.[] | select(.body != null and (.body | startswith($m))) | .id' | head -n1)"
[ -n "$EXISTING_ID" ] && break
page=$((page+1))
done
if [ -n "$EXISTING_ID" ]; then
say "updating existing comment ${EXISTING_ID}"
curl $API_TIMEOUT -sS -X PATCH -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
"${GITEA_API}/issues/comments/${EXISTING_ID}" -d "$POST_BODY" >/dev/null
else
say "creating new comment"
curl $API_TIMEOUT -sS -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
"${GITEA_API}/issues/${PR}/comments" -d "$POST_BODY" >/dev/null
fi
say "done"
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Live status board for a Gadfly review.
#
# Each model process (the cmd/gadfly binary) publishes its per-lens progress to
# $GADFLY_STATUS_DIR/<model>.json as lenses go queued -> running -> finished.
# This script polls that directory and upserts ONE consolidated PR comment that
# aggregates every model's per-lens status, so a human (or an agent watching the
# PR) can see the whole swarm's progress at a glance and know when it's done —
# instead of staring at N separate "⏳ Reviewing…" placeholders.
#
# It is advisory and best-effort: a failed render/post is logged and retried on
# the next tick; nothing here can fail the review or block a merge. It runs in
# the background from entrypoint.sh and exits once the $GADFLY_STATUS_DIR/.done
# sentinel appears (the entrypoint touches it after all model lanes finish),
# after one final render.
#
# Required env:
# GITEA_API https://HOST/api/v1/repos/OWNER/REPO
# GITEA_TOKEN token with repo write access (posts the comment)
# PR pull request number
# GADFLY_STATUS_DIR directory holding the per-model <model>.json files
# Optional:
# GADFLY_STATUS_POLL_SECS render/upsert interval (default 12)
set -uo pipefail
: "${GITEA_API:?GITEA_API required}"
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
: "${PR:?PR required}"
: "${GADFLY_STATUS_DIR:?GADFLY_STATUS_DIR required}"
POLL="${GADFLY_STATUS_POLL_SECS:-12}"
# Guard against a non-numeric poll interval: with `set -uo pipefail` (no set -e)
# a bad `sleep "$POLL"` would fail silently and the `while :` loop would spin,
# hammering the Gitea API. Coerce anything non-integer (or <1) back to 12.
case "$POLL" in ''|*[!0-9]*) POLL=12 ;; esac
[ "$POLL" -ge 1 ] 2>/dev/null || POLL=12
DONE_FILE="${GADFLY_STATUS_DIR}/.done"
MARKER="<!-- gadfly-status-board -->"
API_TIMEOUT="--connect-timeout 20 --max-time 30"
BOARD_ID="" # cached comment id, so we PATCH in place instead of re-searching
say() { echo "[gadfly-status-board] $*" >&2; }
command -v jq >/dev/null 2>&1 || { say "jq not found; status board disabled"; exit 0; }
# render_section FILE -> markdown for one model (its header + per-lens bullets).
# Reads the JSON the binary writes; tolerates a half-written/missing file by
# emitting nothing (jq exits non-zero -> caller skips it this tick).
render_section() {
jq -r '
def icon(state; errored):
if state == "finished" then (if errored then "⚠️" else "✅" end)
elif state == "running" then "🔄"
else "⏸️" end;
def lensline:
"- " + icon(.state; (.errored // false)) + " **" + .name + "** — " +
( if .state == "finished" then (if (.errored // false) then "could not complete" else (.verdict // "done") end)
elif .state == "running" then "running"
else "queued" end );
( [.lenses[] | select(.state == "finished")] | length ) as $fin
| ( .lenses | length ) as $tot
| ( if .done then "✅ done"
elif $tot == 0 then "⏳ waiting to start"
else "⏳ " + ($fin|tostring) + "/" + ($tot|tostring) + " lenses" end ) as $sum
| "#### `" + .model + "` · " + .provider + " — " + $sum + "\n"
+ ( if $tot == 0 then "- ⏸️ _no lenses reported yet_"
else ([.lenses[] | lensline] | join("\n")) end )
' "$1" 2>/dev/null
}
# render_body -> the full consolidated comment body (marker + header + sections).
render_body() {
local f sections="" total=0 done=0 ts
shopt -s nullglob
local files=("${GADFLY_STATUS_DIR}"/*.json)
shopt -u nullglob
for f in "${files[@]}"; do
local sec
sec="$(render_section "$f")" || continue
[ -z "$sec" ] && continue
total=$((total + 1))
if [ "$(jq -r 'if .done then 1 else 0 end' "$f" 2>/dev/null)" = "1" ]; then
done=$((done + 1))
fi
sections="${sections}${sec}"$'\n\n'
done
ts="$(date -u '+%Y-%m-%d %H:%M:%SZ')"
if [ "$total" -eq 0 ]; then
sections="_Waiting for reviewers to start…_"$'\n'
fi
printf '%s\n## 🪰 Gadfly — live review status\n\n%d/%d reviewers finished · updated %s\n\n%s\n<sub>Live status board. Findings are posted in each model'\''s own comment. Advisory only — does not block merge.</sub>' \
"$MARKER" "$done" "$total" "$ts" "$sections"
}
# find_existing -> id of the board comment if it already exists (paginate by
# marker). Used once, to recover the comment across container restarts.
find_existing() {
local page=1 cmts id
while [ "$page" -le 10 ]; do
cmts="$(curl $API_TIMEOUT -fsS -H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API}/issues/${PR}/comments?limit=50&page=${page}" 2>/dev/null || echo '[]')"
[ "$(echo "$cmts" | jq 'length' 2>/dev/null || echo 0)" = "0" ] && break
id="$(echo "$cmts" | jq -r --arg m "$MARKER" \
'.[] | select(.body != null and (.body | startswith($m))) | .id' 2>/dev/null | head -n1)"
[ -n "$id" ] && { echo "$id"; return; }
page=$((page + 1))
done
echo ""
}
# upsert BODY — PATCH the cached/known board comment, else POST a new one and
# cache its id. A failed PATCH (e.g. comment deleted) clears the cache so the
# next tick re-discovers or re-creates it.
upsert() {
local body="$1" post_body resp
post_body="$(jq -n --arg b "$body" '{body:$b}')"
[ -z "$BOARD_ID" ] && BOARD_ID="$(find_existing)"
if [ -n "$BOARD_ID" ]; then
if ! curl $API_TIMEOUT -fsS -X PATCH -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
"${GITEA_API}/issues/comments/${BOARD_ID}" -d "$post_body" >/dev/null 2>&1; then
say "patch of comment ${BOARD_ID} failed; will re-discover"
BOARD_ID=""
fi
else
resp="$(curl $API_TIMEOUT -fsS -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
"${GITEA_API}/issues/${PR}/comments" -d "$post_body" 2>/dev/null || echo '{}')"
BOARD_ID="$(echo "$resp" | jq -r '.id // ""' 2>/dev/null)"
fi
}
say "starting (poll ${POLL}s, dir ${GADFLY_STATUS_DIR})"
while :; do
upsert "$(render_body)"
[ -f "$DONE_FILE" ] && break
sleep "$POLL"
done
say "done"
+13 -15
View File
@@ -1,6 +1,15 @@
You are Gadfly, an ADVERSARIAL code reviewer. Your job is to find real problems in the You are Gadfly, an ADVERSARIAL code reviewer. Your job is to find real problems in the
pull request below — not to praise it. A gadfly does not let things slide. pull request below — not to praise it. A gadfly does not let things slide.
You review through ONE assigned lens (named at the end of this prompt). Evaluate the change
THROUGH THAT LENS — that is your job. A separate reviewer independently covers each other
angle, so problems outside your lens WILL be caught without you. Do not restate a finding that
plainly belongs to another lens just to have something to report — that only creates noise. If
your lens turns up nothing material, say so plainly; an honest "nothing in my area" beats
re-reporting the obvious bug every other lens already sees. Only exception: if you spot a
SEVERE issue clearly outside your lens, you may add ONE line prefixed "Outside my lens:" — but
your actual findings must stay within your lens.
You are AGENTIC: you have read-only tools over the repository AT THIS PR's checked-out You are AGENTIC: you have read-only tools over the repository AT THIS PR's checked-out
state. USE THEM to verify before you report. Do not review the diff in isolation. state. USE THEM to verify before you report. Do not review the diff in isolation.
- read_file(path[, start_line, limit]) — read a file with line numbers. - read_file(path[, start_line, limit]) — read a file with line numbers.
@@ -19,21 +28,10 @@ Mandatory verification discipline — this is the whole point of giving you tool
- If you cannot confirm a suspicion with the tools, either drop it or clearly label it - If you cannot confirm a suspicion with the tools, either drop it or clearly label it
"unverified" — do NOT present an unchecked guess as a finding. "unverified" — do NOT present an unchecked guess as a finding.
Be skeptical and concrete. Hunt specifically for: Be skeptical and concrete, and apply your assigned lens rigorously. (If your lens leads you to
- Correctness bugs and logic errors introduced by the change. a constant, conversion factor, formula, unit, or threshold, don't trust it because it looks
- SEMANTIC / domain correctness — the failure mode plausible-looking code hides best. reasonable — re-derive the expected value from first principles and compare; plausible-looking
Do NOT trust a constant, conversion factor, formula, unit, or threshold just because magic numbers hide real bugs. Pursue this when it's in your lane, not as a reason to leave it.)
it looks reasonable. Independently RE-DERIVE the expected value from first principles
(units, dimensions, edge values) and compare. A magic number that "looks about right"
is exactly where real bugs hide (e.g. a linear factor used where it must be squared).
- Concurrency issues: data races, deadlocks, unsynchronized shared state, leaked tasks.
- Security problems: injection, missing authz/authn, secret leakage, unsafe input handling.
- Error handling gaps: ignored errors, swallowed exceptions, missing rollback/cleanup.
- Resource leaks: unclosed handles/bodies/files, context/lifetime misuse, unbounded growth.
- Missed edge cases: off-by-one, nil/null, empty collection, overflow, zero/negative.
- Violations of THIS repo's own conventions. Discover them — do not assume. Read any
README / CONTRIBUTING / CLAUDE.md / AGENTS.md / lint config the repo ships, and hold
the change to the patterns the surrounding code actually uses.
Output rules: Output rules:
- Output GitHub-flavored markdown, concise. No filler, no restating the diff. - Output GitHub-flavored markdown, concise. No filler, no restating the diff.