Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions cmd/slmcode/cmd_compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,37 @@ func formatCompositionCLI(resp compositionCLIResponse) string {
writeCLIKeyVal(&b, "corrector", c.Execute.Corrector)
writeCLIKeyVal(&b, "max_waves", fmt.Sprintf("%d", c.Execute.MaxWaves))

// The library teams on the run and who manages each — the decision the
// charter phase will act on, shown before it happens.
if len(c.Teams) > 0 || c.TeamNote != "" {
b.WriteString("\n")
switch c.TeamMode {
case "parallel":
b.WriteString(cli.Bold("Teams (in parallel)"))
case "single":
b.WriteString(cli.Bold("Team (staffs this run)"))
default:
b.WriteString(cli.Bold("Teams"))
}
b.WriteString("\n")
if c.TeamNote != "" {
b.WriteString(" " + cli.Dim(c.TeamNote) + "\n")
}
for _, t := range c.Teams {
manager := t.Manager
if manager == "" {
manager = "-"
} else if t.ManagerDefault {
manager += " (run default)"
}
fmt.Fprintf(&b, " %-16s worker=%s reviewer=%s tester=%s manager=%s\n", t.ID,
valueOrDash(t.Worker), valueOrDash(t.Reviewer), valueOrDash(t.Tester), manager)
if t.Reason != "" {
b.WriteString(" " + strings.Repeat(" ", 16) + " " + cli.Dim(t.Reason) + "\n")
}
}
}

if len(c.Team) > 0 {
b.WriteString("\n")
b.WriteString(cli.Bold("Team"))
Expand Down Expand Up @@ -242,3 +273,10 @@ func minInt(a, b int) int {
}
return b
}

func valueOrDash(v string) string {
if strings.TrimSpace(v) == "" {
return "-"
}
return v
}
63 changes: 63 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,68 @@
# Changelog

## Unreleased

Teams are a thing you build and send work to, not a panel that fills in when a
run happens to assemble some.

### Added

- **Send a request to teams.** The Teams page has a *Send a request* section:
type a request and see the teams it selects and why, who staffs and manages
each, what the selection does to the run, and the pipeline the composer would
assemble — then **Run** it with exactly those teams pinned, or **Activate**
them as the org chart. The Live view's command bar has the same **Teams**
control; both send `teams` on `POST /api/runs`, run-scoped as documented.
- **Every team has a manager, and can have its own.** Cards and the editor show
the manager the run will actually use — the team's own when it answers the
triage contract, else the run default, marked as such. *Give it a manager*
(`POST /api/teams/{id}/manager`) creates `<team>-triage`, seeded with the
team's charter, people and territory, and points the team at it.
- **How the teams worked** (`GET /api/teams/activity`): the run's team-relevant
events, classified — manager decisions, reassignments, cross-team waits,
contract clauses, gates, integration — filterable by team, with one row per
manager. Live during a run and kept afterwards; `?query=<id>` reads a past run.
- **Teams on the composition.** `composer.Composition` carries `teams`,
`team_mode` and `team_note`: the library teams the run will use, each with its
resolved manager and the evidence that chose it. Shown by the run setup panel,
`slmcode compose --explain` and the composition preview, which now honors
per-run pins (`teams` on `POST /api/composition/preview`).
- **Assign a task to a team** on the board (`squad` on `PATCH /api/tasks/{id}`),
under the rule the wave fence applies: files owned by one team keep the task
there, and the refusal names the owner.

### Changed

- **One pinned team staffs the run.** A single selected team no longer runs "as
one stream wearing a hat" with its staffing ignored: its worker, reviewer and
tester take the execute loop, its skills are pinned, and its charter and
territory ride in the handoff.
- **The composer model is told the teams** on the run, so the roles it picks
agree with the charter phase. It cannot add or remove a team.
- **The charter phase keeps the library's answer.** One matched team used to
send the charter to the manager model, which could invent two squads behind
a composition that had just said "one stream"; the model is now asked only
when the library has nothing to say. On a single-team run the team's own
manager (not the run default) triages its rejected work, with its people
first in the roster.
- The "manager must answer the triage contract, else the run default" rule
lives once, in `agents.ResolveManager`, and is what the Teams page, the
composition and the loop all apply.
- Un-assigning a task whose files one team owns is refused like a wrong
assignment would be: ownership would re-stamp it on the next save.
- `GET /api/teams` reports `default_manager`, `dynamic_enabled` and `running`;
each team carries `effective_manager` and `manager_default`;
`POST /api/teams/preselect` reports `mode`, `note` and per-team staffing.

### Fixed

- **A resumed run kept no org chart.** The squad plan lived on disk and the
orchestrator's handle died with the process, so a resumed run executed with
no squad brief, no ownership fence, no per-team gates and no integration.
Resume now restores the plan when the board's tasks are stamped with its teams.
- A run started after `squads` was switched off in a long-lived Studio
process could inherit the previous run's org chart.

## v0.24.0 — 2026-09-01

Ten defects found by running v0.23.0 against a local 30B, thirteen times. Nearly
Expand Down
71 changes: 65 additions & 6 deletions docs/squads.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,12 @@ at. Every card carries its team as a badge and a coloured left edge, and the
colour comes from the team's id — so it is the same everywhere, and adding a
team never recolours the others.

**The Teams page** keeps the record: *How the teams worked* lists every manager
decision, reassignment, cross-team wait, gate and integration result of the
run, live and after it, filterable by team. The **board** lets you move a task to
a team by hand — within the same rule the wave fence applies: a task whose files
all sit in one team's territory stays with that team, and the refusal says so.

**The live view** pins one line above the stream: the agent, its task, its team,
the model, the run's token usage, and a clock that keeps ticking. On a local 30B
the next log line can be four minutes out, and a wall of finished lines under a
Expand Down Expand Up @@ -718,10 +724,32 @@ contested path.

### Managing them

On the **Teams** page: create, edit, duplicate, delete, and *Try a request* —
type a query and see which teams it would get **and why**, from the same code
the run uses, before starting anything. Pick two or more and **Activate** to
write the org chart the next run inherits.
On the **Teams** page: create, edit, duplicate and delete teams, and give each
its **project manager**. Every team has one whether you named it or not — a team
that names nobody answers to the run's default manager (`triage`), and the card
says so rather than showing an empty seat. *Give it a manager* creates a
dedicated one in one click: a triage-capable agent named `<team>-triage`, seeded
with the team's charter, people and territory, and the team pointed at it. A
manager you name that cannot answer the triage contract (a worker, say) is
reported as such and the run default stands in.

**Send a request** is the part that makes teams do work. Type a request and see,
before anything starts and from the same code the run uses:

- which teams it selects and **why** — the evidence per team;
- **who staffs each**: worker, reviewer, tester, and the manager, resolved;
- what the selection **does to the run** — two or more teams build in parallel
behind a frozen contract, one team staffs the whole run, none is the plain
single stream;
- the **pipeline the composer would assemble** for it, phases and loop.

Then **Run** sends the request with exactly those teams pinned for that run, or
**Activate** writes them as the org chart every later run inherits.

**How the teams worked** is the record of a run, live while it goes: the
managers' decisions on rejected deliveries, tasks moved between agents, a team
waiting on another's interface, each half's gate, and integration — filterable
by team and by kind, with one row per manager summing up what they did.

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

Studio's run setup sends the same thing per run; it is restored when the run
ends, so a one-off choice never quietly governs every later run.
Studio sends the same thing per run — the **Teams** control on the Live view's
command bar, or *Run* on the Teams page — and it is restored when the run ends,
so a one-off choice never quietly governs every later run.

**One pinned team is a request to that team.** Two teams run in parallel; one
team cannot, but it still *staffs* the run: its worker, reviewer and tester take
the execute loop, its skills are pinned into every task pack, its charter and
territory ride in the handoff, and **its manager** is the one asked when a
delivery is rejected. "Send this to the backend team" means the backend's
people, even when the request has no second half — and the charter phase keeps
that decision: it does not ask the manager agent to invent a second team. Only
a library with nothing to say (no team matched, nothing pinned) hands the
question to the model.

### The composer knows the teams

The dynamic pipeline composer used to decide phases and loop roles with no idea
which teams the charter phase would then assemble — the run setup panel showed
one staffing and the org chart used another. The team decision is now made once,
from the library, and stamped onto the composition:

```text
Teams (in parallel)
2 teams build in parallel behind a frozen contract: backend-go, frontend-react
backend-go worker=go-worker reviewer=go-reviewer tester=go-tester manager=triage (run default)
workspace has "go.mod"; workspace contains ".go" files
frontend-react worker=react-worker reviewer=react-reviewer tester=react-tester manager=triage (run default)
query mentions "react"
```

The composer *model* is told which teams are on the run so the roles it picks
agree with them; it cannot add or remove one. `slmcode compose --explain`, the
Live view's run setup panel and the Teams page all show the same answer.

### Attaching teams to a pipeline

Expand Down
6 changes: 3 additions & 3 deletions docs/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ the binary. `make web-check` runs the SPA's lint, typecheck, tests and build.
| `/runs` | **Runs** | Run history, and a per-run **trace** with per-phase wall time and token/cost attribution |
| `/pipeline` | **Pipeline** | Edit the phase graph, bind agents to phases, insert slots, configure the execute loop |
| `/agents` | **Agents** | Create/edit/delete custom specialists with a full prompt editor |
| `/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 |
| `/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 |
| `/blocks` | **Blocks** | Browse and apply pipeline / agent / quality / pack / team blocks |
| `/files` | **Files** | Workspace tree browser, read-only, with diff against the last checkpoint |
| `/skills` | **Skills** | Manage `SKILL.md` packs |
Expand Down Expand Up @@ -306,8 +306,8 @@ Roughly 60 endpoints under `/api/`, grouped: `health` · `readiness` · `config`
interrupted) · `clarify` · `plan` · `continue` · `escalate` · `shell` (the five HITL gates, each
`GET …/pending` + `POST …/answer|approve`) · `rewind` · `compact` · `events` · `status` · `models`
· `auth` · `mcp` · `stacks` · `agents` · `pipeline` · `composition` · `blocks` · `packs` ·
`squads` (the current run's org chart) · `teams` (the library — CRUD, plus `teams/preselect` and
`teams/activate`) · `archives` · `queries` (+ `/events`, `/trace`) · `review` ·
`squads` (the current run's org chart) · `teams` (the library — CRUD, plus `teams/preselect`,
`teams/activate`, `teams/activity` and `teams/{id}/manager`) · `archives` · `queries` (+ `/events`, `/trace`) · `review` ·
`workspace/file` · `workspace/tree`.

`slmcode config schema` and `GET /api/config/schema` both emit the machine-readable config schema
Expand Down
17 changes: 17 additions & 0 deletions pkg/agents/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,23 @@ func AgentsEmitting(schemaRole string, custom []CustomSpec) []string {
return ids
}

// ResolveManager decides which agent actually triages a team's rejected work.
//
// The rule lives here, once, because it is applied in three places that must
// agree — the Teams page, the composition, and the loop's triage call: a team
// may name any agent, but only one that answers the triage contract can be its
// manager (its decoding grammar comes from its own prompt, so anything else
// replies in a shape the reassignment step cannot read). Empty, or a nominee
// that cannot triage, falls back to the run default, which always can. The
// second result is true when the default stood in.
func ResolveManager(named string, canTriage func(id string) bool) (string, bool) {
named = strings.ToLower(strings.TrimSpace(named))
if named == "" || canTriage == nil || !canTriage(named) {
return RoleTriage, true
}
return named, false
}

// EmitsSchema reports whether id is an agent that answers with the given
// pkg/schema contract.
//
Expand Down
110 changes: 110 additions & 0 deletions pkg/composer/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,50 @@ type TeamMember struct {
Skills []string `json:"skills,omitempty"`
}

// TeamChoice is one library team the composition puts on the run, with the
// staffing the run will actually dispatch.
//
// This is NOT the composer model's to invent. Which teams a request involves is
// answered deterministically from the library (pkg/teams) and stamped onto the
// composition by the harness, so the composer prompt, the run setup panel and
// the org chart the run builds all describe the same teams — a composition that
// said "go-worker" while the charter phase then staffed the backend with
// someone else was the two halves of one decision disagreeing.
type TeamChoice struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Charter string `json:"charter,omitempty"`
Owns []string `json:"owns,omitempty"`
Acceptance string `json:"acceptance,omitempty"`
Worker string `json:"worker,omitempty"`
Reviewer string `json:"reviewer,omitempty"`
Tester string `json:"tester,omitempty"`
// Manager is the agent that triages this team's rejected work: the team's
// own when it names one the harness can dispatch, else the run default.
Manager string `json:"manager,omitempty"`
// ManagerDefault is true when Manager is the run's default rather than a
// manager the team chose for itself.
ManagerDefault bool `json:"manager_default,omitempty"`
Agents []string `json:"agents,omitempty"`
Skills []string `json:"skills,omitempty"`
// Reason says why this team is on the run — the evidence that scored it, or
// "pinned" for a team the user chose by hand.
Reason string `json:"reason,omitempty"`
Pinned bool `json:"pinned,omitempty"`
Score int `json:"score,omitempty"`
}

// Team modes — how the chosen teams shape the run.
const (
// TeamModeParallel is two or more teams building at once behind a frozen
// contract (pkg/squads).
TeamModeParallel = "parallel"
// TeamModeSingle is one team staffing the whole single-stream run: its
// worker, reviewer and tester take the execute loop, its skills are pinned
// and its charter rides in the handoff.
TeamModeSingle = "single"
)

// Composition is the composer's structured output: a full dynamic pipeline plan.
type Composition struct {
// Summary is a one-line description of the assembled plan.
Expand All @@ -110,6 +154,15 @@ type Composition struct {
Team []TeamMember `json:"team,omitempty"`
// Slots are extra insertable specialists around phase anchors.
Slots []pipeline.Slot `json:"slots,omitempty"`
// Teams are the library teams on this run and how they are staffed. Filled
// by the harness, never by the composer model — see TeamChoice.
Teams []TeamChoice `json:"teams,omitempty"`
// TeamMode says what the teams do to the run: TeamModeParallel,
// TeamModeSingle, or empty when no team is on it.
TeamMode string `json:"team_mode,omitempty"`
// TeamNote is the one-line human explanation of the team decision — why
// these teams, or why none — in the words the run setup panel shows.
TeamNote string `json:"team_note,omitempty"`
}

// Normalize lowercases and trims identifiers, drops empty entries, and applies
Expand Down Expand Up @@ -165,6 +218,54 @@ func (c *Composition) Normalize() {
c.Slots[i].ID = strings.ToLower(strings.TrimSpace(c.Slots[i].ID))
c.Slots[i].Agent = strings.ToLower(strings.TrimSpace(c.Slots[i].Agent))
}

var teams []TeamChoice
seenTeam := map[string]bool{}
for _, t := range c.Teams {
t.ID = strings.ToLower(strings.TrimSpace(t.ID))
if t.ID == "" || seenTeam[t.ID] {
continue
}
seenTeam[t.ID] = true
t.Name = strings.TrimSpace(t.Name)
t.Charter = strings.TrimSpace(t.Charter)
t.Acceptance = strings.TrimSpace(t.Acceptance)
t.Worker = strings.ToLower(strings.TrimSpace(t.Worker))
t.Reviewer = strings.ToLower(strings.TrimSpace(t.Reviewer))
t.Tester = strings.ToLower(strings.TrimSpace(t.Tester))
t.Manager = strings.ToLower(strings.TrimSpace(t.Manager))
t.Owns = cleanListPreserveCase(t.Owns)
t.Agents = cleanList(t.Agents)
t.Skills = cleanList(t.Skills)
t.Reason = strings.TrimSpace(t.Reason)
teams = append(teams, t)
}
c.Teams = teams
c.TeamMode = strings.ToLower(strings.TrimSpace(c.TeamMode))
if c.TeamMode != TeamModeParallel && c.TeamMode != TeamModeSingle {
c.TeamMode = ""
}
c.TeamNote = strings.TrimSpace(c.TeamNote)
}

// TeamIDs lists the teams on the run, in rank order.
func (c Composition) TeamIDs() []string {
out := make([]string, 0, len(c.Teams))
for _, t := range c.Teams {
out = append(out, t.ID)
}
return out
}

// TeamChoiceFor returns the choice for one team id.
func (c Composition) TeamChoiceFor(id string) (TeamChoice, bool) {
id = strings.ToLower(strings.TrimSpace(id))
for _, t := range c.Teams {
if t.ID == id {
return t, true
}
}
return TeamChoice{}, false
}

func cleanList(in []string) []string {
Expand Down Expand Up @@ -385,5 +486,14 @@ func (c Composition) AgentSet() map[string]bool {
for _, s := range c.Slots {
add(s.Agent)
}
for _, t := range c.Teams {
add(t.Worker)
add(t.Reviewer)
add(t.Tester)
add(t.Manager)
for _, a := range t.Agents {
add(a)
}
}
return out
}
Loading
Loading