From 0c98a5b66ae135f6c79318e719484c105b1bb993 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 20:52:17 +0000 Subject: [PATCH 1/2] feat(teams): teams you send work to, with managers, on the composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Teams page was a library plus an org-chart editor: it could author a team but not put one to work, and a run's team decision was made in a different place from its pipeline decision. This makes teams the thing a request is sent to, and makes the harness show what the managers and teams then did. Composer / orchestrator - composer.Composition carries Teams, TeamMode and TeamNote: the library teams the run will use, each with its resolved manager (the team's own when it answers the triage contract, else the run default, marked) and the evidence that chose it. Stamped deterministically from the library before the language hint; the composer model is told the teams and cannot change them. - One pinned team STAFFS the run: its worker, reviewer and tester take the execute loop, its skills are pinned, its charter and territory ride in the handoff. Two or more run in parallel as before. Neither, and why, is said. - Resume restores the saved squad plan when the board's tasks are stamped with its teams; a run started after squads was switched off no longer inherits the previous run's plan. Server - GET /api/teams/activity: the run's team-relevant events classified — manager decisions, reassignments, cross-team waits, contract clauses, gates, integration — with one summary row per manager; ?query= reads a past run. - POST /api/teams/{id}/manager creates -triage (tool-less, seeded with the team's charter, people and territory) and points the team at it. - PATCH /api/tasks/{id} accepts `squad`, refused with the owner named when the task's files belong to another team. - Team views report effective_manager / manager_default; preselect reports mode, note and per-team staffing; the composition preview honours per-run team pins. Studio - Teams page: Send a request (preselect with staffing and managers, the composer's pipeline, Run with these teams, Activate), manager rows and "Give it a manager" on cards and in the editor, and "How the teams worked". - Live view: a Teams control on the command bar pins teams for one run; the run setup panel shows the teams and their managers. - Board: a task can be assigned to a team. Docs: squads.md, studio.md, changelog. CLI: compose --explain prints teams. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011RfCkcsbJZgHoQLzwkqFUL --- cmd/slmcode/cmd_compose.go | 38 ++ docs/changelog.md | 52 +++ docs/squads.md | 67 ++- docs/studio.md | 6 +- pkg/composer/composer.go | 110 +++++ pkg/composer/fit.go | 17 + pkg/orchestrator/compose_teams.go | 373 ++++++++++++++++ pkg/orchestrator/compose_teams_test.go | 285 ++++++++++++ pkg/orchestrator/composer.go | 29 +- pkg/orchestrator/orchestrator.go | 4 + pkg/orchestrator/resume.go | 44 ++ pkg/server/server.go | 66 ++- pkg/server/team_activity.go | 457 ++++++++++++++++++++ pkg/server/team_activity_test.go | 349 +++++++++++++++ pkg/server/teams.go | 287 +++++++++++- web/src/api/client.ts | 38 +- web/src/components/Board/KanbanBoard.tsx | 6 +- web/src/components/Board/TaskCard.tsx | 31 +- web/src/components/Live/LiveView.tsx | 34 +- web/src/components/Live/RunSetup.test.tsx | 49 ++- web/src/components/Live/RunSetup.tsx | 48 +- web/src/components/Live/TeamPicker.tsx | 142 ++++++ web/src/components/Teams/TeamActivity.tsx | 298 +++++++++++++ web/src/components/Teams/TeamComposer.tsx | 417 ++++++++++++++++++ web/src/components/Teams/TeamEditor.tsx | 54 ++- web/src/components/Teams/TeamLibrary.tsx | 248 ++++------- web/src/components/Teams/TeamsView.test.tsx | 222 +++++++++- web/src/components/Teams/TeamsView.tsx | 295 ++++++++++--- web/src/types/index.ts | 139 ++++++ 29 files changed, 3930 insertions(+), 275 deletions(-) create mode 100644 pkg/orchestrator/compose_teams.go create mode 100644 pkg/orchestrator/compose_teams_test.go create mode 100644 pkg/server/team_activity.go create mode 100644 pkg/server/team_activity_test.go create mode 100644 web/src/components/Live/TeamPicker.tsx create mode 100644 web/src/components/Teams/TeamActivity.tsx create mode 100644 web/src/components/Teams/TeamComposer.tsx diff --git a/cmd/slmcode/cmd_compose.go b/cmd/slmcode/cmd_compose.go index 2682bcc..cd91bd6 100644 --- a/cmd/slmcode/cmd_compose.go +++ b/cmd/slmcode/cmd_compose.go @@ -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")) @@ -242,3 +273,10 @@ func minInt(a, b int) int { } return b } + +func valueOrDash(v string) string { + if strings.TrimSpace(v) == "" { + return "-" + } + return v +} diff --git a/docs/changelog.md b/docs/changelog.md index 0166742..e16289b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,57 @@ # 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 `-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=` 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 honours + 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. +- `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 diff --git a/docs/squads.md b/docs/squads.md index 387ba71..e89e166 100644 --- a/docs/squads.md +++ b/docs/squads.md @@ -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 @@ -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 `-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 @@ -739,8 +767,35 @@ 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, and its charter +and territory ride in the handoff. "Send this to the backend team" means the +backend's people, even when the request has no second half. + +### 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 diff --git a/docs/studio.md b/docs/studio.md index 1faddec..f672be6 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -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 | @@ -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 diff --git a/pkg/composer/composer.go b/pkg/composer/composer.go index dd26037..652885e 100644 --- a/pkg/composer/composer.go +++ b/pkg/composer/composer.go @@ -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. @@ -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 @@ -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 { @@ -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 } diff --git a/pkg/composer/fit.go b/pkg/composer/fit.go index 69bcd46..3356c30 100644 --- a/pkg/composer/fit.go +++ b/pkg/composer/fit.go @@ -50,6 +50,16 @@ func FitHints(c Composition, dynamicEnabled bool, contextLimit int) []string { if len(c.Team) == 0 { hints = append(hints, "team is empty; runtime will fall back to default agents") } + switch c.TeamMode { + case TeamModeParallel: + hints = append(hints, fmt.Sprintf("%d teams build in parallel behind a frozen contract: %s", + len(c.Teams), strings.Join(c.TeamIDs(), ", "))) + case TeamModeSingle: + if len(c.Teams) > 0 { + hints = append(hints, fmt.Sprintf("team %s staffs this run (manager: %s)", + c.Teams[0].ID, valueOr(c.Teams[0].Manager, "run default"))) + } + } if genericRole(c.Execute.DefaultRole) { hints = append(hints, "worker role is generic; apply a language pack or mention the stack/language for stronger specialists") } @@ -72,3 +82,10 @@ func genericRole(role string) bool { role = strings.ToLower(strings.TrimSpace(role)) return role == "" || role == "worker" || role == "tester" || role == "deep" } + +func valueOr(v, fallback string) string { + if strings.TrimSpace(v) == "" { + return fallback + } + return v +} diff --git a/pkg/orchestrator/compose_teams.go b/pkg/orchestrator/compose_teams.go new file mode 100644 index 0000000..950b3e5 --- /dev/null +++ b/pkg/orchestrator/compose_teams.go @@ -0,0 +1,373 @@ +package orchestrator + +import ( + "fmt" + "strings" + + "github.com/UnicoLab/slmcode/pkg/agents" + "github.com/UnicoLab/slmcode/pkg/composer" + "github.com/UnicoLab/slmcode/pkg/schema" + "github.com/UnicoLab/slmcode/pkg/teams" +) + +// ── Teams in the dynamic composition ───────────────────────────────────── +// +// The composer decided phases, loop roles and skills, and the charter phase +// decided teams, several phases later and from a different input. The two +// never met: the run setup panel showed a composition staffed with go-worker +// while the org chart the charter built put the same half under someone else, +// the composer prompt never mentioned that a React team existed, and a user +// who pinned "the backend team" for a request that only involved that team got +// a single stream staffed by whoever language routing picked — the team's +// worker, reviewer, tester, manager and skills all ignored. +// +// So the team decision is made ONCE, deterministically, from the library, and +// stamped onto the composition: +// +// - two or more teams → they run in parallel behind a frozen contract, and +// the composition says so, with each team's staffing and manager, in the +// same words the charter phase will act on; +// - exactly one team → it STAFFS the run: its worker, reviewer and tester +// take the execute loop, its skills are pinned, and its charter and +// territory ride in the handoff. That is what "send this request to the +// backend team" means when the request has no second half; +// - none → said plainly, with the evidence, so "why did my teams not run" has +// an answer on the panel rather than in a log. +// +// The composer MODEL never picks teams. It is told which teams are on the run +// so the roles it picks agree with them, and anything it says about teams is +// discarded — the same rule fillContract applies to the squad list. + +// teamChoices computes the library teams for this request as the composition +// will carry them: chosen teams in rank order, the mode they put the run in, +// and a one-line note explaining the decision. +func (o *Orchestrator) teamChoices(query string, inventory []string) ([]composer.TeamChoice, string, string) { + if o == nil || o.cfg == nil { + return nil, "", "" + } + if !o.cfg.TeamLibrary { + if o.cfg.Squads { + return nil, "", "the team library is not used for this project (team_library: false) — the manager agent assembles teams at charter" + } + return nil, "", "" + } + sel, roster := o.preselectTeams(query, inventory) + if len(roster) == 0 { + return nil, "", "no teams in the library — create one on the Teams page to staff runs by team" + } + + evidence := map[string]teams.Evidence{} + for _, ev := range sel.Evidence { + evidence[ev.TeamID] = ev + } + choices := make([]composer.TeamChoice, 0, len(sel.Teams)) + for _, t := range sel.Teams { + c := o.teamChoice(t) + if ev, ok := evidence[t.ID]; ok { + c.Pinned, c.Score = ev.Pinned, ev.Score + switch { + case ev.Pinned: + c.Reason = "pinned by hand" + default: + c.Reason = strings.Join(ev.Reasons, "; ") + } + } + choices = append(choices, c) + } + + switch { + case len(choices) == 0: + if len(sel.Evidence) == 0 { + return nil, "", "no team matched this request — it runs as one stream" + } + return nil, "", "no team matched this request (" + evidenceLine(sel) + ") — it runs as one stream" + case len(choices) == 1: + c := choices[0] + return choices, composer.TeamModeSingle, fmt.Sprintf( + "team %s staffs this run as one stream (%s) — manager %s", c.ID, c.Reason, c.Manager) + case !o.cfg.Squads: + return choices, "", fmt.Sprintf( + "%d teams matched (%s) but teams are turned off for this project (squads: false) — it runs as one stream", + len(choices), strings.Join(idsOf(choices), ", ")) + default: + return choices, composer.TeamModeParallel, fmt.Sprintf( + "%d teams build in parallel behind a frozen contract: %s", + len(choices), strings.Join(idsOf(choices), ", ")) + } +} + +// teamChoice renders one library team with the staffing the run will dispatch. +// +// A seat naming an agent this harness cannot dispatch is cleared here for the +// same reason teams.StaffCheck clears it at charter: the composition must +// describe what WILL run, and "go-worker" on a machine without the Go pack is +// a promise the loop then breaks silently. The manager gets the stricter +// check — it must answer the triage contract, not merely exist — and falls +// back to the run default, which always can. +func (o *Orchestrator) teamChoice(t teams.Team) composer.TeamChoice { + registered := func(id string) bool { + if o == nil || o.factory == nil { + return true + } + return o.factory.HasRole(id) + } + seat := func(id string) string { + id = strings.ToLower(strings.TrimSpace(id)) + if id == "" || !registered(id) { + return "" + } + return id + } + c := composer.TeamChoice{ + ID: t.ID, + Name: t.Name, + Charter: t.Charter, + Owns: append([]string(nil), t.Owns...), + Acceptance: t.Acceptance, + Worker: seat(t.Worker), + Reviewer: seat(t.Reviewer), + Tester: seat(t.Tester), + Skills: append([]string(nil), t.Skills...), + } + for _, a := range t.Agents { + if id := seat(a); id != "" { + c.Agents = append(c.Agents, id) + } + } + c.Manager, c.ManagerDefault = o.effectiveManager(t.Manager) + return c +} + +// effectiveManager resolves a team's manager to the agent that will actually +// triage its rejected work. +func (o *Orchestrator) effectiveManager(named string) (string, bool) { + named = strings.ToLower(strings.TrimSpace(named)) + if named == "" { + return agents.RoleTriage, true + } + if o != nil && o.factory != nil && !o.factory.EmitsSchema(named, schema.RoleTriage) { + return agents.RoleTriage, true + } + return named, false +} + +func idsOf(choices []composer.TeamChoice) []string { + out := make([]string, 0, len(choices)) + for _, c := range choices { + out = append(out, c.ID) + } + return out +} + +// composeTeams stamps the team decision onto a composition and, when one team +// staffs the run, adopts its staffing. +// +// Adoption follows the same rule the language hint does — a generic role is +// replaced, a specific one the composer chose is kept — with one exception: a +// PINNED team was chosen by hand, and "run this with the backend team" means +// the backend team's people, whatever the composer preferred. +func (o *Orchestrator) composeTeams(comp *composer.Composition, query string, inventory []string) { + if comp == nil { + return + } + choices, mode, note := o.teamChoices(query, inventory) + comp.Teams, comp.TeamMode, comp.TeamNote = choices, mode, note + if mode != composer.TeamModeSingle || len(choices) == 0 { + return + } + adoptTeamStaffing(comp, choices[0]) +} + +func adoptTeamStaffing(comp *composer.Composition, t composer.TeamChoice) { + if comp == nil { + return + } + take := func(current *string, want string) { + if want == "" || current == nil { + return + } + if t.Pinned || genericAgent(*current) { + *current = want + } + } + take(&comp.Execute.DefaultRole, t.Worker) + if t.Reviewer != "" && (t.Pinned || comp.Execute.Reviewer == "" || comp.Execute.Reviewer == "reviewer") { + comp.Execute.Reviewer = t.Reviewer + } + for i := range comp.Phases { + switch comp.Phases[i].ID { + case "execute": + take(&comp.Phases[i].Agent, t.Worker) + case "test": + take(&comp.Phases[i].Agent, t.Tester) + } + } +} + +// teamHandoff is what later specialists are told about the teams, appended to +// the handoff after the generic lines and kept to two bullets: the handoff is +// pasted into every task pack, and a team's whole charter is already carried +// by the squad brief when teams run in parallel. +func teamHandoff(comp *composer.Composition) { + if comp == nil || len(comp.Teams) == 0 { + return + } + add := func(line string) { + line = strings.TrimSpace(line) + if line == "" || handoffContains(comp.Handoff, line) { + return + } + comp.Handoff = append(comp.Handoff, line) + } + switch comp.TeamMode { + 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) + } + } + line := "Team " + t.ID + " staffs this run" + if len(staff) > 0 { + line += ": " + strings.Join(staff, ", ") + } + if t.Charter != "" { + line += ". " + strings.TrimSuffix(t.Charter, ".") + } + add(line) + if len(t.Owns) > 0 { + add("Team territory: " + strings.Join(limitList(t.Owns, 6), ", ") + + " — touch files outside it only when the request requires it") + } + case composer.TeamModeParallel: + parts := make([]string, 0, len(comp.Teams)) + for _, t := range comp.Teams { + part := t.ID + if len(t.Owns) > 0 { + part += " (" + strings.Join(limitList(t.Owns, 3), ", ") + ")" + } + parts = append(parts, part) + } + add("Teams build in parallel: " + strings.Join(parts, " · ") + + " — every task's files stay inside ONE team; the seam is frozen in CONTRACT.md") + } +} + +// teamSeats folds each chosen team's people into the composition's team +// roster, carrying the team's skills, so the "Team" panel and the skill packs +// describe the people who will actually work. +func teamSeats(comp *composer.Composition, skills map[string]bool) { + if comp == nil || len(comp.Teams) == 0 { + return + } + index := map[string]int{} + for i, m := range comp.Team { + index[m.Role] = i + } + for _, t := range comp.Teams { + want := filterKnownSkills(t.Skills, skills) + ids := []string{t.Worker, t.Reviewer, t.Tester, t.Manager} + ids = append(ids, t.Agents...) + for _, id := range ids { + id = strings.ToLower(strings.TrimSpace(id)) + if id == "" { + continue + } + if i, ok := index[id]; ok { + comp.Team[i].Skills = mergeSkills(comp.Team[i].Skills, want) + continue + } + member := composer.TeamMember{Role: id, Skills: defaultSkillsForRole(id, skills)} + member.Skills = mergeSkills(member.Skills, want) + comp.Team = append(comp.Team, member) + index[id] = len(comp.Team) - 1 + } + } +} + +func mergeSkills(have, more []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(have)+len(more)) + for _, s := range append(append([]string{}, have...), more...) { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" || seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} + +// teamsPromptSection tells the composer model which teams are on the run, so +// the roles it picks agree with them. It is guidance, not a menu: the model +// cannot add or remove a team, and the section says so. +func teamsPromptSection(choices []composer.TeamChoice, mode, note string) string { + var b strings.Builder + b.WriteString("## Teams on this run (decided from the library — do NOT change them)\n") + if note != "" { + b.WriteString(note + "\n") + } + for _, t := range choices { + fmt.Fprintf(&b, "- %s", t.ID) + if t.Name != "" && t.Name != t.ID { + fmt.Fprintf(&b, " (%s)", t.Name) + } + 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) + } + } + if len(staff) > 0 { + b.WriteString(" — " + strings.Join(staff, ", ")) + } + if len(t.Owns) > 0 { + b.WriteString(" — owns " + strings.Join(limitList(t.Owns, 4), ", ")) + } + b.WriteString("\n") + } + switch mode { + case composer.TeamModeParallel: + b.WriteString("Bind execute/test to specialists these teams can dispatch; each team's own seats take its tasks.\n") + case composer.TeamModeSingle: + b.WriteString("Bind execute/test to this team's worker and tester unless the request plainly needs another language.\n") + } + b.WriteString("\n") + return b.String() +} + +// teamsMarkdown renders the team decision for the composition summary. +func teamsMarkdown(c composer.Composition) string { + if len(c.Teams) == 0 && c.TeamNote == "" { + return "" + } + var b strings.Builder + b.WriteString("## Teams\n\n") + if c.TeamNote != "" { + b.WriteString(c.TeamNote + "\n\n") + } + for _, t := range c.Teams { + manager := t.Manager + if t.ManagerDefault && manager != "" { + manager += " (run default)" + } + fmt.Fprintf(&b, "- `%s` — worker=%s · reviewer=%s · tester=%s · manager=%s", + t.ID, valueOr(t.Worker, "default"), valueOr(t.Reviewer, "default"), + valueOr(t.Tester, "default"), valueOr(manager, "default")) + if len(t.Owns) > 0 { + b.WriteString(" · owns " + strings.Join(t.Owns, ", ")) + } + if t.Reason != "" { + b.WriteString(" — " + t.Reason) + } + b.WriteString("\n") + } + b.WriteString("\n") + return b.String() +} diff --git a/pkg/orchestrator/compose_teams_test.go b/pkg/orchestrator/compose_teams_test.go new file mode 100644 index 0000000..42883a9 --- /dev/null +++ b/pkg/orchestrator/compose_teams_test.go @@ -0,0 +1,285 @@ +package orchestrator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/UnicoLab/slmcode/pkg/composer" + "github.com/UnicoLab/slmcode/pkg/config" + "github.com/UnicoLab/slmcode/pkg/plan" + "github.com/UnicoLab/slmcode/pkg/squads" +) + +// fullstackRoot is a workspace the builtin library reads as two halves. +func fullstackRoot(t *testing.T) string { + t.Helper() + root := t.TempDir() + write := func(rel, body string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + write("go.mod", "module demo\n\ngo 1.23\n") + write("cmd/server/main.go", "package main\n\nfunc main() {}\n") + write("web/package.json", `{"name":"web"}`) + write("web/src/App.tsx", "export default function App() { return null }\n") + return root +} + +// The complaint: the composition and the charter phase decided teams from two +// different inputs and never agreed. A two-domain request now carries both +// teams on the composition, with the manager each will answer to. +func TestCompositionCarriesTheTeamsTheRunWillUse(t *testing.T) { + cfg := config.Default(fullstackRoot(t)) + o, err := New(cfg) + if err != nil { + t.Fatal(err) + } + comp := o.PreviewComposition("add a Go API endpoint and the React page that calls it") + + if comp.TeamMode != composer.TeamModeParallel { + t.Fatalf("mode=%q note=%q teams=%+v", comp.TeamMode, comp.TeamNote, comp.Teams) + } + ids := strings.Join(comp.TeamIDs(), ",") + if !strings.Contains(ids, "backend-go") || !strings.Contains(ids, "frontend-react") { + t.Fatalf("teams=%s", ids) + } + for _, tc := range comp.Teams { + if tc.Manager == "" { + t.Fatalf("team %s has no effective manager", tc.ID) + } + if !tc.ManagerDefault { + t.Fatalf("builtin %s names no manager, so the run default should be marked: %+v", tc.ID, tc) + } + if tc.Reason == "" { + t.Fatalf("team %s carries no reason", tc.ID) + } + } + if !strings.Contains(comp.TeamNote, "in parallel") { + t.Fatalf("note=%q", comp.TeamNote) + } + joined := strings.Join(comp.Handoff, "\n") + if !strings.Contains(joined, "Teams build in parallel") || !strings.Contains(joined, "CONTRACT.md") { + t.Fatalf("handoff does not brief the teams: %v", comp.Handoff) + } + // The people on each team are on the composition's roster too, so the + // "Team" panel shows who will actually work. + roles := map[string]bool{} + for _, m := range comp.Team { + roles[m.Role] = true + } + for _, want := range []string{"go-worker", "react-worker", "triage"} { + if !roles[want] { + t.Fatalf("roster lacks %s: %+v", want, comp.Team) + } + } +} + +// "Send this request to the backend team": one pinned team staffs the run — +// its worker and tester take the loop even when the language hint would have +// picked the same specialists for a different reason, and its charter rides +// in the handoff. +func TestOnePinnedTeamStaffsTheRun(t *testing.T) { + // A React-only workspace, so no second team scores on evidence and the + // pinned team is the whole selection. + root := t.TempDir() + for rel, body := range map[string]string{ + "web/package.json": `{"name":"web"}`, + "web/src/App.tsx": "export default function App() { return null }\n", + } { + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + cfg := config.Default(root) + cfg.Teams = []string{"frontend-react"} + o, err := New(cfg) + if err != nil { + t.Fatal(err) + } + // The query names no team at all; the pin is the user's instruction. + comp := o.PreviewComposition("tidy the module manifest") + + if comp.TeamMode != composer.TeamModeSingle || len(comp.Teams) != 1 || comp.Teams[0].ID != "frontend-react" { + t.Fatalf("mode=%q teams=%+v note=%q", comp.TeamMode, comp.Teams, comp.TeamNote) + } + if !comp.Teams[0].Pinned { + t.Fatalf("pinned team not marked: %+v", comp.Teams[0]) + } + if comp.Execute.DefaultRole != "react-worker" { + t.Fatalf("worker=%q — the pinned team's worker should staff the loop", comp.Execute.DefaultRole) + } + for _, p := range comp.Phases { + switch p.ID { + case "execute": + if p.Agent != "react-worker" { + t.Fatalf("execute agent=%q", p.Agent) + } + case "test": + if p.Agent != "react-tester" { + t.Fatalf("test agent=%q", p.Agent) + } + } + } + joined := strings.Join(comp.Handoff, "\n") + if !strings.Contains(joined, "Team frontend-react staffs this run") { + t.Fatalf("handoff=%v", comp.Handoff) + } + if !strings.Contains(joined, "Team territory: web/**") { + t.Fatalf("handoff does not name the territory: %v", comp.Handoff) + } + if !strings.Contains(comp.TeamNote, "staffs this run") { + t.Fatalf("note=%q", comp.TeamNote) + } +} + +// A request no team matches says so on the composition, so "why did my teams +// not run" is answered on the panel rather than in a log. +func TestNoTeamMatchIsExplained(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hi\n"), 0o600); err != nil { + t.Fatal(err) + } + cfg := config.Default(root) + comp := PreviewCompositionForConfig(cfg, "rewrite the poem") + if len(comp.Teams) != 0 || comp.TeamMode != "" { + t.Fatalf("teams=%+v mode=%q", comp.Teams, comp.TeamMode) + } + if !strings.Contains(comp.TeamNote, "no team matched") { + t.Fatalf("note=%q", comp.TeamNote) + } +} + +// Teams switched off is not the same as no teams: the panel has to say which, +// or the user goes looking for a library problem that is a config flag. +func TestTeamsOffIsExplainedNotHidden(t *testing.T) { + cfg := config.Default(fullstackRoot(t)) + cfg.Squads = false + comp := PreviewCompositionForConfig(cfg, "add a Go API endpoint and the React page that calls it") + if comp.TeamMode != "" { + t.Fatalf("mode=%q with squads off", comp.TeamMode) + } + if len(comp.Teams) < 2 { + t.Fatalf("the matched teams should still be listed: %+v", comp.Teams) + } + if !strings.Contains(comp.TeamNote, "squads: false") { + t.Fatalf("note=%q", comp.TeamNote) + } + + cfg.Squads, cfg.TeamLibrary = true, false + comp = PreviewCompositionForConfig(cfg, "add a Go API endpoint") + if len(comp.Teams) != 0 || !strings.Contains(comp.TeamNote, "team_library: false") { + t.Fatalf("teams=%+v note=%q", comp.Teams, comp.TeamNote) + } +} + +// A manager that cannot answer the triage contract is not a manager. The +// composition names who will actually decide, and says it is the default. +func TestEffectiveManagerFallsBackToTheRunDefault(t *testing.T) { + cfg := config.Default(fullstackRoot(t)) + o, err := New(cfg) + if err != nil { + t.Fatal(err) + } + if m, def := o.effectiveManager("go-worker"); m != "triage" || !def { + t.Fatalf("a worker cannot triage: got %q default=%v", m, def) + } + if m, def := o.effectiveManager("triage"); m != "triage" || def { + t.Fatalf("the builtin manager is not a fallback: got %q default=%v", m, def) + } + if m, def := o.effectiveManager(""); m != "triage" || !def { + t.Fatalf("empty → default: got %q default=%v", m, def) + } +} + +// The composer model is told which teams are on the run, and told it may not +// change them — so the roles it picks agree with the charter phase. +func TestComposerPromptNamesTheTeams(t *testing.T) { + cfg := config.Default(fullstackRoot(t)) + o, err := New(cfg) + if err != nil { + t.Fatal(err) + } + prompt := o.buildComposerPrompt("add a Go API endpoint and the React page that calls it", + []string{"go.mod", "web/package.json"}, "", "") + if !strings.Contains(prompt, "## Teams on this run") { + t.Fatalf("prompt lacks the teams section:\n%s", prompt) + } + if !strings.Contains(prompt, "backend-go") || !strings.Contains(prompt, "manager=triage") { + t.Fatalf("prompt does not name the teams and their managers:\n%s", prompt) + } +} + +func TestCompositionMarkdownAndBriefRenderTeams(t *testing.T) { + c := composer.Composition{ + Summary: "two halves", + TeamMode: composer.TeamModeParallel, + TeamNote: "2 teams build in parallel", + Teams: []composer.TeamChoice{ + {ID: "backend-go", Worker: "go-worker", Manager: "triage", ManagerDefault: true, Owns: []string{"cmd/**"}}, + {ID: "frontend-react", Worker: "react-worker", Manager: "fe-triage", Owns: []string{"web/**"}}, + }, + } + md := compositionMarkdown(c) + if !strings.Contains(md, "## Teams") || !strings.Contains(md, "manager=triage (run default)") || + !strings.Contains(md, "manager=fe-triage") { + t.Fatalf("markdown:\n%s", md) + } + brief := compositionBrief(c) + if !strings.Contains(brief, "Teams on this run (in parallel") || !strings.Contains(brief, "frontend-react manager=fe-triage") { + t.Fatalf("brief:\n%s", brief) + } +} + +// A resumed run used to execute with no org chart at all: the plan lived on +// disk and the orchestrator's handle died with the process. The board decides +// whether the saved plan belongs to this run. +func TestResumeRestoresTheSquadPlanTheBoardWasBuiltUnder(t *testing.T) { + cfg := config.Default(fullstackRoot(t)) + o, err := New(cfg) + if err != nil { + t.Fatal(err) + } + p := squads.Plan{ + Summary: "Go API + React SPA", + Squads: []squads.Squad{ + {ID: "backend-go", Owns: []string{"cmd/**"}, Acceptance: "go test ./..."}, + {ID: "frontend-react", Owns: []string{"web/**"}, Acceptance: "npm test"}, + }, + } + if err := squads.Save(cfg.SlmDir(), p); err != nil { + t.Fatal(err) + } + + // A board no team touched: the plan on disk is some other run's. + foreign := &plan.Board{Tasks: []plan.Task{{ID: "T1", Title: "x", Files: []string{"README.md"}}}} + o.restoreSquadPlan(foreign) + if o.squadPlan != nil { + t.Fatalf("a board with no team stamps must not adopt the saved plan") + } + + stamped := &plan.Board{Tasks: []plan.Task{{ID: "T1", Title: "x", Squad: "backend-go", Files: []string{"cmd/main.go"}}}} + o.restoreSquadPlan(stamped) + if o.squadPlan == nil || len(o.squadPlan.Squads) != 2 { + t.Fatalf("plan not restored: %+v", o.squadPlan) + } + + // Teams switched off: the saved file is ignored whatever the board says. + o.squadPlan = nil + o.cfg.Squads = false + o.restoreSquadPlan(stamped) + if o.squadPlan != nil { + t.Fatalf("squads: false must not restore a plan") + } +} diff --git a/pkg/orchestrator/composer.go b/pkg/orchestrator/composer.go index 7216677..54d14a4 100644 --- a/pkg/orchestrator/composer.go +++ b/pkg/orchestrator/composer.go @@ -253,6 +253,11 @@ func (o *Orchestrator) prepareDynamicComposition(comp *composer.Composition, que if o != nil && o.factory != nil { unknown = o.sanitizeComposition(comp) } + // The team decision, made once from the library and stamped onto the + // composition. BEFORE the language hint: a team's own worker is the user's + // configuration and the hint is a guess from file extensions, so the team + // takes a generic seat first and the hint fills whatever is still generic. + o.composeTeams(comp, query, inventory) workerHint, testerHint := queryLanguageSpecialists(query) if workerHint == "" && o.cfg != nil { workerHint, testerHint = projectLanguageSpecialists(detectProjectLang(o.cfg.Root)) @@ -283,8 +288,11 @@ func (o *Orchestrator) prepareDynamicComposition(comp *composer.Composition, que lang = detectProjectLang(o.cfg.Root) } ensureCompositionHandoff(comp, query, inventory, lang, workerHint, testerHint) + teamHandoff(comp) if o != nil { - ensureCompositionTeam(comp, o.availableSkillNames()) + skills := o.availableSkillNames() + ensureCompositionTeam(comp, skills) + teamSeats(comp, skills) } return unknown } @@ -1331,6 +1339,10 @@ func (o *Orchestrator) buildComposerPrompt(query string, inventory []string, exp b.WriteString("\n\n") } + if choices, mode, note := o.teamChoices(query, inventory); len(choices) > 0 || note != "" { + b.WriteString(teamsPromptSection(choices, mode, note)) + } + b.WriteString("## Canonical phases (copy ids exactly)\n") def := pipeline.Default() for _, id := range def.Order { @@ -1409,6 +1421,20 @@ func compositionBrief(c composer.Composition) string { } } } + if len(c.Teams) > 0 { + b.WriteString("\nTeams on this run") + if c.TeamMode == composer.TeamModeParallel { + b.WriteString(" (in parallel, behind the frozen contract)") + } + b.WriteString(":\n") + for _, t := range c.Teams { + b.WriteString("- " + t.ID + " manager=" + valueOr(t.Manager, "default")) + if len(t.Owns) > 0 { + b.WriteString(" owns=" + strings.Join(t.Owns, ", ")) + } + b.WriteString("\n") + } + } return b.String() } @@ -1435,6 +1461,7 @@ func compositionMarkdown(c composer.Composition) string { } b.WriteString("\n") } + b.WriteString(teamsMarkdown(c)) b.WriteString("## Phases\n\n") for _, p := range c.Phases { state := "disabled" diff --git a/pkg/orchestrator/orchestrator.go b/pkg/orchestrator/orchestrator.go index 57532d4..e20eb33 100644 --- a/pkg/orchestrator/orchestrator.go +++ b/pkg/orchestrator/orchestrator.go @@ -1218,6 +1218,10 @@ func (o *Orchestrator) runSLM(ctx context.Context, runID, query, skillPack strin // BEFORE plan/split so the splitter's tasks can be routed to an owner, and // returns nil — meaning "one stream" — for every single-domain query and // every failure mode. + // Cleared first: the handle outlives a run in a long-lived Studio process, + // and a run started after teams were switched off must not inherit the + // previous run's org chart. + o.squadPlan = nil if o.cfg.Squads { o.squadPlan = o.assembleSquads(ctx, query, inventory, exploreOut, archOut) } diff --git a/pkg/orchestrator/resume.go b/pkg/orchestrator/resume.go index 2db510a..945ff95 100644 --- a/pkg/orchestrator/resume.go +++ b/pkg/orchestrator/resume.go @@ -18,6 +18,7 @@ import ( "github.com/UnicoLab/slmcode/pkg/quality" "github.com/UnicoLab/slmcode/pkg/session" "github.com/UnicoLab/slmcode/pkg/skills" + "github.com/UnicoLab/slmcode/pkg/squads" "github.com/UnicoLab/slmcode/pkg/stream" ) @@ -61,6 +62,7 @@ func (o *Orchestrator) Resume(ctx context.Context, turnID string) (*Result, erro o.decisions = nil o.gates = nil o.mu.Unlock() + o.restoreSquadPlan(&board) defer func() { o.mu.Lock() o.currentTurn = nil @@ -1408,3 +1410,45 @@ func (o *Orchestrator) qaFailureLines(max int) []string { } return out } + +// restoreSquadPlan gives a resumed run back the org chart its board was built +// under. +// +// The plan is written to disk at charter and the orchestrator's own handle +// dies with the process, so a resumed run used to execute with squadPlan nil: +// no squad brief in the packs, no ownership fence on the waves, no per-team +// acceptance and no integration step — the team structure the user watched +// being assembled silently gone, on the one path where the work was already +// half done under it. +// +// The saved file is a PROJECT fact and the resumed turn may not be the run +// that wrote it, so the board is the arbiter: the plan is restored only when +// a task on it is stamped with one of the plan's teams. A board no team ever +// touched gets no plan, which is what it ran with. +func (o *Orchestrator) restoreSquadPlan(board *plan.Board) { + if o == nil || o.cfg == nil || board == nil || !o.cfg.Squads { + return + } + p, ok, err := squads.Load(o.cfg.SlmDir()) + if err != nil { + o.emitWarn("init", "could not read the saved team plan ("+err.Error()+") — resuming as a single stream", "") + return + } + if !ok || !p.Enabled() { + return + } + stamped := false + for _, t := range board.Tasks { + if _, on := p.Squad(t.Squad); t.Squad != "" && on { + stamped = true + break + } + } + if !stamped { + return + } + o.mu.Lock() + o.squadPlan = &p + o.mu.Unlock() + o.emit("init", "restored the team plan for this run: "+p.Summarize(), "") +} diff --git a/pkg/server/server.go b/pkg/server/server.go index ba4dc8f..0b2dfeb 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -35,6 +35,7 @@ import ( "github.com/UnicoLab/slmcode/pkg/rewind" "github.com/UnicoLab/slmcode/pkg/session" "github.com/UnicoLab/slmcode/pkg/skills" + "github.com/UnicoLab/slmcode/pkg/squads" "github.com/UnicoLab/slmcode/pkg/stacks" "github.com/UnicoLab/slmcode/pkg/updatecheck" "github.com/UnicoLab/slmcode/pkg/workspace" @@ -435,6 +436,8 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/teams", s.handleCreateTeam) s.mux.HandleFunc("POST /api/teams/preselect", s.handlePreselectTeams) s.mux.HandleFunc("POST /api/teams/activate", s.handleActivateTeams) + s.mux.HandleFunc("GET /api/teams/activity", s.handleTeamActivity) + s.mux.HandleFunc("POST /api/teams/{id}/manager", s.handleCreateTeamManager) s.mux.HandleFunc("GET /api/teams/{id}", s.handleGetTeam) s.mux.HandleFunc("PUT /api/teams/{id}", s.handlePutTeam) s.mux.HandleFunc("DELETE /api/teams/{id}", s.handleDeleteTeam) @@ -691,17 +694,46 @@ func (s *Server) handleAddTask(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePatchTask(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") + raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, err.Error(), 400) + return + } var patch plan.Task - if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + if err := json.Unmarshal(raw, &patch); err != nil { http.Error(w, err.Error(), 400) return } + // "squad" is the one field where an empty value is an instruction + // (un-assign) rather than an omission, so presence is read off the raw + // body instead of the zero value. + var keys map[string]json.RawMessage + _ = json.Unmarshal(raw, &keys) + _, squadSet := keys["squad"] + var teamPlan *squads.Plan + if squadSet { + if p, ok, err := squads.Load(s.slmDir()); err == nil && ok && len(p.Squads) > 0 { + teamPlan = &p + } + if refused := teamAssignmentProblem(teamPlan, patch.Squad); refused != "" { + http.Error(w, refused, http.StatusBadRequest) + return + } + } var out plan.Task - err := s.orch().Board().Update(func(b *plan.Board) error { + var problems []string + err = s.orch().Board().Update(func(b *plan.Board) error { t, ok := b.Get(id) if !ok { return fmt.Errorf("not found") } + if squadSet { + if why := ownershipRefusal(teamPlan, t, patch.Squad); why != "" { + problems = append(problems, why) + return nil + } + t.Squad = strings.ToLower(strings.TrimSpace(patch.Squad)) + } if patch.Title != "" { t.Title = patch.Title } @@ -734,6 +766,20 @@ func (s *Server) handlePatchTask(w http.ResponseWriter, r *http.Request) { out = t return nil }) + if len(problems) > 0 { + writeProblems(w, problems) + return + } + if err == nil && squadSet { + team := out.Squad + if team == "" { + team = "nobody" + } + s.emit(orchestrator.Event{ + Phase: "charter", Kind: "output", TaskID: out.ID, + Message: out.ID + " assigned to team " + team + " by hand", Time: time.Now(), + }) + } if err != nil { http.Error(w, err.Error(), 404) return @@ -1949,6 +1995,10 @@ func (s *Server) handleGetComposition(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePreviewComposition(w http.ResponseWriter, r *http.Request) { var req struct { Query string `json:"query"` + // Teams pinned for the run being set up. The preview has to see the + // same pins the run will, or the panel shows one staffing and the run + // uses another. + Teams []string `json:"teams"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), 400) @@ -1959,9 +2009,17 @@ func (s *Server) handlePreviewComposition(w http.ResponseWriter, r *http.Request return } var comp composer.Composition - if s.h != nil && s.orch() != nil { + switch { + case len(req.Teams) > 0: + // A copy of the config with the run's pins in place: the preview must + // not touch the shared config, and the orchestrator's own preview + // reads the shared one. + c := *s.cfg() + c.Teams = append([]string{}, req.Teams...) + comp = orchestrator.PreviewCompositionForConfig(&c, req.Query) + case s.h != nil && s.orch() != nil: comp = s.orch().PreviewComposition(req.Query) - } else { + default: comp = orchestrator.PreviewCompositionForConfig(s.cfg(), req.Query) } prof := config.ResolveModelProfile(s.cfg().ModelProfiles, s.cfg().Model) diff --git a/pkg/server/team_activity.go b/pkg/server/team_activity.go new file mode 100644 index 0000000..29d2047 --- /dev/null +++ b/pkg/server/team_activity.go @@ -0,0 +1,457 @@ +package server + +import ( + "net/http" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/UnicoLab/slmcode/pkg/orchestrator" + "github.com/UnicoLab/slmcode/pkg/plan" + "github.com/UnicoLab/slmcode/pkg/session" + "github.com/UnicoLab/slmcode/pkg/squads" + "github.com/UnicoLab/slmcode/pkg/stream" +) + +// ── How the teams worked together ──────────────────────────────────────── +// +// Everything a project manager decided, every handoff between teams and every +// gate a half passed or failed is already in the event stream — interleaved +// with a thousand tool calls and token deltas. Nobody reads it there. The +// question the Teams page has to answer after a run is "what did the managers +// DO, and how did the teams collaborate", and the answer is this derived +// timeline: the team-relevant events only, each classified (a triage verdict, +// a reassignment, a contract clause, a stall, a gate) and stamped with the team +// it concerns, so it can be filtered by team and read top to bottom. +// +// Derived, not stored. The events are the record; a second store would be a +// second thing to keep in sync with them. + +// Activity kinds, in the vocabulary the page groups by. +const ( + ActivitySelection = "selection" // which teams, and why + ActivityContract = "contract" // a clause frozen, or the contract attached + ActivityRouting = "routing" // a task given to a team or a specialist + ActivityTriage = "triage" // a manager's verdict on a rejected delivery + ActivityReassign = "reassign" // a task moved to another agent + ActivityStall = "stall" // a team waiting on another's interface + ActivityWave = "wave" // who is working, and for which team + ActivityGate = "gate" // a team's acceptance result + ActivityIntegration = "integration" // joining the halves + ActivityProgress = "progress" // per-team progress between waves + ActivityEdit = "edit" // a human changed the org chart +) + +// ActivityEntry is one line of the timeline. +type ActivityEntry struct { + Time string `json:"time"` + Kind string `json:"kind"` + Phase string `json:"phase,omitempty"` + Level string `json:"level,omitempty"` + Team string `json:"team,omitempty"` + Agent string `json:"agent,omitempty"` + TaskID string `json:"task_id,omitempty"` + Message string `json:"message"` +} + +// activitySource is the subset of an event the classifier reads, so the live +// ring buffer (stream.Event) and a past run's JSONL (session.EventRecord) go +// through the same code. +type activitySource struct { + Time string + Phase string + Kind string + Level string + Agent string + TaskID string + Message string +} + +var ( + reTeamWord = regexp.MustCompile(`\b(?:team|squad) ([a-z0-9][a-z0-9-]*)`) + reWaitingOn = regexp.MustCompile(`^([a-z0-9][a-z0-9-]*) is waiting on`) + reProposes = regexp.MustCompile(`^(\S+) proposes (\S+)`) + reReassigned = regexp.MustCompile(`^(\S+) reassigned from (\S+) to (\S+)`) + reFrozen = regexp.MustCompile(`^frozen: `) + reWave = regexp.MustCompile(`^wave \d+: `) + reAssigned = regexp.MustCompile(`^assigned `) + reHandAssign = regexp.MustCompile(`^(\S+) assigned to team ([a-z0-9][a-z0-9-]*)`) + reTeamPrefix = regexp.MustCompile(`^(?:team|squad) ([a-z0-9][a-z0-9-]*)`) + reTeamLibrary = regexp.MustCompile(`^team library:`) + reProvidedBy = regexp.MustCompile(`provided by ([a-z0-9][a-z0-9-]*)`) +) + +// classifyActivity decides whether one event belongs on the timeline and, if +// so, what it is. Returns ok=false for everything that is not about teams. +// +// The patterns are the exact shapes the orchestrator and loop emit (see +// pkg/orchestrator/squads.go, teams.go, teamgate.go and pkg/loop/squads.go). +// A message that changes there and not here falls off the timeline rather +// than being misfiled — the tests pin the ones that matter. +func classifyActivity(ev activitySource) (string, bool) { + msg := strings.TrimSpace(ev.Message) + if msg == "" || ev.Kind == stream.KindToken || ev.Kind == stream.KindTool { + return "", false + } + lower := strings.ToLower(msg) + switch ev.Phase { + case "charter": + switch { + case reFrozen.MatchString(lower), strings.HasPrefix(lower, "freezing the interface"), + strings.HasPrefix(lower, "contract attached"): + return ActivityContract, true + case strings.HasPrefix(lower, "squad assignment:"), strings.Contains(lower, "spans both squads"), + strings.Contains(lower, "no squad owns"), strings.Contains(lower, "cut along"), + strings.Contains(lower, "dropped the dependency"), strings.Contains(lower, "no longer waits"): + return ActivityRouting, true + case strings.HasPrefix(lower, "squad plan edited"), strings.HasPrefix(lower, "teams activated"), + reHandAssign.MatchString(lower): + return ActivityEdit, true + default: + return ActivitySelection, true + } + case "coord": + if reProposes.MatchString(msg) { + return ActivityTriage, true + } + if strings.Contains(lower, "triage") || strings.Contains(lower, "project manager") { + return ActivityTriage, true + } + case "plan": + switch { + case reReassigned.MatchString(msg): + return ActivityReassign, true + case strings.Contains(lower, "triage ignored"): + return ActivityTriage, true + case strings.HasPrefix(lower, "squad edit"), strings.HasPrefix(lower, "squad plan edited"): + return ActivityEdit, true + } + case "split": + if reAssigned.MatchString(lower) && ev.TaskID != "" { + return ActivityRouting, true + } + if strings.HasPrefix(lower, "routed ") { + return ActivityRouting, true + } + case "execute": + switch { + case strings.HasPrefix(lower, "squads: "): + return ActivityProgress, true + case strings.Contains(lower, " is waiting on "): + return ActivityStall, true + case reWave.MatchString(lower) && strings.Contains(lower, "team"): + return ActivityWave, true + case reReassigned.MatchString(msg): + return ActivityReassign, true + case strings.HasPrefix(lower, "re-assigned "), strings.HasPrefix(lower, "re-staffed wave"): + return ActivityReassign, true + } + case "verify": + if strings.HasPrefix(lower, "team ") || strings.Contains(lower, "for team ") || + strings.Contains(lower, "team stamp") { + return ActivityGate, true + } + case "integrate": + return ActivityIntegration, true + } + // Loop-level events carry no team phase; the wave announcement is the one + // that names teams. + if ev.Kind == stream.KindCoord && reWave.MatchString(lower) && strings.Contains(lower, "team") { + return ActivityWave, true + } + if reReassigned.MatchString(msg) { + return ActivityReassign, true + } + return "", false +} + +// teamOf names the team an entry concerns, from the strongest signal down: a +// task the board has stamped, a "team X" / "squad X" mention, a stall's +// subject, then any plan team id mentioned as a word. +func teamOf(ev activitySource, kind string, taskTeams map[string]string, teamIDs []string) string { + if ev.TaskID != "" { + if t := taskTeams[ev.TaskID]; t != "" { + return t + } + } + lower := strings.ToLower(ev.Message) + // A captured word is a team only if the plan says so — "team library", + // "squad assignment" and "squad plan" are all "team " to a regex. + // With no plan to check against, the words those messages use are + // excluded by name. + known := func(id string) bool { + if len(teamIDs) > 0 { + for _, t := range teamIDs { + if t == id { + return true + } + } + return false + } + return !notATeamWord[id] + } + if m := reHandAssign.FindStringSubmatch(lower); m != nil && known(m[2]) { + return m[2] + } + if m := reTeamPrefix.FindStringSubmatch(lower); m != nil && known(m[1]) { + return m[1] + } + if kind == ActivityStall { + if m := reWaitingOn.FindStringSubmatch(lower); m != nil && known(m[1]) { + return m[1] + } + } + // A contract clause belongs to the team that owes it. + if kind == ActivityContract { + if m := reProvidedBy.FindStringSubmatch(lower); m != nil && known(m[1]) { + return m[1] + } + } + if m := reTeamWord.FindStringSubmatch(lower); m != nil && !reTeamLibrary.MatchString(lower) && known(m[1]) { + return m[1] + } + // A line naming exactly one team is about that team; a line naming two is + // about the run and gets no stamp, or "2 teams selected: a, b" would be + // filed under a. + var mentioned []string + for _, id := range teamIDs { + if containsWordASCII(lower, id) { + mentioned = append(mentioned, id) + } + } + if len(mentioned) == 1 { + return mentioned[0] + } + return "" +} + +// notATeamWord is the vocabulary that follows "team"/"squad" in the messages +// the orchestrator emits about ALL teams at once. +var notATeamWord = map[string]bool{ + "library": true, "assignment": true, "plan": true, "plans": true, "is": true, "are": true, + "gates": true, "gate": true, "stamp": true, "stamps": true, "edit": true, "edits": true, + "was": true, "has": true, "of": true, "and": true, "the": true, "structure": true, +} + +func containsWordASCII(haystack, needle string) bool { + if needle == "" { + return false + } + from := 0 + for { + i := strings.Index(haystack[from:], needle) + if i < 0 { + return false + } + i += from + before := i == 0 || !isWordByte(haystack[i-1]) + after := i+len(needle) >= len(haystack) || !isWordByte(haystack[i+len(needle)]) + if before && after { + return true + } + from = i + 1 + if from >= len(haystack) { + return false + } + } +} + +func isWordByte(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' +} + +// deriveTeamActivity turns a run's events into the team timeline. +func deriveTeamActivity(events []activitySource, p *squads.Plan, taskTeams map[string]string) []ActivityEntry { + var ids []string + if p != nil { + for _, s := range p.Squads { + ids = append(ids, s.ID) + } + } + out := make([]ActivityEntry, 0, 32) + for _, ev := range events { + kind, ok := classifyActivity(ev) + if !ok { + continue + } + e := ActivityEntry{ + Time: ev.Time, + Kind: kind, + Phase: ev.Phase, + Level: ev.Level, + Agent: ev.Agent, + TaskID: ev.TaskID, + Message: strings.TrimSpace(ev.Message), + Team: teamOf(ev, kind, taskTeams, ids), + } + // A triage verdict is announced by the manager that gave it, and a + // reassignment by the agent that received it; the agent field on the + // event already says which. Nothing to rewrite — but the "manager" + // specialist that assembles teams is the run's charter voice, not a + // team's manager, and is dropped so the filter "decisions by X" is not + // polluted with selection lines. + if kind == ActivitySelection && e.Agent == "manager" { + e.Agent = "" + } + out = append(out, e) + } + return out +} + +// ManagerSummary is one manager's record on this run. +type ManagerSummary struct { + Team string `json:"team"` + Manager string `json:"manager"` + Default bool `json:"default,omitempty"` + Decisions int `json:"decisions"` + Moved int `json:"moved"` + Stalls int `json:"stalls"` + Gate string `json:"gate,omitempty"` // green | red | unverified | "" +} + +// summarizeManagers folds the timeline into one row per team. +func summarizeManagers(p *squads.Plan, entries []ActivityEntry, gates []orchestrator.TeamGate, defaultManager string) []ManagerSummary { + if p == nil { + return nil + } + rows := make([]ManagerSummary, 0, len(p.Squads)) + index := map[string]int{} + for _, s := range p.Squads { + row := ManagerSummary{Team: s.ID, Manager: strings.TrimSpace(s.Manager)} + if row.Manager == "" { + row.Manager, row.Default = defaultManager, true + } + index[s.ID] = len(rows) + rows = append(rows, row) + } + for _, e := range entries { + i, ok := index[e.Team] + if !ok { + continue + } + switch e.Kind { + case ActivityTriage: + rows[i].Decisions++ + case ActivityReassign: + rows[i].Moved++ + case ActivityStall: + rows[i].Stalls++ + } + } + for _, g := range gates { + i, ok := index[g.Team] + if !ok { + continue + } + switch { + case !g.Ran: + rows[i].Gate = "unverified" + case g.OK: + rows[i].Gate = "green" + default: + rows[i].Gate = "red" + } + } + return rows +} + +// handleTeamActivity serves the timeline for the current run, or for a past +// one when ?query= names it. +func (s *Server) handleTeamActivity(w http.ResponseWriter, r *http.Request) { + limit := 400 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + var sources []activitySource + queryID := strings.TrimSpace(r.URL.Query().Get("query")) + if queryID != "" { + recs, err := session.ReadEvents(s.slmDir(), queryID, 5000) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + for _, rec := range recs { + sources = append(sources, activitySource{ + Time: rec.Time, Phase: rec.Phase, Kind: rec.Kind, Agent: rec.Agent, + TaskID: rec.TaskID, Message: rec.Message, + }) + } + } else { + s.mu.Lock() + for _, se := range s.events { + sources = append(sources, activitySource{ + Time: se.Event.Time.Format(time.RFC3339Nano), Phase: se.Event.Phase, Kind: se.Event.Kind, + Level: se.Event.Level, Agent: se.Event.Agent, TaskID: se.Event.TaskID, Message: se.Event.Message, + }) + } + s.mu.Unlock() + } + + var planPtr *squads.Plan + if p, ok, err := squads.Load(s.slmDir()); err == nil && ok && len(p.Squads) > 0 { + planPtr = &p + } + tasks := s.boardTasks() + if queryID != "" { + if turn, err := session.LoadTurn(s.slmDir(), queryID); err == nil && turn != nil { + tasks = turn.Board.Tasks + } + } + taskTeams := map[string]string{} + for _, t := range tasks { + if t.Squad != "" { + taskTeams[t.ID] = t.Squad + } + } + + entries := deriveTeamActivity(sources, planPtr, taskTeams) + if len(entries) > limit { + entries = entries[len(entries)-limit:] + } + counts := map[string]int{} + for _, e := range entries { + counts[e.Kind]++ + } + var gates []orchestrator.TeamGate + if o := s.orch(); o != nil && queryID == "" { + gates = o.TeamGates() + } + teamIDs := []string{} + if planPtr != nil { + teamIDs = planPtr.IDs() + } + sort.Strings(teamIDs) + writeJSON(w, map[string]interface{}{ + "ok": true, + "query": queryID, + "teams": teamIDs, + "entries": entries, + "counts": counts, + "managers": summarizeManagers(planPtr, entries, gates, runDefaultManager), + "tasks": teamTaskRows(tasks), + }) +} + +// teamTaskRows is the board seen by team: which tasks each team holds, who is +// on them and where they stand. The board page has the cards; this is the +// roll-up the Teams page needs to say "backend has four, two blocked". +func teamTaskRows(tasks []plan.Task) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(tasks)) + // Unassigned tasks are listed too: the seam is real work, and a roll-up + // that hid it would under-count the run. + for _, t := range tasks { + out = append(out, map[string]interface{}{ + "id": t.ID, + "title": t.Title, + "team": t.Squad, + "role": t.Role, + "column": t.Column, + "status": t.Status, + }) + } + return out +} diff --git a/pkg/server/team_activity_test.go b/pkg/server/team_activity_test.go new file mode 100644 index 0000000..59e17d5 --- /dev/null +++ b/pkg/server/team_activity_test.go @@ -0,0 +1,349 @@ +package server + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/UnicoLab/slmcode/pkg/squads" + "github.com/UnicoLab/slmcode/pkg/stream" + "github.com/UnicoLab/slmcode/pkg/teams" +) + +// twoTeamPlan is the org chart the tests below run against. +func twoTeamPlan() squads.Plan { + return squads.Plan{ + Summary: "Go API + React SPA", + Squads: []squads.Squad{ + {ID: "backend-go", Owns: []string{"cmd/**"}, Acceptance: "go test ./...", Manager: "backend-triage"}, + {ID: "frontend-react", Owns: []string{"web/**"}, Acceptance: "npm run build"}, + }, + Contract: squads.Contract{Interfaces: []squads.Interface{ + {ID: "GET /api/todos", Provider: "backend-go", Consumers: []string{"frontend-react"}}, + }}, + } +} + +// The classifier pins the exact shapes the orchestrator and loop emit. A +// message that changes there falls off the timeline; this is where that is +// noticed. +func TestTeamActivityClassifiesTheRunsTeamEvents(t *testing.T) { + p := twoTeamPlan() + taskTeams := map[string]string{"T3": "backend-go"} + events := []activitySource{ + {Phase: "charter", Kind: "phase", Message: "team library: 2 teams selected: backend-go, frontend-react"}, + {Phase: "charter", Kind: "agent_start", Agent: "manager", Message: `team backend-go — workspace has "go.mod"`}, + {Phase: "charter", Kind: "phase", Message: "frozen: GET /api/todos — provided by backend-go, consumed by frontend-react"}, + {Phase: "charter", Kind: "phase", Message: "squad assignment: 4 assigned · 1 straddling"}, + {Phase: "split", Kind: "agent_start", Agent: "go-worker", TaskID: "T3", Message: "assigned go-worker — files are Go"}, + {Phase: "coord", Kind: "debug", Agent: "backend-triage", TaskID: "T3", Message: "backend-triage proposes go-corrector — the handler returns a bare slice"}, + {Phase: "plan", Kind: "output", Agent: "go-corrector", TaskID: "T3", Message: "T3 reassigned from go-worker to go-corrector — compile error the worker could not resolve"}, + {Phase: "execute", Kind: "phase", Message: "squads: backend-go 1/4 working · frontend-react 3/3 done"}, + {Phase: "execute", Kind: "phase", Level: "warning", Message: "frontend-react is waiting on backend-go to deliver \"GET /api/todos\" — this is a contract dependency, not a task defect"}, + {Kind: "coord", Agent: "loop", Message: "wave 2: T3(backend-go), T5(frontend-react) — teams live: backend-go, frontend-react"}, + {Phase: "verify", Kind: "phase", Message: "team backend-go is green: go test ./..."}, + {Phase: "verify", Kind: "phase", Level: "warning", Message: "team frontend-react is RED — its own half does not pass: exit 1"}, + {Phase: "integrate", Kind: "phase", Message: "every squad is green — joining the halves: make e2e"}, + {Phase: "charter", Kind: "output", Message: "squad plan edited: backend-go + frontend-react"}, + {Phase: "charter", Kind: "output", TaskID: "T9", Message: "T9 assigned to team frontend-react by hand"}, + // Noise the timeline must not carry. + {Phase: "execute", Kind: "tool", Message: "ws_read cmd/main.go"}, + {Phase: "execute", Kind: "token", Message: "func"}, + {Phase: "plan", Kind: "phase", Message: "planning 6 tasks"}, + } + got := deriveTeamActivity(events, &p, taskTeams) + + type want struct{ kind, team string } + wants := []want{ + {ActivitySelection, ""}, + {ActivitySelection, "backend-go"}, + {ActivityContract, "backend-go"}, + {ActivityRouting, ""}, + {ActivityRouting, "backend-go"}, + {ActivityTriage, "backend-go"}, + {ActivityReassign, "backend-go"}, + // Lines about the whole run — progress, a wave, an edit naming both + // teams — carry no stamp: filing them under the first team would + // make its filter lie. + {ActivityProgress, ""}, + {ActivityStall, "frontend-react"}, + {ActivityWave, ""}, + {ActivityGate, "backend-go"}, + {ActivityGate, "frontend-react"}, + {ActivityIntegration, ""}, + {ActivityEdit, ""}, + {ActivityEdit, "frontend-react"}, + } + if len(got) != len(wants) { + var lines []string + for _, e := range got { + lines = append(lines, e.Kind+"/"+e.Team+": "+e.Message) + } + t.Fatalf("got %d entries, want %d:\n%s", len(got), len(wants), strings.Join(lines, "\n")) + } + for i, w := range wants { + if got[i].Kind != w.kind || got[i].Team != w.team { + t.Errorf("entry %d: kind=%s team=%q, want %s/%q (%s)", i, got[i].Kind, got[i].Team, w.kind, w.team, got[i].Message) + } + } + // The triage verdict keeps its manager; the selection line drops the + // charter voice so "decisions by X" is not polluted. + if got[5].Agent != "backend-triage" { + t.Fatalf("triage agent=%q", got[5].Agent) + } + if got[1].Agent != "" { + t.Fatalf("selection agent should be cleared, got %q", got[1].Agent) + } + + rows := summarizeManagers(&p, got, nil, "triage") + if len(rows) != 2 { + t.Fatalf("rows=%+v", rows) + } + if rows[0].Team != "backend-go" || rows[0].Manager != "backend-triage" || rows[0].Default || + rows[0].Decisions != 1 || rows[0].Moved != 1 { + t.Fatalf("backend row=%+v", rows[0]) + } + if rows[1].Manager != "triage" || !rows[1].Default || rows[1].Stalls != 1 { + t.Fatalf("frontend row=%+v", rows[1]) + } +} + +// The endpoint reads the live ring buffer and the saved plan, so the Teams +// page can show what the managers did on the run that just finished. +func TestTeamActivityEndpointServesTheCurrentRun(t *testing.T) { + s, root := teamServer(t) + if err := squads.Save(filepath.Join(root, ".slmcode"), twoTeamPlan()); err != nil { + t.Fatal(err) + } + s.emit(stream.Event{Phase: "charter", Kind: "phase", Message: "team library: 2 teams selected: backend-go, frontend-react"}) + s.emit(stream.Event{Phase: "coord", Kind: "debug", Agent: "backend-triage", TaskID: "T1", + Message: "backend-triage proposes go-corrector — compile error"}) + s.emit(stream.Event{Phase: "execute", Kind: "tool", Message: "ws_read x"}) + + rec := do(t, s, http.MethodGet, "/api/teams/activity", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + body := decode(t, rec) + entries, _ := body["entries"].([]interface{}) + if len(entries) != 2 { + t.Fatalf("entries=%v", body["entries"]) + } + counts, _ := body["counts"].(map[string]interface{}) + if counts[ActivityTriage] != float64(1) { + t.Fatalf("counts=%v", counts) + } + managers, _ := body["managers"].([]interface{}) + if len(managers) != 2 { + t.Fatalf("managers=%v", body["managers"]) + } + teams, _ := body["teams"].([]interface{}) + if len(teams) != 2 { + t.Fatalf("teams=%v", body["teams"]) + } +} + +// A team's manager is resolved the way the run resolves it: named and +// triage-capable, or the run default — and the page is told which. +func TestTeamViewReportsTheEffectiveManager(t *testing.T) { + s, _ := teamServer(t) + rec := do(t, s, http.MethodGet, "/api/teams/backend-go", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + body := decode(t, rec) + if body["effective_manager"] != "triage" || body["manager_default"] != true { + t.Fatalf("builtin without a manager: %v / %v", body["effective_manager"], body["manager_default"]) + } + + // Naming an agent that cannot triage does not make it a manager. + rec = do(t, s, http.MethodPut, "/api/teams/backend-go", map[string]interface{}{ + "id": "backend-go", "owns": []string{"cmd/**"}, "manager": "go-worker", + }) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + body = decode(t, rec) + if body["manager"] != "go-worker" || body["effective_manager"] != "triage" || body["manager_default"] != true { + t.Fatalf("worker as manager should fall back: %v", body) + } + + list := decode(t, do(t, s, http.MethodGet, "/api/teams", nil)) + if list["default_manager"] != "triage" { + t.Fatalf("default_manager=%v", list["default_manager"]) + } +} + +// One click gives a team its own project manager: a triage-capable agent with +// the team written into its prompt, and the team pointed at it. +func TestCreateTeamManagerWritesAgentAndSeat(t *testing.T) { + s, root := teamServer(t) + rec := do(t, s, http.MethodPost, "/api/teams/backend-go/manager", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + body := decode(t, rec) + if body["manager"] != "backend-go-triage" || body["created"] != true { + t.Fatalf("body=%v", body) + } + team, _ := body["team"].(map[string]interface{}) + if team["manager"] != "backend-go-triage" || team["effective_manager"] != "backend-go-triage" || team["manager_default"] != false { + t.Fatalf("team=%v", team) + } + managers, _ := body["managers"].([]interface{}) + found := false + for _, m := range managers { + if m == "backend-go-triage" { + found = true + } + } + if !found { + t.Fatalf("new manager is not triage-capable: %v", managers) + } + + // The agent file carries the team's charter and people, on top of the + // builtin rules the decoding grammar is derived from. + agentBody := readFile(t, filepath.Join(root, ".slmcode", "agents", "backend-go-triage.yaml")) + for _, want := range []string{"project manager of the Backend", "go-worker", "cmd/**", "Pick from the ROSTER"} { + if !strings.Contains(agentBody, want) { + t.Fatalf("agent prompt lacks %q:\n%s", want, agentBody) + } + } + if !strings.Contains(agentBody, "tools: false") { + t.Fatalf("a manager must have no tools, or it answers the worker contract:\n%s", agentBody) + } + + // Idempotent: the agent is kept, the seat is (re)written. + rec = do(t, s, http.MethodPost, "/api/teams/backend-go/manager", nil) + if rec.Code != http.StatusOK || decode(t, rec)["created"] != false { + t.Fatalf("second call: status=%d body=%s", rec.Code, rec.Body.String()) + } + + rec = do(t, s, http.MethodPost, "/api/teams/no-such-team/manager", nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("unknown team: status=%d", rec.Code) + } +} + +// Assigning a task to a team by hand goes through the same rule the wave +// fence applies: ownership decides, so an assignment the next save would undo +// is refused with the reason instead. +func TestPatchTaskAssignsATeamUnlessOwnershipDisagrees(t *testing.T) { + s, root := teamServer(t) + if err := squads.Save(filepath.Join(root, ".slmcode"), twoTeamPlan()); err != nil { + t.Fatal(err) + } + add := func(id string, files []string) { + t.Helper() + rec := do(t, s, http.MethodPost, "/api/tasks", map[string]interface{}{ + "id": id, "title": id, "role": "worker", "files": files, + }) + if rec.Code != http.StatusOK { + t.Fatalf("add %s: status=%d body=%s", id, rec.Code, rec.Body.String()) + } + } + add("T1", nil) + add("T2", []string{"cmd/main.go"}) + + // No files: whatever a human says sticks. + rec := do(t, s, http.MethodPatch, "/api/tasks/T1", map[string]interface{}{"squad": "frontend-react"}) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if got := decode(t, rec)["squad"]; got != "frontend-react" { + t.Fatalf("squad=%v", got) + } + // Files the backend owns: the frontend cannot have it, and the reason + // names the owner. + rec = do(t, s, http.MethodPatch, "/api/tasks/T2", map[string]interface{}{"squad": "frontend-react"}) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "owned by backend-go") { + t.Fatalf("body=%s", rec.Body.String()) + } + // The owning team is fine, and so is un-assigning. + rec = do(t, s, http.MethodPatch, "/api/tasks/T2", map[string]interface{}{"squad": "backend-go"}) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + rec = do(t, s, http.MethodPatch, "/api/tasks/T1", map[string]interface{}{"squad": ""}) + if rec.Code != http.StatusOK || decode(t, rec)["squad"] != nil { + t.Fatalf("un-assign: status=%d body=%s", rec.Code, rec.Body.String()) + } + // A team that is not on the chart is a 400, not a silent stamp. + rec = do(t, s, http.MethodPatch, "/api/tasks/T1", map[string]interface{}{"squad": "platform"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("unknown team: status=%d body=%s", rec.Code, rec.Body.String()) + } + // Any other patch leaves the stamp alone. + rec = do(t, s, http.MethodPatch, "/api/tasks/T2", map[string]interface{}{"title": "renamed"}) + if rec.Code != http.StatusOK || decode(t, rec)["squad"] != "backend-go" { + t.Fatalf("title patch moved the stamp: status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +// The preselect preview says what the selection does to the run and who +// staffs each team, in the same words the composition will carry. +func TestPreselectReportsModeAndStaffing(t *testing.T) { + s, _ := teamServer(t) + rec := do(t, s, http.MethodPost, "/api/teams/preselect", map[string]interface{}{ + "query": "add a Go API endpoint and the React page that calls it", + }) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + body := decode(t, rec) + if body["mode"] != "parallel" { + t.Fatalf("mode=%v note=%v", body["mode"], body["note"]) + } + staffed, _ := body["teams"].([]interface{}) + if len(staffed) != 2 { + t.Fatalf("teams=%v", body["teams"]) + } + first, _ := staffed[0].(map[string]interface{}) + if first["manager"] != "triage" || first["manager_default"] != true { + t.Fatalf("staffing=%v", first) + } + + // A pin is additive: the workspace's own markers still put the other half + // on the run, and the pinned team leads. + rec = do(t, s, http.MethodPost, "/api/teams/preselect", map[string]interface{}{ + "query": "tidy the module manifest", "pinned": []string{"frontend-react"}, + }) + body = decode(t, rec) + selected, _ := body["selected"].([]interface{}) + if body["mode"] != "parallel" || len(selected) == 0 || selected[0] != "frontend-react" { + t.Fatalf("pinned first: mode=%v selected=%v", body["mode"], body["selected"]) + } +} + +// The mode line is the one sentence the page leads with, so each shape has +// its own words. +func TestTeamModeNoteSaysWhatTheSelectionDoes(t *testing.T) { + one := teams.Selection{Teams: []teams.Team{{ID: "backend-go"}}} + two := teams.Selection{Teams: []teams.Team{{ID: "backend-go"}, {ID: "frontend-react"}}} + if mode, note := teamModeNote(teams.Selection{}, true); mode != "" || !strings.Contains(note, "no team matched") { + t.Fatalf("none: %q %q", mode, note) + } + if mode, note := teamModeNote(one, true); mode != "single" || !strings.Contains(note, "staffs the run") { + t.Fatalf("one: %q %q", mode, note) + } + if mode, note := teamModeNote(two, true); mode != "parallel" || !strings.Contains(note, "in parallel") { + t.Fatalf("two: %q %q", mode, note) + } + if mode, note := teamModeNote(two, false); mode != "" || !strings.Contains(note, "squads: false") { + t.Fatalf("two, teams off: %q %q", mode, note) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) //nolint:gosec // test fixture path under t.TempDir() + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/pkg/server/teams.go b/pkg/server/teams.go index c8f7925..f14e73d 100644 --- a/pkg/server/teams.go +++ b/pkg/server/teams.go @@ -2,6 +2,7 @@ package server import ( "encoding/json" + "fmt" "net/http" "sort" "strings" @@ -44,24 +45,45 @@ type teamPayload struct { Language string `json:"language,omitempty"` } +// runDefaultManager is the agent that triages a team's rejected work when the +// team names nobody, or names someone who cannot answer the triage contract. +const runDefaultManager = agents.RoleTriage + +// effectiveManager resolves the manager a team will actually get. +// +// The same rule the run applies (orchestrator.effectiveManager): a manager has +// to answer the triage contract, not merely exist, and the run default always +// can. Reported alongside the team so the page never shows a manager the run +// would then silently replace. +func effectiveManager(named string, managers map[string]bool) (string, bool) { + named = strings.ToLower(strings.TrimSpace(named)) + if named == "" || !managers[named] { + return runDefaultManager, true + } + return named, false +} + // teamView renders one library team for Studio. -func teamView(b *blocks.TeamBlock) map[string]interface{} { +func teamView(b *blocks.TeamBlock, managers map[string]bool) map[string]interface{} { if b == nil { return nil } t := b.Spec + manager, isDefault := effectiveManager(t.Manager, managers) return map[string]interface{}{ - "id": t.ID, - "name": t.Name, - "charter": t.Charter, - "owns": t.Owns, - "acceptance": t.Acceptance, - "worker": t.Worker, - "reviewer": t.Reviewer, - "tester": t.Tester, - "manager": t.Manager, - "agents": t.Agents, - "skills": t.Skills, + "effective_manager": manager, + "manager_default": isDefault, + "id": t.ID, + "name": t.Name, + "charter": t.Charter, + "owns": t.Owns, + "acceptance": t.Acceptance, + "worker": t.Worker, + "reviewer": t.Reviewer, + "tester": t.Tester, + "manager": t.Manager, + "agents": t.Agents, + "skills": t.Skills, "match": map[string]interface{}{ "keywords": t.Match.Keywords, "files": t.Match.Files, @@ -90,9 +112,10 @@ func (s *Server) handleListTeams(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + managers := s.managerSet() list := make([]map[string]interface{}, 0, len(reg.Teams)) for _, id := range sortedTeamIDs(reg) { - list = append(list, teamView(reg.Teams[id])) + list = append(list, teamView(reg.Teams[id], managers)) } // An optional query previews what WOULD be selected, so the page can show @@ -110,12 +133,30 @@ func (s *Server) handleListTeams(w http.ResponseWriter, r *http.Request) { "managers": s.triageCapableAgents(), "library_enabled": cfg.TeamLibrary, "squads_enabled": cfg.Squads, + "dynamic_enabled": cfg.DynamicPipeline, + "default_manager": runDefaultManager, "pinned": cfg.Teams, "pipeline_teams": s.pipelineTeams(), "preselect": preselect, + "running": s.isRunning(), }) } +func (s *Server) isRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.running +} + +// managerSet is the triage-capable roster as a lookup. +func (s *Server) managerSet() map[string]bool { + out := map[string]bool{} + for _, id := range s.triageCapableAgents() { + out[strings.ToLower(id)] = true + } + return out +} + func sortedTeamIDs(reg *blocks.Registry) []string { out := make([]string, 0, len(reg.Teams)) for id := range reg.Teams { @@ -136,7 +177,7 @@ func (s *Server) handleGetTeam(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", http.StatusNotFound) return } - writeJSON(w, teamView(b)) + writeJSON(w, teamView(b, s.managerSet())) } func (s *Server) handleCreateTeam(w http.ResponseWriter, r *http.Request) { @@ -202,7 +243,7 @@ func (s *Server) writeTeam(w http.ResponseWriter, r *http.Request, pathID string http.Error(w, "saved but not discoverable — check .slmcode/blocks/teams/", http.StatusInternalServerError) return } - writeJSON(w, teamView(saved)) + writeJSON(w, teamView(saved, s.managerSet())) } // handleDeleteTeam removes a project-level team. @@ -270,19 +311,54 @@ func (s *Server) preselectView(reg *blocks.Registry, query string, pinned []stri "selected": ev.Selected, "conflict": ev.Conflict, "pinned": ev.Pinned, }) } + + // Who will actually work, per selected team, after the staff check — the + // same answer the composition carries (composer.TeamChoice), so the page + // and the run setup panel never disagree about a team's manager. + managers := s.managerSet() + staffed := make([]map[string]interface{}, 0, len(p.Squads)) + for _, sq := range p.Squads { + manager, isDefault := effectiveManager(sq.Manager, managers) + staffed = append(staffed, map[string]interface{}{ + "id": sq.ID, "name": sq.Name, "worker": sq.Worker, "reviewer": sq.Reviewer, + "tester": sq.Tester, "manager": manager, "manager_default": isDefault, + "agents": sq.Agents, "skills": sq.Skills, "owns": sq.Owns, "acceptance": sq.Acceptance, + }) + } + mode, note := teamModeNote(sel, cfg.Squads) return map[string]interface{}{ "query": query, "selected": sel.IDs(), "evidence": evidence, // enabled is the fact that matters: fewer than two teams means this // request runs as one stream no matter how well any single team scored. - "enabled": sel.Enabled(), + "enabled": sel.Enabled() && cfg.Squads, + "mode": mode, + "note": note, + "teams": staffed, "problems": problems, "staffing": notes, "pinned": pinned, } } +// teamModeNote says what a selection does to a run, in the composition's +// vocabulary (composer.TeamMode*): parallel teams, one team staffing the run, +// or none. +func teamModeNote(sel teams.Selection, squadsOn bool) (string, string) { + ids := sel.IDs() + switch { + case len(ids) == 0: + return "", "no team matched — the request runs as one stream" + case len(ids) == 1: + return "single", "team " + ids[0] + " staffs the run as one stream: its worker, reviewer, tester and skills take the pipeline" + case !squadsOn: + return "", strings.Join(ids, ", ") + " matched, but teams are turned off for this project (squads: false) — the request runs as one stream" + default: + return "parallel", strings.Join(ids, " + ") + " build in parallel behind a frozen contract" + } +} + // handleActivateTeams writes a squad plan from library teams, outside a run. // // This is what makes the Teams page usable when nothing is running. Composing @@ -486,3 +562,182 @@ func (s *Server) pipelineTeams() []string { } return o.Pipeline().Teams } + +// ── A dedicated project manager for one team ───────────────────────────── + +// handleCreateTeamManager gives a team its own project manager. +// +// A manager is an ordinary custom agent whose id ends in "-triage" — that +// suffix is what maps it to the triage contract (agents.NormalizeDecoding), so +// the loop can read its verdicts. Writing one by hand means knowing that rule, +// the prompt the builtin manager runs with, and that a manager must have no +// tools; this endpoint does all three, seeds the prompt with the team's charter +// and roster so the manager knows whose work it answers for, and points the +// team at it in one step. Idempotent: an existing agent of that id is kept and +// only the team's manager seat is written. +func (s *Server) handleCreateTeamManager(w http.ResponseWriter, r *http.Request) { + if s.rejectMutationWhileRunning(w) { + return + } + id := strings.ToLower(strings.TrimSpace(r.PathValue("id"))) + var body struct { + Title string `json:"title"` + SystemPrompt string `json:"system_prompt"` + } + if r.Body != nil && r.ContentLength != 0 { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + } + reg, err := blocks.Load(s.cfg().Root) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + b, ok := reg.GetTeam(id) + if !ok { + http.Error(w, "not found", http.StatusNotFound) + return + } + team := b.Spec + + managerID := team.ID + "-triage" + created := false + if !s.agentRegistered()(managerID) { + title := strings.TrimSpace(body.Title) + if title == "" { + title = team.Name + " project manager" + } + prompt := strings.TrimSpace(body.SystemPrompt) + if prompt == "" { + prompt = managerPrompt(team) + } + spec := agents.CustomSpec{ + ID: managerID, + Title: title, + Description: "Decides who on the " + team.Name + " team takes a rejected delivery next, and what they need to know.", + SystemPrompt: prompt, + Tools: agents.BoolPtr(false), + MaxIter: 2, + Temperature: 0.15, + MaxTokens: 640, + } + if _, err := agents.WriteCustom(s.cfg().AgentsDir(), spec); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := s.rebuildOrchestrator(); err != nil { + http.Error(w, "manager saved but rebuild failed: "+err.Error(), http.StatusInternalServerError) + return + } + created = true + } + + // The team now names its manager. Written as a project override, which is + // what editing any team does — a builtin is shadowed, not mutated. + block := &blocks.TeamBlock{Meta: b.Meta, Spec: team} + block.Spec.Manager = managerID + block.Spec.Source, block.Spec.Path = "", "" + if err := block.Validate(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if _, err := blocks.Save(s.cfg().Root, block); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + reg, err = blocks.Load(s.cfg().Root) + if err != nil { + http.Error(w, "saved but reload failed: "+err.Error(), http.StatusInternalServerError) + return + } + saved, ok := reg.GetTeam(team.ID) + if !ok { + http.Error(w, "saved but not discoverable", http.StatusInternalServerError) + return + } + s.emit(orchestrator.Event{ + Phase: "charter", Kind: "output", + Message: "team " + team.ID + " now has its own project manager: " + managerID, Time: time.Now(), + }) + writeJSON(w, map[string]interface{}{ + "ok": true, + "manager": managerID, + "created": created, + "team": teamView(saved, s.managerSet()), + "managers": s.triageCapableAgents(), + }) +} + +// managerPrompt is the builtin triage prompt with the team written in: whose +// work this manager answers for, who is on the team, and where its territory +// ends. The rules and the output shape are the builtin's verbatim, because the +// decoding grammar is derived from them. +func managerPrompt(t teams.Team) string { + var b strings.Builder + b.WriteString("You are the project manager of the " + t.Name + " team (" + t.ID + ").\n") + if t.Charter != "" { + b.WriteString("Team charter: " + t.Charter + "\n") + } + var people []string + for _, id := range append([]string{t.Worker, t.Reviewer, t.Tester}, t.Agents...) { + if id = strings.TrimSpace(id); id != "" { + people = append(people, id) + } + } + if len(people) > 0 { + b.WriteString("Your people: " + strings.Join(people, ", ") + ". Prefer them; reach outside the team only when the fix needs a skill they lack.\n") + } + if len(t.Owns) > 0 { + b.WriteString("Team territory: " + strings.Join(t.Owns, ", ") + ". A fix that needs files outside it belongs to another team or to integration — say so in guidance.\n") + } + b.WriteString("\n") + b.WriteString(agents.PromptTriage) + return b.String() +} + +// ── Assigning a task to a team by hand ─────────────────────────────────── + +// teamAssignmentProblem refuses a team that is not on the org chart. +func teamAssignmentProblem(p *squads.Plan, team string) string { + team = strings.ToLower(strings.TrimSpace(team)) + if team == "" { + return "" + } + if p == nil { + return "no org chart — activate teams on the Teams page before assigning tasks to one" + } + if _, ok := p.Squad(team); !ok { + return "no team " + team + " on the org chart (" + strings.Join(p.IDs(), ", ") + ")" + } + return "" +} + +// ownershipRefusal is why a hand assignment would not stick. +// +// A stamp is a write permission at the wave fence, and the fence is derived +// from the task's FILES: a task whose files all sit in the backend's territory +// is re-stamped backend on the next save whatever the board says +// (squads.RetargetAssignments). Refusing here, with the reason, beats +// accepting an assignment the next wave silently undoes. A task with no files, +// or files no single team owns, keeps whatever a human puts there. +func ownershipRefusal(p *squads.Plan, t plan.Task, team string) string { + team = strings.ToLower(strings.TrimSpace(team)) + if p == nil || team == "" || len(t.Files) == 0 { + return "" + } + a := p.Assign(t.Files) + if a.Squad == "" || a.Squad == team { + return "" + } + return fmt.Sprintf("%s cannot move to %s: its files (%s) are owned by %s, and ownership decides the stamp — "+ + "change the task's files or the team's paths first", t.ID, team, strings.Join(limitPaths(t.Files, 3), ", "), a.Squad) +} + +func limitPaths(in []string, n int) []string { + if len(in) <= n { + return in + } + return append(append([]string{}, in[:n]...), fmt.Sprintf("+%d more", len(in)-n)) +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 37fc900..d97d1b3 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -53,6 +53,8 @@ import type { TeamsLibrary, TeamSpec, TeamPreselect, + TeamActivity, + TeamManagerResponse, ConfigureResult, } from '@/types'; @@ -446,10 +448,15 @@ export async function getComposition(): Promise { return request('/composition'); } -export async function previewComposition(query: string): Promise { +/** + * POST /api/composition/preview → the deterministic composition for a query, + * with `teams` pinned when the run being set up pins them, so the preview shows + * the staffing the run will actually use. + */ +export async function previewComposition(query: string, teams?: string[]): Promise { return request('/composition/preview', { method: 'POST', - body: JSON.stringify({ query }), + body: JSON.stringify(teams && teams.length > 0 ? { query, teams } : { query }), }); } @@ -830,3 +837,30 @@ export async function activateTeams( body: JSON.stringify({ teams, summary }), }); } + +/** + * GET /api/teams/activity → what the managers decided and how the teams + * collaborated on the current run, or on a past one when `query` names it. + */ +export async function getTeamActivity(query?: string, limit?: number): Promise { + const params = new URLSearchParams(); + if (query) params.set('query', query); + if (limit) params.set('limit', String(limit)); + const qs = params.toString(); + return request(`/teams/activity${qs ? `?${qs}` : ''}`); +} + +/** + * POST /api/teams/{id}/manager → give a team its own project manager: a + * triage-capable agent seeded with the team's charter and roster, and the team + * pointed at it. Idempotent. + */ +export async function createTeamManager( + id: string, + opts?: { title?: string; system_prompt?: string }, +): Promise { + return request(`/teams/${encodeURIComponent(id)}/manager`, { + method: 'POST', + body: JSON.stringify(opts ?? {}), + }); +} diff --git a/web/src/components/Board/KanbanBoard.tsx b/web/src/components/Board/KanbanBoard.tsx index 3d70510..ae3eb54 100644 --- a/web/src/components/Board/KanbanBoard.tsx +++ b/web/src/components/Board/KanbanBoard.tsx @@ -62,6 +62,8 @@ export default function KanbanBoard() { // which is most of them — the strip hides itself rather than showing an empty // header nobody can act on. const [squads, setSquads] = useState(null); + // The org chart's team ids, so a card can be assigned to one. + const teamIDs = useMemo(() => (squads?.ok ? (squads.squads ?? []).map((s) => s.id) : []), [squads]); const [teamFilter, setTeamFilter] = useState(''); const addTitleRef = useRef(null); @@ -428,7 +430,7 @@ export default function KanbanBoard() { >
{tasks.map((task) => ( - + ))} {tasks.length === 0 && (
@@ -445,7 +447,7 @@ export default function KanbanBoard() { {activeTask && (
- +
)}
diff --git a/web/src/components/Board/TaskCard.tsx b/web/src/components/Board/TaskCard.tsx index 11a6426..82ec3d7 100644 --- a/web/src/components/Board/TaskCard.tsx +++ b/web/src/components/Board/TaskCard.tsx @@ -29,6 +29,8 @@ interface TaskCardProps { task: Task; columns: string[]; columnLabels: Record; + /** Teams on the org chart, so a task can be assigned to one. Empty hides the field. */ + teams?: string[]; onUpdate: () => void; isDragOverlay?: boolean; } @@ -37,6 +39,7 @@ interface TaskDraft { title: string; description: string; role: string; + squad: string; column: string; status: string; priority: string; @@ -53,7 +56,7 @@ const STATUS_ICON: Record = { const STATUS_OPTIONS = ['todo', 'scoped', 'ready', 'running', 'review', 'correcting', 'blocked', 'failed', 'done']; -export default function TaskCard({ task, columns, columnLabels, onUpdate, isDragOverlay }: TaskCardProps) { +export default function TaskCard({ task, columns, columnLabels, teams = [], onUpdate, isDragOverlay }: TaskCardProps) { const confirm = useConfirm(); const toast = useToast(); const [expanded, setExpanded] = useState(false); @@ -97,6 +100,9 @@ export default function TaskCard({ task, columns, columnLabels, onUpdate, isDrag if (title !== task.title) patch.title = title; if (draft.description !== (task.description || '')) patch.description = draft.description; if (draft.role.trim() !== (task.role || '')) patch.role = draft.role.trim(); + // Sent only when changed: the server treats a present `squad` as an + // instruction, and un-assigning is a real one. + if (draft.squad !== (task.squad || '')) patch.squad = draft.squad; if (draft.column !== (task.column || 'to_scope')) patch.column = draft.column; if (draft.status !== (task.status || '')) patch.status = draft.status; @@ -118,6 +124,10 @@ export default function TaskCard({ task, columns, columnLabels, onUpdate, isDrag } setEditing(false); setExpanded(true); + } catch (err) { + // An assignment ownership refuses comes back as a 422 with the reason + // — the one save error a user can act on, so it is shown, not swallowed. + toast.reportError(err, 'Could not save the task'); } finally { setSaving(false); } @@ -340,6 +350,24 @@ export default function TaskCard({ task, columns, columnLabels, onUpdate, isDrag placeholder="worker" /> + {(teams.length > 0 || task.squad) && ( + + )}
+ + ); +} + +/** + * PreselectSummary says what would happen AND why. + * + * "Teams: backend, frontend" is not enough to argue with. The reasons are what + * turn a preselection the user disagrees with into an edit they can make — a + * keyword to add, a marker file to name, a glob to narrow — and the staffing is + * what turns "the backend team" into the people who will actually show up. + */ +function PreselectSummary({ + preselect, + squadsEnabled, + defaultManager, + byID, +}: { + preselect: TeamPreselect; + squadsEnabled: boolean; + defaultManager: string; + byID: Map; +}) { + const chosen = preselect.selected ?? []; + const rejected = (preselect.evidence ?? []).filter((e) => !e.selected); + const evidence = new Map((preselect.evidence ?? []).map((e) => [e.team_id, e])); + // The server resolves managers; a preview from an older server (no `teams`) + // falls back to the library card's own answer. + const staffing: TeamStaffing[] = + preselect.teams && preselect.teams.length > 0 + ? preselect.teams + : chosen.map((id) => { + const t = byID.get(id); + return { + id, + name: t?.name, + worker: t?.worker, + reviewer: t?.reviewer, + tester: t?.tester, + manager: t?.effective_manager || t?.manager || defaultManager, + manager_default: t?.manager_default ?? !t?.manager, + skills: t?.skills, + owns: t?.owns, + }; + }); + const parallel = preselect.enabled ?? (chosen.length >= 2 && squadsEnabled); + + return ( +
+

+

+ + {staffing.length > 0 && ( +
+ {staffing.map((s) => { + const ev = evidence.get(s.id); + const color = teamColor(s.id); + return ( +
+
+ {s.id} + {ev?.pinned ? ( + pinned + ) : ev ? ( + score {ev.score} + ) : null} +
+
+ + + +
+
manager
+
+ {s.manager || defaultManager} + {(s.manager_default ?? !s.manager) && ( + + (run default) + + )} +
+
+ {(s.skills ?? []).length > 0 && ( +
+
skills
+
{s.skills!.join(' ')}
+
+ )} +
+ {ev && !ev.pinned && (ev.reasons ?? []).length > 0 && ( +

{ev.reasons!.join('; ')}

+ )} + {ev?.pinned &&

pinned by hand

} +
+ ); + })} +
+ )} + + {rejected.length > 0 && ( +
+ {rejected.length} team(s) considered and not selected +
    + {rejected.map((e) => ( +
  • + {e.team_id} (score {e.score}) + {e.conflict && <> — territory already claimed by {e.conflict}} + {!e.conflict && (e.reasons ?? []).length > 0 && <> — {(e.reasons ?? []).join('; ')}} +
  • + ))} +
+
+ )} + {(preselect.staffing ?? []).length > 0 && ( +
    + {preselect.staffing!.map((n) => ( +
  • +
  • + ))} +
+ )} + {(preselect.problems ?? []).length > 0 && ( +
    + {preselect.problems!.map((p) => ( +
  • + {p} +
  • + ))} +
+ )} +
+ ); +} + +function Seat({ label, id }: { label: string; id?: string }) { + return ( +
+
{label}
+
+ {id || pipeline default} +
+
+ ); +} + +/** + * CompositionPreview is the pipeline the composer would assemble — the + * deterministic guess, from the same code the run falls back to. It is shown + * here because the team decision and the pipeline decision are one decision + * now: a team's worker is who execute gets bound to. + */ +function CompositionPreview({ + composition, + dynamicEnabled, +}: { + composition: DynamicComposition; + dynamicEnabled: boolean; +}) { + const phases = (composition.phases ?? []).filter((p) => p.enabled && p.when !== 'never'); + const exec = composition.execute; + return ( +
+
+
+ {composition.team_note && ( +

+

+ )} +
+ {phases.map((p, i) => ( + + {p.id} + {p.agent && @{p.agent}} + + ))} +
+

+ loop: worker {exec?.default_role || 'worker'} · reviewer{' '} + {exec?.reviewer || 'reviewer'} · corrector{' '} + {exec?.corrector || 'corrector'} + {exec?.max_waves ? <> · waves {exec.max_waves} : null} +

+
+ ); +} diff --git a/web/src/components/Teams/TeamEditor.tsx b/web/src/components/Teams/TeamEditor.tsx index 4952298..5badadd 100644 --- a/web/src/components/Teams/TeamEditor.tsx +++ b/web/src/components/Teams/TeamEditor.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { UserCog } from 'lucide-react'; import { Modal } from '@/components/ui/Modal'; import { slugify } from './teamId'; import MultiPicker from './MultiPicker'; @@ -31,9 +32,16 @@ export interface TeamEditorProps { skills?: Skill[]; /** Ids already taken, so a create cannot silently overwrite a team. */ takenIds: string[]; + /** The agent that manages a team naming no manager of its own. */ + defaultManager?: string; saving?: boolean; onCancel: () => void; onSave: (team: TeamSpec) => void; + /** + * Create a dedicated project manager for a SAVED team and return its id, or + * null when it could not be created. The editor adopts it into the draft. + */ + onCreateManager?: (teamID: string) => Promise; } const BLANK: TeamSpec = { @@ -58,11 +66,14 @@ export default function TeamEditor({ managers, skills = [], takenIds, + defaultManager = 'triage', saving, onCancel, onSave, + onCreateManager, }: TeamEditorProps) { const [draft, setDraft] = useState(BLANK); + const [creatingManager, setCreatingManager] = useState(false); // Reset on every open. A draft left over from the last team is how someone // saves the frontend's globs onto the backend team. @@ -231,9 +242,48 @@ export default function TeamEditor({ label="Project manager" value={draft.manager ?? ''} agents={managers} + emptyLabel={`run default (${defaultManager})`} onChange={(v) => patch({ manager: v })} />
+ {/* Every team has a manager — the run default when it names none. + A DEDICATED one knows the team's charter and people, and is what + makes "the backend's manager decides" mean something. It exists + only for a saved team, because the agent is named after the id. */} +
+
{/* The roster, in whatever number the team needs. Four seats is a shape the harness dispatches, not a shape a team has to be — and a form that offers only four quietly refuses the fifth person the @@ -363,12 +413,14 @@ function AgentPicker({ label, value, agents, + emptyLabel = 'pipeline default', onChange, }: { id: string; label: string; value: string; agents: string[]; + emptyLabel?: string; onChange: (v: string) => void; }) { const missing = value !== '' && !agents.includes(value); @@ -383,7 +435,7 @@ function AgentPicker({ onChange={(e) => onChange(e.target.value)} className="input h-8 w-full text-[11px]" > - + {missing && } {agents.map((a) => (