# 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](/content/gh/entireio/cli/session/93833a17-c2c6-4cb0-85b2-663c867b105f#timeline-116f0cd95d56/index.html)

## Changes

11

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

- Mcmd_test.go+106

- Afanout.go+90

- Afanout_internal_test.go+139

- Mmanifest.go+29/-2

- Mmanifest_test.go+61

- Mprofile.go+32

- Mrun.go+16

- Mrun_multi.go+8

- types

- Msink.go+6

- docs/architecture

- Mreview-command.md+1/-1

```
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
