Skip to content

Commit d8815dc

Browse files
authored
Merge pull request #1112 from entireio/feat/entire-review-v2-b2
refactor(review): cross-agent synthesis, plugin-cache dedupe, docs (2/2)
2 parents 24774c3 + 79978b4 commit d8815dc

17 files changed

Lines changed: 1830 additions & 252 deletions

.golangci.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,16 +110,14 @@ linters:
110110
- github.com/entireio/cli/cmd/entire/cli/agent\..+
111111
- github.com/entireio/cli/cmd/entire/cli/review/types.Process
112112
- github.com/entireio/cli/cmd/entire/cli/review/types.AgentReviewer
113+
- github.com/entireio/cli/cmd/entire/cli/review.SynthesisProvider
113114
- github.com/entireio/cli/cmd/entire/cli/checkpoint.CommittedReader
114115
- github.com/entireio/cli/cmd/entire/cli/strategy.Strategy
115116
- github.com/go-git/go-git/v6/x/plugin.Signer
116117
- github.com/go-git/go-git/v6/plumbing/storer.ReferenceIter
117118
- github.com/go-git/go-git/v6/plumbing.EncodedObject
118119
- github.com/go-git/go-git/v6/storage.Storer
119120
- github.com/go-git/go-git/v6/plumbing/storer.EncodedObjectIter
120-
- github.com/go-git/go-git/v6/x/plugin.Signer
121-
- golang.org/x/crypto/ssh/agent.Agent
122-
- github.com/entireio/cli/cmd/entire/cli/summarize.Generator
123121
- github.com/entireio/cli/e2e/agents.Session
124122
- github.com/go-git/go-billy/v6.Filesystem
125123
- golang.org/x/crypto/ssh/agent.Agent

CLAUDE.md

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -679,15 +679,17 @@ Trailers:
679679
#### Command Surface
680680

681681
```
682-
entire review # Normal run: load config, spawn configured agent
682+
entire review # Normal run: load config, run configured agent(s)
683683
entire review --edit # Re-open the skills picker before running
684-
entire review --agent <name> # Override which configured agent runs
684+
entire review --agent <name> # Force a specific configured agent (skips multi-picker)
685685
entire review attach <session-id> # Tag an existing agent session as a review (post-hoc)
686686
entire review attach --force # Skip confirmation
687687
entire review attach --agent <name> # Agent that created the session
688688
entire review attach --skills <s,...> # Declare which skills were run
689689
```
690690

691+
When two or more launchable agents are configured and `--agent` is not set, a multi-select picker appears with an optional per-run prompt field (e.g. "focus on security"). Selecting one agent or passing `--agent` runs the single-agent path; selecting two or more runs the N-agent path.
692+
691693
#### Settings Schema
692694

693695
Review skills are configured per-agent in `.entire/settings.json`:
@@ -724,11 +726,26 @@ Review metadata is stored at two levels on `entire/checkpoints/v1`:
724726

725727
- **`AgentReviewer` interface** (`cmd/entire/cli/review/types/reviewer.go`): per-agent contract with `Name() string` and `Start(ctx, RunConfig) (Process, error)`. Each launchable agent implements this in its own package.
726728
- **`ReviewerTemplate`** (`cmd/entire/cli/review/types/template.go`): shared scaffolding (Spawn → pipe stdout → run parser → forward events → close). Each agent supplies only its `BuildCmd` (argv/env) and `Parser` (stdout-to-Event stream).
727-
- **`Sink` interface**: consumers of the event stream. `DumpSink` renders the per-agent narrative dump after the run. CU8 will add a TUI sink and a synthesis sink.
728-
- **`Run(ctx, reviewer, cfg, sinks)`** (`cmd/entire/cli/review/run.go`): single-agent orchestrator. Forwards events to all sinks via `AgentEvent`, calls `RunFinished` once at end with a populated `RunSummary`. Sink dispatch is serialized; sinks need not internally synchronize. Multi-agent fan-out (CU8) preserves this serial-dispatch contract via fan-in.
729+
- **`Sink` interface**: consumers of the event stream. Production sinks: `DumpSink` (post-run per-agent narrative), `TUISink` (Bubble Tea live dashboard with Ctrl+O drill-in), `SynthesisSink` (opt-in y/N cross-agent verdict). Sinks are composed by `composeMultiAgentSinks` based on TTY detection.
730+
- **`Run(ctx, reviewer, cfg, sinks)`** (`cmd/entire/cli/review/run.go`): single-agent orchestrator. Forwards events to all sinks via `AgentEvent`, calls `RunFinished` once at end with a populated `RunSummary`. Sink dispatch is serialized; sinks need not internally synchronize.
731+
- **`RunMulti(ctx, reviewers, cfg, sinks)`** (`cmd/entire/cli/review/run_multi.go`): N-agent orchestrator. Each agent runs concurrently in its own goroutine; events fan into a single dispatch loop so the serial-dispatch contract is preserved. Per-agent skills/prompts are injected via `perAgentConfiguredReviewer` adapter (each reviewer sees its own `RunConfig` despite the shared API surface).
729732
- **Env-var contract** (`cmd/entire/cli/review/env.go`): single source of truth for `ENTIRE_REVIEW_*` constants used by spawn-side and lifecycle adoption.
730733
- **Scope detection** (`cmd/entire/cli/review/scope.go`): `detectScopeBaseRef` finds the closest non-self ancestor branch by tip timestamp, with fallback chain `origin/HEAD → origin/main → origin/master → main → master`. Banner output: "Reviewing feat/X vs main: 3 commits, 7 files changed, 2 uncommitted".
731734

735+
#### Multi-Agent UI
736+
737+
When `RunMulti` is dispatched in a TTY, the sink slice is `[TUISink, DumpSink, SynthesisSink?]`:
738+
739+
- **`TUISink` / `reviewTUIModel`** (`cmd/entire/cli/review/tui_sink.go`, `tui_model.go`, `tui_detail.go`): live dashboard with one row per agent (name, status, tokens, last assistant preview, duration). `Ctrl+O` enters drill-in mode on the alt screen showing the full event buffer for the selected agent; `Esc` returns to the dashboard. `Ctrl+C` cancels the run via the shared `CancelFunc`. The model uses `tea.WithoutSignalHandler` so the cobra root retains SIGINT routing. After all agents finish, the user dismisses with any key — `RunFinished` blocks on dismissal so `DumpSink` renders below the TUI rather than overlapping it.
740+
- **`SynthesisSink`** (`cmd/entire/cli/review/synthesis_sink.go`): opt-in y/N prompt offered after the dump. On "y", composes a synthesis prompt covering all agent narratives + per-run user prompt, calls the configured summary provider, and prints the unified verdict. Skipped silently when stdin can't prompt, the run was cancelled, or fewer than 2 agents produced usable output. Provider failures degrade gracefully ("synthesis unavailable: <err>") so the user can still commit.
741+
- **Sink composition** (`composeMultiAgentSinks` in `cmd/entire/cli/review/cmd.go`): pure helper taking explicit `isTTY`/`canPrompt` so tests don't depend on real TTY detection. `findTUISink` picks the TUI out of the slice for `Start`/`Wait` lifecycle hooks.
742+
743+
#### Skill Discovery (Claude Code)
744+
745+
`DiscoverReviewSkills` (`cmd/entire/cli/agent/claudecode/discovery.go`) walks three roots: plugin cache (`~/.claude/plugins/cache/<market>/<plugin>/<version>/{skills,commands,agents}`), user skills (`~/.claude/skills`), user commands/agents (`~/.claude/commands`, `~/.claude/agents`).
746+
747+
For the plugin cache, `pickLatestVersion` picks ONE version directory per plugin: highest valid semver wins; if no entries parse as semver, the lexicographic max is picked (handles the `unknown` sentinel some plugins ship). Without this, multiple installed versions of a plugin produced duplicate skill entries in the picker and prompt.
748+
732749
#### Anti-Features (do NOT recreate)
733750

734751
The redesign eliminated several constructs from the prior implementation. None should be reintroduced without explicit design:
@@ -745,16 +762,19 @@ The redesign eliminated several constructs from the prior implementation. None s
745762

746763
#### Key Files
747764

748-
- `cmd/entire/cli/review/cmd.go``NewCommand()`, `runReview` flow (launchable vs non-launchable dispatch)
749-
- `cmd/entire/cli/review/picker.go` — config-edit picker, first-run setup, agent selection helpers
765+
- `cmd/entire/cli/review/cmd.go``NewCommand()`, `runReview` dispatch fork, `composeMultiAgentSinks`
766+
- `cmd/entire/cli/review/picker.go` / `multipicker.go` — config-edit picker, first-run setup, single- and multi-agent selection
750767
- `cmd/entire/cli/review/attach.go` + `cli/review_helpers.go:newReviewAttachCmd``entire review attach` subcommand
751768
- `cmd/entire/cli/review/marker_fallback.go` — non-launchable agent flow (single shared file)
752-
- `cmd/entire/cli/review/prompt.go` / `scope.go` / `run.go` / `dump.go` — core machinery (CU4–CU5)
769+
- `cmd/entire/cli/review/prompt.go` / `scope.go` / `run.go` / `dump.go` / `run_multi.go` — core machinery (single-agent + N-agent fan-in)
770+
- `cmd/entire/cli/review/tui_sink.go` / `tui_model.go` / `tui_detail.go` — Bubble Tea TUI sink
771+
- `cmd/entire/cli/review/synthesis_sink.go` / `synthesis_prompt.go` — opt-in cross-agent verdict
753772
- `cmd/entire/cli/review/types/{reviewer,sink,template}.go` — interface contracts (CU2 + CU4 + CU5b)
754773
- `cmd/entire/cli/review/env.go``ENTIRE_REVIEW_*` constants + `EncodeSkills`/`DecodeSkills` + `AppendReviewEnv`
755774
- `cmd/entire/cli/agent/{claudecode,codex,geminicli}/reviewer.go` — per-agent `AgentReviewer` implementations (claude-code, codex with chrome filter, gemini-cli)
775+
- `cmd/entire/cli/agent/claudecode/discovery.go` — skill discovery + `pickLatestVersion` plugin-cache dedupe
756776
- `cmd/entire/cli/lifecycle.go``adoptReviewEnv` reads `ENTIRE_REVIEW_*` from process env; replaces marker-file adoption
757-
- `cmd/entire/cli/review_bridge.go` / `review_helpers.go` — bridge code in `cli` package for cycle-bound functions (`headHasReviewCheckpoint`, `launchableReviewerFor`, `newReviewAttachCmd`)
777+
- `cmd/entire/cli/review_bridge.go` / `review_helpers.go` — bridge code in `cli` package for cycle-bound functions (`headHasReviewCheckpoint`, `launchableReviewerFor`, `newReviewAttachCmd`, `lazySynthesisProvider`)
758778
- `cmd/entire/cli/checkpoint/checkpoint.go``Kind`, `ReviewSkills`, `ReviewPrompt` on `CommittedMetadata`; `HasReview` on `CheckpointSummary`
759779
- `cmd/entire/cli/settings/settings.go``EntireSettings.Review` field
760780

cmd/entire/cli/agent/claudecode/discovery.go

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import (
66
"log/slog"
77
"os"
88
"path/filepath"
9+
"sort"
910
"strings"
1011

12+
"golang.org/x/mod/semver"
13+
1114
"github.com/entireio/cli/cmd/entire/cli/agent"
1215
"github.com/entireio/cli/cmd/entire/cli/agent/skilldiscovery"
1316
"github.com/entireio/cli/cmd/entire/cli/logging"
@@ -50,6 +53,12 @@ func (c *ClaudeCodeAgent) DiscoverReviewSkills(ctx context.Context) ([]agent.Dis
5053

5154
// scanPluginCache walks <root>/<marketplace>/<plugin>/<version>/{skills,commands,agents}/
5255
// One plugin can contribute through any or all three directories.
56+
//
57+
// Multiple version directories per plugin are common after upgrades. Walking
58+
// every version produces duplicate skills (same invocation name, same
59+
// description) — confusing in the picker and wasteful in the prompt. We pick
60+
// a single version per plugin via pickLatestVersion: prefer valid semver
61+
// (highest), fall back to lexicographic max.
5362
func scanPluginCache(ctx context.Context, root string) []agent.DiscoveredSkill {
5463
entries, err := os.ReadDir(root)
5564
if err != nil {
@@ -77,20 +86,65 @@ func scanPluginCache(ctx context.Context, root string) []agent.DiscoveredSkill {
7786
if err != nil {
7887
continue
7988
}
80-
for _, verEntry := range versionEntries {
81-
if !verEntry.IsDir() {
82-
continue
83-
}
84-
versionRoot := filepath.Join(pluginRoot, verEntry.Name())
85-
found = append(found, readSkillsDir(ctx, filepath.Join(versionRoot, "skills"), pluginName)...)
86-
found = append(found, scanFlatMarkdownDir(ctx, filepath.Join(versionRoot, "commands"), pluginName)...)
87-
found = append(found, scanFlatMarkdownDir(ctx, filepath.Join(versionRoot, "agents"), pluginName)...)
89+
versionDir, ok := pickLatestVersion(versionEntries)
90+
if !ok {
91+
continue
8892
}
93+
versionRoot := filepath.Join(pluginRoot, versionDir)
94+
found = append(found, readSkillsDir(ctx, filepath.Join(versionRoot, "skills"), pluginName)...)
95+
found = append(found, scanFlatMarkdownDir(ctx, filepath.Join(versionRoot, "commands"), pluginName)...)
96+
found = append(found, scanFlatMarkdownDir(ctx, filepath.Join(versionRoot, "agents"), pluginName)...)
8997
}
9098
}
9199
return found
92100
}
93101

102+
// pickLatestVersion returns the name of the "newest" version directory among
103+
// entries. Strategy:
104+
//
105+
// - If any entry name parses as semver (with or without a leading "v"), pick
106+
// the highest semver among those that parse. Non-semver entries are
107+
// ignored when at least one semver entry exists.
108+
// - Otherwise, fall back to the lexicographic max of all directory names.
109+
// This handles the "unknown" sentinel some plugins ship and one-off names.
110+
//
111+
// Returns ("", false) if no usable directory entry exists.
112+
func pickLatestVersion(entries []os.DirEntry) (string, bool) {
113+
var dirs []string
114+
for _, e := range entries {
115+
if e.IsDir() {
116+
dirs = append(dirs, e.Name())
117+
}
118+
}
119+
if len(dirs) == 0 {
120+
return "", false
121+
}
122+
var semverDirs []string
123+
for _, d := range dirs {
124+
if semver.IsValid(semverWithV(d)) {
125+
semverDirs = append(semverDirs, d)
126+
}
127+
}
128+
if len(semverDirs) > 0 {
129+
sort.Slice(semverDirs, func(i, j int) bool {
130+
return semver.Compare(semverWithV(semverDirs[i]), semverWithV(semverDirs[j])) > 0
131+
})
132+
return semverDirs[0], true
133+
}
134+
sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
135+
return dirs[0], true
136+
}
137+
138+
// semverWithV ensures a version string has the "v" prefix that
139+
// golang.org/x/mod/semver requires. Plugin version dirs are usually bare
140+
// (e.g. "0.1.0"), but we tolerate either form.
141+
func semverWithV(s string) string {
142+
if strings.HasPrefix(s, "v") {
143+
return s
144+
}
145+
return "v" + s
146+
}
147+
94148
// scanUserSkills walks ~/.claude/skills/<skill>/SKILL.md.
95149
func scanUserSkills(ctx context.Context, root string) []agent.DiscoveredSkill {
96150
return readSkillsDir(ctx, root, "" /* no plugin prefix */)

cmd/entire/cli/agent/claudecode/discovery_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,111 @@ func TestDiscoverReviewSkills_SkipsReadme(t *testing.T) {
252252
}
253253
}
254254

255+
// TestDiscoverReviewSkills_DedupesPluginVersions verifies that when a plugin
256+
// has multiple version directories (common after an upgrade — old version
257+
// isn't always cleaned up), only the latest version's skills appear once.
258+
//
259+
// Without dedupe, the picker would show every review skill twice with no
260+
// way to tell the entries apart, and the prompt to the agent would list
261+
// the same skill multiple times.
262+
func TestDiscoverReviewSkills_DedupesPluginVersions(t *testing.T) {
263+
home := withFakeHome(t)
264+
old := filepath.Join(home, ".claude", "plugins", "cache",
265+
"fake-market", "pr-review-toolkit", "0.1.0", "skills", "review-pr")
266+
newer := filepath.Join(home, ".claude", "plugins", "cache",
267+
"fake-market", "pr-review-toolkit", "0.2.0", "skills", "review-pr")
268+
if err := os.MkdirAll(old, 0o755); err != nil {
269+
t.Fatal(err)
270+
}
271+
if err := os.MkdirAll(newer, 0o755); err != nil {
272+
t.Fatal(err)
273+
}
274+
oldContent := "---\nname: review-pr\ndescription: Old review\n---\n"
275+
newContent := "---\nname: review-pr\ndescription: New review\n---\n"
276+
if err := os.WriteFile(filepath.Join(old, "SKILL.md"), []byte(oldContent), 0o644); err != nil {
277+
t.Fatal(err)
278+
}
279+
if err := os.WriteFile(filepath.Join(newer, "SKILL.md"), []byte(newContent), 0o644); err != nil {
280+
t.Fatal(err)
281+
}
282+
283+
a := &claudecode.ClaudeCodeAgent{}
284+
skills, err := a.DiscoverReviewSkills(context.Background())
285+
if err != nil {
286+
t.Fatal(err)
287+
}
288+
if len(skills) != 1 {
289+
t.Fatalf("expected 1 deduped skill, got %d: %+v", len(skills), skills)
290+
}
291+
if skills[0].Description != "New review" {
292+
t.Errorf("Description = %q, want %q (latest version should win)", skills[0].Description, "New review")
293+
}
294+
}
295+
296+
// TestDiscoverReviewSkills_NonSemverVersionFallback verifies that a plugin
297+
// version dir like "unknown" (which pr-review-toolkit ships) is still
298+
// scanned when no semver dirs are present.
299+
func TestDiscoverReviewSkills_NonSemverVersionFallback(t *testing.T) {
300+
home := withFakeHome(t)
301+
skillDir := filepath.Join(home, ".claude", "plugins", "cache",
302+
"fake-market", "pr-review-toolkit", "unknown", "skills", "review-pr")
303+
if err := os.MkdirAll(skillDir, 0o755); err != nil {
304+
t.Fatal(err)
305+
}
306+
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"),
307+
[]byte("---\nname: review-pr\ndescription: Review\n---\n"), 0o644); err != nil {
308+
t.Fatal(err)
309+
}
310+
311+
a := &claudecode.ClaudeCodeAgent{}
312+
skills, err := a.DiscoverReviewSkills(context.Background())
313+
if err != nil {
314+
t.Fatal(err)
315+
}
316+
if len(skills) != 1 {
317+
t.Fatalf("expected 1 skill from non-semver version, got %d: %+v", len(skills), skills)
318+
}
319+
}
320+
321+
// TestDiscoverReviewSkills_SemverWinsOverNonSemver verifies that when a
322+
// plugin has both a semver-shaped version and a non-semver one (e.g.
323+
// "0.2.0" alongside "unknown"), the semver dir is picked. This matches
324+
// real upgrade flows where the old "unknown" stub remains alongside the
325+
// installed semver version.
326+
func TestDiscoverReviewSkills_SemverWinsOverNonSemver(t *testing.T) {
327+
home := withFakeHome(t)
328+
semverSkill := filepath.Join(home, ".claude", "plugins", "cache",
329+
"fake-market", "pr-review-toolkit", "0.2.0", "skills", "review-pr")
330+
unknownSkill := filepath.Join(home, ".claude", "plugins", "cache",
331+
"fake-market", "pr-review-toolkit", "unknown", "skills", "review-pr")
332+
if err := os.MkdirAll(semverSkill, 0o755); err != nil {
333+
t.Fatal(err)
334+
}
335+
if err := os.MkdirAll(unknownSkill, 0o755); err != nil {
336+
t.Fatal(err)
337+
}
338+
if err := os.WriteFile(filepath.Join(semverSkill, "SKILL.md"),
339+
[]byte("---\nname: review-pr\ndescription: From 0.2.0\n---\n"), 0o644); err != nil {
340+
t.Fatal(err)
341+
}
342+
if err := os.WriteFile(filepath.Join(unknownSkill, "SKILL.md"),
343+
[]byte("---\nname: review-pr\ndescription: From unknown\n---\n"), 0o644); err != nil {
344+
t.Fatal(err)
345+
}
346+
347+
a := &claudecode.ClaudeCodeAgent{}
348+
skills, err := a.DiscoverReviewSkills(context.Background())
349+
if err != nil {
350+
t.Fatal(err)
351+
}
352+
if len(skills) != 1 {
353+
t.Fatalf("expected 1 skill, got %d: %+v", len(skills), skills)
354+
}
355+
if skills[0].Description != "From 0.2.0" {
356+
t.Errorf("Description = %q, want From 0.2.0 (semver should win)", skills[0].Description)
357+
}
358+
}
359+
255360
func TestDiscoverReviewSkills_UserSkillsDir(t *testing.T) {
256361
home := withFakeHome(t)
257362
userSkillDir := filepath.Join(home, ".claude", "skills", "my-review")

0 commit comments

Comments
 (0)