diff --git a/.gitea/workflows/adversarial-review.yml b/.gitea/workflows/adversarial-review.yml index 8ef119c..1a329d0 100644 --- a/.gitea/workflows/adversarial-review.yml +++ b/.gitea/workflows/adversarial-review.yml @@ -53,3 +53,6 @@ jobs: # 5-lens suite) from review-reusable.yml. Only the consumer-specific # allow-list is set here. allowed_users: "steve,fizi,dazed" + # Gitea >= 1.27 does not propagate dispatch inputs into a called workflow's + # github.event — thread the PR number explicitly (empty on non-dispatch events). + pr_number: ${{ github.event.inputs.pr_number }} diff --git a/.gitea/workflows/review-reusable.yml b/.gitea/workflows/review-reusable.yml index 7fb18a6..1be4f9a 100644 --- a/.gitea/workflows/review-reusable.yml +++ b/.gitea/workflows/review-reusable.yml @@ -99,7 +99,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: ${{ inputs.timeout_minutes }} steps: - - uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-3095ebf + # sha-3095ebf predated the Gitea >= 1.27 workflow_call reclassification + # (entrypoint.sh, added in 9d74cb9) — so the dogfood self-review skipped every + # PR with "event 'workflow_call' not handled". Bumped to sha-f468fe6 (current + # main HEAD) which contains that fix. Bump per Gadfly release. + - uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-f468fe6 env: # --- event context (from the CALLER's github.*) ------------------- GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} @@ -116,6 +120,8 @@ jobs: 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 powers both the ollama-cloud majordomo path AND + # the opencode engine (GADFLY_MODELS entry "opencode/"). OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/CLAUDE.md b/CLAUDE.md index 09efb15..8df96f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,10 @@ verifies each one against the actual code, and posts its findings as a comment. cmd/gadfly/ the reviewer binary — pure producer of review markdown (stdout) main.go orchestration: fan specialists out (executus/fanout), each a review pass + recheck engine.go reviewEngine abstraction: executus run.Executor (majordomo agent loop + - compaction/bounding/budget/critic) vs claude-code CLI shell-out + compaction/bounding/budget/critic) vs claude-code / opencode CLI shell-outs + opencode.go the opencode CLI engine: ollama-cloud model through the OpenCode harness + (read-only via a generated OPENCODE_CONFIG_CONTENT agent+provider); for + benchmarking the boutique harness vs a free one on the same model executus.go executus wiring: tool.Registry over the repo tools, the run.Executor build (compact + model context-limit threshold + per-PR budget + wrap-up critic) specialists.go specialist lenses: built-ins, default suite, env + .gadfly.yml resolution diff --git a/Dockerfile b/Dockerfile index 114b3f1..f7730ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,26 @@ RUN apk add --no-cache bash git curl jq ca-certificates nodejs npm procps # 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 +# Bundle the OpenCode CLI (opencode.ai) for the `opencode` review engine +# (GADFLY_MODELS=opencode/): a freely-available agentic harness driving an +# ollama-cloud model, used to benchmark it against gadfly's own executus harness +# on the same model. Auth reuses OLLAMA_CLOUD_API_KEY at runtime. opencode ships a +# compiled (Bun) binary; it publishes musl variants (opencode-linux-*-musl) that +# npm auto-selects on alpine via the package "libc" field. libstdc++/libgcc are +# the Bun binary's runtime deps; gcompat is a belt-and-suspenders fallback in case +# npm ever resolves a glibc build here. +RUN apk add --no-cache gcompat libstdc++ libgcc \ + && npm install -g opencode-ai \ + && npm cache clean --force +# Best-effort: confirm the binary runs and pre-warm the openai-compatible provider +# package into opencode's cache so a review doesn't pay a first-run npm fetch. The +# warm-up model call intentionally fails against a dead URL. Never fail the build: +# a musl/runtime quirk here must not break the shared image for ollama/claude +# users — a broken opencode engine degrades to a normal (advisory) pass error. +RUN opencode --version >/dev/null 2>&1 \ + && OPENCODE_CONFIG_CONTENT='{"provider":{"gadfly":{"npm":"@ai-sdk/openai-compatible","options":{"baseURL":"http://127.0.0.1:9/v1"},"models":{"x":{}}}}}' \ + timeout 120 opencode run --model gadfly/x "warm" >/dev/null 2>&1 \ + ; true COPY --from=build /out/gadfly /usr/local/bin/gadfly COPY scripts /app/scripts COPY entrypoint.sh /entrypoint.sh diff --git a/README.md b/README.md index 2b0eb99..a7dc0ef 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,58 @@ as an example, not wired or tested here. > specialist selection and the `delegate_investigation` worker are majordomo-only and are skipped > with this engine (Claude Code does its own legwork). +### OpenCode engine (`opencode`) + +The same shell-out idea, but with a **freely-available** harness: Gadfly can review through the +**[OpenCode](https://opencode.ai) CLI**, which — like Claude Code — brings its own read tools and +verifies findings against the checked-out repo, but drives an **ollama-cloud** model. The point is +to benchmark gadfly's boutique executus harness against a good open harness *on the same model*: +run `ollama-cloud/glm-5.2` (majordomo loop) and `opencode/glm-5.2` (OpenCode) side by side and +compare their findings. This is the wired, no-proxy version of the "alternate backends" comparison +described above. The CLI is bundled in the image (Node + `opencode-ai`). + +Select it as a model id: + +| Spec | Meaning | +|------|---------| +| `opencode/glm-5.2` | serve `glm-5.2` via ollama-cloud through OpenCode | +| `open-code/glm-5.2` | accepted alias spelling (`opencode` is canonical) | +| `opencode/qwen3-coder:480b-cloud` | model ids are taken **verbatim** — colons are preserved (no `:thinking` suffix here, unlike claude-code) | +| `opencode//` | escape hatch: pass `/` straight to OpenCode's own provider registry/auth (e.g. `opencode/anthropic/claude-sonnet-4-6`) | +| `opencode` | bare: OpenCode's configured default model | + +```yaml +GADFLY_MODELS: "ollama-cloud/glm-5.2,opencode/glm-5.2" # the benchmark pairing +``` + +Auth reuses **`OLLAMA_CLOUD_API_KEY`** (the same secret the ollama-cloud path uses; it's mapped to +`OLLAMA_API_KEY`, which the generated provider references as `{env:OLLAMA_API_KEY}` — never a literal +secret in config). Tuning knobs (all optional): + +| Env | Default | Meaning | +|-----|---------|---------| +| `GADFLY_OPENCODE_MODEL` | *(from the spec suffix)* | overrides the model | +| `GADFLY_OPENCODE_BASE_URL` | `https://ollama.com/v1` | ollama-cloud endpoint; point at a local Ollama (`http://localhost:11434/v1`) or any OpenAI-compatible server | +| `GADFLY_OPENCODE_EXTRA_ARGS` | *(unset)* | extra `opencode run` args, **whitespace-split**, appended before the positional task | +| `GADFLY_OPENCODE_BIN` | `opencode` | CLI binary path | + +> **Read-only is enforced through config, not a flag.** OpenCode has no `--append-system-prompt`, so +> Gadfly generates a per-lens config — the lens system prompt as a `gadfly` agent's prompt, with the +> mutating and network tools (`edit`/`bash`/`webfetch`/`websearch`/`external_directory`) denied at both +> the global and agent level — and injects it via `OPENCODE_CONFIG_CONTENT`. That env var is the +> highest-precedence config source in the container, so it **outranks any `opencode.json` a reviewed +> repo ships** — a repo can't re-enable edits on the reviewer. The subprocess runs with a **reduced +> environment**: the provider keys OpenCode needs to authenticate (`OLLAMA_API_KEY` for the primary +> path, plus `ANTHROPIC_*`/`OPENAI_*`/`GOOGLE_*`/`GEMINI_*` for the `opencode//` +> pass-through) alongside `PATH`/`HOME`/locale/`OPENCODE_*`/`GADFLY_OPENCODE_*` — but **not** gadfly's +> own secrets (the Gitea token, the findings token, or the claude-code subscription token), which the +> CLI has no use for. + +> **Newly wired, lightly tested.** Like the claude-code engine, `auto` specialist selection and the +> `delegate_investigation` worker are majordomo-only and are skipped here (OpenCode does its own +> legwork). Output capture reads OpenCode's default text output, so treat the engine as new and +> sanity-check a run before trusting a benchmark. + ### Endpoint aliases via env vars For multiple named backends (e.g. a couple of Ollama boxes on your LAN), register them by @@ -384,6 +436,7 @@ The reviewer binary reads these (the stub/entrypoint set sane defaults): | `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/`); see [Claude Code engine](#claude-code-engine-claude-code) for its `GADFLY_CLAUDE_*` knobs | +| `opencode` model id | — | route an ollama-cloud model through the bundled OpenCode CLI (`opencode/`); see [OpenCode engine](#opencode-engine-opencode) for its `GADFLY_OPENCODE_*` knobs | | `GADFLY_SPECIALISTS` | default suite | csv of lenses, `all`, or `auto` (dynamic selection) | | `GADFLY_SELECTOR_MODEL` | review model | model that picks lenses in `auto` mode | | `GADFLY_WORKER_MODEL` | — | cheap model for `delegate_investigation`; unset = no delegation | diff --git a/cmd/gadfly/engine.go b/cmd/gadfly/engine.go index 6d87489..853240f 100644 --- a/cmd/gadfly/engine.go +++ b/cmd/gadfly/engine.go @@ -19,16 +19,19 @@ import ( // 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: +// engine-agnostic. Three 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. +// - openCodeEngine (opencode.go): shells out to the `opencode` CLI likewise, +// but driving an ollama-cloud model — for benchmarking the two harnesses on +// the same model. // // maxSteps is the tool-step budget for engines that have one (majordomo); the -// claude-code engine manages its own loop and ignores it. +// shell-out engines manage their own loop and ignore it. type reviewEngine interface { runPass(ctx context.Context, system, task string, maxSteps int) (string, error) } @@ -157,16 +160,7 @@ func (e *claudeCodeEngine) runPass(ctx context.Context, system, task string, _ i // 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 - } + killGroupOnCancel(cmd) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -217,13 +211,39 @@ func (e *claudeCodeEngine) runPass(ctx context.Context, system, task string, _ i return "", fmt.Errorf("claude -p produced no parseable output") } +// killGroupOnCancel puts cmd in its own process group and, on context cancel, +// SIGKILLs the whole group — so a timed-out shell-out CLI (claude/opencode) can't +// leave orphaned Node children behind in the container. Call before cmd.Run. +func killGroupOnCancel(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } +} + +// filterEnv returns the current process environment reduced to the variables for +// which keep returns true. Shared by the shell-out engines' minimal-env builders +// (claudeEnv, openCodeEnv), which differ only in their keep predicate. +func filterEnv(keep func(string) bool) []string { + var env []string + for _, kv := range os.Environ() { + if k, _, ok := strings.Cut(kv, "="); ok && keep(k) { + env = append(env, kv) + } + } + return env +} + // 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 { + return filterEnv(func(k string) bool { switch k { case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "MAX_THINKING_TOKENS": return true @@ -234,14 +254,7 @@ func claudeEnv() []string { 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, diff --git a/cmd/gadfly/main.go b/cmd/gadfly/main.go index 18e6dc5..59e6bb8 100644 --- a/cmd/gadfly/main.go +++ b/cmd/gadfly/main.go @@ -22,7 +22,11 @@ // // GADFLY_MODEL model id, or a full "provider/model" spec / majordomo // alias / failover chain (required). A bare id is -// prefixed with GADFLY_PROVIDER. +// prefixed with GADFLY_PROVIDER. Two prefixes select a +// shell-out CLI engine instead of the in-process loop: +// "claude-code/" (Claude Code, see engine.go) and +// "opencode/" (OpenCode over ollama-cloud, see +// opencode.go) — both bring their own repo tools. // 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 @@ -147,15 +151,18 @@ func run() error { return err } - // Resolve the review engine. The claude-code engine shells out to the - // `claude` CLI (its own repo tools); every other spec is a majordomo model. - // 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")) + // Resolve the review engine. The shell-out engines (claude-code, opencode) + // bring their OWN repo tools; every other spec is an in-process majordomo + // model. auto-selection and the delegate worker are majordomo-only — with a + // shell-out engine they're skipped (the CLI does its own legwork). + spec := os.Getenv("GADFLY_MODEL") var eng reviewEngine - if ccSpec { - eng = newClaudeCodeEngine(os.Getenv("GADFLY_MODEL"), fsTools.root) - } else { + switch { + case isClaudeCodeSpec(spec): + eng = newClaudeCodeEngine(spec, fsTools.root) + case isOpenCodeSpec(spec): + eng = newOpenCodeEngine(spec, fsTools.root) + default: mdl, merr := resolveModel() if merr != nil { return fmt.Errorf("resolve model: %w", merr) @@ -187,13 +194,15 @@ func run() error { } // Dynamic selection: a (cheap) model picks the lenses this diff needs. - // Majordomo-only — the selector is an llm.Model. + // Majordomo-only — the selector is an llm.Model, so a shell-out engine + // (claude-code, opencode) can't provide one; fall back to the default suite. if auto { - if ccSpec { - fmt.Fprintln(os.Stderr, "gadfly: auto-select is not supported with the claude-code engine; using the default suite") + md, ok := eng.(*majordomoEngine) + if !ok { + fmt.Fprintln(os.Stderr, "gadfly: auto-select requires an in-process model engine; using the default suite") specialists = suiteFromRegistry(registry, defaultSuite) } else { - selector, serr := resolveSelectorModel(eng.(*majordomoEngine).mdl) + selector, serr := resolveSelectorModel(md.mdl) if serr != nil { return fmt.Errorf("resolve selector model: %w", serr) } diff --git a/cmd/gadfly/opencode.go b/cmd/gadfly/opencode.go new file mode 100644 index 0000000..c1f9594 --- /dev/null +++ b/cmd/gadfly/opencode.go @@ -0,0 +1,273 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" +) + +// openCodeEngine reviews by shelling out to the `opencode` CLI (opencode.ai) in +// non-interactive `run` mode. Like the claude-code engine it is a pure shell-out +// — OpenCode brings its OWN read tools (read/grep/glob/list) and reads the +// checked-out tree, so findings are verified against real code — but it drives an +// ollama-cloud model instead of a Claude subscription. The point is to benchmark +// gadfly's boutique executus harness against a freely-available agentic harness +// on the SAME models (e.g. "ollama-cloud/glm-5.2" vs "opencode/glm-5.2"). +// +// OpenCode has no --append-system-prompt flag, so the lens system prompt AND the +// read-only discipline are delivered through a generated config injected via the +// OPENCODE_CONFIG_CONTENT env var (see config): a custom "gadfly" agent whose +// prompt is the system prompt with edit/bash denied, plus a "gadfly" provider +// pointing at ollama-cloud. OPENCODE_CONFIG_CONTENT is the highest-precedence +// config source that matters in the container — it outranks any opencode.json a +// reviewed repo might ship — so a repo can't re-enable edits on us. +type openCodeEngine struct { + bin string // CLI binary (GADFLY_OPENCODE_BIN, default "opencode") + providerModel string // ollama model id for the generated "gadfly" provider ("" = none) + modelRef string // --model value ("gadfly/", a pass-through "/", or "" = CLI default) + baseURL string // ollama-cloud base URL (GADFLY_OPENCODE_BASE_URL) + repoDir string // cwd for the CLI, so its tools read the checked-out tree + extraArgs []string // appended verbatim (GADFLY_OPENCODE_EXTRA_ARGS) +} + +// openCodeProviderName / openCodeAgentName are the internal names of the provider +// and agent gadfly generates in the injected config. "gadfly" won't collide with +// OpenCode's models.dev provider registry. +const ( + openCodeProviderName = "gadfly" + openCodeAgentName = "gadfly" +) + +// defaultOpenCodeBaseURL is ollama-cloud's OpenAI-compatible endpoint. +const defaultOpenCodeBaseURL = "https://ollama.com/v1" + +// isOpenCodeSpec reports whether a GADFLY_MODEL spec selects the opencode engine: +// the bare id "opencode"/"open-code" or an "opencode/" form (both +// spellings accepted; "opencode" is canonical). +func isOpenCodeSpec(model string) bool { + m := strings.TrimSpace(model) + for _, p := range []string{"opencode", "open-code"} { + if m == p || strings.HasPrefix(m, p+"/") { + return true + } + } + return false +} + +// newOpenCodeEngine builds the engine from the GADFLY_MODEL spec and the optional +// GADFLY_OPENCODE_* overrides. The part after the FIRST slash is the model, taken +// verbatim — no ":"-suffix parsing, because ollama model ids legitimately contain +// colons (e.g. "qwen3-coder:480b-cloud"). Three spec forms: +// +// opencode → bare: no --model, no generated provider (CLI default model) +// opencode/ → wrap in the generated ollama-cloud "gadfly" provider +// opencode// → pass-through: --model /, using OpenCode's +// own provider registry/auth (escape hatch, no generated provider) +// +// GADFLY_OPENCODE_MODEL overrides the model taken from the spec (and is itself run +// through the same slash logic). It does not verify the CLI is installed — a +// missing binary surfaces as a normal pass error (advisory, never fatal). +func newOpenCodeEngine(spec, repoDir string) *openCodeEngine { + e := &openCodeEngine{ + bin: envOr("GADFLY_OPENCODE_BIN", "opencode"), + baseURL: envOr("GADFLY_OPENCODE_BASE_URL", defaultOpenCodeBaseURL), + repoDir: repoDir, + extraArgs: strings.Fields(os.Getenv("GADFLY_OPENCODE_EXTRA_ARGS")), + } + var after string + if _, a, ok := strings.Cut(strings.TrimSpace(spec), "/"); ok { + after = strings.TrimSpace(a) + } + if env := strings.TrimSpace(os.Getenv("GADFLY_OPENCODE_MODEL")); env != "" { + after = env + } + switch { + case after == "": + // bare spec: let OpenCode's configured default model apply. + case strings.Contains(after, "/"): + // "/" pass-through to an OpenCode built-in provider. + e.modelRef = after + default: + // A bare model id → serve it via the generated ollama-cloud provider. + e.providerModel = after + e.modelRef = openCodeProviderName + "/" + after + } + return e +} + +// args assembles the `opencode` argv for one pass. Factored out (and pure) so it +// can be unit-tested without invoking the CLI. The task is the positional message +// and MUST come last; it never begins with '-' (buildTask output starts with "PR +// title:"/"Review …"), so no "--" terminator is needed. Note: in `opencode run`, +// -p is basic-auth password, NOT the prompt — the message is positional. +func (e *openCodeEngine) args(task string) []string { + a := []string{"run", "--agent", openCodeAgentName} + if e.modelRef != "" { + a = append(a, "--model", e.modelRef) + } + a = append(a, e.extraArgs...) + return append(a, task) +} + +// openCode config structs — the minimal shape gadfly generates. Marshaled to JSON +// and handed to the CLI via OPENCODE_CONFIG_CONTENT. +type openCodeConfig struct { + Schema string `json:"$schema"` + Permission openCodePermission `json:"permission"` + Provider map[string]openCodeProvider `json:"provider,omitempty"` + Agent map[string]openCodeAgent `json:"agent"` +} + +// openCodePermission is OpenCode's permission map: tool key → "allow"|"ask"|"deny". +type openCodePermission map[string]string + +// denyMutations denies every OpenCode permission that could change the repo, run +// commands, or reach the network / the filesystem outside the checked-out tree — +// keeping the reviewer strictly read-only. The read/search tools OpenCode gates +// separately (read/glob/grep/list/lsp) stay at their default so the agent can +// still verify findings against the code. OpenCode's permission keys are +// enumerated at https://opencode.ai/docs/agents; a key it doesn't recognize is +// simply ignored, so listing extras is safe. +func denyMutations() openCodePermission { + return openCodePermission{ + "edit": "deny", + "bash": "deny", + "webfetch": "deny", + "websearch": "deny", + "external_directory": "deny", + } +} + +type openCodeProvider struct { + NPM string `json:"npm"` + Name string `json:"name"` + Options openCodeProviderOptions `json:"options"` + Models map[string]struct{} `json:"models"` +} + +type openCodeProviderOptions struct { + BaseURL string `json:"baseURL"` + APIKey string `json:"apiKey"` +} + +type openCodeAgent struct { + Description string `json:"description"` + Mode string `json:"mode"` + Prompt string `json:"prompt"` + Permission openCodePermission `json:"permission"` +} + +// config builds the OpenCode config JSON for one pass. The system prompt becomes +// the "gadfly" agent's prompt; the mutating/network tools are denied at BOTH the +// global and agent level (defense in depth — OpenCode's read tools stay +// available). For a bare or pass-through spec the generated ollama-cloud provider +// block is omitted. The API key is always the "{env:OLLAMA_API_KEY}" reference, +// never a literal secret baked into the config. +func (e *openCodeEngine) config(system string) ([]byte, error) { + deny := denyMutations() + cfg := openCodeConfig{ + Schema: "https://opencode.ai/config.json", + Permission: deny, + Agent: map[string]openCodeAgent{ + openCodeAgentName: { + Description: "Gadfly adversarial code-review lens (read-only).", + Mode: "primary", + Prompt: system, + Permission: deny, + }, + }, + } + if e.providerModel != "" { + cfg.Provider = map[string]openCodeProvider{ + openCodeProviderName: { + NPM: "@ai-sdk/openai-compatible", + Name: openCodeProviderName, + Options: openCodeProviderOptions{ + BaseURL: e.baseURL, + APIKey: "{env:OLLAMA_API_KEY}", + }, + Models: map[string]struct{}{e.providerModel: {}}, + }, + } + } + return json.Marshal(cfg) +} + +func (e *openCodeEngine) runPass(ctx context.Context, system, task string, _ int) (string, error) { + cfg, err := e.config(system) + if err != nil { + return "", fmt.Errorf("opencode config: %w", err) + } + cmd := exec.CommandContext(ctx, e.bin, e.args(task)...) + cmd.Dir = e.repoDir + // Inject the review config (system prompt as the agent prompt + read-only + // permissions + the ollama-cloud provider) via OPENCODE_CONFIG_CONTENT, which + // outranks any opencode.json the reviewed repo itself ships. NO_COLOR keeps the + // captured stdout free of ANSI decoration. + cmd.Env = append(openCodeEnv(), "OPENCODE_CONFIG_CONTENT="+string(cfg), "NO_COLOR=1") + killGroupOnCancel(cmd) // don't orphan the CLI's Node children on a timed-out lens + 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("opencode run %v", ctx.Err()) + } + if runErr != nil { + detail := truncateForErr(stderr.String()) + if detail == "" { + detail = truncateForErr(stdout.String()) + } + if detail != "" { + return "", fmt.Errorf("opencode run failed: %v: %s", runErr, detail) + } + return "", fmt.Errorf("opencode run failed: %v", runErr) + } + // OpenCode's default (non-JSON) format prints the assistant's final text; trust + // it as the review. An empty result on a clean exit is an error, never "". + if out := strings.TrimSpace(stdout.String()); out != "" { + return out, nil + } + return "", fmt.Errorf("opencode run returned no output") +} + +// openCodeEnv builds a minimal environment for the `opencode` subprocess. It +// forwards what the CLI needs to reach a model provider: OLLAMA_API_KEY for the +// generated ollama-cloud provider (the primary opencode/ path), PLUS the +// standard provider keys — ANTHROPIC_*, OPENAI_*, GOOGLE_*, GEMINI_* — so the +// opencode// pass-through form can authenticate against +// OpenCode's own built-in providers (those keys are otherwise stripped, which +// broke the documented escape hatch). It still withholds gadfly's OWN secrets — +// GITEA_TOKEN, GADFLY_API_KEY, GADFLY_FINDINGS_TOKEN, and the claude-code +// subscription token (CLAUDE_CODE_OAUTH_TOKEN, which OpenCode can't use anyway) — +// so they never reach the third-party CLI. OPENCODE_CONFIG_CONTENT is never +// inherited: runPass sets it, and a duplicate key would be ambiguous (getenv +// returns the first occurrence). +func openCodeEnv() []string { + return filterEnv(func(k string) bool { + if k == "OPENCODE_CONFIG_CONTENT" { + return false // set explicitly by runPass; never inherit a competing value + } + switch k { + case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "OLLAMA_API_KEY": + return true + } + return strings.HasPrefix(k, "LC_") || + strings.HasPrefix(k, "OPENCODE_") || + strings.HasPrefix(k, "GADFLY_OPENCODE_") || + strings.HasPrefix(k, "NODE_") || + strings.HasPrefix(k, "XDG_") || + strings.HasPrefix(k, "ANTHROPIC_") || + strings.HasPrefix(k, "OPENAI_") || + strings.HasPrefix(k, "GOOGLE_") || + strings.HasPrefix(k, "GEMINI_") + }) +} diff --git a/cmd/gadfly/opencode_test.go b/cmd/gadfly/opencode_test.go new file mode 100644 index 0000000..1a6b8af --- /dev/null +++ b/cmd/gadfly/opencode_test.go @@ -0,0 +1,303 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "slices" + "strings" + "testing" +) + +func TestIsOpenCodeSpec(t *testing.T) { + cases := map[string]bool{ + "opencode": true, + "opencode/glm-5.2": true, + "open-code/glm-5.2": true, // accepted alias spelling + "opencode/qwen3-coder:480b-cloud": true, // colon-bearing model id + "opencode/anthropic/claude": true, // pass-through form + " opencode ": true, // trimmed + "opencode-extra": false, // not the bare id, not a "/" form + "qwen3-coder:480b-cloud": false, + "claude-code/opus": false, + "": false, + } + for spec, want := range cases { + if got := isOpenCodeSpec(spec); got != want { + t.Errorf("isOpenCodeSpec(%q) = %v, want %v", spec, got, want) + } + } +} + +func TestNewOpenCodeEngineModel(t *testing.T) { + t.Setenv("GADFLY_OPENCODE_MODEL", "") + + // "opencode/" → wrapped in the generated "gadfly" provider. + if e := newOpenCodeEngine("opencode/glm-5.2", "/repo"); e.providerModel != "glm-5.2" || e.modelRef != "gadfly/glm-5.2" { + t.Errorf("glm-5.2: providerModel=%q modelRef=%q, want glm-5.2 / gadfly/glm-5.2", e.providerModel, e.modelRef) + } + // Colon-bearing ollama id is preserved verbatim — NOT split on ":". + if e := newOpenCodeEngine("opencode/qwen3-coder:480b-cloud", "/repo"); e.providerModel != "qwen3-coder:480b-cloud" { + t.Errorf("colon id: providerModel=%q, want qwen3-coder:480b-cloud (no split)", e.providerModel) + } + // "open-code/" spelling behaves identically. + if e := newOpenCodeEngine("open-code/glm-5.2", "/repo"); e.modelRef != "gadfly/glm-5.2" { + t.Errorf("open-code alias: modelRef=%q, want gadfly/glm-5.2", e.modelRef) + } + // Pass-through "opencode//" → no generated provider. + if e := newOpenCodeEngine("opencode/anthropic/claude-sonnet-4-6", "/repo"); e.providerModel != "" || e.modelRef != "anthropic/claude-sonnet-4-6" { + t.Errorf("pass-through: providerModel=%q modelRef=%q, want '' / anthropic/claude-sonnet-4-6", e.providerModel, e.modelRef) + } + // Bare spec → no model, no provider (CLI default applies). + if e := newOpenCodeEngine("opencode", "/repo"); e.providerModel != "" || e.modelRef != "" { + t.Errorf("bare: providerModel=%q modelRef=%q, want both empty", e.providerModel, e.modelRef) + } + // GADFLY_OPENCODE_MODEL overrides the spec suffix. + t.Setenv("GADFLY_OPENCODE_MODEL", "deepseek-v3") + if e := newOpenCodeEngine("opencode/glm-5.2", "/repo"); e.providerModel != "deepseek-v3" || e.modelRef != "gadfly/deepseek-v3" { + t.Errorf("env override: providerModel=%q modelRef=%q, want deepseek-v3 / gadfly/deepseek-v3", e.providerModel, e.modelRef) + } +} + +func TestOpenCodeEngineDefaults(t *testing.T) { + t.Setenv("GADFLY_OPENCODE_BIN", "") + t.Setenv("GADFLY_OPENCODE_BASE_URL", "") + t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "") + e := newOpenCodeEngine("opencode/glm-5.2", "/repo") + if e.bin != "opencode" { + t.Errorf("bin = %q, want opencode", e.bin) + } + if e.baseURL != defaultOpenCodeBaseURL { + t.Errorf("baseURL = %q, want %q", e.baseURL, defaultOpenCodeBaseURL) + } + if e.repoDir != "/repo" { + t.Errorf("repoDir = %q, want /repo", e.repoDir) + } +} + +func TestOpenCodeArgs(t *testing.T) { + t.Setenv("GADFLY_OPENCODE_MODEL", "") + t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "--variant reasoning") + e := newOpenCodeEngine("opencode/glm-5.2", "/repo") + args := e.args("TASK-PROMPT") + + // "run" is the subcommand and must be first. + if len(args) == 0 || args[0] != "run" { + t.Fatalf("args[0] = %q, want run (args=%v)", args, args) + } + if argAfter(args, "--agent") != openCodeAgentName { + t.Errorf("--agent = %q, want %q", argAfter(args, "--agent"), openCodeAgentName) + } + if argAfter(args, "--model") != "gadfly/glm-5.2" { + t.Errorf("--model = %q, want gadfly/glm-5.2", argAfter(args, "--model")) + } + // extra args appended verbatim (split on whitespace). + if !strings.Contains(strings.Join(args, " "), "--variant reasoning") { + t.Errorf("extra args not appended: %v", args) + } + // task is the positional message and must be LAST. + if args[len(args)-1] != "TASK-PROMPT" { + t.Errorf("last arg = %q, want TASK-PROMPT (args=%v)", args[len(args)-1], args) + } +} + +func TestOpenCodeArgsBareModelOmitsFlag(t *testing.T) { + t.Setenv("GADFLY_OPENCODE_MODEL", "") + t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "") + e := newOpenCodeEngine("opencode", "/repo") + args := e.args("t") + if slices.Contains(args, "--model") { + t.Errorf("--model should be omitted for a bare opencode spec: %v", args) + } + if args[len(args)-1] != "t" { + t.Errorf("last arg = %q, want t", args[len(args)-1]) + } +} + +func TestOpenCodeConfig(t *testing.T) { + t.Setenv("GADFLY_OPENCODE_MODEL", "") + t.Setenv("GADFLY_OPENCODE_BASE_URL", "") + + // Round-trip a system prompt containing quotes and newlines. + sys := "Line one with \"quotes\".\nLine two." + e := newOpenCodeEngine("opencode/glm-5.2", "/repo") + raw, err := e.config(sys) + if err != nil { + t.Fatalf("config: %v", err) + } + var cfg openCodeConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("generated config is not valid JSON: %v\n%s", err, raw) + } + + // Agent carries the system prompt verbatim and denies edit+bash. + ag, ok := cfg.Agent[openCodeAgentName] + if !ok { + t.Fatalf("agent %q missing from config", openCodeAgentName) + } + if ag.Prompt != sys { + t.Errorf("agent prompt = %q, want it to round-trip the system prompt", ag.Prompt) + } + // Mutating/network tools are denied at BOTH the agent and global level (defense + // in depth); the read/search tools stay at OpenCode's default. + for _, k := range []string{"edit", "bash", "webfetch", "websearch", "external_directory"} { + if ag.Permission[k] != "deny" { + t.Errorf("agent permission[%q] = %q, want deny", k, ag.Permission[k]) + } + if cfg.Permission[k] != "deny" { + t.Errorf("global permission[%q] = %q, want deny", k, cfg.Permission[k]) + } + } + + // Provider block: correct npm, default baseURL, env-ref apiKey, model in map. + prov, ok := cfg.Provider[openCodeProviderName] + if !ok { + t.Fatalf("provider %q missing from config", openCodeProviderName) + } + if prov.NPM != "@ai-sdk/openai-compatible" { + t.Errorf("provider npm = %q, want @ai-sdk/openai-compatible", prov.NPM) + } + if prov.Options.BaseURL != defaultOpenCodeBaseURL { + t.Errorf("provider baseURL = %q, want %q", prov.Options.BaseURL, defaultOpenCodeBaseURL) + } + if prov.Options.APIKey != "{env:OLLAMA_API_KEY}" { + t.Errorf("provider apiKey = %q, want {env:OLLAMA_API_KEY} (never a literal secret)", prov.Options.APIKey) + } + if _, ok := prov.Models["glm-5.2"]; !ok { + t.Errorf("provider models = %v, want it to contain glm-5.2", prov.Models) + } + + // GADFLY_OPENCODE_BASE_URL override reaches the provider. + t.Setenv("GADFLY_OPENCODE_BASE_URL", "http://localhost:11434/v1") + e2 := newOpenCodeEngine("opencode/glm-5.2", "/repo") + raw2, err := e2.config(sys) + if err != nil { + t.Fatalf("config override: %v", err) + } + var cfg2 openCodeConfig + if err := json.Unmarshal(raw2, &cfg2); err != nil { + t.Fatalf("override config invalid JSON: %v", err) + } + if got := cfg2.Provider[openCodeProviderName].Options.BaseURL; got != "http://localhost:11434/v1" { + t.Errorf("override baseURL = %q, want http://localhost:11434/v1", got) + } +} + +func TestOpenCodeConfigNoProviderForPassThroughAndBare(t *testing.T) { + t.Setenv("GADFLY_OPENCODE_MODEL", "") + for _, spec := range []string{"opencode", "opencode/anthropic/claude-sonnet-4-6"} { + e := newOpenCodeEngine(spec, "/repo") + raw, err := e.config("sys") + if err != nil { + t.Fatalf("config(%q): %v", spec, err) + } + if strings.Contains(string(raw), "\"provider\"") { + t.Errorf("spec %q: config should omit the provider block, got %s", spec, raw) + } + } +} + +func TestOpenCodeEnvFilters(t *testing.T) { + t.Setenv("GITEA_TOKEN", "secret-gitea") + t.Setenv("OLLAMA_API_KEY", "keep-ollama") + t.Setenv("GADFLY_API_KEY", "secret-gadfly") + t.Setenv("GADFLY_FINDINGS_TOKEN", "secret-findings") + t.Setenv("ANTHROPIC_API_KEY", "keep-anthropic") // pass-through provider auth + t.Setenv("OPENAI_API_KEY", "keep-openai") // pass-through provider auth + t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "secret-claude") + t.Setenv("GADFLY_OPENCODE_MODEL", "keep-knob") + t.Setenv("OPENCODE_CONFIG_CONTENT", "should-not-inherit") + + env := openCodeEnv() + has := func(k string) bool { + for _, kv := range env { + if strings.HasPrefix(kv, k+"=") { + return true + } + } + return false + } + // kept: the ollama key + the standard provider keys the opencode// + // pass-through form needs + opencode knobs + PATH + for _, k := range []string{"OLLAMA_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GADFLY_OPENCODE_MODEL", "PATH"} { + if !has(k) { + t.Errorf("openCodeEnv dropped %s, but it should be kept", k) + } + } + // dropped: gadfly's own secrets + the claude engine's subscription token + // (OpenCode's anthropic provider uses ANTHROPIC_API_KEY, not this OAuth token). + for _, k := range []string{"GITEA_TOKEN", "GADFLY_API_KEY", "GADFLY_FINDINGS_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"} { + if has(k) { + t.Errorf("openCodeEnv leaked %s into the subprocess env", k) + } + } + // OPENCODE_CONFIG_CONTENT must NOT be inherited — runPass sets it, and a + // duplicate key would be ambiguous. + if has("OPENCODE_CONFIG_CONTENT") { + t.Errorf("openCodeEnv inherited OPENCODE_CONFIG_CONTENT; runPass sets it explicitly") + } +} + +// stubOpenCode writes an executable shell stub that prints body and exits code, +// and returns an engine pointed at it. +func stubOpenCode(t *testing.T, body string, code int) *openCodeEngine { + t.Helper() + dir := t.TempDir() + path := dir + "/opencode-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 &openCodeEngine{bin: path, repoDir: dir} +} + +func TestOpenCodeRunPassCleanResult(t *testing.T) { + e := stubOpenCode(t, " REVIEW TEXT ", 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 TestOpenCodeRunPassEmptyIsError(t *testing.T) { + e := stubOpenCode(t, " ", 0) + out, err := e.runPass(context.Background(), "sys", "task", 0) + if err == nil { + t.Fatalf("empty output should be an error, got out=%q", out) + } +} + +func TestOpenCodeRunPassNonZero(t *testing.T) { + e := stubOpenCode(t, "fatal: provider auth failed", 1) + _, err := e.runPass(context.Background(), "sys", "task", 0) + if err == nil || !strings.Contains(err.Error(), "opencode run failed") { + t.Fatalf("non-zero exit should error with detail, got %v", err) + } +} + +// TestOpenCodeRunPassInjectsConfig proves the end-to-end env plumbing: the stub +// echoes OPENCODE_CONFIG_CONTENT back, and the emitted JSON must carry the exact +// system prompt as the gadfly agent's prompt. +func TestOpenCodeRunPassInjectsConfig(t *testing.T) { + dir := t.TempDir() + stub := dir + "/opencode-stub.sh" + script := "#!/bin/sh\nprintf '%s' \"$OPENCODE_CONFIG_CONTENT\"\n" + if err := os.WriteFile(stub, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + e := newOpenCodeEngine("opencode/glm-5.2", dir) + e.bin = stub + + sys := "SYSTEM-PROMPT-SENTINEL\nwith a second line" + out, err := e.runPass(context.Background(), sys, "task", 0) + if err != nil { + t.Fatalf("runPass: %v", err) + } + var cfg openCodeConfig + if err := json.Unmarshal([]byte(out), &cfg); err != nil { + t.Fatalf("injected config is not valid JSON: %v\n%s", err, out) + } + if got := cfg.Agent[openCodeAgentName].Prompt; got != sys { + t.Errorf("injected agent prompt = %q, want the system prompt", got) + } +} diff --git a/entrypoint.sh b/entrypoint.sh index 0d52448..6f5f81d 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -39,6 +39,10 @@ # CLAUDE_CODE_OAUTH_TOKEN auth for the claude-code engine (GADFLY_MODELS entry # "claude-code"/"claude-code/"); Pro/Max subscription # token from `claude setup-token`. Else ANTHROPIC_API_KEY. +# OLLAMA_CLOUD_API_KEY also feeds the opencode engine (GADFLY_MODELS entry +# "opencode/"): the bundled `opencode` CLI drives +# that ollama-cloud model, for benchmarking the two +# harnesses on the same model. Tune via GADFLY_OPENCODE_*. # GADFLY_TRIGGER_PHRASE comment phrase that triggers a re-review (default "@gadfly review") # GADFLY_ALLOWED_USERS comma-separated usernames allowed to comment-trigger; # empty => fall back to "is a repo collaborator" diff --git a/examples/README.md b/examples/README.md index 22b170c..dd2412a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,6 +12,7 @@ set the secrets/vars it references. Gadfly is advisory only — it never blocks | [`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_` | | [`claude-code.yml`](claude-code.yml) | the bundled **Claude Code CLI** engine (`claude-code/`) | secret `CLAUDE_CODE_OAUTH_TOKEN` (or `ANTHROPIC_API_KEY`) | +| [`opencode.yml`](opencode.yml) | the bundled **OpenCode CLI** engine (`opencode/`) driving an ollama-cloud model — benchmark it against the majordomo loop on the same model | secret `OLLAMA_CLOUD_API_KEY` | | [`.gadfly.yml`](.gadfly.yml) | **per-repo specialist config** (not a workflow — goes at your repo root) | — | Common to all: diff --git a/examples/opencode.yml b/examples/opencode.yml new file mode 100644 index 0000000..7fb64aa --- /dev/null +++ b/examples/opencode.yml @@ -0,0 +1,76 @@ +# Gadfly reviewing via the OpenCode CLI engine. +# Copy to .gitea/workflows/adversarial-review.yml in your repo. +# +# Instead of gadfly's own majordomo loop, each lens shells out to the bundled +# `opencode` CLI (opencode.ai) inside the checked-out repo — it uses its own read +# tools to verify findings — while driving an ollama-cloud model. Gadfly then runs +# its usual verdict + recheck + consolidate pipeline. +# +# Why: benchmark gadfly's boutique harness against a freely-available one ON THE +# SAME MODEL. List both entries to get one comment section each and compare: +# GADFLY_MODELS: "ollama-cloud/glm-5.2,opencode/glm-5.2" +# +# Auth: reuses the OLLAMA_CLOUD_API_KEY secret (same as the ollama-cloud path) — +# no OpenCode-specific credential is needed for the ollama-cloud provider. +# +# Heads-up: this engine is newly wired and lightly tested — read the README's +# "OpenCode 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 }} + # --- OpenCode engine --- + # Reuses the ollama-cloud key; mapped to OLLAMA_API_KEY in-container and + # referenced by the generated provider as {env:OLLAMA_API_KEY}. + OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }} + # "opencode/" serves that model via ollama-cloud through OpenCode. + # Model ids are verbatim (colons preserved). List an "ollama-cloud/" + # entry too to benchmark the two harnesses on the same model. + GADFLY_MODELS: "opencode/glm-5.2" + # Optional CLI tuning: + # GADFLY_OPENCODE_BASE_URL: "https://ollama.com/v1" # or a local Ollama /v1 + # GADFLY_OPENCODE_MODEL: "glm-5.2" # overrides the spec suffix + # GADFLY_OPENCODE_EXTRA_ARGS: "--variant reasoning" # whitespace-split + # Escape hatch: "opencode//" passes straight to OpenCode's + # own provider registry/auth (e.g. opencode/anthropic/claude-sonnet-4-6). + 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 }} diff --git a/scripts/run.sh b/scripts/run.sh index 1731e6a..6dc2944 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -29,6 +29,13 @@ # tuning are read straight from the inherited environment — same as the other # provider keys (OPENAI_API_KEY, …) — so no extra wiring is needed here. # +# opencode engine: when MODEL is "opencode" or "opencode/" the binary +# shells out to the bundled `opencode` CLI, driving an ollama-cloud model (for +# benchmarking against the majordomo path on the same model). Its auth reuses +# OLLAMA_CLOUD_API_KEY (mapped to OLLAMA_API_KEY below, same as the ollama-cloud +# path) and GADFLY_OPENCODE_* tuning is read from the inherited environment — so +# no extra wiring is needed here either. +# # Optional: # 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