Skip to content

Commit 24774c3

Browse files
authored
Merge pull request #1111 from entireio/feat/entire-review-v2-b1
refactor(review): multi-agent picker, orchestrator, and live TUI (1/2)
2 parents 40faef0 + a76d144 commit 24774c3

13 files changed

Lines changed: 2790 additions & 32 deletions

cmd/entire/cli/review/cmd.go

Lines changed: 216 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
6874
type 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.
315499
func currentHeadSHA(ctx context.Context, repoRoot string) (string, error) {
316500
out, err := runGit(ctx, repoRoot, "rev-parse", "HEAD")

0 commit comments

Comments
 (0)