feat(review): fan multi-skill workers out into parallel single-skill runs · Entire

feat(review): fan multi-skill workers out into parallel single-skill runs

df384dd·

peyton-alt·1w ago·11 files·+497 added/-9 removed

A worker configured with N skills previously joined them into one child's prompt: skills executed sequentially (or blended), so selecting more skills made the user wait for the SUM of their durations. Measured live: a two-skill claude worker ran ~9 minutes as one child.

explodeSkillWorkers splits each multi-skill worker into one worker per skill at plan time (keys like claude-code:review, deduped against existing workers), so skills run concurrently as ordinary slots — the wait becomes the slowest skill. Two exploded workers also mean the judge consolidates per-skill reports, extending the crew+judge value prop to single-agent multi-skill profiles.

--agent now selects ALL of that agent's workers as a filtered crew (previously an ambiguity error), running the single-agent path only when exactly one worker matches.

Same-agent SAME-model exploded workers defeat the existing agent+model session matching (assignments could cross, attributing tokens and transcripts to the wrong skill). AgentRun gains Skills, propagated through planned runs and both run paths, and the matcher requires skill-set agreement when both sides carry skills — mirroring the same-agent different-model disambiguation from #1313.

Verified end-to-end with a claude shim: a two-skill profile spawns two one-skill children in parallel (~3s wall for both) plus the judge.

Sessions

116f0cd95d56View transcript

?# Handoff To Claude: entire review RedoClaude Code·42 steps

Changes

11

846 unmodified lines

847
848
849
850
851
852
853
854
855
853
856
857
858
859
860
861
862
860
861
863
864
865
863
866
867
868
810 unmodified lines

1679
1680
1681
1680
1681
1682
1683
1684
1685
1686
1687

846 unmodified lines

}
profile.Task = profileTask(profileName, profile)
profile.Agents = nonZeroAgentConfigs(profile.Agents)
// Fan out multi-skill workers into one worker per skill so skills run
// concurrently: the wait is the slowest skill, not the sum.
profile = explodeSkillWorkers(profile)
outputMode := profileOutput(profile)

if agentOverride != "" {
    workerName, cfg, selectErr := selectProfileWorker(profile, agentOverride)
    workerName, cfg, single, selectErr := applyAgentOverride(&profile, agentOverride, modelOverride)
    if selectErr != nil {
        cmd.SilenceUsage = true
        err := fmt.Errorf("%w in review profile %q", selectErr, profileName)
        fmt.Fprintln(cmd.ErrOrStderr(), err.Error())
        return silentErr(err)
    }
    if modelOverride != "" {
        cfg.Model = modelOverride
    if single {
        return runSingleAgentPath(ctx, cmd, profileName, workerName, baseOverride, perRunPrompt, profile.Task, outputMode, timeout, cfg, installed, deps, out)
    }
    return runSingleAgentPath(ctx, cmd, profileName, workerName, baseOverride, perRunPrompt, profile.Task, outputMode, timeout, cfg, installed, deps, out)
}

if missing := missingInstalledProfileAgents(profile.Agents, installed); len(missing) > 0 {
810 unmodified lines

}
return r.inner.Name()
}
func (r *perAgentConfiguredReviewer) ActualAgentName() string { return r.inner.Name() }
func (r *perAgentConfiguredReviewer) ModelName() string       { return strings.TrimSpace(r.cfg.Model) }
func (r *perAgentConfiguredReviewer) ActualAgentName() string  { return r.inner.Name() }
func (r *perAgentConfiguredReviewer) ModelName() string        { return strings.TrimSpace(r.cfg.Model) }
func (r *perAgentConfiguredReviewer) ReviewerSkills() []string { return r.cfg.Skills }
func (r *perAgentConfiguredReviewer) Start(ctx context.Context, _ reviewtypes.RunConfig) (reviewtypes.Process, error) {
    return r.inner.Start(ctx, r.cfg) //nolint:wrapcheck // transparent adapter; callers see inner's error type directly
}

Mcmd/entire/cli/review/cmd.go+9/-6

5 unmodified lines

6
7
8
9
10
11
12
1414 unmodified lines

1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534

5 unmodified lines

"errors"
    "os"
    "strings"
    "sync"
    "testing"
    "time"

1414 unmodified lines

})
}
// multiStartCaptureReviewer records every Start call — the fan-out spawns
// the same agent multiple times, once per exploded skill worker.
type multiStartCaptureReviewer struct {
    name string
    mu   sync.Mutex
    got  []reviewtypes.RunConfig
}

func (r *multiStartCaptureReviewer) Name() string { return r.name }
func (r *multiStartCaptureReviewer) Start(_ context.Context, cfg reviewtypes.RunConfig) (reviewtypes.Process, error) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.got = append(r.got, cfg)
    return &stubDispatchProcess{}, nil
}

func (r *multiStartCaptureReviewer) captured() []reviewtypes.RunConfig {
    r.mu.Lock()
    defer r.mu.Unlock()
    return append([]reviewtypes.RunConfig(nil), r.got...)
}

func multiCaptureDeps(reviewer *multiStartCaptureReviewer) review.Deps {
    return review.Deps{
        GetAgentsWithHooksInstalled: func(_ context.Context) []types.AgentName {
            return []types.AgentName{types.AgentName(reviewer.name)}
        },
        NewSilentError: func(err error) error { return err },
        HeadHasReviewCheckpoint: func(_ context.Context) (bool, string) {
            return false, ""
        },
        ReviewerFor: func(agentName string) reviewtypes.AgentReviewer {
            if agentName == reviewer.name {
                return reviewer
            }
            return nil
        },
    }
}
// TestRunReview_MultiSkillWorkerFansOut verifies a worker with two skills
// spawns two parallel children, one skill each — wait is the slowest skill,
// not the sum.
func TestRunReview_MultiSkillWorkerFansOut(t *testing.T) {
    setupCmdTestRepo(t)
    if err := seedReviewProfile(context.Background(), settings.ReviewProfileConfig{
        Agents: map[string]settings.ReviewConfig{
            testAgentName: {Skills: []string{"/review", "/security-review"}},
        },
    }); err != nil {
        t.Fatal(err)
    }

reviewer := &multiStartCaptureReviewer{name: testAgentName}
    cmd := review.NewCommand(multiCaptureDeps(reviewer))
    cmd.SetOut(&bytes.Buffer{})
    cmd.SetErr(&bytes.Buffer{})
    cmd.SetArgs([]string{"general"})
    if err := cmd.Execute(); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

got := reviewer.captured()
    if len(got) != 2 {
        t.Fatalf("Start called %d times, want 2 (one per skill)", len(got))
    }
    skills := map[string]bool{}
    for _, cfg := range got {
        if len(cfg.Skills) != 1 {
            t.Errorf("run Skills = %v, want exactly one per exploded worker", cfg.Skills)
            continue
        }
        skills[cfg.Skills[0]] = true
        }
    if !skills["/review"] || !skills["/security-review"] {
        t.Errorf("fan-out skills = %v, want both configured skills", skills)
    }
}
// TestRunReview_AgentOverrideRunsAllExplodedWorkers verifies --agent with a
// multi-skill agent runs every exploded worker for that agent instead of
// erroring on ambiguity.
func TestRunReview_AgentOverrideRunsAllExplodedWorkers(t *testing.T) {
    setupCmdTestRepo(t)
    if err := seedReviewProfile(context.Background(), settings.ReviewProfileConfig{
        Agents: map[string]settings.ReviewConfig{
            testAgentName: {Skills: []string{"/review", "/security-review"}},
        },
    }); err != nil {
        t.Fatal(err)
    }

reviewer := &multiStartCaptureReviewer{name: testAgentName}
    cmd := review.NewCommand(multiCaptureDeps(reviewer))
    cmd.SetOut(&bytes.Buffer{})
    cmd.SetErr(&bytes.Buffer{})
    cmd.SetArgs([]string{"general", "--agent", testAgentName})
    if err := cmd.Execute(); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got := reviewer.captured(); len(got) != 2 {
        t.Fatalf("Start called %d times, want 2 (agent override filters, not selects one)", len(got))
    }
}

Mcmd/entire/cli/review/cmd_test.go+106

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

// Package review — see env.go for package-level rationale.
//
// fanout.go implements skill fan-out: a worker configured with N skills is
// exploded into N single-skill workers before planning, so the skills run
// concurrently as ordinary worker slots. Previously all N skills were joined
// into one child's prompt and executed sequentially (or blended) — selecting
// more skills made the user wait for the SUM of their durations; after
// explosion the wait is the slowest skill.
package review

import (
    "fmt"

"github.com/entireio/cli/cmd/entire/cli/settings"
)

// explodeSkillWorkers returns a copy of profile whose multi-skill workers are
// split into one worker per skill. Single-skill and skill-less workers pass
// through unchanged under their original keys. Exploded workers keep the
// source worker's model and prompt, carry an explicit Agent so the derived
// key still resolves to the real agent, and get deterministic keys
// (<worker>:<skill-slug>, deduped against existing keys).
func explodeSkillWorkers(profile settings.ReviewProfileConfig) settings.ReviewProfileConfig {
    out := profile
    agents := make(map[string]settings.ReviewConfig, len(profile.Agents))

// Pass-through workers claim their keys first so exploded keys can never
    // clobber an existing worker that happens to match a derived name.
    multiSkill := make([]string, 0, len(profile.Agents))
    for _, name := range sortedMapKeys(profile.Agents) {
        cfg := profile.Agents[name]
        if len(cfg.Skills) <= 1 {
            agents[name] = cfg
            continue
        }
        multiSkill = append(multiSkill, name)
    }

for _, name := range multiSkill {
        cfg := profile.Agents[name]
        agentName := reviewAgentName(name, cfg)
        for _, skill := range cfg.Skills {
            worker := cfg
            worker.Skills = []string{skill}
            worker.Agent = agentName
            agents[workerIDForSkill(name, skill, agents)] = worker
        }
    }

out.Agents = agents
    return out
}

// workerIDForSkill derives a stable worker key for one exploded skill run,
// following the workerIDForAgentModel convention (<base>:<slug>, numeric
// suffix on collision).
func workerIDForSkill(base, skill string, existing map[string]settings.ReviewConfig) string {
    candidate := base + ":" + sanitizeWorkerIDPart(skill)
    for i := 2; ; i++ {
        if _, exists := existing[candidate]; !exists {
            return candidate
        }
        candidate = fmt.Sprintf("%s:%s-%d", base, sanitizeWorkerIDPart(skill), i)
    }
}

// applyAgentOverride narrows profile.Agents to the workers matching the
// --agent selector, applying an optional model override to each. Exactly one
// match returns (workerName, cfg, true) for the single-agent path; multiple
// matches — the agent's exploded skill workers — narrow the profile in place
// and run as a filtered crew through the normal fan-out flow.
func applyAgentOverride(profile *settings.ReviewProfileConfig, agentOverride, modelOverride string) (string, settings.ReviewConfig, bool, error) {
    matched, err := selectProfileWorkers(*profile, agentOverride)
    if err != nil {
        return "", settings.ReviewConfig{}, false, err
    }
    if modelOverride != "" {
        for workerName, cfg := range matched {
            cfg.Model = modelOverride
            matched[workerName] = cfg
        }
    }
    if len(matched) == 1 {
        for workerName, cfg := range matched {
            return workerName, cfg, true, nil
        }
    }
    profile.Agents = matched
    return "", settings.ReviewConfig{}, false, nil
}

Mcmd/entire/cli/review/fanout.go+90

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139

package review

import (
    "context"
    "errors"
    "testing"

reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types"
    "github.com/entireio/cli/cmd/entire/cli/settings"
)

// TestExplodeSkillWorkers_SplitsMultiSkillWorker verifies a worker with N
// skills becomes N workers, one skill each, running as ordinary parallel
// slots — wait becomes the slowest skill, not the sum.
func TestExplodeSkillWorkers_SplitsMultiSkillWorker(t *testing.T) {
    t.Parallel()
    profile := settings.ReviewProfileConfig{
        Task: "user task",
        Agents: map[string]settings.ReviewConfig{
            "claude-code": {
                Skills: []string{"/review", "/pr-review-toolkit:review-pr"},
                Model:  "opus",
                Prompt: "focus on auth",
            },
        },
    }
    got := explodeSkillWorkers(profile)

if len(got.Agents) != 2 {
        t.Fatalf("Agents = %v, want 2 exploded workers", got.Agents)
    }
    if got.Task != "user task" {
        t.Errorf("Task = %q, want preserved", got.Task)
    }
    seenSkills := map[string]bool{}
    for key, cfg := range got.Agents {
        if len(cfg.Skills) != 1 {
            t.Errorf("worker %q Skills = %v, want exactly one", key, cfg.Skills)
        } else {
            seenSkills[cfg.Skills[0]] = true
        }
        if reviewAgentName(key, cfg) != "claude-code" {
            t.Errorf("worker %q resolves agent %q, want claude-code", key, reviewAgentName(key, cfg))
        }
        if cfg.Model != "opus" {
            t.Errorf("worker %q Model = %q, want opus preserved", key, cfg.Model)
        }
        if cfg.Prompt != "focus on auth" {
            t.Errorf("worker %q Prompt = %q, want preserved", key, cfg.Prompt)
        }
    }
    if !seenSkills["/review"] || !seenSkills["/pr-review-toolkit:review-pr"] {
        t.Errorf("skills split incorrectly: %v", seenSkills)
    }
}
// TestExplodeSkillWorkers_PassThrough verifies single-skill and skill-less
// workers are untouched (same keys, same configs).
func TestExplodeSkillWorkers_PassThrough(t *testing.T) {
    t.Parallel()
    profile := settings.ReviewProfileConfig{
        Agents: map[string]settings.ReviewConfig{
            "codex": {Skills: []string{"/review"}},
            "pi":    {Prompt: "review the change"},
        },
    }
    got := explodeSkillWorkers(profile)
    if len(got.Agents) != 2 {
        t.Fatalf("Agents = %v, want unchanged worker count", got.Agents)
    }
    if _, ok := got.Agents["codex"]; !ok {
        t.Error("single-skill worker key changed")
    }
    if _, ok := got.Agents["pi"]; !ok {
        t.Error("skill-less worker key changed")
    }
}
// TestExplodeSkillWorkers_DeterministicDistinctKeys verifies exploded keys
// are stable across calls and never collide, including with an existing
// worker whose name matches a derived key.
func TestExplodeSkillWorkers_DeterministicDistinctKeys(t *testing.T) {
    t.Parallel()
    profile := settings.ReviewProfileConfig{
        Agents: map[string]settings.ReviewConfig{
            "claude-code":        {Skills: []string{"/review", "/security-review"}},
            "claude-code:review": {Agent: "claude-code", Prompt: "existing worker with colliding name"},
        },
    }
    a := explodeSkillWorkers(profile)
    b := explodeSkillWorkers(profile)
    if len(a.Agents) != 3 {
        t.Fatalf("Agents = %v, want 3 (2 exploded + 1 pass-through)", a.Agents)
    }
    for key := range a.Agents {
        if _, ok := b.Agents[key]; !ok {
            t.Errorf("keys not deterministic: %q missing on second call", key)
        }
    }
    if cfg, ok := a.Agents["claude-code:review"]; !ok || cfg.Prompt != "existing worker with colliding name" {
        t.Error("existing worker was clobbered by an exploded key")
    }
}

type fanoutStubReviewer struct{ name string }

func (r fanoutStubReviewer) Name() string { return r.name }
func (r fanoutStubReviewer) Start(context.Context, reviewtypes.RunConfig) (reviewtypes.Process, error) {
    return nil, errors.New("not started in tests")
}

// TestPlannedAgentRunsCarrySkills verifies the planned runs propagate each
// worker's skills into the summary AgentRuns — without this, the manifest's
// skills-based session disambiguation never sees a signal in production.
func TestPlannedAgentRunsCarrySkills(t *testing.T) {
    t.Parallel()
    reviewers := []reviewtypes.AgentReviewer{
        &perAgentConfiguredReviewer{
            name:  "claude-code:review",
            inner: fanoutStubReviewer{name: "claude-code"},
            cfg:   reviewtypes.RunConfig{Skills: []string{"/review"}},
        },
        &perAgentConfiguredReviewer{
            name:  "claude-code:security-review",
            inner: fanoutStubReviewer{name: "claude-code"},
            cfg:   reviewtypes.RunConfig{Skills: []string{"/security-review"}},
        },
    }
    planned := plannedAgentRunsForReviewers(reviewers, reviewtypes.RunConfig{})
    if len(planned) != 2 {
        t.Fatalf("planned = %d runs, want 2", len(planned))
    }
    if len(planned[0].Skills) != 1 || planned[0].Skills[0] != "/review" {
        t.Errorf("planned[0].Skills = %v, want [/review]", planned[0].Skills)
    }
    if len(planned[1].Skills) != 1 || planned[1].Skills[0] != "/security-review" {
        t.Errorf("planned[1].Skills = %v, want [/security-review]", planned[1].Skills)
    }
}

Mcmd/entire/cli/review/fanout_internal_test.go+139

319 unmodified lines

320
321
322
323
323
324
325
326
64 unmodified lines

391
392
393
394
394
395
396
397
86 unmodified lines

484
485
486
487
488
489
490
18 unmodified lines

509
510
511
512
513
514
515
516
517
518
519
520
521
1 unmodified line

523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547

319 unmodified lines

if usedSessions == nil {
        usedSessions = map[string]bool{}
    }
    st := matchReviewSessionState(worktreeRoot, headSHA, run.StartedAt, agentNameForRun(run), run.Model, states, usedSessions)
    st := matchReviewSessionState(worktreeRoot, headSHA, run.StartedAt, agentNameForRun(run), run.Model, run.Skills, states, usedSessions)
    if st == nil || st.SessionID == "" {
        return st
    }
64 unmodified lines

if (strings.TrimSpace(run.Model) != "") != explicitModel {
            continue // belongs to the other pass
        }
        st := matchReviewSessionState(worktreeRoot, headSHA, summary.StartedAt, agentNameForRun(run), run.Model, states, usedSessions)
        st := matchReviewSessionState(worktreeRoot, headSHA, summary.StartedAt, agentNameForRun(run), run.Model, run.Skills, states, usedSessions)
        if st == nil || st.SessionID == "" {
            continue
        }
86 unmodified lines

runStartedAt time.Time,
    agentName string,
    modelName string,
    runSkills []string,
    states []*session.State,
    used map[string]bool,
) *session.State {
    *best = nil
    for _, run := range agentRuns {
        if strings.TrimSpace(modelName) != run.Model {
            continue
        }
        st := matchReviewSessionState(worktreeRoot, headSHA, run.StartedAt, agentNameForRun(run), run.Model, states, usedSessions)
        if st == nil || st.SessionID == "" {
            continue
        }
}
1 unmodified line

return best
}

// stringSetsEqual reports whether two string slices contain the same
// elements regardless of order.
func stringSetsEqual(a, b []string) bool {
    if len(a) != len(b) {
        return false
    }
    set := make(map[string]int, len(a))
    for _, s := range a {
        set[s]++
    }
    for _, s := range b {
        set[s]--
        if set[s] < 0 {
            return false
        }
    }
    return true
}

func reviewRunModelMatches(want, got string) bool {
    want = normalizeReviewModelID(want)
    got = normalizeReviewModelID(got)
}

// Mmanifest.go+29/-2

562 unmodified lines

563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629

562 unmodified lines

} // TestBuildLocalReviewManifestFromSummary_DisambiguatesSameModelBySkills // covers exploded skill workers: the same agent with the SAME model runs // twice (once per skill), so agent+model matching alone can cross-assign // sessions — the session started later would be claimed by whichever run is // iterated first, attributing tokens and transcripts to the wrong skill. // ReviewSkills (recorded from ENTIRE_REVIEW_SKILLS by the lifecycle hook) is // the discriminator. The states are ordered so newest-first claiming WOULD // cross-assign without the skills check. func TestBuildLocalReviewManifestFromSummary_DisambiguatesSameModelBySkills(t *testing.T) { started := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) summary := reviewtypes.RunSummary{ StartedAt: started, AgentRuns: []reviewtypes.AgentRun{ { Name: "claude-code:review", AgentName: "claude-code", Skills: []string{"/review"}, Status: reviewtypes.AgentStatusSucceeded, Buffer: []reviewtypes.Event{reviewtypes.AssistantText{Text: "review finding"}}, }, { Name: "claude-code:security-review", AgentName: "claude-code", Skills: []string{"/security-review"}, Status: reviewtypes.AgentStatusSucceeded, Buffer: []reviewtypes.Event{reviewtypes.AssistantText{Text: "security finding"}}, }, }, } states := []*session.State{ { SessionID: "security-session", Kind: session.KindAgentReview, WorktreePath: "/repo", BaseCommit: "abc123", StartedAt: started.Add(2 * time.Second), // newer — would be claimed first without skills ReviewSkills: []string{"/security-review"}, }, { SessionID: "review-session", Kind: session.KindAgentReview, WorktreePath: "/repo", BaseCommit: "abc123", StartedAt: started.Add(time.Second), ReviewSkills: []string{"/review"}, }, }

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 != "review-session" || manifest.Sources[0].Label != "claude-code:review" { t.Fatalf("review source mismatch (cross-assigned?): %#v", manifest.Sources[0]) } if manifest.Sources[1].SessionID != "security-session" || manifest.Sources[1].Label != "claude-code:security-review" { t.Fatalf("security source mismatch (cross-assigned?): %#v", manifest.Sources[1]) } }

func TestWarnManifestNotWritten_PrintsReasonAndDiagnosticHints(t *testing.T) { var b strings.Builder


Mcmd/entire/cli/review/manifest_test.go+61

258 unmodified lines

259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296

258 unmodified lines

} // selectProfileWorkers resolves an --agent/worker selector to every matching // worker. An exact worker key wins alone; otherwise all workers whose agent // resolves to the selector are returned — multiple matches are the agent's // exploded skill workers, which run together as a filtered crew rather than // erroring on ambiguity. func selectProfileWorkers(profile settings.ReviewProfileConfig, selector string) (map[string]settings.ReviewConfig, error) { selector = strings.TrimSpace(selector) if selector == "" { return nil, errors.New("empty review reviewer selector") } if cfg, ok := profile.Agents[selector]; ok && !cfg.IsZero() { return map[string]settings.ReviewConfig{selector: cfg}, nil } matched := make(map[string]settings.ReviewConfig) for workerName, cfg := range profile.Agents { if cfg.IsZero() { continue } if reviewAgentName(workerName, cfg) == selector { matched[workerName] = cfg } } if len(matched) == 0 { configured := sortedMapKeys(profile.Agents) if len(configured) == 0 { return nil, fmt.Errorf("review reviewer or agent %q is not configured", selector) } return nil, fmt.Errorf("review reviewer or agent %q is not configured; configured reviewers: %s", selector, strings.Join(configured, ", ")) } return matched, nil }

func workerIDForAgentModel(agentName, model string, existing map[string]settings.ReviewConfig) string { base := strings.TrimSpace(agentName) if strings.TrimSpace(model) != "" { }


Mcmd/entire/cli/review/profile.go+32

33 unmodified lines

34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 119 unmodified lines

173 174 175 176 177 178 179 74 unmodified lines

254 255 256 257 258 259 260

33 unmodified lines

return "" }

// reviewerSkillsMetadata is the optional interface exposing a worker's skill // invocations for planned-run construction (session matching needs them to // disambiguate same-agent same-model exploded skill workers). type reviewerSkillsMetadata interface { ReviewerSkills() []string }

func reviewerSkills(r reviewtypes.AgentReviewer) []string { if meta, ok := r.(reviewerSkillsMetadata); ok { return meta.ReviewerSkills() } return nil }

// defaultReviewerTimeout bounds a single reviewer's run when the caller // doesn't set RunConfig.ReviewerTimeout. A stuck agent is cancelled (its // process killed) and marked failed rather than hanging the review forever. 119 unmodified lines

Cancelled: status == reviewtypes.AgentStatusCancelled, AgentRuns: []reviewtypes.AgentRun{{ Name: displayName, Skills: cfg.Skills, AgentName: agentName, Model: modelName, Status: status, Tokens: tokens, Buffer: buffer, }, },

}
}

Mcmd/entire/cli/review/run.go+16


46 unmodified lines

47
48
49
50
51
52
53
67 unmodified lines

121
122
123
124
125
126
127
141 unmodified lines

269
270
271
272
273
274
275
30 unmodified lines

306
307
308
309
310
311
312
313
314
315
316
317
318
319
320

46 unmodified lines

name         string
    agentName    string
    model        string
    skills       []string
    proc         reviewtypes.Process
    startErr     error
    startedAt    time.Time
67 unmodified lines

name:      run.Name,
        agentName: run.AgentName,
        model:     run.Model,
        skills:    run.Skills,
        startedAt: time.Now(),
    }
    }
}

141 unmodified lines

Name:      st.name,
        AgentName: st.agentName,
        Model:     st.model,
        Skills:    st.skills,
        Status:    status,
        Tokens:    st.tokens,
        Buffer:    st.buffer,
    }
30 unmodified lines

if model == "" {
            model = cfg.Model
        }
        skills := reviewerSkills(r)
        if len(skills) == 0 {
            skills = cfg.Skills
        }
        planned[i] = reviewtypes.AgentRun{
            Name:      r.Name(),
            AgentName: reviewerActualAgentName(r),
            Model:     model,
            Skills:    skills,
        }
    }
    return planned
}

Mcmd/entire/cli/review/run_multi.go+8

63 unmodified lines

64 65 66 67 68 69 70 71 72 73 74 75

63 unmodified lines

// Model is the optional model hint used for this worker. Model string

// Skills are the skill invocations this worker ran. When the same agent // runs multiple exploded skill workers with the same model, skills are // the only discriminator left for session matching (the lifecycle hook // records the child's ENTIRE_REVIEW_SKILLS as session.State.ReviewSkills). Skills []string

Status AgentStatus Tokens Tokens


Mcmd/entire/cli/review/types/sink.go+6

56 unmodified lines

57 58 59 60 60 61 62 63

56 unmodified lines

entire review --models lists the models each review-runner agent advertises via the optional agent.ModelLister capability (cmd/entire/cli/agent/model_lister.go). claude-code returns a curated list of real aliases (opus/sonnet/haiku); Pi enumerates live by shelling out to pi --list-models. Agents whose CLI has no enumeration command (codex, gemini) do not implement ListModels, so the picker offers only Default + Custom. The --model flag still forwards any value the agent CLI accepts.

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 parse-tolerated and can be exposed as a legacy general profile when no review_profiles are configured.

The profile-level task is the shared work item. Each agents map entry is a worker id. A worker configured with multiple skills is exploded at plan time into one worker per skill (keys like claude-code:review), so skills run concurrently — the wait is the slowest skill, not the sum — and each exploded worker's session is matched back via the skills recorded on session state (ReviewSkills), which disambiguates same-agent same-model workers. --agent <name> selects ALL of that agent's workers (a filtered crew), running single-agent only when exactly one matches. 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 parse-tolerated and can be exposed as a legacy general profile when no review_profiles are configured.

Behavior