Skip to content

Commit da67f50

Browse files
feat(teams): teams you send work to, with managers, on the composition (#36)
Teams are a thing you build and send work to, not a panel that fills in when a run happens to assemble some. - The team decision is made once per run from the library and stamped onto the composition (teams, team_mode, team_note), each team with its resolved manager; the composer model is told the teams and cannot change them. - One pinned team staffs the run: its worker, reviewer and tester take the loop, its skills are pinned, its charter rides in the handoff, its manager triages its rejected work, and the charter phase keeps that answer instead of asking the model to invent a second team. - Teams page: Send a request (preselect with staffing and managers, the composer's pipeline, Run with these teams, Activate), Give it a manager (POST /api/teams/{id}/manager), and How the teams worked (GET /api/teams/activity). - Live view: a per-run Teams control; the run setup panel shows teams and managers; the preview honors per-run pins. Board: a task can be assigned to a team under the ownership rule. - Fixes: a resumed run restores its squad plan; no stale plan after squads is switched off; one manager-resolution rule in agents.ResolveManager. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RfCkcsbJZgHoQLzwkqFUL
1 parent 8c64c8a commit da67f50

33 files changed

Lines changed: 4375 additions & 321 deletions

‎cmd/slmcode/cmd_compose.go‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,37 @@ func formatCompositionCLI(resp compositionCLIResponse) string {
191191
writeCLIKeyVal(&b, "corrector", c.Execute.Corrector)
192192
writeCLIKeyVal(&b, "max_waves", fmt.Sprintf("%d", c.Execute.MaxWaves))
193193

194+
// The library teams on the run and who manages each — the decision the
195+
// charter phase will act on, shown before it happens.
196+
if len(c.Teams) > 0 || c.TeamNote != "" {
197+
b.WriteString("\n")
198+
switch c.TeamMode {
199+
case "parallel":
200+
b.WriteString(cli.Bold("Teams (in parallel)"))
201+
case "single":
202+
b.WriteString(cli.Bold("Team (staffs this run)"))
203+
default:
204+
b.WriteString(cli.Bold("Teams"))
205+
}
206+
b.WriteString("\n")
207+
if c.TeamNote != "" {
208+
b.WriteString(" " + cli.Dim(c.TeamNote) + "\n")
209+
}
210+
for _, t := range c.Teams {
211+
manager := t.Manager
212+
if manager == "" {
213+
manager = "-"
214+
} else if t.ManagerDefault {
215+
manager += " (run default)"
216+
}
217+
fmt.Fprintf(&b, " %-16s worker=%s reviewer=%s tester=%s manager=%s\n", t.ID,
218+
valueOrDash(t.Worker), valueOrDash(t.Reviewer), valueOrDash(t.Tester), manager)
219+
if t.Reason != "" {
220+
b.WriteString(" " + strings.Repeat(" ", 16) + " " + cli.Dim(t.Reason) + "\n")
221+
}
222+
}
223+
}
224+
194225
if len(c.Team) > 0 {
195226
b.WriteString("\n")
196227
b.WriteString(cli.Bold("Team"))
@@ -242,3 +273,10 @@ func minInt(a, b int) int {
242273
}
243274
return b
244275
}
276+
277+
func valueOrDash(v string) string {
278+
if strings.TrimSpace(v) == "" {
279+
return "-"
280+
}
281+
return v
282+
}

‎docs/changelog.md‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,68 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
Teams are a thing you build and send work to, not a panel that fills in when a
6+
run happens to assemble some.
7+
8+
### Added
9+
10+
- **Send a request to teams.** The Teams page has a *Send a request* section:
11+
type a request and see the teams it selects and why, who staffs and manages
12+
each, what the selection does to the run, and the pipeline the composer would
13+
assemble — then **Run** it with exactly those teams pinned, or **Activate**
14+
them as the org chart. The Live view's command bar has the same **Teams**
15+
control; both send `teams` on `POST /api/runs`, run-scoped as documented.
16+
- **Every team has a manager, and can have its own.** Cards and the editor show
17+
the manager the run will actually use — the team's own when it answers the
18+
triage contract, else the run default, marked as such. *Give it a manager*
19+
(`POST /api/teams/{id}/manager`) creates `<team>-triage`, seeded with the
20+
team's charter, people and territory, and points the team at it.
21+
- **How the teams worked** (`GET /api/teams/activity`): the run's team-relevant
22+
events, classified — manager decisions, reassignments, cross-team waits,
23+
contract clauses, gates, integration — filterable by team, with one row per
24+
manager. Live during a run and kept afterwards; `?query=<id>` reads a past run.
25+
- **Teams on the composition.** `composer.Composition` carries `teams`,
26+
`team_mode` and `team_note`: the library teams the run will use, each with its
27+
resolved manager and the evidence that chose it. Shown by the run setup panel,
28+
`slmcode compose --explain` and the composition preview, which now honors
29+
per-run pins (`teams` on `POST /api/composition/preview`).
30+
- **Assign a task to a team** on the board (`squad` on `PATCH /api/tasks/{id}`),
31+
under the rule the wave fence applies: files owned by one team keep the task
32+
there, and the refusal names the owner.
33+
34+
### Changed
35+
36+
- **One pinned team staffs the run.** A single selected team no longer runs "as
37+
one stream wearing a hat" with its staffing ignored: its worker, reviewer and
38+
tester take the execute loop, its skills are pinned, and its charter and
39+
territory ride in the handoff.
40+
- **The composer model is told the teams** on the run, so the roles it picks
41+
agree with the charter phase. It cannot add or remove a team.
42+
- **The charter phase keeps the library's answer.** One matched team used to
43+
send the charter to the manager model, which could invent two squads behind
44+
a composition that had just said "one stream"; the model is now asked only
45+
when the library has nothing to say. On a single-team run the team's own
46+
manager (not the run default) triages its rejected work, with its people
47+
first in the roster.
48+
- The "manager must answer the triage contract, else the run default" rule
49+
lives once, in `agents.ResolveManager`, and is what the Teams page, the
50+
composition and the loop all apply.
51+
- Un-assigning a task whose files one team owns is refused like a wrong
52+
assignment would be: ownership would re-stamp it on the next save.
53+
- `GET /api/teams` reports `default_manager`, `dynamic_enabled` and `running`;
54+
each team carries `effective_manager` and `manager_default`;
55+
`POST /api/teams/preselect` reports `mode`, `note` and per-team staffing.
56+
57+
### Fixed
58+
59+
- **A resumed run kept no org chart.** The squad plan lived on disk and the
60+
orchestrator's handle died with the process, so a resumed run executed with
61+
no squad brief, no ownership fence, no per-team gates and no integration.
62+
Resume now restores the plan when the board's tasks are stamped with its teams.
63+
- A run started after `squads` was switched off in a long-lived Studio
64+
process could inherit the previous run's org chart.
65+
366
## v0.24.0 — 2026-09-01
467

568
Ten defects found by running v0.23.0 against a local 30B, thirteen times. Nearly

‎docs/squads.md‎

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,12 @@ at. Every card carries its team as a badge and a coloured left edge, and the
533533
colour comes from the team's id — so it is the same everywhere, and adding a
534534
team never recolours the others.
535535

536+
**The Teams page** keeps the record: *How the teams worked* lists every manager
537+
decision, reassignment, cross-team wait, gate and integration result of the
538+
run, live and after it, filterable by team. The **board** lets you move a task to
539+
a team by hand — within the same rule the wave fence applies: a task whose files
540+
all sit in one team's territory stays with that team, and the refusal says so.
541+
536542
**The live view** pins one line above the stream: the agent, its task, its team,
537543
the model, the run's token usage, and a clock that keeps ticking. On a local 30B
538544
the next log line can be four minutes out, and a wall of finished lines under a
@@ -718,10 +724,32 @@ contested path.
718724

719725
### Managing them
720726

721-
On the **Teams** page: create, edit, duplicate, delete, and *Try a request* —
722-
type a query and see which teams it would get **and why**, from the same code
723-
the run uses, before starting anything. Pick two or more and **Activate** to
724-
write the org chart the next run inherits.
727+
On the **Teams** page: create, edit, duplicate and delete teams, and give each
728+
its **project manager**. Every team has one whether you named it or not — a team
729+
that names nobody answers to the run's default manager (`triage`), and the card
730+
says so rather than showing an empty seat. *Give it a manager* creates a
731+
dedicated one in one click: a triage-capable agent named `<team>-triage`, seeded
732+
with the team's charter, people and territory, and the team pointed at it. A
733+
manager you name that cannot answer the triage contract (a worker, say) is
734+
reported as such and the run default stands in.
735+
736+
**Send a request** is the part that makes teams do work. Type a request and see,
737+
before anything starts and from the same code the run uses:
738+
739+
- which teams it selects and **why** — the evidence per team;
740+
- **who staffs each**: worker, reviewer, tester, and the manager, resolved;
741+
- what the selection **does to the run** — two or more teams build in parallel
742+
behind a frozen contract, one team staffs the whole run, none is the plain
743+
single stream;
744+
- the **pipeline the composer would assemble** for it, phases and loop.
745+
746+
Then **Run** sends the request with exactly those teams pinned for that run, or
747+
**Activate** writes them as the org chart every later run inherits.
748+
749+
**How the teams worked** is the record of a run, live while it goes: the
750+
managers' decisions on rejected deliveries, tasks moved between agents, a team
751+
waiting on another's interface, each half's gate, and integration — filterable
752+
by team and by kind, with one row per manager summing up what they did.
725753

726754
```bash
727755
slmcode blocks list # teams appear under TEAMS
@@ -739,8 +767,39 @@ regardless of what the query says, and it wins the contested paths.
739767
slmcode run "add invoice totals" --team payments --team frontend-react
740768
```
741769

742-
Studio's run setup sends the same thing per run; it is restored when the run
743-
ends, so a one-off choice never quietly governs every later run.
770+
Studio sends the same thing per run — the **Teams** control on the Live view's
771+
command bar, or *Run* on the Teams page — and it is restored when the run ends,
772+
so a one-off choice never quietly governs every later run.
773+
774+
**One pinned team is a request to that team.** Two teams run in parallel; one
775+
team cannot, but it still *staffs* the run: its worker, reviewer and tester take
776+
the execute loop, its skills are pinned into every task pack, its charter and
777+
territory ride in the handoff, and **its manager** is the one asked when a
778+
delivery is rejected. "Send this to the backend team" means the backend's
779+
people, even when the request has no second half — and the charter phase keeps
780+
that decision: it does not ask the manager agent to invent a second team. Only
781+
a library with nothing to say (no team matched, nothing pinned) hands the
782+
question to the model.
783+
784+
### The composer knows the teams
785+
786+
The dynamic pipeline composer used to decide phases and loop roles with no idea
787+
which teams the charter phase would then assemble — the run setup panel showed
788+
one staffing and the org chart used another. The team decision is now made once,
789+
from the library, and stamped onto the composition:
790+
791+
```text
792+
Teams (in parallel)
793+
2 teams build in parallel behind a frozen contract: backend-go, frontend-react
794+
backend-go worker=go-worker reviewer=go-reviewer tester=go-tester manager=triage (run default)
795+
workspace has "go.mod"; workspace contains ".go" files
796+
frontend-react worker=react-worker reviewer=react-reviewer tester=react-tester manager=triage (run default)
797+
query mentions "react"
798+
```
799+
800+
The composer *model* is told which teams are on the run so the roles it picks
801+
agree with them; it cannot add or remove one. `slmcode compose --explain`, the
802+
Live view's run setup panel and the Teams page all show the same answer.
744803

745804
### Attaching teams to a pipeline
746805

‎docs/studio.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ the binary. `make web-check` runs the SPA's lint, typecheck, tests and build.
7171
| `/runs` | **Runs** | Run history, and a per-run **trace** with per-phase wall time and token/cost attribution |
7272
| `/pipeline` | **Pipeline** | Edit the phase graph, bind agents to phases, insert slots, configure the execute loop |
7373
| `/agents` | **Agents** | Create/edit/delete custom specialists with a full prompt editor |
74-
| `/teams` | **Teams** | The [team library](squads.md#the-team-library) — author teams from existing agents, try a request against them, and edit the org chart and its frozen contract |
74+
| `/teams` | **Teams** | The [team library](squads.md#the-team-library) — build teams from existing agents and give each a project manager, send a request to teams and see who would work on it and how, edit the org chart and its frozen contract, and read how the managers and teams collaborated on a run |
7575
| `/blocks` | **Blocks** | Browse and apply pipeline / agent / quality / pack / team blocks |
7676
| `/files` | **Files** | Workspace tree browser, read-only, with diff against the last checkpoint |
7777
| `/skills` | **Skills** | Manage `SKILL.md` packs |
@@ -306,8 +306,8 @@ Roughly 60 endpoints under `/api/`, grouped: `health` · `readiness` · `config`
306306
interrupted) · `clarify` · `plan` · `continue` · `escalate` · `shell` (the five HITL gates, each
307307
`GET …/pending` + `POST …/answer|approve`) · `rewind` · `compact` · `events` · `status` · `models`
308308
· `auth` · `mcp` · `stacks` · `agents` · `pipeline` · `composition` · `blocks` · `packs` ·
309-
`squads` (the current run's org chart) · `teams` (the library — CRUD, plus `teams/preselect` and
310-
`teams/activate`) · `archives` · `queries` (+ `/events`, `/trace`) · `review` ·
309+
`squads` (the current run's org chart) · `teams` (the library — CRUD, plus `teams/preselect`,
310+
`teams/activate`, `teams/activity` and `teams/{id}/manager`) · `archives` · `queries` (+ `/events`, `/trace`) · `review` ·
311311
`workspace/file` · `workspace/tree`.
312312

313313
`slmcode config schema` and `GET /api/config/schema` both emit the machine-readable config schema

‎pkg/agents/factory.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,23 @@ func AgentsEmitting(schemaRole string, custom []CustomSpec) []string {
625625
return ids
626626
}
627627

628+
// ResolveManager decides which agent actually triages a team's rejected work.
629+
//
630+
// The rule lives here, once, because it is applied in three places that must
631+
// agree — the Teams page, the composition, and the loop's triage call: a team
632+
// may name any agent, but only one that answers the triage contract can be its
633+
// manager (its decoding grammar comes from its own prompt, so anything else
634+
// replies in a shape the reassignment step cannot read). Empty, or a nominee
635+
// that cannot triage, falls back to the run default, which always can. The
636+
// second result is true when the default stood in.
637+
func ResolveManager(named string, canTriage func(id string) bool) (string, bool) {
638+
named = strings.ToLower(strings.TrimSpace(named))
639+
if named == "" || canTriage == nil || !canTriage(named) {
640+
return RoleTriage, true
641+
}
642+
return named, false
643+
}
644+
628645
// EmitsSchema reports whether id is an agent that answers with the given
629646
// pkg/schema contract.
630647
//

‎pkg/composer/composer.go‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,50 @@ type TeamMember struct {
8888
Skills []string `json:"skills,omitempty"`
8989
}
9090

91+
// TeamChoice is one library team the composition puts on the run, with the
92+
// staffing the run will actually dispatch.
93+
//
94+
// This is NOT the composer model's to invent. Which teams a request involves is
95+
// answered deterministically from the library (pkg/teams) and stamped onto the
96+
// composition by the harness, so the composer prompt, the run setup panel and
97+
// the org chart the run builds all describe the same teams — a composition that
98+
// said "go-worker" while the charter phase then staffed the backend with
99+
// someone else was the two halves of one decision disagreeing.
100+
type TeamChoice struct {
101+
ID string `json:"id"`
102+
Name string `json:"name,omitempty"`
103+
Charter string `json:"charter,omitempty"`
104+
Owns []string `json:"owns,omitempty"`
105+
Acceptance string `json:"acceptance,omitempty"`
106+
Worker string `json:"worker,omitempty"`
107+
Reviewer string `json:"reviewer,omitempty"`
108+
Tester string `json:"tester,omitempty"`
109+
// Manager is the agent that triages this team's rejected work: the team's
110+
// own when it names one the harness can dispatch, else the run default.
111+
Manager string `json:"manager,omitempty"`
112+
// ManagerDefault is true when Manager is the run's default rather than a
113+
// manager the team chose for itself.
114+
ManagerDefault bool `json:"manager_default,omitempty"`
115+
Agents []string `json:"agents,omitempty"`
116+
Skills []string `json:"skills,omitempty"`
117+
// Reason says why this team is on the run — the evidence that scored it, or
118+
// "pinned" for a team the user chose by hand.
119+
Reason string `json:"reason,omitempty"`
120+
Pinned bool `json:"pinned,omitempty"`
121+
Score int `json:"score,omitempty"`
122+
}
123+
124+
// Team modes — how the chosen teams shape the run.
125+
const (
126+
// TeamModeParallel is two or more teams building at once behind a frozen
127+
// contract (pkg/squads).
128+
TeamModeParallel = "parallel"
129+
// TeamModeSingle is one team staffing the whole single-stream run: its
130+
// worker, reviewer and tester take the execute loop, its skills are pinned
131+
// and its charter rides in the handoff.
132+
TeamModeSingle = "single"
133+
)
134+
91135
// Composition is the composer's structured output: a full dynamic pipeline plan.
92136
type Composition struct {
93137
// Summary is a one-line description of the assembled plan.
@@ -110,6 +154,15 @@ type Composition struct {
110154
Team []TeamMember `json:"team,omitempty"`
111155
// Slots are extra insertable specialists around phase anchors.
112156
Slots []pipeline.Slot `json:"slots,omitempty"`
157+
// Teams are the library teams on this run and how they are staffed. Filled
158+
// by the harness, never by the composer model — see TeamChoice.
159+
Teams []TeamChoice `json:"teams,omitempty"`
160+
// TeamMode says what the teams do to the run: TeamModeParallel,
161+
// TeamModeSingle, or empty when no team is on it.
162+
TeamMode string `json:"team_mode,omitempty"`
163+
// TeamNote is the one-line human explanation of the team decision — why
164+
// these teams, or why none — in the words the run setup panel shows.
165+
TeamNote string `json:"team_note,omitempty"`
113166
}
114167

115168
// Normalize lowercases and trims identifiers, drops empty entries, and applies
@@ -165,6 +218,54 @@ func (c *Composition) Normalize() {
165218
c.Slots[i].ID = strings.ToLower(strings.TrimSpace(c.Slots[i].ID))
166219
c.Slots[i].Agent = strings.ToLower(strings.TrimSpace(c.Slots[i].Agent))
167220
}
221+
222+
var teams []TeamChoice
223+
seenTeam := map[string]bool{}
224+
for _, t := range c.Teams {
225+
t.ID = strings.ToLower(strings.TrimSpace(t.ID))
226+
if t.ID == "" || seenTeam[t.ID] {
227+
continue
228+
}
229+
seenTeam[t.ID] = true
230+
t.Name = strings.TrimSpace(t.Name)
231+
t.Charter = strings.TrimSpace(t.Charter)
232+
t.Acceptance = strings.TrimSpace(t.Acceptance)
233+
t.Worker = strings.ToLower(strings.TrimSpace(t.Worker))
234+
t.Reviewer = strings.ToLower(strings.TrimSpace(t.Reviewer))
235+
t.Tester = strings.ToLower(strings.TrimSpace(t.Tester))
236+
t.Manager = strings.ToLower(strings.TrimSpace(t.Manager))
237+
t.Owns = cleanListPreserveCase(t.Owns)
238+
t.Agents = cleanList(t.Agents)
239+
t.Skills = cleanList(t.Skills)
240+
t.Reason = strings.TrimSpace(t.Reason)
241+
teams = append(teams, t)
242+
}
243+
c.Teams = teams
244+
c.TeamMode = strings.ToLower(strings.TrimSpace(c.TeamMode))
245+
if c.TeamMode != TeamModeParallel && c.TeamMode != TeamModeSingle {
246+
c.TeamMode = ""
247+
}
248+
c.TeamNote = strings.TrimSpace(c.TeamNote)
249+
}
250+
251+
// TeamIDs lists the teams on the run, in rank order.
252+
func (c Composition) TeamIDs() []string {
253+
out := make([]string, 0, len(c.Teams))
254+
for _, t := range c.Teams {
255+
out = append(out, t.ID)
256+
}
257+
return out
258+
}
259+
260+
// TeamChoiceFor returns the choice for one team id.
261+
func (c Composition) TeamChoiceFor(id string) (TeamChoice, bool) {
262+
id = strings.ToLower(strings.TrimSpace(id))
263+
for _, t := range c.Teams {
264+
if t.ID == id {
265+
return t, true
266+
}
267+
}
268+
return TeamChoice{}, false
168269
}
169270

170271
func cleanList(in []string) []string {
@@ -385,5 +486,14 @@ func (c Composition) AgentSet() map[string]bool {
385486
for _, s := range c.Slots {
386487
add(s.Agent)
387488
}
489+
for _, t := range c.Teams {
490+
add(t.Worker)
491+
add(t.Reviewer)
492+
add(t.Tester)
493+
add(t.Manager)
494+
for _, a := range t.Agents {
495+
add(a)
496+
}
497+
}
388498
return out
389499
}

0 commit comments

Comments
 (0)