diff --git a/cmd/entire/cli/agent/pi/generate.go b/cmd/entire/cli/agent/pi/generate.go new file mode 100644 index 000000000..2ef941e24 --- /dev/null +++ b/cmd/entire/cli/agent/pi/generate.go @@ -0,0 +1,25 @@ +package pi + +import ( + "context" + "fmt" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// GenerateText sends a prompt to Pi in non-interactive text mode and returns +// the raw response. The prompt is passed as a positional message because Pi's +// CLI consumes prompts from argv in --print mode. +func (a *PiAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { + args := []string{"--print", "--no-tools", "--no-session"} + if model != "" { + args = append(args, "--model", model) + } + args = append(args, prompt) + + result, err := agent.RunIsolatedTextGeneratorCLI(ctx, nil, "pi", "pi", args, "") + if err != nil { + return "", fmt.Errorf("pi text generation failed: %w", err) + } + return result, nil +} diff --git a/cmd/entire/cli/agent/pi/lifecycle.go b/cmd/entire/cli/agent/pi/lifecycle.go index de9bdf51d..bdbc24e36 100644 --- a/cmd/entire/cli/agent/pi/lifecycle.go +++ b/cmd/entire/cli/agent/pi/lifecycle.go @@ -177,6 +177,7 @@ func (a *PiAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io. Type: agent.TurnEnd, SessionID: sessionID, SessionRef: sessionRef, + Model: extractModelFromPiSessionFile(sessionRef), Timestamp: now, }, nil @@ -262,6 +263,22 @@ func cacheSessionID(ctx context.Context, id string) { } } +func extractModelFromPiSessionFile(path string) string { + if path == "" { + return "" + } + //nolint:gosec // path comes from Pi's hook payload or our captured transcript path + data, err := os.ReadFile(path) + if err != nil { + return "" + } + model, err := (&PiAgent{}).ExtractModel(data) + if err != nil { + return "" + } + return model +} + func readCachedSessionID(ctx context.Context) string { dir := resolveSessionDir(ctx) //nolint:gosec // path constructed from validated repo root diff --git a/cmd/entire/cli/agent/pi/models.go b/cmd/entire/cli/agent/pi/models.go new file mode 100644 index 000000000..4e11aad62 --- /dev/null +++ b/cmd/entire/cli/agent/pi/models.go @@ -0,0 +1,49 @@ +package pi + +import ( + "bufio" + "context" + "fmt" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +var _ agent.ModelLister = (*PiAgent)(nil) + +// ListModels returns Pi's live model catalog by shelling out to +// `pi --list-models`. Unlike the curated lists for claude-code/codex/gemini, +// Pi has a real enumeration command spanning every configured provider, so the +// result reflects what this machine/account can actually use. +func (a *PiAgent) ListModels(ctx context.Context) ([]agent.ModelInfo, error) { + out, err := agent.RunIsolatedTextGeneratorCLI(ctx, nil, "pi", "pi", []string{"--list-models"}, "") + if err != nil { + return nil, fmt.Errorf("pi --list-models: %w", err) + } + return parsePiModelList(out), nil +} + +// parsePiModelList parses the tabular `pi --list-models` output. Each non-header +// row is " "; the model +// ID is rendered as "provider/model" (the unambiguous form Pi's --model accepts) +// with the context window kept as a note. +func parsePiModelList(raw string) []agent.ModelInfo { + var models []agent.ModelInfo + scanner := bufio.NewScanner(strings.NewReader(raw)) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + provider, model := fields[0], fields[1] + if provider == "provider" && model == "model" { + continue // header row + } + note := "" + if len(fields) >= 3 { + note = fields[2] + " ctx" + } + models = append(models, agent.ModelInfo{ID: provider + "/" + model, Note: note}) + } + return models +} diff --git a/cmd/entire/cli/agent/pi/models_test.go b/cmd/entire/cli/agent/pi/models_test.go new file mode 100644 index 000000000..82700c465 --- /dev/null +++ b/cmd/entire/cli/agent/pi/models_test.go @@ -0,0 +1,35 @@ +package pi + +import "testing" + +func TestParsePiModelList(t *testing.T) { + raw := "provider model context max-out thinking images\n" + + "anthropic claude-opus-4-0 200K 32K yes yes \n" + + "openai gpt-5 400K 128K yes no \n" + + "\n" + + "google gemini-2.5-pro 1M 64K yes yes \n" + + got := parsePiModelList(raw) + if len(got) != 3 { + t.Fatalf("parsed %d models, want 3: %#v", len(got), got) + } + want := []struct{ id, note string }{ + {"anthropic/claude-opus-4-0", "200K ctx"}, + {"openai/gpt-5", "400K ctx"}, + {"google/gemini-2.5-pro", "1M ctx"}, + } + for i, w := range want { + if got[i].ID != w.id { + t.Errorf("model[%d].ID = %q, want %q", i, got[i].ID, w.id) + } + if got[i].Note != w.note { + t.Errorf("model[%d].Note = %q, want %q", i, got[i].Note, w.note) + } + } +} + +func TestParsePiModelList_HeaderAndBlanksSkipped(t *testing.T) { + if got := parsePiModelList("provider model\n\n \n"); len(got) != 0 { + t.Fatalf("expected no models, got %#v", got) + } +} diff --git a/cmd/entire/cli/agent/pi/reviewer.go b/cmd/entire/cli/agent/pi/reviewer.go new file mode 100644 index 000000000..edf0b0721 --- /dev/null +++ b/cmd/entire/cli/agent/pi/reviewer.go @@ -0,0 +1,216 @@ +package pi + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/review" + reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types" +) + +// NewReviewer returns the AgentReviewer for Pi. +// +// Argv shape: pi --mode json --print [--model ] . +// The prompt is passed as a positional message because Pi's CLI accepts prompts +// as message arguments in non-interactive mode. Stdout is newline-delimited JSON +// session events; the parser maps Pi's AgentSessionEvent stream into Entire's +// review Event stream. +func NewReviewer() *reviewtypes.ReviewerTemplate { + return &reviewtypes.ReviewerTemplate{ + AgentName: string(agent.AgentNamePi), + BuildCmd: buildPiReviewCmd, + Parser: parsePiReviewOutput, + } +} + +func buildPiReviewCmd(ctx context.Context, cfg reviewtypes.RunConfig) *exec.Cmd { + prompt := review.ComposeReviewPrompt(cfg) + args := []string{"--mode", "json", "--print"} + if cfg.Model != "" { + args = append(args, "--model", cfg.Model) + } + args = append(args, prompt) + cmd := exec.CommandContext(ctx, "pi", args...) + cmd.Env = review.AppendReviewEnv(os.Environ(), string(agent.AgentNamePi), cfg, prompt) + return cmd +} + +func parsePiReviewOutput(r io.Reader) <-chan reviewtypes.Event { + out := make(chan reviewtypes.Event, 32) + go func() { + defer close(out) + out <- reviewtypes.Started{} + + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, min(1024*1024, piReviewMaxScannerBuf)), piReviewMaxScannerBuf) + messageIDsWithTextDelta := map[string]struct{}{} + finished := false + success := true + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var env piReviewEnvelope + if err := json.Unmarshal(line, &env); err != nil { + out <- reviewtypes.RunError{Err: fmt.Errorf("pi --mode json: %w", err)} + continue + } + + switch env.Type { + case "session", "agent_start", "turn_start", "queue_update", "compaction_start", "compaction_end", "auto_retry_start", "auto_retry_end": + // Session/control events do not map to user-visible review output. + case "message_update": + if text := env.AssistantMessageEvent.TextDelta(); text != "" { + messageIDsWithTextDelta[env.MessageID()] = struct{}{} + out <- reviewtypes.AssistantText{Text: text} + } + case "message_end": + if env.Message.Role == "assistant" { + if env.Message.StopReason == "error" || env.Message.StopReason == "aborted" { + success = false + } + if env.Message.Usage != nil { + out <- piReviewTokens(env.Message.Usage) + } + if _, sawDelta := messageIDsWithTextDelta[env.MessageID()]; !sawDelta { + if text := piReviewMessageText(env.Message.Content); text != "" { + out <- reviewtypes.AssistantText{Text: text} + } + } + } + case "tool_execution_start": + out <- reviewtypes.ToolCall{Name: env.ToolName, Args: piReviewJSONArg(env.Args)} + case "tool_execution_end": + // Tool errors are part of normal agent execution (for example grep + // finding no matches). The agent's stopReason determines review + // success/failure. + case "turn_end": + if env.Message.StopReason == "error" || env.Message.StopReason == "aborted" { + success = false + } + if env.Message.Usage != nil { + out <- piReviewTokens(env.Message.Usage) + } + case "agent_end": + finished = true + out <- reviewtypes.Finished{Success: success} + default: + // Unknown future events are ignored; Pi's event stream is additive. + } + } + + if err := scanner.Err(); err != nil { + out <- reviewtypes.RunError{Err: fmt.Errorf("read stdout: %w", err)} + out <- reviewtypes.Finished{Success: false} + return + } + if !finished { + out <- reviewtypes.Finished{Success: false} + } + }() + return out +} + +const piReviewMaxScannerBuf = 64 * 1024 * 1024 + +type piReviewEnvelope struct { + Type string `json:"type"` + ID string `json:"id"` + Message piReviewMessage `json:"message"` + AssistantMessageEvent piAssistantMessageEvent `json:"assistantMessageEvent"` + ToolName string `json:"toolName"` + Args json.RawMessage `json:"args"` +} + +func (e piReviewEnvelope) MessageID() string { + if e.Message.ID != "" { + return e.Message.ID + } + return e.ID +} + +type piReviewMessage struct { + ID string `json:"id"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Usage *piReviewUsage `json:"usage"` + StopReason string `json:"stopReason"` +} + +type piAssistantMessageEvent struct { + Type string `json:"type"` + Delta string `json:"delta"` + Text string `json:"text"` +} + +func (e piAssistantMessageEvent) TextDelta() string { + switch e.Type { + case "text_delta": + if e.Delta != "" { + return e.Delta + } + return e.Text + default: + return "" + } +} + +type piReviewUsage struct { + Input int `json:"input"` + Output int `json:"output"` + CacheRead int `json:"cacheRead"` + CacheWrite int `json:"cacheWrite"` +} + +func piReviewTokens(usage *piReviewUsage) reviewtypes.Tokens { + if usage == nil { + return reviewtypes.Tokens{} + } + return reviewtypes.Tokens{ + In: usage.Input + usage.CacheRead + usage.CacheWrite, + Out: usage.Output, + } +} + +func piReviewJSONArg(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + return string(raw) +} + +func piReviewMessageText(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + var items []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &items); err != nil { + return "" + } + text := "" + for _, item := range items { + if item.Type != "text" || item.Text == "" { + continue + } + if text != "" { + text += "\n" + } + text += item.Text + } + return text +} diff --git a/cmd/entire/cli/agent/pi/reviewer_test.go b/cmd/entire/cli/agent/pi/reviewer_test.go new file mode 100644 index 000000000..9e1234c61 --- /dev/null +++ b/cmd/entire/cli/agent/pi/reviewer_test.go @@ -0,0 +1,140 @@ +package pi + +import ( + "context" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/review" + reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types" +) + +var _ reviewtypes.AgentReviewer = (*reviewtypes.ReviewerTemplate)(nil) + +func TestPiReviewer_NameMatchesRegistryKey(t *testing.T) { + t.Parallel() + if got := NewReviewer().Name(); got != string(agent.AgentNamePi) { + t.Fatalf("Name() = %q, want %q", got, agent.AgentNamePi) + } +} + +func TestPiReviewer_BuildCmd(t *testing.T) { + t.Parallel() + cfg := reviewtypes.RunConfig{ + Model: "anthropic/claude-sonnet-4-5:high", + Task: "Review the change.", + AlwaysPrompt: "Focus on API regressions.", + StartingSHA: "abc123", + } + cmd := buildPiReviewCmd(context.Background(), cfg) + + if cmd.Args[0] != "pi" { + t.Fatalf("Args[0] = %q, want pi; args=%v", cmd.Args[0], cmd.Args) + } + wantPrefix := []string{"pi", "--mode", "json", "--print", "--model", "anthropic/claude-sonnet-4-5:high"} + if len(cmd.Args) != len(wantPrefix)+1 { + t.Fatalf("args len = %d, want %d: %v", len(cmd.Args), len(wantPrefix)+1, cmd.Args) + } + for i, want := range wantPrefix { + if cmd.Args[i] != want { + t.Fatalf("Args[%d] = %q, want %q; args=%v", i, cmd.Args[i], want, cmd.Args) + } + } + if prompt := cmd.Args[len(cmd.Args)-1]; !strings.Contains(prompt, "Review the change.") || !strings.Contains(prompt, "Focus on API regressions.") { + t.Fatalf("prompt arg missing composed review content: %q", prompt) + } + + env := envMap(cmd.Env) + if env[review.EnvSession] != "1" { + t.Errorf("%s = %q, want 1", review.EnvSession, env[review.EnvSession]) + } + if env[review.EnvAgent] != string(agent.AgentNamePi) { + t.Errorf("%s = %q, want %q", review.EnvAgent, env[review.EnvAgent], agent.AgentNamePi) + } + if env[review.EnvStartingSHA] != "abc123" { + t.Errorf("%s = %q, want abc123", review.EnvStartingSHA, env[review.EnvStartingSHA]) + } +} + +func TestPiReviewer_ParseJSONEventStream(t *testing.T) { + t.Parallel() + input := strings.Join([]string{ + `{"type":"session","version":3,"id":"s1","cwd":"/repo"}`, + `{"type":"agent_start"}`, + `{"type":"turn_start"}`, + `{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"text_delta","delta":"Finding "}}`, + `{"type":"tool_execution_start","toolName":"bash","args":{"command":"git diff --stat"}}`, + `{"type":"message_update","message":{"role":"assistant"},"assistantMessageEvent":{"type":"text_delta","delta":"one"}}`, + `{"type":"message_end","message":{"role":"assistant","usage":{"input":10,"output":4,"cacheRead":2,"cacheWrite":3},"stopReason":"stop"}}`, + `{"type":"agent_end","messages":[]}`, + }, "\n") + + events := collectPiReviewEvents(input) + if len(events) != 6 { + t.Fatalf("events len = %d, want 6: %#v", len(events), events) + } + if _, ok := events[0].(reviewtypes.Started); !ok { + t.Fatalf("events[0] = %T, want Started", events[0]) + } + if got, ok := events[1].(reviewtypes.AssistantText); !ok || got.Text != "Finding " { + t.Fatalf("events[1] = %#v, want AssistantText{Finding }", events[1]) + } + tool, ok := events[2].(reviewtypes.ToolCall) + if !ok || tool.Name != "bash" || !strings.Contains(tool.Args, "git diff --stat") { + t.Fatalf("events[2] = %#v, want ToolCall(bash)", events[2]) + } + if got, ok := events[3].(reviewtypes.AssistantText); !ok || got.Text != "one" { + t.Fatalf("events[3] = %#v, want AssistantText{one}", events[3]) + } + tokens, ok := events[4].(reviewtypes.Tokens) + if !ok || tokens.In != 15 || tokens.Out != 4 { + t.Fatalf("events[4] = %#v, want Tokens{In:15 Out:4}", events[4]) + } + finished, ok := events[5].(reviewtypes.Finished) + if !ok || !finished.Success { + t.Fatalf("events[5] = %#v, want Finished{Success:true}", events[5]) + } +} + +func TestPiReviewer_ParseMessageEndTextWithoutDeltas(t *testing.T) { + t.Parallel() + input := strings.Join([]string{ + `{"type":"agent_start"}`, + `{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Final review text"}],"stopReason":"stop"}}`, + `{"type":"agent_end"}`, + }, "\n") + + events := collectPiReviewEvents(input) + var found bool + for _, ev := range events { + text, ok := ev.(reviewtypes.AssistantText) + if ok && text.Text == "Final review text" { + found = true + } + } + if !found { + t.Fatalf("expected AssistantText from message_end content, got %#v", events) + } +} + +func collectPiReviewEvents(input string) []reviewtypes.Event { + ch := parsePiReviewOutput(strings.NewReader(input)) + var events []reviewtypes.Event + for ev := range ch { + events = append(events, ev) + } + return events +} + +func envMap(env []string) map[string]string { + out := map[string]string{} + for _, kv := range env { + idx := strings.IndexByte(kv, '=') + if idx < 0 { + continue + } + out[kv[:idx]] = kv[idx+1:] + } + return out +} diff --git a/cmd/entire/cli/agent/text_generator_cli.go b/cmd/entire/cli/agent/text_generator_cli.go index 6a18eb3a2..47e743bed 100644 --- a/cmd/entire/cli/agent/text_generator_cli.go +++ b/cmd/entire/cli/agent/text_generator_cli.go @@ -76,6 +76,7 @@ var summaryProviderBinaries = map[types.AgentName]string{ AgentNameCopilotCLI: "copilot", AgentNameCursor: "agent", AgentNameGemini: "gemini", + AgentNamePi: "pi", } // IsSummaryCLIAvailable reports whether the CLI binary for a summary-capable diff --git a/cmd/entire/cli/explain_summary_provider.go b/cmd/entire/cli/explain_summary_provider.go index 0f24caeca..573b93da8 100644 --- a/cmd/entire/cli/explain_summary_provider.go +++ b/cmd/entire/cli/explain_summary_provider.go @@ -61,7 +61,7 @@ func resolveCheckpointSummaryProvider(ctx context.Context, w io.Writer) (*checkp switch len(candidates) { case 0: - return nil, errors.New("no summary-capable provider is available; install claude, codex, gemini, cursor, or copilot, install an external entire-agent-* plugin that declares text_generator, or set summary_generation.provider in settings") + return nil, errors.New("no summary-capable provider is available; install claude, codex, gemini, pi, cursor, or copilot, install an external entire-agent-* plugin that declares text_generator, or set summary_generation.provider in settings") case 1: return autoSelectSummaryProvider(ctx, w, candidates[0].Name, "non-interactive auto-select: single installed provider") default: diff --git a/cmd/entire/cli/review/cmd.go b/cmd/entire/cli/review/cmd.go index 3a0c12f7c..f983c9fc2 100644 --- a/cmd/entire/cli/review/cmd.go +++ b/cmd/entire/cli/review/cmd.go @@ -2,7 +2,7 @@ // // cmd.go provides NewCommand(), the cobra entry point for `entire review`. // It routes through the new AgentReviewer / Sink / Run architecture for -// agents with review-runner adapters (claude-code, codex, gemini) and falls +// agents with review-runner adapters (claude-code, codex, gemini, pi) and falls // back to RunMarkerFallback for agents that are not yet wired into that review // runner contract. package review diff --git a/cmd/entire/cli/review/manifest_test.go b/cmd/entire/cli/review/manifest_test.go index bdab02c19..e17fd6622 100644 --- a/cmd/entire/cli/review/manifest_test.go +++ b/cmd/entire/cli/review/manifest_test.go @@ -370,6 +370,59 @@ func TestBuildLocalReviewManifestFromSummary_GroupsAgentSessionsAndAggregate(t * } } +func TestBuildLocalReviewManifestFromSummary_DisambiguatesSameAgentByModel(t *testing.T) { + started := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + summary := reviewtypes.RunSummary{ + StartedAt: started, + AgentRuns: []reviewtypes.AgentRun{ + { + Name: "pi-sonnet", + AgentName: "pi", + Model: "anthropic/claude-sonnet:high", + Status: reviewtypes.AgentStatusSucceeded, + Buffer: []reviewtypes.Event{reviewtypes.AssistantText{Text: "Sonnet finding"}}, + }, + { + Name: "pi-opus", + AgentName: "pi", + Model: "opus", + Status: reviewtypes.AgentStatusSucceeded, + Buffer: []reviewtypes.Event{reviewtypes.AssistantText{Text: "Opus finding"}}, + }, + }, + } + states := []*session.State{ + { + SessionID: "opus-session", + Kind: session.KindAgentReview, + WorktreePath: "/repo", + BaseCommit: "abc123", + StartedAt: started.Add(time.Second), + ModelName: "claude-opus-4-1", + }, + { + SessionID: "sonnet-session", + Kind: session.KindAgentReview, + WorktreePath: "/repo", + BaseCommit: "abc123", + StartedAt: started.Add(2 * time.Second), + ModelName: "claude-sonnet-4-5", + }, + } + + manifest := buildLocalReviewManifestFromSummary("/repo", "abc123", summary, states, "") + + if len(manifest.Sources) != 2 { + t.Fatalf("sources = %d, want 2: %#v", len(manifest.Sources), manifest.Sources) + } + if manifest.Sources[0].SessionID != "sonnet-session" || manifest.Sources[0].Label != "pi-sonnet" { + t.Fatalf("sonnet source mismatch: %#v", manifest.Sources[0]) + } + if manifest.Sources[1].SessionID != "opus-session" || manifest.Sources[1].Label != "pi-opus" { + t.Fatalf("opus source mismatch: %#v", manifest.Sources[1]) + } +} + func TestWarnManifestNotWritten_PrintsReasonAndDiagnosticHints(t *testing.T) { var b strings.Builder diff --git a/cmd/entire/cli/review/picker.go b/cmd/entire/cli/review/picker.go index 28ef6b227..18939a40a 100644 --- a/cmd/entire/cli/review/picker.go +++ b/cmd/entire/cli/review/picker.go @@ -99,7 +99,7 @@ func RunReviewGuidedSetup( launchable := launchableInstalledAgentNames(installed, reviewerFor) if len(launchable) == 0 { - return "", settings.ReviewProfileConfig{}, errors.New("no agents with review runner adapters and hooks installed; run `entire configure --agent claude-code`, `entire configure --agent codex`, or `entire configure --agent gemini`") + return "", settings.ReviewProfileConfig{}, errors.New("no agents with review runner adapters and hooks installed; run `entire configure --agent claude-code`, `entire configure --agent codex`, `entire configure --agent gemini`, or `entire configure --agent pi`") } profileName = strings.TrimSpace(profileName) diff --git a/cmd/entire/cli/review/profile.go b/cmd/entire/cli/review/profile.go index 0f5ff9e55..a30586956 100644 --- a/cmd/entire/cli/review/profile.go +++ b/cmd/entire/cli/review/profile.go @@ -258,7 +258,7 @@ func defaultReviewProfileForInstalledAgents( agents[name] = cfg } if len(agents) == 0 { - return settings.ReviewProfileConfig{}, errors.New("no agents with review runner adapters and hooks installed; run `entire configure --agent claude-code`, `entire configure --agent codex`, or `entire configure --agent gemini`") + return settings.ReviewProfileConfig{}, errors.New("no agents with review runner adapters and hooks installed; run `entire configure --agent claude-code`, `entire configure --agent codex`, `entire configure --agent gemini`, or `entire configure --agent pi`") } return settings.ReviewProfileConfig{ Task: profileTask(profileName, settings.ReviewProfileConfig{}), @@ -277,7 +277,7 @@ func defaultReviewAgentConfig(profileName, agentName string) settings.ReviewConf return settings.ReviewConfig{Skills: []string{"/review"}, Prompt: focus} case string(agent.AgentNameCodex): return settings.ReviewConfig{Skills: []string{"/review"}, Prompt: focus} - case string(agent.AgentNameGemini): + case string(agent.AgentNameGemini), string(agent.AgentNamePi): prompt := "Review the change according to the profile task." if focus != "" { prompt += " " + focus @@ -300,7 +300,7 @@ func defaultProfileFocus(profileName string) string { } func defaultReviewMaster(ctx context.Context, configured map[string]settings.ReviewConfig) string { - for _, preferred := range []string{string(agent.AgentNameClaudeCode), string(agent.AgentNameCodex), string(agent.AgentNameGemini)} { + for _, preferred := range []string{string(agent.AgentNameClaudeCode), string(agent.AgentNameCodex), string(agent.AgentNameGemini), string(agent.AgentNamePi)} { for _, workerName := range sortedReviewConfigKeys(configured) { cfg := configured[workerName] if reviewAgentName(workerName, cfg) == preferred && agentSupportsTextGeneration(ctx, preferred) { diff --git a/cmd/entire/cli/review/types/template.go b/cmd/entire/cli/review/types/template.go index f3fe95f62..0f683ed36 100644 --- a/cmd/entire/cli/review/types/template.go +++ b/cmd/entire/cli/review/types/template.go @@ -4,7 +4,7 @@ // AgentReviewer using two caller-supplied functions: BuildCmd (per-agent // argv/env construction) and Parser (per-agent stdout-to-Event stream). // -// All three currently-supported agents (claude-code, codex, gemini) +// Current adapter-backed review agents (claude-code, codex, gemini, pi) // share the Start/Process/Wait/Events scaffolding. Only the build-cmd // step and the stdout parser genuinely differ. The template owns the // shared lifecycle (spawn → pipe stdout → run parser → forward events diff --git a/cmd/entire/cli/review_bridge.go b/cmd/entire/cli/review_bridge.go index 7e0cb9094..5d4509a77 100644 --- a/cmd/entire/cli/review_bridge.go +++ b/cmd/entire/cli/review_bridge.go @@ -5,7 +5,7 @@ package cli // access (headHasReviewCheckpoint) and per-agent reviewer constructors // (launchableReviewerFor) live here to avoid the import cycle: // review → checkpoint → codex → review -// review → claudecode/codex/geminicli → review +// review → claudecode/codex/geminicli/pi → review import ( "github.com/spf13/cobra" @@ -14,6 +14,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" "github.com/entireio/cli/cmd/entire/cli/agent/codex" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + "github.com/entireio/cli/cmd/entire/cli/agent/pi" cliReview "github.com/entireio/cli/cmd/entire/cli/review" reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types" ) @@ -38,7 +39,7 @@ func buildReviewDeps(attachCmd *cobra.Command) cliReview.Deps { // adapter, or nil for agents that are known to Entire but not yet wired into // `entire review` fan-out. This lives in the cli package to avoid the import cycle: // -// review/cmd.go → claudecode/codex/geminicli → review +// review/cmd.go → claudecode/codex/geminicli/pi → review func launchableReviewerFor(agentName string) reviewtypes.AgentReviewer { switch agentName { case string(agent.AgentNameClaudeCode): @@ -47,6 +48,8 @@ func launchableReviewerFor(agentName string) reviewtypes.AgentReviewer { return codex.NewReviewer() case string(agent.AgentNameGemini): return geminicli.NewReviewer() + case string(agent.AgentNamePi): + return pi.NewReviewer() default: return nil } diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 5d19637f0..2dee0df9e 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -163,7 +163,7 @@ type ClonePreferences struct { // checkpoint summaries generated by explain --generate. type SummaryGenerationSettings struct { // Provider is the selected summary provider agent name - // (for example "claude-code", "codex", or "gemini"). + // (for example "claude-code", "codex", "gemini", or "pi"). Provider string `json:"provider,omitempty"` // Model is an optional model hint passed to the selected provider. diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 15532dd7a..55b2938f2 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -739,7 +739,7 @@ Examples: cmd.Flags().BoolVarP(&opts.ForceHooks, flagForce, "f", false, "Reinstall the Entire git hook") cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push") cmd.Flags().StringVar(&opts.CheckpointRemote, flagCheckpointRemote, "", "Checkpoint remote in provider:owner/repo format (e.g., github:org/checkpoints-repo)") - cmd.Flags().StringVar(&summarizeProvider, flagSummarizeAgent, "", "Set the provider used by explain --generate (e.g., claude-code, codex, gemini, cursor, copilot-cli)") + cmd.Flags().StringVar(&summarizeProvider, flagSummarizeAgent, "", "Set the provider used by explain --generate (e.g., claude-code, codex, gemini, pi, cursor, copilot-cli)") cmd.Flags().StringVar(&summarizeModel, flagSummarizeModel, "", "Set the model hint used by explain --generate") cmd.Flags().IntVar(&summarizeTimeoutSeconds, flagSummarizeTimeout, 0, "Set the hard deadline (seconds) for explain --generate summary generation. 0 clears (falls back to 5m default).") cmd.Flags().BoolVar(&opts.Telemetry, flagTelemetry, true, "Enable anonymous usage analytics") diff --git a/docs/architecture/review-command.md b/docs/architecture/review-command.md index e85d4b092..d48ed9e4d 100644 --- a/docs/architecture/review-command.md +++ b/docs/architecture/review-command.md @@ -45,7 +45,8 @@ Review profiles are configured in clone-local preferences (or settings) under `r "task": "Review this change for correctness, regressions, tests, and maintainability.", "agents": { "claude-code": {"skills": ["/review"]}, - "codex": {"skills": ["/review"]} + "codex": {"skills": ["/review"]}, + "pi": {"model": "anthropic/claude-sonnet", "prompt": "Review the change according to the profile task."} }, "master": "claude-code" }, @@ -62,14 +63,14 @@ Review profiles are configured in clone-local preferences (or settings) under `r } ``` -`entire review --models` lists the models each review-runner agent advertises via the optional `agent.ModelLister` capability (`cmd/entire/cli/agent/model_lister.go`). Agents whose CLI has no enumeration command (claude-code, codex, gemini) return a curated, non-exhaustive list of common models/aliases; the `--model` flag still forwards any value the agent CLI accepts. Agents whose CLI can enumerate live (e.g. Pi's `pi --list-models`) may implement `ListModels` by shelling out. +`entire review --models` lists the models each review-runner agent advertises via the optional `agent.ModelLister` capability (`cmd/entire/cli/agent/model_lister.go`). Agents whose CLI has no enumeration command (claude-code, codex, gemini) return a curated, non-exhaustive list of common models/aliases; the `--model` flag still forwards any value the agent CLI accepts. Pi implements `ListModels` by shelling out to `pi --list-models`, so its entries are the live model catalog. -The profile-level `task` is the shared work item. Each `agents` map entry is a worker id. For simple entries the worker id is also the agent name; to run the same agent more than once, use aliases and set `agent` plus `model`. Per-worker `skills`, `prompt`, and `model` adapt that task to agent-specific mechanics. Settings fields: `EntireSettings.ReviewProfiles` and `EntireSettings.ReviewDefaultProfile` in `cmd/entire/cli/settings/settings.go`. The old top-level `review` map is no longer used by `entire review`. +The profile-level `task` is the shared work item. Each `agents` map entry is a worker id. For simple entries the worker id is also the agent name; to run the same agent more than once, use aliases and set `agent` plus `model`. Per-worker `skills`, `prompt`, and `model` adapt that task to agent-specific mechanics. Pi is a prompt/model-driven worker (`pi --mode json --print [--model ...]`) rather than a slash-command worker. Settings fields: `EntireSettings.ReviewProfiles` and `EntireSettings.ReviewDefaultProfile` in `cmd/entire/cli/settings/settings.go`. The old top-level `review` map is no longer used by `entire review`. ## How It Works (env-var handshake) 1. `entire review` selects a profile (positional/`--profile` → `review_default_profile` → `general` → only configured profile). If no profiles exist, it runs simple guided setup in an interactive terminal and asks before starting agents, or writes an opinionated clone-local default profile in non-interactive mode. It then composes worker prompts via `review.ComposeReviewPrompt` and computes scope (mainline base ref via `review.ComputeScopeStats`, overridable with `--base`). -2. **For agents with review-runner adapters** (claude-code, codex, gemini-cli): the spawned agent process is given env vars `ENTIRE_REVIEW_{SESSION,AGENT,SKILLS,PROMPT,STARTING_SHA}` that the agent's `UserPromptSubmit` lifecycle hook reads to tag the session as `Kind = "agent_review"` with the configured skills/prompt. Each spawned process has its own env, so multiple worktrees and multi-agent runs are correct by construction (no shared marker file, no race). +2. **For agents with review-runner adapters** (claude-code, codex, gemini-cli, pi): the spawned agent process is given env vars `ENTIRE_REVIEW_{SESSION,AGENT,SKILLS,PROMPT,STARTING_SHA}` that the agent's `UserPromptSubmit` lifecycle hook reads to tag the session as `Kind = "agent_review"` with the configured skills/prompt. Each spawned process has its own env, so multiple worktrees and multi-agent runs are correct by construction (no shared marker file, no race). 3. **For agents without review-runner adapters yet**: `RunMarkerFallback` writes a `PendingReviewMarker` file and prints guidance — the user opens the agent themselves and runs the skills. This is an adapter backlog path, not a statement that the agent cannot be launched headlessly. 4. Worker agents run the selected profile's task; each session ends naturally. 5. In multi-worker profiles, the configured master agent receives all worker reports and produces one critical final report. The master prompt asks it to reject unsupported claims, resolve contradictions, merge duplicates, and prioritize evidence-backed findings. @@ -133,7 +134,7 @@ The redesign eliminated several constructs from the prior implementation. None s - `cmd/entire/cli/review/synthesis_sink.go` / `synthesis_prompt.go` — profile master adjudication (runs automatically for multi-worker profiles) plus the legacy opt-in synthesis path - `cmd/entire/cli/review/types/{reviewer,sink,template}.go` — interface contracts (CU2 + CU4 + CU5b) - `cmd/entire/cli/review/env.go` — `ENTIRE_REVIEW_*` constants + `EncodeSkills`/`DecodeSkills` + `AppendReviewEnv` -- `cmd/entire/cli/agent/{claudecode,codex,geminicli}/reviewer.go` — per-agent `AgentReviewer` implementations (claude-code, codex, gemini-cli) +- `cmd/entire/cli/agent/{claudecode,codex,geminicli,pi}/reviewer.go` — per-agent `AgentReviewer` implementations (claude-code, codex, gemini-cli, pi) - `cmd/entire/cli/agent/claudecode/discovery.go` — skill discovery + `pickLatestVersion` plugin-cache dedupe - `cmd/entire/cli/lifecycle.go` — `adoptReviewEnv` reads `ENTIRE_REVIEW_*` from process env; replaces marker-file adoption - `cmd/entire/cli/review_bridge.go` / `review_helpers.go` — bridge code in `cli` package for cycle-bound functions (`headHasReviewCheckpoint`, `launchableReviewerFor`, `newReviewAttachCmd`)