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..a759391 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,68 @@ # Changelog +## Unreleased + +Teams are a thing you build and send work to, not a panel that fills in when a +run happens to assemble some. + +### Added + +- **Send a request to teams.** The Teams page has a *Send a request* section: + type a request and see the teams it selects and why, who staffs and manages + each, what the selection does to the run, and the pipeline the composer would + assemble — then **Run** it with exactly those teams pinned, or **Activate** + them as the org chart. The Live view's command bar has the same **Teams** + control; both send `teams` on `POST /api/runs`, run-scoped as documented. +- **Every team has a manager, and can have its own.** Cards and the editor show + the manager the run will actually use — the team's own when it answers the + triage contract, else the run default, marked as such. *Give it a manager* + (`POST /api/teams/{id}/manager`) creates `-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 honors + per-run pins (`teams` on `POST /api/composition/preview`). +- **Assign a task to a team** on the board (`squad` on `PATCH /api/tasks/{id}`), + under the rule the wave fence applies: files owned by one team keep the task + there, and the refusal names the owner. + +### Changed + +- **One pinned team staffs the run.** A single selected team no longer runs "as + one stream wearing a hat" with its staffing ignored: its worker, reviewer and + tester take the execute loop, its skills are pinned, and its charter and + territory ride in the handoff. +- **The composer model is told the teams** on the run, so the roles it picks + agree with the charter phase. It cannot add or remove a team. +- **The charter phase keeps the library's answer.** One matched team used to + send the charter to the manager model, which could invent two squads behind + a composition that had just said "one stream"; the model is now asked only + when the library has nothing to say. On a single-team run the team's own + manager (not the run default) triages its rejected work, with its people + first in the roster. +- The "manager must answer the triage contract, else the run default" rule + lives once, in `agents.ResolveManager`, and is what the Teams page, the + composition and the loop all apply. +- Un-assigning a task whose files one team owns is refused like a wrong + assignment would be: ownership would re-stamp it on the next save. +- `GET /api/teams` reports `default_manager`, `dynamic_enabled` and `running`; + each team carries `effective_manager` and `manager_default`; + `POST /api/teams/preselect` reports `mode`, `note` and per-team staffing. + +### Fixed + +- **A resumed run kept no org chart.** The squad plan lived on disk and the + orchestrator's handle died with the process, so a resumed run executed with + no squad brief, no ownership fence, no per-team gates and no integration. + Resume now restores the plan when the board's tasks are stamped with its teams. +- A run started after `squads` was switched off in a long-lived Studio + process could inherit the previous run's org chart. + ## v0.24.0 — 2026-09-01 Ten defects found by running v0.23.0 against a local 30B, thirteen times. Nearly diff --git a/docs/squads.md b/docs/squads.md index 387ba71..a647ef5 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,39 @@ regardless of what the query says, and it wins the contested paths. slmcode run "add invoice totals" --team payments --team frontend-react ``` -Studio's run setup sends the same thing per run; it is restored when the run -ends, so a one-off choice never quietly governs every later run. +Studio sends the same thing per run — the **Teams** control on the Live view's +command bar, or *Run* on the Teams page — and it is restored when the run ends, +so a one-off choice never quietly governs every later run. + +**One pinned team is a request to that team.** Two teams run in parallel; one +team cannot, but it still *staffs* the run: its worker, reviewer and tester take +the execute loop, its skills are pinned into every task pack, its charter and +territory ride in the handoff, and **its manager** is the one asked when a +delivery is rejected. "Send this to the backend team" means the backend's +people, even when the request has no second half — and the charter phase keeps +that decision: it does not ask the manager agent to invent a second team. Only +a library with nothing to say (no team matched, nothing pinned) hands the +question to the model. + +### The composer knows the teams + +The dynamic pipeline composer used to decide phases and loop roles with no idea +which teams the charter phase would then assemble — the run setup panel showed +one staffing and the org chart used another. The team decision is now made once, +from the library, and stamped onto the composition: + +```text +Teams (in parallel) + 2 teams build in parallel behind a frozen contract: backend-go, frontend-react + backend-go worker=go-worker reviewer=go-reviewer tester=go-tester manager=triage (run default) + workspace has "go.mod"; workspace contains ".go" files + frontend-react worker=react-worker reviewer=react-reviewer tester=react-tester manager=triage (run default) + query mentions "react" +``` + +The composer *model* is told which teams are on the run so the roles it picks +agree with them; it cannot add or remove one. `slmcode compose --explain`, the +Live view's run setup panel and the Teams page all show the same answer. ### Attaching teams to a pipeline 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/agents/factory.go b/pkg/agents/factory.go index c96a0ab..4d4345d 100644 --- a/pkg/agents/factory.go +++ b/pkg/agents/factory.go @@ -625,6 +625,23 @@ func AgentsEmitting(schemaRole string, custom []CustomSpec) []string { return ids } +// ResolveManager decides which agent actually triages a team's rejected work. +// +// The rule lives here, once, because it is applied in three places that must +// agree — the Teams page, the composition, and the loop's triage call: a team +// may name any agent, but only one that answers the triage contract can be its +// manager (its decoding grammar comes from its own prompt, so anything else +// replies in a shape the reassignment step cannot read). Empty, or a nominee +// that cannot triage, falls back to the run default, which always can. The +// second result is true when the default stood in. +func ResolveManager(named string, canTriage func(id string) bool) (string, bool) { + named = strings.ToLower(strings.TrimSpace(named)) + if named == "" || canTriage == nil || !canTriage(named) { + return RoleTriage, true + } + return named, false +} + // EmitsSchema reports whether id is an agent that answers with the given // pkg/schema contract. // 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..a7b02a4 --- /dev/null +++ b/pkg/orchestrator/compose_teams.go @@ -0,0 +1,501 @@ +package orchestrator + +import ( + "fmt" + "strings" + + "github.com/UnicoLab/slmcode/pkg/agents" + "github.com/UnicoLab/slmcode/pkg/composer" + "github.com/UnicoLab/slmcode/pkg/plan" + "github.com/UnicoLab/slmcode/pkg/schema" + "github.com/UnicoLab/slmcode/pkg/squads" + "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 per run, 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, its charter and territory +// ride in the handoff, and its manager triages its rejected work. That is +// what "send this request to the backend team" means when the request has +// no second half — and the charter phase honors it (assembleSquads does +// not ask the model to invent a second team); +// - 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. + +// teamDecision is the team answer for one request, computed once and handed +// to every consumer in the run — the composer prompt, the composition, and +// the charter phase — so none of them can compute a different one. +type teamDecision struct { + Choices []composer.TeamChoice + Mode string + Note string +} + +// decideTeams 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. +// +// pins overrides the run-level pin (cfg.Teams) when non-nil, which is how a +// preview of "this request, sent to these teams" sees exactly what that run +// would; nil reads the configured pins. +func (o *Orchestrator) decideTeams(query string, inventory []string, pins []string) teamDecision { + if o == nil || o.cfg == nil { + return teamDecision{} + } + if !o.cfg.TeamLibrary { + if o.cfg.Squads { + return teamDecision{Note: "the team library is not used for this project (team_library: false) — the manager agent assembles teams at charter"} + } + return teamDecision{} + } + sel, roster := o.preselectTeamsWith(query, inventory, pins) + if len(roster) == 0 { + return teamDecision{Note: "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: + why := "no team matched this request" + if len(sel.Evidence) > 0 { + why += " (" + evidenceLine(sel) + ")" + } + if o.cfg.Squads { + // With nothing from the library the charter phase still asks the + // manager agent, so the honest answer is "one stream unless…". + return teamDecision{Note: why + " — it runs as one stream unless the manager agent assembles teams at charter"} + } + return teamDecision{Note: why + " — it runs as one stream"} + case len(choices) == 1: + c := choices[0] + return teamDecision{Choices: choices, Mode: composer.TeamModeSingle, Note: fmt.Sprintf( + "team %s staffs this run as one stream (%s) — manager %s", c.ID, c.Reason, c.Manager)} + case !o.cfg.Squads: + return teamDecision{Choices: choices, Note: 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 teamDecision{Choices: choices, Mode: composer.TeamModeParallel, Note: fmt.Sprintf( + "%d teams build in parallel behind a frozen contract: %s", + len(choices), strings.Join(idsOf(choices), ", "))} + } +} + +// preselectTeamsWith is preselectTeams with the run-level pin overridden. +func (o *Orchestrator) preselectTeamsWith(query string, inventory []string, pins []string) (teams.Selection, []teams.Team) { + roster := o.teamRoster() + if len(roster) == 0 { + return teams.Selection{}, nil + } + files := plan.ListWorkspaceFiles(o.cfg.Root, teamInventoryLimit) + if len(files) == 0 { + files = inventory + } + sel := teams.Select(roster, teams.Signals{Query: query, Files: files}, teams.Options{ + Pinned: o.pinnedTeamsWith(pins), + }) + return sel, roster +} + +// pinnedTeamsWith is pinnedTeams with cfg.Teams replaced by pins when pins is +// non-nil — the same precedence a run started with those teams would have +// (server.applyRunOptions swaps them into cfg.Teams for the run's duration), +// so a preview and the run it previews read identical pins. +func (o *Orchestrator) pinnedTeamsWith(pins []string) []string { + if o == nil || o.cfg == nil { + return nil + } + if pins == nil { + return o.pinnedTeams() + } + var out []string + seen := map[string]bool{} + add := func(ids []string) { + for _, id := range ids { + id = strings.ToLower(strings.TrimSpace(id)) + if id == "" || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + } + add(pins) + add(o.Pipeline().Teams) + return out +} + +// 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 +} + +// canTriage reports whether an agent answers the triage contract. With no +// factory (a config-only preview) every nominee is taken at its word. +func (o *Orchestrator) canTriage(id string) bool { + if o == nil || o.factory == nil { + return true + } + return o.factory.EmitsSchema(id, schema.RoleTriage) +} + +// effectiveManager resolves a team's manager to the agent that will actually +// triage its rejected work — the one rule, from pkg/agents. +func (o *Orchestrator) effectiveManager(named string) (string, bool) { + return agents.ResolveManager(named, o.canTriage) +} + +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 composeTeams(comp *composer.Composition, td teamDecision) { + if comp == nil { + return + } + comp.Teams, comp.TeamMode, comp.TeamNote = td.Choices, td.Mode, td.Note + if td.Mode != composer.TeamModeSingle || len(td.Choices) == 0 { + return + } + adoptTeamStaffing(comp, td.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) + } + } +} + +// ── The single team, at run time ───────────────────────────────────────── +// +// With two or more teams the squad plan carries each team's manager and +// roster to the loop. With ONE team there is no plan — the plan is the +// contract between halves, and one half has no seam — so the team's manager +// used to be reported on the composition and then never asked: triage fell to +// the run default, and the dedicated manager the user had just created for +// the team was ignored on exactly the runs that were sent to that team. +// +// The composition's single team is therefore kept on the run and rendered as +// a one-squad staffing wherever the loop asks "who manages this task": the +// manager, and the team's own people first in the roster. + +// singleTeamPlan renders the run's single team as a one-squad plan, for the +// staffing helpers that read one. Nil when the run has no single team. +func (o *Orchestrator) singleTeamPlan() *squads.Plan { + if o == nil { + return nil + } + o.mu.Lock() + t := o.singleTeam + o.mu.Unlock() + if t == nil { + return nil + } + return &squads.Plan{Squads: []squads.Squad{{ + ID: t.ID, Name: t.Name, Charter: t.Charter, Owns: append([]string(nil), t.Owns...), + Acceptance: t.Acceptance, Worker: t.Worker, Reviewer: t.Reviewer, Tester: t.Tester, + Manager: t.Manager, Agents: append([]string(nil), t.Agents...), Skills: append([]string(nil), t.Skills...), + }}} +} + +// staffingPlan is the plan the loop's staffing questions are answered from: +// the squad plan when the run has one, else the single team's. +func (o *Orchestrator) staffingPlan() *squads.Plan { + if o == nil { + return nil + } + o.mu.Lock() + p := o.squadPlan + o.mu.Unlock() + if p != nil { + return p + } + return o.singleTeamPlan() +} + +// singleTeamStaffing is the staffing a task gets on a single-team run: the +// whole run is that team's, so every task is. +func (o *Orchestrator) singleTeamStaffing() squads.Staffing { + p := o.singleTeamPlan() + if p == nil { + return squads.Staffing{} + } + return squads.StaffingFor(p, p.Squads[0].ID) +} + +// 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(td teamDecision) string { + if len(td.Choices) == 0 && td.Note == "" { + return "" + } + var b strings.Builder + b.WriteString("## Teams on this run (decided from the library — do NOT change them)\n") + if td.Note != "" { + b.WriteString(td.Note + "\n") + } + for _, t := range td.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 td.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..1071208 --- /dev/null +++ b/pkg/orchestrator/compose_teams_test.go @@ -0,0 +1,401 @@ +package orchestrator + +import ( + "context" + "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) + } + query := "add a Go API endpoint and the React page that calls it" + inventory := []string{"go.mod", "web/package.json"} + prompt := o.buildComposerPrompt(query, inventory, "", "", o.decideTeams(query, inventory, nil)) + 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") + } +} + +// The library's "one team" is a decision the charter phase keeps: it does not +// ask the model to invent a second team behind the composition's back. +func TestOneLibraryTeamIsDecidedNotHandedToTheModel(t *testing.T) { + 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) + o, err := New(cfg) + if err != nil { + t.Fatal(err) + } + p, decided := o.teamsFromLibrary(context.Background(), "add a dark mode toggle to the dashboard", nil, "", "") + if p != nil || !decided { + t.Fatalf("one matched team: plan=%v decided=%v — the library decided, the model must not be asked", p, decided) + } + + // Nothing matched and nothing pinned: the question stays open. + empty := t.TempDir() + if err := os.WriteFile(filepath.Join(empty, "notes.txt"), []byte("hi\n"), 0o600); err != nil { + t.Fatal(err) + } + o2, err := New(config.Default(empty)) + if err != nil { + t.Fatal(err) + } + if p, decided := o2.teamsFromLibrary(context.Background(), "rewrite the poem", nil, "", ""); p != nil || decided { + t.Fatalf("no match: plan=%v decided=%v — the model may still be asked", p, decided) + } +} + +// On a single-team run the team's own manager is the one asked about a +// rejected delivery, with the team's people first in the roster — not the run +// default, which is what a run with no squad plan used to fall back to. +func TestSingleTeamManagerStaffsTriage(t *testing.T) { + cfg := config.Default(fullstackRoot(t)) + o, err := New(cfg) + if err != nil { + t.Fatal(err) + } + o.singleTeam = &composer.TeamChoice{ + ID: "frontend-react", Worker: "react-worker", Reviewer: "react-reviewer", Tester: "react-tester", + Manager: "triage", Agents: []string{"worker"}, + } + st := o.singleTeamStaffing() + if st.Squad != "frontend-react" || st.Manager != "triage" { + t.Fatalf("staffing=%+v", st) + } + if got := strings.Join(st.Members, ","); !strings.HasPrefix(got, "react-worker,react-reviewer,react-tester,worker") { + t.Fatalf("members=%v", st.Members) + } + // The staffing plan the loop's helpers read is the single team's when + // there is no squad plan, and the squad plan when there is one. + if p := o.staffingPlan(); p == nil || len(p.Squads) != 1 || p.Squads[0].ID != "frontend-react" { + t.Fatalf("staffingPlan=%+v", p) + } + o.squadPlan = &squads.Plan{Squads: []squads.Squad{{ID: "a"}, {ID: "b"}}} + if p := o.staffingPlan(); p == nil || len(p.Squads) != 2 { + t.Fatalf("staffingPlan with a squad plan=%+v", p) + } + o.singleTeam = nil + o.squadPlan = nil + if o.staffingPlan() != nil || o.singleTeamStaffing().Squad != "" { + t.Fatalf("no team: staffing should be empty") + } +} + +// A preview with pins goes through the factory like the run does: a seat the +// run would clear is shown cleared, and a manager it would replace is shown +// replaced. +func TestPreviewWithPinsAppliesTheFactoryChecks(t *testing.T) { + root := fullstackRoot(t) + teamsDir := filepath.Join(root, ".slmcode", "blocks", "teams") + if err := os.MkdirAll(teamsDir, 0o750); err != nil { + t.Fatal(err) + } + team := "api_version: blocks/v1\nkind: team\nid: ghost\nname: Ghost\nspec:\n id: ghost\n" + + " owns: [ghost/**]\n worker: no-such-worker\n manager: go-worker\n" + if err := os.WriteFile(filepath.Join(teamsDir, "ghost.yaml"), []byte(team), 0o600); err != nil { + t.Fatal(err) + } + o, err := New(config.Default(root)) + if err != nil { + t.Fatal(err) + } + comp := o.PreviewCompositionWithTeams("tidy the module manifest", []string{"ghost"}) + tc, ok := comp.TeamChoiceFor("ghost") + if !ok { + t.Fatalf("pinned team not on the composition: %+v", comp.Teams) + } + if !tc.Pinned { + t.Fatalf("pin not marked: %+v", tc) + } + if tc.Worker != "" { + t.Fatalf("an unregistered worker must be cleared, got %q", tc.Worker) + } + if tc.Manager != "triage" || !tc.ManagerDefault { + t.Fatalf("a worker cannot manage; want the run default, got %+v", tc) + } + // The configured pins are untouched by a preview with an override. + if len(o.cfg.Teams) != 0 { + t.Fatalf("preview leaked pins into the config: %v", o.cfg.Teams) + } +} diff --git a/pkg/orchestrator/composer.go b/pkg/orchestrator/composer.go index 7216677..f7b6abe 100644 --- a/pkg/orchestrator/composer.go +++ b/pkg/orchestrator/composer.go @@ -54,18 +54,22 @@ func (o *Orchestrator) composeDynamicPipeline(ctx context.Context, query string, // specialists and skills is worth its tokens. Skipping the composer // because the classifier was SURE the work was dangerous would be the // precise inversion of the point. + // The team decision, made ONCE for the run: the composer prompt, the + // composition and the charter phase all read this same answer. + td := o.decideTeams(query, inventory, nil) + if cls := composer.Classify(query); cls.Confident && (cls.Complexity == composer.ComplexityTrivial || cls.Kind == composer.KindInquiry) { o.emitAgent("compose", composer.RoleID, "", fmt.Sprintf("budget class %s:%s — composing deterministically (%s)", cls.Complexity, cls.Kind, cls.Why), "", "") - o.composeHeuristicDynamicPipeline(query, inventory, exploreOut, archOut) + o.composeHeuristicDynamicPipeline(query, inventory, exploreOut, archOut, &td) return } o.emitAgent("compose", composer.RoleID, "", "assembling task-specific pipeline", "", "") - input := o.buildComposerPrompt(query, inventory, exploreOut, archOut) + input := o.buildComposerPrompt(query, inventory, exploreOut, archOut, td) pack := o.skillPackFor(composer.RoleID, query) if strings.TrimSpace(pack) != "" { input = pack + "\n\n" + input @@ -78,13 +82,13 @@ func (o *Orchestrator) composeDynamicPipeline(ctx context.Context, query string, return } o.emitWarn("compose", "composer failed ("+err.Error()+") — using deterministic composition", "") - o.composeHeuristicDynamicPipeline(query, inventory, exploreOut, archOut) + o.composeHeuristicDynamicPipeline(query, inventory, exploreOut, archOut, &td) return } comp, err := composer.Parse(out) if err != nil { o.emitWarn("compose", "unparsable composition ("+err.Error()+") — using deterministic composition", "") - o.composeHeuristicDynamicPipeline(query, inventory, exploreOut, archOut) + o.composeHeuristicDynamicPipeline(query, inventory, exploreOut, archOut, &td) return } // No "omitted enabled" repair here any more: composer.PhaseChoice grew an @@ -93,18 +97,18 @@ func (o *Orchestrator) composeDynamicPipeline(ctx context.Context, query string, // the raw text was a no-op on this path; ensureCriticalComposition (inside // activateDynamicComposition) still protects plan/split/execute/test from // an EXPLICIT false. - if err := o.activateDynamicComposition(&comp, query, inventory, false); err != nil { + if err := o.activateDynamicComposition(&comp, query, inventory, false, &td); err != nil { o.emitWarn("compose", "invalid composition ("+err.Error()+") — using deterministic composition", "") - o.composeHeuristicDynamicPipeline(query, inventory, exploreOut, archOut) + o.composeHeuristicDynamicPipeline(query, inventory, exploreOut, archOut, &td) } } -func (o *Orchestrator) composeHeuristicDynamicPipeline(query string, inventory []string, exploreOut, archOut string) { +func (o *Orchestrator) composeHeuristicDynamicPipeline(query string, inventory []string, exploreOut, archOut string, td *teamDecision) { if o == nil || o.cfg == nil { return } comp := heuristicComposition(query, inventory, detectProjectLang(o.cfg.Root), exploreOut, archOut) - if err := o.activateDynamicComposition(&comp, query, inventory, true); err != nil { + if err := o.activateDynamicComposition(&comp, query, inventory, true, td); err != nil { o.emitWarn("compose", "deterministic composition failed ("+err.Error()+") — keeping static pipeline", "") } } @@ -113,6 +117,15 @@ func (o *Orchestrator) composeHeuristicDynamicPipeline(query string, inventory [ // be used as the local-model fallback for this query. It does not call an LLM, // mutate pipeline state, write files, or emit events. func (o *Orchestrator) PreviewComposition(query string) composer.Composition { + return o.PreviewCompositionWithTeams(query, nil) +} + +// PreviewCompositionWithTeams is PreviewComposition for a run that would pin +// these teams (nil → the configured pins). Same factory checks as the run, so +// a seat the run would clear or a manager it would replace is shown as the run +// will have it — a preview computed without the factory would show staffing +// the run then quietly changes. +func (o *Orchestrator) PreviewCompositionWithTeams(query string, pins []string) composer.Composition { if o == nil { return PreviewCompositionForConfig(nil, query) } @@ -123,7 +136,8 @@ func (o *Orchestrator) PreviewComposition(query string) composer.Composition { lang = detectProjectLang(o.cfg.Root) } comp := heuristicComposition(query, inventory, lang, "", "") - _ = o.prepareDynamicComposition(&comp, query, inventory) + td := o.decideTeams(query, inventory, pins) + _ = o.prepareDynamicComposition(&comp, query, inventory, &td) return comp } @@ -139,15 +153,15 @@ func PreviewCompositionForConfig(cfg *config.Config, query string) composer.Comp } comp := heuristicComposition(query, inventory, lang, "", "") o := &Orchestrator{cfg: cfg} - _ = o.prepareDynamicComposition(&comp, query, inventory) + _ = o.prepareDynamicComposition(&comp, query, inventory, nil) return comp } -func (o *Orchestrator) activateDynamicComposition(comp *composer.Composition, query string, inventory []string, heuristic bool) error { +func (o *Orchestrator) activateDynamicComposition(comp *composer.Composition, query string, inventory []string, heuristic bool, td *teamDecision) error { if o == nil || o.cfg == nil || o.factory == nil || comp == nil { return fmt.Errorf("missing dynamic composition dependencies") } - unknown := o.prepareDynamicComposition(comp, query, inventory) + unknown := o.prepareDynamicComposition(comp, query, inventory, td) if len(unknown) > 0 { sort.Strings(unknown) o.emitWarn("compose", "unknown agents dropped — "+strings.Join(unknown, ", "), "") @@ -170,6 +184,13 @@ func (o *Orchestrator) activateDynamicComposition(comp *composer.Composition, qu o.dynamicBrief = compositionBrief(*comp) cp := *comp o.dynamicComposition = &cp + // The single team stays on the run so its manager is the one asked when + // a delivery is rejected — see singleTeamPlan. + o.singleTeam = nil + if comp.TeamMode == composer.TeamModeSingle && len(comp.Teams) == 1 { + st := comp.Teams[0] + o.singleTeam = &st + } o.mu.Unlock() // Persist for inspection; pipeline.yaml is intentionally left untouched. @@ -245,7 +266,10 @@ func applyBudgetProfile(runner *loop.Runner, cfg *config.Config, prof composer.P } } -func (o *Orchestrator) prepareDynamicComposition(comp *composer.Composition, query string, inventory []string) []string { +// prepareDynamicComposition repairs and completes a composition before it is +// activated or previewed. td is the run's team decision; nil computes one from +// the configured pins (the config-only preview path). +func (o *Orchestrator) prepareDynamicComposition(comp *composer.Composition, query string, inventory []string, td *teamDecision) []string { if comp == nil { return nil } @@ -253,6 +277,15 @@ func (o *Orchestrator) prepareDynamicComposition(comp *composer.Composition, que if o != nil && o.factory != nil { unknown = o.sanitizeComposition(comp) } + // The team decision, 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. + if td == nil { + d := o.decideTeams(query, inventory, nil) + td = &d + } + composeTeams(comp, *td) workerHint, testerHint := queryLanguageSpecialists(query) if workerHint == "" && o.cfg != nil { workerHint, testerHint = projectLanguageSpecialists(detectProjectLang(o.cfg.Root)) @@ -283,8 +316,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 } @@ -1287,7 +1323,7 @@ func (o *Orchestrator) availableSkillNames() map[string]bool { // buildComposerPrompt renders the full composer context (query + inventory + // exploration + phases + roster + skills) with the STRICT JSON schema contract. -func (o *Orchestrator) buildComposerPrompt(query string, inventory []string, exploreOut, archOut string) string { +func (o *Orchestrator) buildComposerPrompt(query string, inventory []string, exploreOut, archOut string, td teamDecision) string { var b strings.Builder b.WriteString("## Query\n") b.WriteString(truncate(query, 2000)) @@ -1331,6 +1367,8 @@ func (o *Orchestrator) buildComposerPrompt(query string, inventory []string, exp b.WriteString("\n\n") } + b.WriteString(teamsPromptSection(td)) + b.WriteString("## Canonical phases (copy ids exactly)\n") def := pipeline.Default() for _, id := range def.Order { @@ -1409,6 +1447,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 +1487,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/composer_test.go b/pkg/orchestrator/composer_test.go index 69233c6..6eb31f1 100644 --- a/pkg/orchestrator/composer_test.go +++ b/pkg/orchestrator/composer_test.go @@ -440,7 +440,7 @@ func TestActivateDynamicCompositionEmitsAnnotatedTelemetryWithoutPersistingHints Execute: composer.ExecuteChoice{DefaultRole: "worker", Reviewer: "reviewer", Corrector: "corrector", MaxWaves: 1}, Team: []composer.TeamMember{{Role: "worker"}}, } - if err := o.activateDynamicComposition(comp, "fix package", []string{"pkg/a.go"}, true); err != nil { + if err := o.activateDynamicComposition(comp, "fix package", []string{"pkg/a.go"}, true, nil); err != nil { t.Fatal(err) } annotated, ok := data.(composer.AnnotatedComposition) diff --git a/pkg/orchestrator/orchestrator.go b/pkg/orchestrator/orchestrator.go index 57532d4..30d13e2 100644 --- a/pkg/orchestrator/orchestrator.go +++ b/pkg/orchestrator/orchestrator.go @@ -120,6 +120,11 @@ type Orchestrator struct { // single-stream run. Set by the manager phase before plan/split; read by // the board router, the execute loop and the between-wave report. squadPlan *squads.Plan + // singleTeam is the one library team staffing a single-stream run, when + // the composition chose one (composer.TeamModeSingle). It is what makes + // that team's manager the one asked about its rejected work; a squad plan + // exists only for two or more teams. Nil on every other run. + singleTeam *composer.TeamChoice // teamGates holds each team's acceptance result for THIS run. Run state, // never written into squads.json — that file is the org chart the next run // inherits, not a record of what this one proved. @@ -1207,6 +1212,12 @@ func (o *Orchestrator) runSLM(ctx context.Context, runID, query, skillPack strin return nil, r.err } + // Team state is per run, and the orchestrator outlives a run in a + // long-lived Studio process: cleared before anything below can set it. + o.mu.Lock() + o.squadPlan, o.singleTeam = nil, nil + o.mu.Unlock() + // 2a Dynamic pipeline composition (optional): the composer specialist assembles // a task-specific pipeline (phases, team, tools, skills) before design/plan. if o.cfg.DynamicPipeline { 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/orchestrator/squads.go b/pkg/orchestrator/squads.go index 40258eb..866779e 100644 --- a/pkg/orchestrator/squads.go +++ b/pkg/orchestrator/squads.go @@ -44,9 +44,18 @@ func (o *Orchestrator) assembleSquads(ctx context.Context, query string, invento // here — overlapping globs, unregistered worker ids, unparsable JSON — // from a decision that everything downstream is derived from. if o.cfg.TeamLibrary { - if p := o.teamsFromLibrary(ctx, query, inventory, exploreOut, archOut); p != nil { + p, decided := o.teamsFromLibrary(ctx, query, inventory, exploreOut, archOut) + if p != nil { return p } + // The library's "one team" is an answer, not a gap: the composition + // already told the user that team staffs a single stream, and asking + // the model to invent a second team here would run the two halves of + // one decision against each other. Only a library with NOTHING to + // say — no roster, no match — hands the question to the model. + if decided { + return nil + } } o.emitAgent("charter", squadRoleID, "", "assembling parallel squads", "", "") @@ -577,6 +586,15 @@ func (o *Orchestrator) applyPlanEdits(board *plan.Board, edits *plan.PlanEdits) // validates the answer before applying it, so a bad verdict costs one call, not // a stalled task. func (o *Orchestrator) triageRejectedDelivery(ctx context.Context, req loop.TriageRequest) (plan.TriageDecision, bool) { + // On a single-team run there is no squad plan for the loop to read the + // staffing from, so the request arrives with none: the run's one team is + // the staffing, its people first in the roster. + if strings.TrimSpace(req.Staffing.Squad) == "" { + if st := o.singleTeamStaffing(); st.Squad != "" { + req.Staffing = st + req.Roster = squads.Colleagues(o.singleTeamPlan(), st.Squad, req.Roster) + } + } // No executor means nothing can be dispatched at all. Triage is an // accelerator over the deterministic ladder, never a prerequisite, so a // half-built orchestrator declines the question instead of crashing on it. @@ -641,11 +659,9 @@ func (o *Orchestrator) triageRejectedDelivery(ctx context.Context, req loop.Tria // A team may name its own manager; the run's `triage` agent answers for // the teams that did not. The nominee has to actually answer the triage - // contract — see Factory.EmitsSchema for why existing is not enough. - manager := agents.RoleTriage - if m := strings.TrimSpace(req.Staffing.Manager); m != "" && o.factory.EmitsSchema(m, schema.RoleTriage) { - manager = m - } + // contract — see Factory.EmitsSchema for why existing is not enough — and + // the rule is the one the Teams page and the composition apply. + manager, _ := agents.ResolveManager(req.Staffing.Manager, o.canTriage) out, err := o.runRoleTracked(ctx, manager, "", b.String()) if err != nil { @@ -717,14 +733,14 @@ func (o *Orchestrator) triageRepeatTickets(ctx context.Context, board *plan.Boar if strings.Contains(t.Notes, reassignedMarker) { continue } - staff := squads.StaffingFor(o.squadPlan, t.Squad) + staff := squads.StaffingFor(o.staffingPlan(), t.Squad) d, ok := o.triageRejectedDelivery(ctx, loop.TriageRequest{ Task: t, // The ticket body IS the review here: the tester's findings were // written into it when it was raised, and re-deriving a summary // from the board would only lose detail. Review: plan.ReviewResult{Summary: ticketHeadline(t), Issues: ticketFindings(t)}, - Roster: squads.Colleagues(o.squadPlan, t.Squad, roster), + Roster: squads.Colleagues(o.staffingPlan(), t.Squad, roster), Language: plan.LanguageOf(t.Files), Staffing: staff, }) diff --git a/pkg/orchestrator/teams.go b/pkg/orchestrator/teams.go index da23ed0..c50d2ea 100644 --- a/pkg/orchestrator/teams.go +++ b/pkg/orchestrator/teams.go @@ -87,35 +87,28 @@ const teamInventoryLimit = 2000 // preselectTeams picks the teams for this request without a model call. func (o *Orchestrator) preselectTeams(query string, inventory []string) (teams.Selection, []teams.Team) { - roster := o.teamRoster() - if len(roster) == 0 { - return teams.Selection{}, nil - } - files := plan.ListWorkspaceFiles(o.cfg.Root, teamInventoryLimit) - if len(files) == 0 { - files = inventory - } - sel := teams.Select(roster, teams.Signals{Query: query, Files: files}, teams.Options{ - Pinned: o.pinnedTeams(), - }) - return sel, roster + return o.preselectTeamsWith(query, inventory, nil) } // teamsFromLibrary builds and activates a squad plan from the library. // -// Returns nil when the library cannot answer — no teams, or fewer than two -// selected — which is the signal for assembleSquads to fall back to the model. -func (o *Orchestrator) teamsFromLibrary(ctx context.Context, query string, inventory []string, exploreOut, archOut string) *squads.Plan { +// The plan is nil when the library did not produce two runnable teams. The +// second result says whether the library DECIDED anyway: one team selected is +// a decision — that team staffs a single stream, as the composition already +// announced — and assembleSquads must not then ask the model to invent a +// second one. Only a library with nothing to say (no roster, no match) leaves +// the question open. +func (o *Orchestrator) teamsFromLibrary(ctx context.Context, query string, inventory []string, exploreOut, archOut string) (*squads.Plan, bool) { sel, roster := o.preselectTeams(query, inventory) if len(roster) == 0 { - return nil + return nil, false } if !sel.Enabled() { // Reported, not warned: one team (or none) is the correct answer for a // single-domain request, which is most of them. Saying WHY is what // stops "why did my teams not run" from being unanswerable. o.emit("charter", "team library: "+selectionLine(sel), "") - return nil + return nil, len(sel.Teams) > 0 } p := teams.Compose(sel, "teams preselected from the library: "+strings.Join(sel.IDs(), " + ")) @@ -156,18 +149,18 @@ func (o *Orchestrator) teamsFromLibrary(ctx context.Context, query string, inven o.emitWarn("charter", "team plan rejected: "+pr.Message, "") } } - return nil + return nil, true } if err := squads.Save(o.cfg.SlmDir(), p); err != nil { o.emitWarn("charter", "could not save the team plan ("+err.Error()+") — running as a single stream", "") - return nil + return nil, true } o.emit("charter", p.Summarize(), "") for _, s := range p.Squads { o.emitAgent("charter", "manager", "", fmt.Sprintf("team %s owns %s", s.ID, strings.Join(s.Owns, ", ")), "", "") } - return &p + return &p, true } // fillContract asks the manager for the seam between teams that already exist. @@ -274,8 +267,8 @@ func selectionLine(sel teams.Selection) string { return "no team matched this request (" + evidenceLine(sel) + ") — running as a single stream" } if len(sel.Teams) == 1 { - return "only " + sel.Teams[0].ID + " matched — one team is the single-stream pipeline wearing a hat, " + - "so it runs as one stream" + return "only " + sel.Teams[0].ID + " matched — it staffs this run as one stream (its worker, reviewer, " + + "tester, skills and manager), and no second team is invented" } return fmt.Sprintf("%d teams selected: %s", len(sel.Teams), strings.Join(sel.IDs(), ", ")) } diff --git a/pkg/server/server.go b/pkg/server/server.go index ba4dc8f..c141bf8 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,51 @@ 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") } + // Files first: a patch that moves a task AND changes its files is + // judged on the files it will have, not the ones it had. + if patch.Files != nil { + t.Files = patch.Files + } + 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 +771,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 +2000,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 +2014,19 @@ func (s *Server) handlePreviewComposition(w http.ResponseWriter, r *http.Request return } var comp composer.Composition - if s.h != nil && s.orch() != nil { - comp = s.orch().PreviewComposition(req.Query) - } else { + switch { + case s.h != nil && s.orch() != nil: + // Through the orchestrator whenever there is one, pins or not: it has + // the agent factory, so a seat the run would clear or a manager it + // would replace is previewed as the run will have it. + comp = s.orch().PreviewCompositionWithTeams(req.Query, req.Teams) + case len(req.Teams) > 0: + // No orchestrator: a copy of the config with the run's pins in place, + // never the shared config itself. + c := *s.cfg() + c.Teams = append([]string{}, req.Teams...) + comp = orchestrator.PreviewCompositionForConfig(&c, req.Query) + 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..821da43 --- /dev/null +++ b/pkg/server/team_activity.go @@ -0,0 +1,488 @@ +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 != "" { + // ReadEvents keeps the FIRST n records; a run that streamed tokens + // has tens of thousands, and the gates and late triage that matter + // most sit at the end. Read the whole log. + recs, err := session.ReadEvents(s.slmDir(), queryID, pastRunEventCap) + 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() + } + + tasks := s.boardTasks() + if queryID != "" { + if turn, err := session.LoadTurn(s.slmDir(), queryID); err == nil && turn != nil { + tasks = turn.Board.Tasks + } + } + var planPtr *squads.Plan + if p, ok, err := squads.Load(s.slmDir()); err == nil && ok && len(p.Squads) > 0 && planBelongsToBoard(&p, tasks) { + planPtr = &p + } + 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), + }) +} + +// pastRunEventCap bounds a past run's event log read for the timeline. +const pastRunEventCap = 250000 + +// planBelongsToBoard says whether the saved org chart is the one this board +// was built under. +// +// The plan on disk is a PROJECT fact — written by the last run that had +// teams, or by Activate on the Teams page — and the run whose events are +// being read may have had none. The board is the arbiter, the same way +// Resume decides (orchestrator.restoreSquadPlan): a board with tasks and no +// team stamp from this plan ran without it, and showing its managers would +// report two managers for a run that had none. An EMPTY board is the +// pre-run state after Activate, where the chart is exactly what to show. +func planBelongsToBoard(p *squads.Plan, tasks []plan.Task) bool { + if p == nil { + return false + } + if len(tasks) == 0 { + return true + } + for _, t := range tasks { + if _, on := p.Squad(t.Squad); t.Squad != "" && on { + return true + } + } + return false +} + +// 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..5ca6963 --- /dev/null +++ b/pkg/server/team_activity_test.go @@ -0,0 +1,388 @@ +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()) + } + // Un-assigning a task the backend's territory owns would be undone on + // the next save, so it is refused the same way. + rec = do(t, s, http.MethodPatch, "/api/tasks/T2", map[string]interface{}{"squad": ""}) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "cannot be un-assigned") { + t.Fatalf("un-assign owned task: status=%d body=%s", rec.Code, rec.Body.String()) + } + // A move that also changes the files is judged on the new files. + rec = do(t, s, http.MethodPatch, "/api/tasks/T2", map[string]interface{}{ + "squad": "frontend-react", "files": []string{"web/src/App.tsx"}, + }) + if rec.Code != http.StatusOK || decode(t, rec)["squad"] != "frontend-react" { + t.Fatalf("files+squad: 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"] != "frontend-react" { + 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) +} + +// The plan on disk is a project fact; the timeline only attributes it to a +// run whose board was built under it. +func TestActivityIgnoresAPlanTheRunDidNotUse(t *testing.T) { + s, root := teamServer(t) + if err := squads.Save(filepath.Join(root, ".slmcode"), twoTeamPlan()); err != nil { + t.Fatal(err) + } + // An empty board is the post-Activate, pre-run state: the chart shows. + body := decode(t, do(t, s, http.MethodGet, "/api/teams/activity", nil)) + if managers, _ := body["managers"].([]interface{}); len(managers) != 2 { + t.Fatalf("empty board should show the activated chart: %v", body["managers"]) + } + // A board with tasks and no team stamps ran as one stream: no managers. + rec := do(t, s, http.MethodPost, "/api/tasks", map[string]interface{}{"id": "T1", "title": "solo", "role": "worker"}) + if rec.Code != http.StatusOK { + t.Fatalf("add: %d %s", rec.Code, rec.Body.String()) + } + body = decode(t, do(t, s, http.MethodGet, "/api/teams/activity", nil)) + if managers, _ := body["managers"].([]interface{}); len(managers) != 0 { + t.Fatalf("single-stream run must not report the saved chart's managers: %v", body["managers"]) + } + if teams, _ := body["teams"].([]interface{}); len(teams) != 0 { + t.Fatalf("teams=%v", body["teams"]) + } +} diff --git a/pkg/server/teams.go b/pkg/server/teams.go index c8f7925..23ad2df 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,41 @@ 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) { + return agents.ResolveManager(named, func(id string) bool { return managers[id] }) +} + // 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 +108,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 +129,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 +173,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 +239,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 +307,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 +558,188 @@ 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 — and that covers +// un-assigning too: "no team" for a task the backend's territory owns is +// undone just as silently. 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 || len(t.Files) == 0 { + return "" + } + a := p.Assign(t.Files) + if a.Squad == "" || a.Squad == team { + return "" + } + verb := "move to " + team + if team == "" { + verb = "be un-assigned" + } + return fmt.Sprintf("%s cannot %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, verb, 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) => (