Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Diffsmith has no server. The selected model CLI may still send diffs to its own

## Current Status

[v0.1.0-rc1](https://github.com/selyafi/diffsmith/releases/tag/v0.1.0-rc1) released (M8). The product is built end-to-end: GitHub + GitLab providers; Codex, Claude, and Gemini model adapters running in parallel with synthesis via a lead model; three-pane TUI; clipboard/export; inline review-thread posting back to GitHub PRs and GitLab MRs (gated by explicit confirmation); dedup-before-post against existing diffsmith threads; prompt-injection-resilient parser. See [CHANGELOG.md](CHANGELOG.md) for the v0.1.0 release notes.
[v0.1.0-rc1](https://github.com/selyafi/diffsmith/releases/tag/v0.1.0-rc1) released (M8). The product is built end-to-end: GitHub + GitLab providers; Codex, Claude, and Antigravity model adapters running in parallel with synthesis via a lead model; three-pane TUI; clipboard/export; inline review-thread posting back to GitHub PRs and GitLab MRs (gated by explicit confirmation); dedup-before-post against existing diffsmith threads; prompt-injection-resilient parser. See [CHANGELOG.md](CHANGELOG.md) for the v0.1.0 release notes.

## Product Thesis

Expand All @@ -31,7 +31,7 @@ diffsmith review <github-pr-url|gitlab-mr-url> # review a specific PR/MR
diffsmith # inbox: pick from your repo's open PRs/MRs
```

At startup, diffsmith probes which AI CLIs are installed (`codex`, `claude`, `gemini`) and shows an interactive picker. Any subset can be selected; their findings are merged by a synthesis pass via the highest-priority surviving model (priority order: codex → claude → gemini). `antigravity` (CLI binary: `agy`) is registered but disabled in v1 because the CLI has no non-interactive auth path; see `internal/model/antigravitycli/doc.go`.
At startup, diffsmith probes which AI CLIs are installed (`codex`, `claude`, and `agy` for Antigravity) and shows an interactive picker. Any subset can be selected; their findings are merged by a synthesis pass via the highest-priority surviving model (priority order: codex → claude → antigravity). The Antigravity adapter requires a one-time interactive `agy` login (after which its OAuth token persists); see `internal/model/antigravitycli/doc.go`.

To sharpen findings, diffsmith also sends the PR/MR description and the acceptance criteria from any issues the PR/MR formally closes (resolved via `gh`/`glab`) so reviewers can flag scope drift and unmet criteria. This is on by default; pass `--no-context` for a diff-only review that withholds the description and skips the linked-issue fetch. Context fetching is never a gate — if it fails, the review proceeds and the reason is surfaced in the run summary.

Expand Down Expand Up @@ -95,8 +95,8 @@ For repository access:

For AI review:

- `codex`, `claude`, or `gemini` CLI — at least one must be installed and authenticated. The picker shows which are available at startup; all selected models run in parallel and a lead model synthesizes the final findings.
- (`agy` for Antigravity is not a supported install path in v1; the adapter ships disabled — see "V1 Command" above)
- `codex`, `claude`, or `agy` (Antigravity) CLI — at least one must be installed and authenticated. The picker shows which are available at startup; all selected models run in parallel and a lead model synthesizes the final findings.
- `agy` requires a one-time interactive login (run `agy` once); after that its OAuth token persists and diffsmith drives it non-interactively.

## V1 Workflow

Expand Down
7 changes: 4 additions & 3 deletions internal/app/input_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ package app
import "github.com/selyafi/diffsmith/internal/model"

// applyInputBudget delivers --input-budget=N to every selected model
// that implements model.InputBudgetSetter. Adapters without the
// capability (antigravity in v1) are silently skipped — they don't
// have a budget to override.
// that implements model.InputBudgetSetter. The type-assertion is a
// defensive skip path: all current adapters (codex, claude, antigravity)
// implement the capability, but one that didn't would simply keep its
// compiled-in default rather than error.
//
// budget<=0 means "flag unset / not requested"; in that case we leave
// every adapter's compiled-in default in place. Surfacing zero as a
Expand Down
6 changes: 3 additions & 3 deletions internal/app/input_budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ func (f *budgetlessFake) Review(context.Context, *review.ReviewInput) (*review.M
// TestApplyInputBudget_AppliesToSettersOnly is the diffsmith-uc1 unit:
// when the user passes --input-budget=N, every selected model that
// implements InputBudgetSetter must receive SetInputBudget(N) exactly
// once. Models without the capability (e.g. antigravity in v1) are
// silently skipped — they don't have a budget to override.
// once. A model without the capability (here a hypothetical review-only
// adapter) is silently skipped — it has no budget to override.
func TestApplyInputBudget_AppliesToSettersOnly(t *testing.T) {
setter := &budgetSettingFake{name: "codex"}
other := &budgetlessFake{name: "antigravity"}
other := &budgetlessFake{name: "review-only-fake"}
selected := &model.SelectedModels{All: []model.Model{setter, other}}

applyInputBudget(selected, 512*1024)
Expand Down
6 changes: 3 additions & 3 deletions internal/app/multimodel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ var _ model.Model = blockingModel{}
func TestRunModelsInParallel_SlowModelTimesOut(t *testing.T) {
models := []model.Model{
fakeModel{name: "codex", result: &review.ModelReviewResult{}},
blockingModel{name: "gemini", fallback: 500 * time.Millisecond},
blockingModel{name: "antigravity", fallback: 500 * time.Millisecond},
}
results := runModelsInParallel(context.Background(), models, &review.ReviewInput{}, func(tea.Msg) {}, 20*time.Millisecond)

Expand All @@ -131,7 +131,7 @@ func TestRunModelsInParallel_SlowModelTimesOut(t *testing.T) {
if byName["codex"].Err != nil {
t.Errorf("fast model codex should succeed; got %v", byName["codex"].Err)
}
if byName["gemini"].Err == nil {
t.Error("slow model gemini should drop out with a timeout error")
if byName["antigravity"].Err == nil {
t.Error("slow model antigravity should drop out with a timeout error")
}
}
16 changes: 7 additions & 9 deletions internal/app/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"github.com/selyafi/diffsmith/internal/model/antigravitycli"
"github.com/selyafi/diffsmith/internal/model/claudecli"
"github.com/selyafi/diffsmith/internal/model/codexcli"
"github.com/selyafi/diffsmith/internal/model/geminicli"
"github.com/selyafi/diffsmith/internal/provider"
"github.com/selyafi/diffsmith/internal/provider/githubgh"
"github.com/selyafi/diffsmith/internal/provider/gitlabglab"
Expand Down Expand Up @@ -91,9 +90,9 @@ func newRootCmd() *cobra.Command {

// preflightModels probes each adapter and returns a slice of picker
// items annotated with availability. Order is stable: codex, claude,
// gemini, antigravity.
// antigravity.
func preflightModels(ctx context.Context, models map[string]model.Model) []tui.ModelPickerItem {
order := []string{"codex", "claude", "gemini", "antigravity"}
order := []string{"codex", "claude", "antigravity"}
items := make([]tui.ModelPickerItem, 0, len(order))
for _, name := range order {
m, ok := models[name]
Expand Down Expand Up @@ -134,7 +133,7 @@ func runPickerForModels(items []tui.ModelPickerItem, models map[string]model.Mod
}
}
if available == 0 {
return nil, fmt.Errorf("no review CLIs available; install/auth at least one of: codex, claude, gemini, antigravity")
return nil, fmt.Errorf("no review CLIs available; install/auth at least one of: codex, claude, antigravity")
}

picker := tui.NewModelPickerModel(items)
Expand Down Expand Up @@ -166,15 +165,14 @@ func defaultRegistry() *provider.Registry {
}

// defaultModels returns the model registry wired to real CLIs. Codex,
// Claude, and Gemini are the working v1 adapters. Antigravity (agy) is
// still registered so a user who selects it sees the actionable
// Preflight error from spike S8b (no non-interactive auth path) rather
// than an "unknown model" CLI error; the adapter itself refuses to run.
// Claude, and Antigravity (agy) are the working adapters, each a full
// peer (reviewer + synthesizer). The legacy Gemini adapter was removed
// when Google cut off the gemini-cli free-tier OAuth client and agy
// (S8b resolved in agy 1.0.9) took the third slot.
func defaultModels() map[string]model.Model {
return map[string]model.Model{
"codex": codexcli.New(nil),
"claude": claudecli.New(nil),
"gemini": geminicli.New(nil),
"antigravity": antigravitycli.New(nil),
}
}
Expand Down
6 changes: 4 additions & 2 deletions internal/app/synthesis.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ import (
//
// 1. nil leadModel — registry miss (drift between the reviewer's
// surviving outcome's Model name and the selected.All set).
// 2. lead doesn't satisfy model.Synthesizer — review-only adapter
// (e.g. antigravity in v1); diffsmith-dvz.7 made this explicit.
// 2. lead doesn't satisfy model.Synthesizer — a review-only adapter.
// All current adapters (codex, claude, antigravity) are full peers,
// so this is a defensive guard for any future review-only adapter;
// diffsmith-dvz.7 made the skip explicit.
// 3. Synthesize returned an error — typical: budget bust, parse
// failure, network.
// 4. Synthesize returned (nil, nil) — undefined per the adapter
Expand Down
157 changes: 120 additions & 37 deletions internal/model/antigravitycli/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,62 +3,145 @@ package antigravitycli
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"

"github.com/selyafi/diffsmith/internal/model"
"github.com/selyafi/diffsmith/internal/provider"
"github.com/selyafi/diffsmith/internal/review"
)

// Compile-time interface guard: this adapter implements model.Reviewer
// but NOT model.Synthesizer (per S8b — agy has no non-interactive
// auth path). The negative assertion can't be expressed in Go's type
// system; the positive guard plus the runtime test
// TestAdapter_DoesNotImplementSynthesizer in adapter_test.go together
// document and enforce the contract. diffsmith-0hy.
var _ model.Reviewer = (*Adapter)(nil)
// DefaultInputBudgetBytes caps the prompt size sent to agy. Matches the
// codex/claude budget (1 MiB) so users get consistent behavior regardless
// of model choice. See codexcli for the underlying rationale.
const DefaultInputBudgetBytes = 1024 * 1024

// Adapter implements model.Reviewer against the Antigravity CLI (`agy`).
//
// Per spike S8b (see doc.go) the adapter is a Preflight stub in v1: the
// `agy` CLI cannot authenticate non-interactively, so Review never reaches
// the runner. The struct still exposes the constructor signature shared
// by the other adapters so it can sit in defaultModels() and surface an
// actionable error when a user picks `--model antigravity`.
//
// Unlike the codex/claude/gemini adapters, this one does NOT implement
// model.Synthesizer: there is no real CLI invocation to delegate to in
// v1, and a stub Synthesize would only mask the missing capability.
// The synthesis call site type-asserts model.Synthesizer and skips
// lead candidates that don't satisfy it.
// noDeadlinePrintTimeout is the --print-timeout passed when the call's ctx
// has no deadline (i.e. --model-timeout 0, documented as "disables the
// cap"). agy's intrinsic default is 5m, which would cap antigravity while
// codex/claude run unbounded; a large ceiling honors the disabled cap
// without leaving a truly unbounded interactive hang.
const noDeadlinePrintTimeout = "24h"

// printTimeout derives agy's --print-timeout from the call's ctx deadline
// so the user's --model-timeout governs antigravity exactly as it governs
// codex/claude — which pass no internal CLI timeout and are capped solely
// by ctx. agy's intrinsic 5m default would otherwise cap antigravity below
// a longer --model-timeout (default 10m) and ignore --model-timeout 0.
// With a deadline we pass the remaining budget, so agy self-aborts at the
// same point exec.CommandContext would cancel it.
func printTimeout(ctx context.Context) string {
dl, ok := ctx.Deadline()
if !ok {
return noDeadlinePrintTimeout
}
// at/past the deadline: stop promptly rather than passing 0/negative.
remaining := max(time.Until(dl), time.Second)
return remaining.Round(time.Second).String()
}

// Adapter implements the model.Model interface against the Antigravity CLI
// (`agy`). agy 1.0.9 resolved the S8b auth blocker (persistent OAuth
// tokens), so this is a full peer: it reviews, synthesizes, and honors an
// input budget, matching the codex/claude adapters.
type Adapter struct {
lookPath func(name string) (string, error)
run provider.Runner
lookPath func(name string) (string, error)
inputBudget int
}

// New constructs an Adapter. The provider.Runner argument is accepted
// for uniformity with the codex and claude adapters but unused in v1:
// the adapter is gated behind a Preflight error per S8b.
func New(_ provider.Runner) *Adapter {
return &Adapter{lookPath: exec.LookPath}
// New constructs an Adapter. Passing nil uses provider.IsolatedRunner so
// agy can't onboard from the caller's cwd; lookPath defaults to
// exec.LookPath. Tests override fields directly (the package is
// internal-only).
func New(run provider.Runner) *Adapter {
if run == nil {
// Isolate agy from the caller's cwd: the whole diff is piped via
// stdin, so the reviewer needs no workspace, and a neutral temp dir
// keeps reviews deterministic. diffsmith-4tz.
run = provider.IsolatedRunner()
}
return &Adapter{
run: run,
lookPath: exec.LookPath,
inputBudget: DefaultInputBudgetBytes,
}
}

// SetInputBudget overrides the default prompt-size cap. Values <= 0 are
// ignored so an unset --input-budget flag can't silently disable
// enforcement and let an arbitrarily large prompt slip through.
func (a *Adapter) SetInputBudget(bytes int) {
if bytes > 0 {
a.inputBudget = bytes
}
}

// Name returns the model identifier surfaced to users via --model.
// Name returns the model identifier surfaced to users via the picker and
// attached to validated findings.
func (a *Adapter) Name() string { return "antigravity" }

// Preflight always returns an error in v1. If `agy` is missing from PATH
// the error explains how to install it; if it is present the error
// explains the experimental gate (interactive-only OAuth per S8b).
// Preflight verifies the agy binary is on PATH. Auth failures (a user who
// has never run an interactive `agy` login) surface at Review time via
// agy's own stderr, which the runner propagates — matching codex/claude.
func (a *Adapter) Preflight(_ context.Context) error {
if _, err := a.lookPath("agy"); err != nil {
return errors.New("agy (Antigravity CLI) not found on PATH. The antigravity adapter is experimental in v1; install agy or select --model codex or --model claude")
return errors.New("agy (Antigravity CLI) not found on PATH. Install it and run `agy` once to authenticate, or select --model codex or --model claude")
}
return errors.New("antigravity adapter is experimental in v1: agy requires interactive browser OAuth on every invocation with no persistent-token path, so it cannot run as a non-interactive review backend. Select --model codex or --model claude")
return nil
}

// Review delegates to Preflight in v1. The runner is never invoked.
func (a *Adapter) Review(ctx context.Context, _ *review.ReviewInput) (*review.ModelReviewResult, error) {
if err := a.Preflight(ctx); err != nil {
return nil, err
// Review invokes agy against the standard review prompt.
func (a *Adapter) Review(ctx context.Context, input *review.ReviewInput) (*review.ModelReviewResult, error) {
return a.executeWithPrompt(ctx, model.BuildPrompt(input))
}

// Synthesize runs agy against the synthesis prompt that combines the diff
// with N other reviewers' findings. Output is parsed identically to Review.
func (a *Adapter) Synthesize(ctx context.Context, input *review.ReviewInput, results []*review.ModelReviewResult) (*review.ModelReviewResult, error) {
return a.executeWithPrompt(ctx, model.BuildSynthesisPrompt(input, results))
}

// executeWithPrompt runs agy against the given prompt and returns the
// parsed result. Shared by Review and Synthesize.
//
// Invocation: `agy --print=- --print-timeout <dur>` with the prompt piped
// via stdin. agy's --print is a string flag that requires a value; `-` is
// the conventional stdin marker, and when stdin is a pipe agy reads the
// prompt from it (verified — see the design spec). Output is raw model
// text with no envelope, so stdout pipes straight into ParseFindings
// (unlike gemini's -o json wrapper). We deliberately omit
// --dangerously-skip-permissions: agy must not auto-execute tools.
func (a *Adapter) executeWithPrompt(ctx context.Context, prompt string) (*review.ModelReviewResult, error) {
if len(prompt) > a.inputBudget {
return nil, fmt.Errorf("prompt size %d bytes exceeds input budget %d bytes for %s; review a smaller PR, filter files with --include/--exclude, or raise --input-budget",
len(prompt), a.inputBudget, a.Name())
}
return nil, errors.New("antigravity adapter is not implemented; preflight should have rejected this call")

out, err := a.run(ctx, strings.NewReader(prompt), "agy", "--print=-", "--print-timeout", printTimeout(ctx))
if err != nil {
return nil, fmt.Errorf("antigravity: %w", err)
}
findings, err := model.ParseFindings(out)
if err != nil {
// agy has no schema flag, so non-JSON output is the failure shape
// for an unauthenticated agy (it emits auth/login text, not
// findings). Point the user at the one-time login rather than
// surfacing a bare parse error.
return nil, fmt.Errorf("antigravity output not parseable as findings (a likely cause is an unauthenticated agy — run `agy` once to log in): %w", err)
}
return &review.ModelReviewResult{
Model: a.Name(),
Findings: findings,
RawOutput: string(out),
}, nil
}

// Compile-time interface guards: agy is a full peer (diffsmith-0hy).
var (
_ model.Reviewer = (*Adapter)(nil)
_ model.Synthesizer = (*Adapter)(nil)
_ model.InputBudgetSetter = (*Adapter)(nil)
)
Loading
Loading