@@ -11,6 +11,7 @@ import (
1111 "context"
1212 "errors"
1313 "fmt"
14+ "io"
1415 "log/slog"
1516 "strings"
1617
@@ -21,6 +22,7 @@ import (
2122 "github.com/entireio/cli/cmd/entire/cli/agent"
2223 "github.com/entireio/cli/cmd/entire/cli/agent/external"
2324 "github.com/entireio/cli/cmd/entire/cli/agent/types"
25+ "github.com/entireio/cli/cmd/entire/cli/interactive"
2426 "github.com/entireio/cli/cmd/entire/cli/logging"
2527 "github.com/entireio/cli/cmd/entire/cli/paths"
2628 reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types"
@@ -44,6 +46,10 @@ type Deps struct {
4446 // PromptForAgent is used (the real huh form). Tests inject a stub.
4547 PromptForAgentFn func (ctx context.Context , eligible []AgentChoice ) (string , error )
4648
49+ // MultiPickerFn overrides PickAgents for the multi-agent picker. Nil
50+ // means PickAgents is used (the real huh form). Tests inject a stub.
51+ MultiPickerFn func (ctx context.Context , eligible []AgentChoice ) (PickedAgents , error )
52+
4753 // HeadHasReviewCheckpoint checks whether HEAD's checkpoint metadata
4854 // includes a review session. Returns (true, infoString) if HasReview is set.
4955 // Injected to avoid an import cycle: review → checkpoint → codex → review.
@@ -67,6 +73,7 @@ type Deps struct {
6773// Deps value at the package boundary; runReview unpacks the relevant fields.
6874type runReviewDeps struct {
6975 promptForAgentFn func (ctx context.Context , eligible []AgentChoice ) (string , error )
76+ multiPickerFn func (ctx context.Context , eligible []AgentChoice ) (PickedAgents , error )
7077}
7178
7279// NewCommand returns the `entire review` cobra command wired with the
@@ -108,7 +115,10 @@ Subcommands:
108115 _ , err := RunReviewConfigPicker (ctx , cmd .OutOrStdout (), deps .GetAgentsWithHooksInstalled )
109116 return err
110117 }
111- innerDeps := runReviewDeps {promptForAgentFn : deps .PromptForAgentFn }
118+ innerDeps := runReviewDeps {
119+ promptForAgentFn : deps .PromptForAgentFn ,
120+ multiPickerFn : deps .MultiPickerFn ,
121+ }
112122 return runReview (ctx , cmd , agentOverride , deps , innerDeps )
113123 },
114124 }
@@ -160,18 +170,36 @@ func runReview(ctx context.Context, cmd *cobra.Command, agentOverride string, de
160170 fmt .Fprintln (out , "Setup complete — running review now." )
161171 }
162172
163- // 3. Pick agent. When --agent override is empty, base the selection on
164- // the eligible set (configured AND installed) so the run always picks a
165- // usable agent:
173+ // 3. Resolve installed agents and determine the dispatch path.
174+ //
175+ // Three paths:
176+ // - Multi-agent: 2+ launchable eligible agents AND no --agent override →
177+ // show multi-select picker then RunMulti. Steps 3.5, 3.6, and the
178+ // single-agent skill-verify guard are skipped; each reviewer pulls
179+ // its own skills from settings at spawn time via RunConfig.
180+ // - Single-agent (default): 1 or fewer launchable eligible agents, OR
181+ // --agent override set. Falls through to the full agent-selection and
182+ // validation path below (steps 3–3.6).
183+ installed := deps .GetAgentsWithHooksInstalled (ctx )
184+ if agentOverride == "" {
185+ launchableEligible := computeLaunchableEligible (s , installed , deps .ReviewerFor )
186+ if len (launchableEligible ) >= 2 {
187+ return runMultiAgentPath (ctx , cmd , launchableEligible , s , innerDeps , deps , out )
188+ }
189+ }
190+
191+ // Single-agent path: pick agent, verify hooks + skills, scope, run.
192+
193+ // 3a. Base selection on the eligible set (configured AND installed):
166194 // - 0 eligible: fall through; SelectReviewAgent below errors with the
167195 // full configured map (clearer "no installed agent" diagnostic than
168196 // a silent fail).
169197 // - 1 eligible: use it directly. This matters when the alphabetically-
170198 // first configured agent isn't installed but exactly one other is —
171199 // without this, SelectReviewAgent would default to the alphabetical
172200 // first and the verify-hooks check below would error needlessly.
173- // - 2+ eligible: prompt.
174- installed := deps . GetAgentsWithHooksInstalled ( ctx )
201+ // - 2+ eligible: prompt with single-select (non-launchable agents reach
202+ // this branch since computeLaunchableEligible filtered them out above).
175203 if agentOverride == "" {
176204 eligible := ComputeEligibleConfigured (s , installed )
177205 switch {
@@ -207,11 +235,26 @@ func runReview(ctx context.Context, cmd *cobra.Command, agentOverride string, de
207235 return silentErr (err )
208236 }
209237
238+ return runSingleAgentPath (ctx , cmd , agentName , cfg , installed , deps , out )
239+ }
240+
241+ // runSingleAgentPath completes a single-agent review: verifies hooks + skills,
242+ // guards against re-review, resolves scope, then dispatches via Run or
243+ // RunMarkerFallback.
244+ func runSingleAgentPath (
245+ ctx context.Context ,
246+ cmd * cobra.Command ,
247+ agentName string ,
248+ cfg settings.ReviewConfig ,
249+ installed []types.AgentName ,
250+ deps Deps ,
251+ out io.Writer ,
252+ ) error {
253+ silentErr := deps .NewSilentError
254+
210255 // 3.5. Verify hooks are installed for the selected agent.
211- installedNames := make ([]types.AgentName , len (installed ))
212- copy (installedNames , installed )
213256 found := false
214- for _ , n := range installedNames {
257+ for _ , n := range installed {
215258 if string (n ) == agentName {
216259 found = true
217260 break
@@ -261,33 +304,12 @@ func runReview(ctx context.Context, cmd *cobra.Command, agentOverride string, de
261304 return fmt .Errorf ("resolve worktree root: %w" , err )
262305 }
263306
264- // 6. Resolve HEAD SHA and detect scope. Scope work happens BEFORE the
265- // launchability branch so non-launchable agents (cursor, opencode,
266- // factoryai-droid) also see the scope banner and get a scope-aware
267- // prompt persisted to the marker — same context the launchable path
268- // passes to Run(). Best-effort: scope detection failure prints no
269- // banner and leaves ScopeBaseRef empty.
307+ // 6. Resolve HEAD SHA and detect scope.
270308 headSHA , shaErr := currentHeadSHA (ctx , worktreeRoot )
271309 if shaErr != nil {
272310 return fmt .Errorf ("resolve HEAD: %w" , shaErr )
273311 }
274-
275- // Compute scope via the canonical scope.go path: closest non-self
276- // ancestor branch by tip timestamp, fallback chain, full ScopeStats
277- // (commits + files changed + uncommitted). Best-effort: scope detection
278- // failure prints no banner and leaves ScopeBaseRef empty so the run
279- // proceeds with the agent picking its own scope (degraded mode).
280- var scopeBaseRef string
281- if repo , openErr := git .PlainOpen (worktreeRoot ); openErr == nil {
282- if stats , statsErr := ComputeScopeStats (ctx , repo ); statsErr == nil {
283- scopeBaseRef = stats .BaseRef
284- fmt .Fprintln (out , formatScopeBanner (stats ))
285- } else {
286- logging .Debug (ctx , "review scope detection failed" , slog .String ("error" , statsErr .Error ()))
287- }
288- } else {
289- logging .Debug (ctx , "review repo open failed" , slog .String ("error" , openErr .Error ()))
290- }
312+ scopeBaseRef := detectScope (ctx , worktreeRoot , out )
291313
292314 runCfg := reviewtypes.RunConfig {
293315 PromptOverride : cfg .Prompt ,
@@ -311,6 +333,168 @@ func runReview(ctx context.Context, cmd *cobra.Command, agentOverride string, de
311333 return nil
312334}
313335
336+ // detectScope computes the scope base ref for the current repo and prints a
337+ // scope banner to out on success. Best-effort: on any failure, returns an
338+ // empty string and prints no banner so the run proceeds in degraded mode.
339+ func detectScope (ctx context.Context , worktreeRoot string , out io.Writer ) (scopeBaseRef string ) {
340+ if repo , openErr := git .PlainOpen (worktreeRoot ); openErr == nil {
341+ if stats , statsErr := ComputeScopeStats (ctx , repo ); statsErr == nil {
342+ fmt .Fprintln (out , formatScopeBanner (stats ))
343+ return stats .BaseRef
344+ } else { //nolint:revive // else-after-return is clearer here for the error-path log
345+ logging .Debug (ctx , "review scope detection failed" , slog .String ("error" , statsErr .Error ()))
346+ }
347+ } else {
348+ logging .Debug (ctx , "review repo open failed" , slog .String ("error" , openErr .Error ()))
349+ }
350+ return ""
351+ }
352+
353+ // runMultiAgentPath handles the multi-agent review flow: shows the multi-select
354+ // picker, collects an optional per-run prompt, builds per-agent RunConfigs,
355+ // then runs all selected agents concurrently via RunMulti.
356+ //
357+ // This path skips the single-agent validation steps (3.5 hooks, 3.6 skills,
358+ // re-run guard) for brevity — computeLaunchableEligible has already ensured
359+ // each eligible agent has hooks installed and a Reviewer available.
360+ func runMultiAgentPath (
361+ ctx context.Context ,
362+ cmd * cobra.Command ,
363+ launchableEligible []AgentChoice ,
364+ s * settings.EntireSettings ,
365+ innerDeps runReviewDeps ,
366+ deps Deps ,
367+ out io.Writer ,
368+ ) error {
369+ // Note: skill verification is intentionally skipped here. The
370+ // computeLaunchableEligible filter in the dispatch fork already
371+ // guarantees every agent in launchableEligible has hooks installed
372+ // AND a non-nil ReviewerFor mapping, so a per-agent verify pass would
373+ // be redundant.
374+ silentErr := deps .NewSilentError
375+
376+ // Show multi-select picker (or use injected stub in tests).
377+ pickerFn := innerDeps .multiPickerFn
378+ if pickerFn == nil {
379+ pickerFn = PickAgents
380+ }
381+ picked , pickErr := pickerFn (ctx , launchableEligible )
382+ if pickErr != nil {
383+ return handlePickerError (cmd , silentErr , pickErr )
384+ }
385+
386+ // Resolve worktree root and HEAD SHA for scope detection.
387+ worktreeRoot , err := paths .WorktreeRoot (ctx )
388+ if err != nil {
389+ return fmt .Errorf ("resolve worktree root: %w" , err )
390+ }
391+ headSHA , shaErr := currentHeadSHA (ctx , worktreeRoot )
392+ if shaErr != nil {
393+ return fmt .Errorf ("resolve HEAD: %w" , shaErr )
394+ }
395+
396+ scopeBaseRef := detectScope (ctx , worktreeRoot , out )
397+
398+ // Build per-agent reviewers with individual RunConfigs (each agent has
399+ // its own skills + always-prompt from s.Review[name]).
400+ reviewers := make ([]reviewtypes.AgentReviewer , 0 , len (picked .Names ))
401+ for _ , name := range picked .Names {
402+ agentCfg := s .Review [name ] // zero value is safe (empty skills/prompt)
403+ reviewer := deps .ReviewerFor (name )
404+ if reviewer == nil {
405+ // Shouldn't happen given launchableEligible was filtered for
406+ // ReviewerFor != nil, but be defensive.
407+ cmd .SilenceUsage = true
408+ return silentErr (fmt .Errorf ("agent %q is not launchable but appeared in eligible list" , name ))
409+ }
410+ // Wrap the reviewer so it sees the per-agent RunConfig at Start time.
411+ // We cannot pass a different RunConfig per reviewer in RunMulti's
412+ // current API (all reviewers share one RunConfig). Instead, build a
413+ // configuredReviewer adapter that injects per-agent skills into
414+ // RunConfig before forwarding to the underlying reviewer.
415+ reviewers = append (reviewers , & perAgentConfiguredReviewer {
416+ inner : reviewer ,
417+ cfg : reviewtypes.RunConfig {
418+ PromptOverride : agentCfg .Prompt ,
419+ Skills : agentCfg .Skills ,
420+ PerRunPrompt : picked .PerRun ,
421+ ScopeBaseRef : scopeBaseRef ,
422+ StartingSHA : headSHA ,
423+ },
424+ })
425+ }
426+
427+ // Compose sinks based on TTY detection.
428+ // TTY mode: [TUISink, DumpSink] — TUI owns the live dashboard; DumpSink
429+ // renders the post-run narrative after TUI dismisses (RunFinished is called
430+ // on each sink in order, and TUISink.RunFinished blocks until user dismisses).
431+ // Non-TTY mode: [DumpSink] alone.
432+ //
433+ // A derived context is used so the TUI's Ctrl+C handler can cancel the run
434+ // via the same cancelRun function that the orchestrator's context is built on.
435+ runCtx , cancelRun := context .WithCancel (ctx )
436+ defer cancelRun ()
437+
438+ agentNames := make ([]string , len (reviewers ))
439+ for i , r := range reviewers {
440+ agentNames [i ] = r .Name ()
441+ }
442+
443+ // TUI requires both:
444+ // - terminal stdout (otherwise ANSI codes corrupt redirected output)
445+ // - a promptable stdin (otherwise the post-run dismissal loop blocks
446+ // forever — happens when entire review is invoked from inside an
447+ // agent like Claude Code or Gemini CLI, where stdout is a TTY but
448+ // keypresses are never delivered)
449+ var sinks []reviewtypes.Sink
450+ if interactive .IsTerminalWriter (out ) && interactive .CanPromptInteractively () {
451+ tuiSink := NewTUISink (agentNames , cancelRun , out )
452+ tuiSink .Start ()
453+ defer tuiSink .Wait ()
454+ sinks = append (sinks , tuiSink )
455+ sinks = append (sinks , DumpSink {W : out })
456+ } else {
457+ sinks = append (sinks , DumpSink {W : out })
458+ }
459+
460+ _ , waitErr := RunMulti (runCtx , reviewers , reviewtypes.RunConfig {}, sinks )
461+ if waitErr != nil && runCtx .Err () == nil && ctx .Err () == nil {
462+ return fmt .Errorf ("review run: %w" , waitErr )
463+ }
464+ return nil
465+ }
466+
467+ // handlePickerError maps multi-picker error sentinels to the appropriate
468+ // command-layer response.
469+ // - ErrPickerCancelled → return nil (user cancelled; no error shown)
470+ // - ErrNoAgentsSelected → surface error to user
471+ // - other errors → surface to user
472+ func handlePickerError (cmd * cobra.Command , silentErr func (error ) error , pickErr error ) error {
473+ if errors .Is (pickErr , ErrPickerCancelled ) {
474+ return nil
475+ }
476+ cmd .SilenceUsage = true
477+ fmt .Fprintln (cmd .ErrOrStderr (), pickErr .Error ())
478+ return silentErr (pickErr )
479+ }
480+
481+ // perAgentConfiguredReviewer is an AgentReviewer adapter that overrides the
482+ // RunConfig passed to the underlying reviewer's Start method. This lets
483+ // RunMulti pass a single shared RunConfig at the API boundary while each
484+ // agent in a multi-agent run still sees its own skills and always-prompt.
485+ type perAgentConfiguredReviewer struct {
486+ inner reviewtypes.AgentReviewer
487+ cfg reviewtypes.RunConfig
488+ }
489+
490+ func (r * perAgentConfiguredReviewer ) Name () string { return r .inner .Name () }
491+ func (r * perAgentConfiguredReviewer ) Start (ctx context.Context , _ reviewtypes.RunConfig ) (reviewtypes.Process , error ) {
492+ return r .inner .Start (ctx , r .cfg ) //nolint:wrapcheck // transparent adapter; callers see inner's error type directly
493+ }
494+
495+ // Compile-time interface check.
496+ var _ reviewtypes.AgentReviewer = (* perAgentConfiguredReviewer )(nil )
497+
314498// currentHeadSHA returns the current HEAD commit hash as a 40-char hex string.
315499func currentHeadSHA (ctx context.Context , repoRoot string ) (string , error ) {
316500 out , err := runGit (ctx , repoRoot , "rev-parse" , "HEAD" )
0 commit comments