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
24 changes: 23 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,32 @@
## Unreleased

Teams are a thing you build and send work to, not a panel that fills in when a
run happens to assemble some.
run happens to assemble some — and the Live view shows them working.

### Added

- **The Live view is a floor, not a log.** Its centre is a 3D scene (three.js,
loaded only where WebGL exists; a flat map otherwise or on request): each team
at a table on its rug, the manager at the head with the team's board on the
wall behind them, members with monitors that light up when they work, tickets
on the table threaded to whoever holds them; contract conduits with packets
flowing between tables, amber when a consumer waits; green or red rims for
gates; several people work at once, each unmistakably (lit monitor, rising
code, a pool of light, the pop-out saying what they just said); the
pipeline's own phase agents stand on a stage under a screen naming the
current phase. The floor moves with the run — a dispatch arc when a manager sends
someone onto a ticket, a spark when a ticket changes hands, a drop when one
appears, a burst when one finishes or fails, idle people glancing at whoever
is working — and every change slides into a **feed** at the edge. Click a
person or a ticket for its **dossier** (status, manager or reports, tickets,
recent lines; every name a link), and the camera glides to it. Orbit, zoom,
follow the worker, spin. The phase track is a journey the run walks, and the
five side tabs are one activity column with the log by default.
- **Seats the pipeline fills.** When a pipeline needs a seat a team did not name
(a test phase, no tester), the composition, the preselect preview, the run
setup panel, the charter log and the floor all say who takes it and where
they came from (`seats` with `source`, `gaps` in words).

- **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
Expand Down
14 changes: 14 additions & 0 deletions docs/squads.md
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,20 @@ 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.

### When the pipeline needs a seat the team does not have

A team is written once and a pipeline is chosen per run, so the two need not
agree: the pipeline has a test phase and the team names no tester. The seat is
**filled from the pipeline** — the test phase's agent, or the execute loop's
worker or reviewer for those seats — and the fact is said everywhere the team
is shown: the composition (`seats`, with a `source` of `team`, `pipeline` or
`default`, and `gaps` in words), the run setup panel, the Teams page preview,
the charter log line *team backend-go names no tester — the pipeline's
go-tester takes its tester seat*, and the Live floor, where the borrowed tester
sits at the team's table drawn as borrowed. Routing still prefers a specialist
for the language of a task's own files over any seat; the seat is the answer
when the files say nothing. Name the seat on the team to make it the team's own.

### The composer knows the teams

The dynamic pipeline composer used to decide phases and loop roles with no idea
Expand Down
55 changes: 54 additions & 1 deletion docs/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ the binary. `make web-check` runs the SPA's lint, typecheck, tests and build.

| Route | Page | What it does |
|---|---|---|
| `/` | **Live** | SSE-streamed run: phases, `@agent` activity, token stream, event log, HITL modal |
| `/` | **Live** | The run as a place: the **team floor** (each team at its table with its manager, members, monitors and tickets; contract conduits between tables; a spark when a manager moves a ticket), the **phase journey** above it, a ticker of what is happening right now, and one **activity** column beside it — the log by default, with tasks, fixes, files and the result as filters |
| `/board` | **Board** | Kanban — add, edit, delete, move, delegate, drag mid-run |
| `/review` | **Review** | Pending changes from `permission: review`, as diffs, with per-file apply/reject |
| `/runs` | **Runs** | Run history, and a per-run **trace** with per-phase wall time and token/cost attribution |
Expand All @@ -84,6 +84,59 @@ stream health, and an error boundary keeps one broken panel from blanking the ap

---

## The Live floor

The centre of the Live view is a 3D floor, drawn with three.js: one round table
per team on its own rug, the manager at the head with the team's board on the
wall behind them (a column per state, a tile per ticket, the counts above), the
members around the table, each with a monitor that lights up when the log says
that agent is working and a pop-out over their head naming the ticket. Tickets
lie on the table, colored by state, and a thread runs from each one to the
monitor of whoever holds it, with beads travelling along it while they type.
The frozen contract runs between tables as conduits with packets flowing from
the provider to the consumer; a consumer waiting on a clause it has not been
given turns its conduit amber. A gate paints the table's rim green or red.

Whoever is working is unmistakable: their monitor lights up, lines of code
rise off the screen, a pool of light and a breathing ring mark the seat, and
the pop-out over their head says which ticket and what they just said. Several
people can be at it at once — one per ticket in flight — and idle people glance
at whoever is. The pipeline's own people — the planner, splitter, architect,
explorer and the rest, who sit at no table — stand on a **stage** at the left
under a screen naming the phase the run is in and what is being said; the one
speaking is lit, the rest wait in the wings until their phase.

The floor moves when the run does. A manager sending someone onto a ticket is
an arc from the head seat with the ticket's name; a ticket moving from one
person to another is a spark across the table with the reason; a ticket that
appears drops onto the table, one that finishes or fails bursts; idle people
glance at whoever is working. Every change also slides in as a card in the
**feed** at the right edge — *T4 appeared on Backend*, *go-worker started on
T2*, *T2 done ✓*, *Backend: gate green* — for twenty seconds, and each card is a
link.

Everything is clickable. A person opens their **dossier**: seat and team, who
manages them (or, for a manager, who they manage and what they do), whether they
are working right now and on what, the last thing they said, the tickets they
hold and have touched, and their recent lines from the log. A ticket opens its
own: state, holder, everyone who has touched it, any handoff, and *open in
Tasks*. Every name in a dossier is a link to the next one, so the floor can be
explored by clicking around; the camera glides to whatever is selected. Drag to
orbit, wheel to zoom, right-drag to pan, click a table to frame it, **follow**
to keep whoever is working in the middle, **spin** for a slow tour, Esc or a
click on empty floor to clear.

A run with no org chart shows the **pipeline crew** — the planner, splitter,
worker, reviewer and tester the composition chose — at one table, and a single
library team staffing a run shows as that team, with its manager, board and
gate. A seat the team left empty and the pipeline fills (a tester on a team that
names none) sits at the table too, drawn as borrowed and labelled *from the
pipeline*.

Where WebGL is unavailable, or on request (the **3D / map** toggle), the same
floor is drawn as a flat map with the same dossier and feed. Both honor
`prefers-reduced-motion`.

## The review workflow

Set `permission: review` and agent writes stop being writes — they become proposals.
Expand Down
11 changes: 11 additions & 0 deletions pkg/composer/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ type TeamChoice struct {
Reason string `json:"reason,omitempty"`
Pinned bool `json:"pinned,omitempty"`
Score int `json:"score,omitempty"`
// Seats is how the four seats are actually staffed on this run once the
// pipeline fills what the team left empty — see FillSeats. Gaps are the
// borrowed working seats, in words.
Seats []SeatFill `json:"seats,omitempty"`
Gaps []string `json:"gaps,omitempty"`
}

// Team modes — how the chosen teams shape the run.
Expand Down Expand Up @@ -238,6 +243,12 @@ func (c *Composition) Normalize() {
t.Agents = cleanList(t.Agents)
t.Skills = cleanList(t.Skills)
t.Reason = strings.TrimSpace(t.Reason)
for i := range t.Seats {
t.Seats[i].Role = strings.ToLower(strings.TrimSpace(t.Seats[i].Role))
t.Seats[i].Agent = strings.ToLower(strings.TrimSpace(t.Seats[i].Agent))
t.Seats[i].Source = strings.ToLower(strings.TrimSpace(t.Seats[i].Source))
}
t.Gaps = cleanListPreserveCase(t.Gaps)
teams = append(teams, t)
}
c.Teams = teams
Expand Down
112 changes: 112 additions & 0 deletions pkg/composer/seats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package composer

import "strings"

// ── Filling a team's seats from the pipeline ─────────────────────────────
//
// A team is written once and a pipeline is chosen per run, so the two do not
// have to agree: the pipeline has a test phase and the team names no tester;
// the team is a worker and a manager and nothing else. Routing already copes
// — a seat the team leaves empty falls to the run's default for that role —
// but silently, and a user who built a team without a tester and then watched
// "the tester" reject its work had no way to know who that was or why.
//
// FillSeats is the same fallback, written down: for each of the four seats,
// who will actually sit in it on this run and where they came from. It is
// what the composition, the run setup panel, the Teams page and the floor all
// show, so the answer is the same everywhere.

// Seat sources.
const (
// SeatFromTeam is the team's own choice.
SeatFromTeam = "team"
// SeatFromPipeline is the pipeline's agent for that role — the execute
// loop's worker or reviewer, or the test phase's agent.
SeatFromPipeline = "pipeline"
// SeatFromDefault is the harness default when neither named one.
SeatFromDefault = "default"
)

// SeatFill is one seat, staffed.
type SeatFill struct {
Role string `json:"role"`
Agent string `json:"agent"`
Source string `json:"source"`
}

// Borrowed reports whether this seat is filled from outside the team.
func (s SeatFill) Borrowed() bool { return s.Source != SeatFromTeam }

// SeatDefaults are the pipeline's agents for the three working seats.
type SeatDefaults struct {
Worker string
Reviewer string
Tester string
}

// FillSeats staffs the four seats of a team.
//
// managerDefault says the manager given is the run default rather than the
// team's own (see agents.ResolveManager); it is reported as such rather than
// as a pipeline pick, because a manager is not a pipeline role.
func FillSeats(worker, reviewer, tester, manager string, managerDefault bool, d SeatDefaults) []SeatFill {
pick := func(role, own, pipe, fallback string) SeatFill {
if own = strings.TrimSpace(own); own != "" {
return SeatFill{Role: role, Agent: own, Source: SeatFromTeam}
}
if pipe = strings.TrimSpace(pipe); pipe != "" {
return SeatFill{Role: role, Agent: pipe, Source: SeatFromPipeline}
}
return SeatFill{Role: role, Agent: fallback, Source: SeatFromDefault}
}
out := []SeatFill{
pick("worker", worker, d.Worker, "worker"),
pick("reviewer", reviewer, d.Reviewer, "reviewer"),
pick("tester", tester, d.Tester, "tester"),
}
m := SeatFill{Role: "manager", Agent: strings.TrimSpace(manager), Source: SeatFromTeam}
if m.Agent == "" {
m.Agent = "triage"
}
if managerDefault {
m.Source = SeatFromDefault
}
return append(out, m)
}

// Gaps says, in words, which seats a team did not fill itself — one line per
// borrowed working seat, in the form the charter phase emits and the panels
// show. The manager is left out: every team has one by construction.
func Gaps(teamID string, seats []SeatFill) []string {
var out []string
for _, s := range seats {
if s.Role == "manager" || !s.Borrowed() {
continue
}
from := "the pipeline's"
if s.Source == SeatFromDefault {
from = "the default"
}
out = append(out, "team "+teamID+" names no "+s.Role+" — "+from+" "+s.Agent+" takes its "+s.Role+" seat")
}
return out
}

// TesterAgent finds the agent bound to the test phase of a composition, or "".
func (c Composition) TesterAgent() string {
for _, p := range c.Phases {
if p.ID == "test" && p.Enabled && p.When != "never" {
return p.Agent
}
}
return ""
}

// SeatDefaultsOf reads the pipeline seats a composition settled on.
func (c Composition) SeatDefaultsOf() SeatDefaults {
return SeatDefaults{
Worker: c.Execute.DefaultRole,
Reviewer: c.Execute.Reviewer,
Tester: c.TesterAgent(),
}
}
58 changes: 58 additions & 0 deletions pkg/composer/seats_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package composer

import (
"strings"
"testing"
)

// A team is written once and a pipeline is chosen per run: the pipeline has a
// test phase and the team names no tester. The seat is filled from the
// pipeline, and the gap is said in words.
func TestFillSeatsBorrowsWhatTheTeamLeftEmpty(t *testing.T) {
d := SeatDefaults{Worker: "worker", Reviewer: "reviewer", Tester: "go-tester"}
seats := FillSeats("go-worker", "", "", "backend-triage", false, d)
want := []SeatFill{
{Role: "worker", Agent: "go-worker", Source: SeatFromTeam},
{Role: "reviewer", Agent: "reviewer", Source: SeatFromPipeline},
{Role: "tester", Agent: "go-tester", Source: SeatFromPipeline},
{Role: "manager", Agent: "backend-triage", Source: SeatFromTeam},
}
if len(seats) != len(want) {
t.Fatalf("seats=%+v", seats)
}
for i := range want {
if seats[i] != want[i] {
t.Fatalf("seat %d = %+v, want %+v", i, seats[i], want[i])
}
}
gaps := Gaps("backend-go", seats)
if len(gaps) != 2 || !strings.Contains(gaps[1], "names no tester") || !strings.Contains(gaps[1], "go-tester takes its tester seat") {
t.Fatalf("gaps=%v", gaps)
}

// No pipeline pick either: the harness default, reported as such.
seats = FillSeats("", "", "", "", true, SeatDefaults{})
if seats[2] != (SeatFill{Role: "tester", Agent: "tester", Source: SeatFromDefault}) {
t.Fatalf("default tester=%+v", seats[2])
}
if seats[3] != (SeatFill{Role: "manager", Agent: "triage", Source: SeatFromDefault}) {
t.Fatalf("default manager=%+v", seats[3])
}
if got := Gaps("x", seats); len(got) != 3 || !strings.Contains(got[0], "the default worker") {
t.Fatalf("gaps=%v", got)
}
}

func TestSeatDefaultsComeFromTheCompositionsBoundRoles(t *testing.T) {
c := Composition{
Execute: ExecuteChoice{DefaultRole: "react-worker", Reviewer: "react-reviewer"},
Phases: []PhaseChoice{{ID: "execute", Agent: "react-worker", Enabled: true}, {ID: "test", Agent: "react-tester", Enabled: true}},
}
if d := c.SeatDefaultsOf(); d != (SeatDefaults{Worker: "react-worker", Reviewer: "react-reviewer", Tester: "react-tester"}) {
t.Fatalf("defaults=%+v", d)
}
c.Phases[1].Enabled = false
if c.TesterAgent() != "" {
t.Fatalf("a disabled test phase lends no tester")
}
}
43 changes: 36 additions & 7 deletions pkg/orchestrator/compose_teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,10 +240,26 @@ func composeTeams(comp *composer.Composition, td teamDecision) {
return
}
comp.Teams, comp.TeamMode, comp.TeamNote = td.Choices, td.Mode, td.Note
if td.Mode != composer.TeamModeSingle || len(td.Choices) == 0 {
if td.Mode == composer.TeamModeSingle && len(td.Choices) > 0 {
adoptTeamStaffing(comp, td.Choices[0])
}
fillTeamSeats(comp)
}

// fillTeamSeats writes, on every team, who actually sits in each seat once
// the pipeline fills what the team left empty — and the gaps in words. Done
// after adoption and the critical-phase repair so the pipeline roles it reads
// are the ones the run will bind.
func fillTeamSeats(comp *composer.Composition) {
if comp == nil {
return
}
adoptTeamStaffing(comp, td.Choices[0])
d := comp.SeatDefaultsOf()
for i := range comp.Teams {
t := &comp.Teams[i]
t.Seats = composer.FillSeats(t.Worker, t.Reviewer, t.Tester, t.Manager, t.ManagerDefault, d)
t.Gaps = composer.Gaps(t.ID, t.Seats)
}
}

func adoptTeamStaffing(comp *composer.Composition, t composer.TeamChoice) {
Expand Down Expand Up @@ -348,12 +364,15 @@ func teamHandoff(comp *composer.Composition) {
case composer.TeamModeSingle:
t := comp.Teams[0]
var staff []string
for _, s := range []struct{ role, id string }{
{"worker", t.Worker}, {"reviewer", t.Reviewer}, {"tester", t.Tester}, {"manager", t.Manager},
} {
if s.id != "" {
staff = append(staff, s.role+"="+s.id)
for _, s := range t.Seats {
if s.Agent == "" {
continue
}
entry := s.Role + "=" + s.Agent
if s.Borrowed() && s.Role != "manager" {
entry += " (" + s.Source + ")"
}
staff = append(staff, entry)
}
line := "Team " + t.ID + " staffs this run"
if len(staff) > 0 {
Expand Down Expand Up @@ -396,6 +415,13 @@ func teamSeats(comp *composer.Composition, skills map[string]bool) {
want := filterKnownSkills(t.Skills, skills)
ids := []string{t.Worker, t.Reviewer, t.Tester, t.Manager}
ids = append(ids, t.Agents...)
// A borrowed seat is on the team for this run: whoever the pipeline
// lends as tester loads the team's skills like the team's own would.
for _, seat := range t.Seats {
if seat.Borrowed() && seat.Role != "manager" {
ids = append(ids, seat.Agent)
}
}
for _, id := range ids {
id = strings.ToLower(strings.TrimSpace(id))
if id == "" {
Expand Down Expand Up @@ -495,6 +521,9 @@ func teamsMarkdown(c composer.Composition) string {
b.WriteString(" — " + t.Reason)
}
b.WriteString("\n")
for _, g := range t.Gaps {
b.WriteString(" - " + g + "\n")
}
}
b.WriteString("\n")
return b.String()
Expand Down
Loading
Loading