Skip to content

Commit

An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how.

syntaqxcommitted Parent3949905Browse files
69 files+1137−5990/69 viewed
+2−2
1818 | **The workspace's spend limit and AI credit** | Owners and billing managers, under [Billing](/guides/usage-and-billing/#your-spend-limit). | A hard stop for everything, agents included. |
1919 | **The agent budget** | Owners, under **Agents → Budget**. | Every agent's work together, each month. At 100% no agent takes new work until the 1st, or until an owner raises it. |
2020 | **A person's budget** | Owners, under [Workspace → Spend](/guides/spend/#budgets): a default for everyone, and a budget of their own for anyone. | What agents spend on the work one person asks for, each month. At 100% agents take no new work for that person until the 1st, and say so where they were asked. |
21−| **A team's budget** | Owners and the team's maintainers, under the team's **Settings**, [**Lead, channel and budget**](/guides/teams/#lead-channel-and-budget). | What the team's agents, added and home team, spend together each month. At 100% the agents on that team take no new work until the 1st, and say: *Backend's agents have used the team's budget of $150.00 for this month. Someone who manages the team can raise it on its settings.* |
21+| **A team's budget** | Owners and the team's maintainers, under the team's **Settings**, [**Lead, channel and budget**](/guides/teams/#lead-channel-and-budget). | What the agents on the team spend together each month. At 100% the agents on that team take no new work until the 1st, and say: *Backend's agents have used the team's budget of $150.00 for this month. Someone who manages the team can raise it on its settings.* |
2222 | **An agent's own budget** | Owners, on the agent's **Profile**. | Monthly, and optionally daily. At 100% that agent takes no new work. |
2323 | **A session's cap** | The workspace's session cap, or the agent's lower per-session cap. | The session stops at **Needs approval** until an owner approves more. |
2424
7474 | --- | --- |
7575 | **By day** | Spend each day, UTC. |
7676 | **By agent** | Each agent's share, largest first. |
77−| **By team** | Agents grouped by their team or department. |
77+| **By team** | Each team's agents together, by the teams they are on. An agent on two teams counts towards both; agents on none are under **Not on a team**. |
7878 | **By kind of work** | Chat replies, sessions, routines, helping colleagues and subagents. |
7979 | **By who asked** | The work done for each person. Routines and agent-to-agent work have no asker. |
8080 | **Costliest sessions** | This month's most expensive session trees. |
+1−1
159159 | Attach to | Who gets it | Who can attach it there |
160160 | --- | --- | --- |
161161 | Every agent in the workspace | Every agent, `@g1t` included | Owners |
162−| A team | Every agent on the team: added to it, or whose home team it is | Owners, and the team's maintainers |
162+| A team | Every agent on the team | Owners, and the team's maintainers |
163163 | One agent | That agent | Owners |
164164
165165 An agent gets each skill once. When a skill reaches it in more than one
+51−31
11 ---
22 title: Agents
3−description: Describe an agent and g1t drafts it, or hire one into a role, like a colleague. Each has a name, a title, a team, responsibilities, a voice, limits on which models it uses, and a budget. g1t, in every workspace, is the orchestrator who knows them all.
3+description: Describe an agent and g1t drafts it, or hire one into a role, like a colleague. Each has a name, a title, responsibilities, a voice, limits on which models it uses, and a budget, and is on teams the way a person is. g1t, in every workspace, is the orchestrator who knows them all.
44 ---
55
66 import { Steps, CardGrid } from '@astrojs/starlight/components';
1515 hire, usually from a template in a click: for example, Margo from the **QA
1616 Engineer** template, Sam from **Support Specialist**, David from **Sales
1717 Operations**. Each has a name, a handle
18−you mention it by, a title, a team, a list of things it answers for, and a
19−voice of its own. It sits in the member list next to the people. You DM it,
18+you mention it by, a title, a list of things it answers for, and a voice
19+of its own. It is on [teams](/guides/teams/) the way a person is, and sits
20+in the member list next to the people. You DM it,
2021 invite it to channels, and it answers where you asked. A budget caps what it
2122 spends, and g1t picks its model for each step, within limits you set.
2223
2324 A workspace can have as many agents as it likes, and an agent costs nothing
24−while nobody talks to it. Hire one from each department's template and a
25−workspace's org chart can read like a real company's:
25+while nobody talks to it. Hire one from each template, add each to its
26+team, and a workspace's org chart can read like a real company's:
2627
27−| Department | Hired from the template | As |
28+| Team | Hired from the template | As |
2829 | --- | --- | --- |
2930 | Engineering | Otto | Software Engineer |
3031 | QA | Margo | QA Engineer |
4950 | --- | --- |
5051 | **In every workspace** | On every surface: Chat, issues and pull requests, Notifications and MCP. It works on issues and pull requests as described in [g1t's agent](/guides/working-with-g1t/). |
5152 | **Configurable** | Set its personality, model limits, budget and what it may do alone, like any agent. Its job is fixed, and you can add instructions to it. |
52−| **Knows the team** | Every agent you hire: its handle, title, team, responsibilities, whether it is idle, working, paused or out of budget, and its spend this month against its monthly cap. |
53+| **Knows the team** | Every agent you hire: its handle, title, the teams it is on, responsibilities, whether it is idle, working, paused or out of budget, and its spend this month against its monthly cap. |
5354 | **Delegates** | When a specialist's role fits, it [hands the work off](#hand-off) with a brief: here, if the specialist is in the conversation, or in a group message with you and them. At most two hand-offs per message. |
5455 | **Does the work itself when nobody fits** | In a workspace with no specialists, g1t does everything itself, as it does today. |
5556 | **Reports** | Answers "what's everyone working on?" from what it knows of the team. A daily or weekly summary of what the team's agents did is <Soon /> |
8485 asked. It answers from its job and voice exactly as it will in a direct
8586 message, without tools or memory, since it doesn't exist yet. Each answer
8687 is charged to you, and nothing is saved.
87−6. Choose **Create**. Or choose **Edit all fields** for the full form,
88+6. Under **Add to teams**, choose any of the workspace's teams it should
89+ join, as you would add a person. You see the teams you may add agents
90+ to: every team for an owner, and the teams you maintain. A personal
91+ agent is on no team.
92+7. Choose **Create**. Or choose **Edit all fields** for the full form,
8893 with the draft filled in.
8994
9095 </Steps>
112117 Agents sidebar (`g1t.sh/<workspace>/-/agents/templates`), read one in full
113118 and choose **Start from this**.
114119
115−In the form, check its **title**, **team** and **responsibilities** (see
116−[title, team and responsibilities](#title-team-and-responsibilities)), its
117−**personality**, and its [model limits](#model-routing) and
118−[budget](#budgets), then choose **Create agent**.
120+In the form, check its **title** and **responsibilities** (see
121+[title and responsibilities](#title-and-responsibilities)), choose the
122+teams under **Add to teams**, check its **personality**, and its
123+[model limits](#model-routing) and [budget](#budgets), then choose
124+**Create agent**.
119125
120126 ### Its hello
121127
130136
131137 <Steps>
132138
133−1. On **New agent**, pick **QA** under the departments. The name is
139+1. On **New agent**, pick **QA Engineer** under the roles. The name is
134140 *Margo*, the title is *QA Engineer*, the personality is *Crisp*, and her
135141 models never go below Standard, because review must be careful.
136−2. Put her on your **QA** team.
142+2. Under **Add to teams**, choose your **QA** team.
137143 3. Add one responsibility: *Check every release against the release
138144 checklist in #releases.*
139145 4. Set a monthly budget of **$40** and a per-session cap of **$5**.
216222
217223 Templates are starting points g1t provides, the way you would set up an
218224 assistant's instructions yourself: each is a role you configure, rename and
219−change, grouped by department. Each starts with a fun name (and more to
220−shuffle through), a title, broad responsibilities, a voice, sensible model
221−limits, and a subagent or two for its own work. Nothing is installed: once
225+change. A template names a role, never a team: you add the agent to
226+teams yourself. Each starts with a fun name (and more to shuffle through),
227+a title, broad responsibilities, a voice, sensible model limits, and a
228+subagent or two for its own work. Nothing is installed: once
222229 you start an agent from a template, the agent is yours, and the template
223230 never changes under it.
224231
228235 works with, the instructions it starts from, and what you configure when
229236 you start one. Every member can read them; owners start agents from them.
230237
231−| Department | Suggested name | Title | Answers for | Voice | Models |
238+| Template | Suggested name | Title | Answers for | Voice | Models |
232239 | --- | --- | --- | --- | --- | --- |
233240 | Engineering | Otto, or Pixel, Bolt, Tinker… | Software Engineer | Implementing issues; fixing bugs with a test that proves the fix; healthy dependencies and builds | Crisp | Auto |
234241 | QA | Margo, or Wren, Hawk, Edna… | QA Engineer | Reviewing pull requests for risk and test coverage; test plans; flaky checks; reproducing bug reports | Crisp | Never below Standard |
244251 high-volume intake and summaries never run on the most capable one. Change
245252 either if your team works differently.
246253
247−## Title, team and responsibilities
254+## Title and responsibilities
248255
249256 A role is broad on purpose. You hire Margo into QA, not into "review pull
250257 request #418".
254261 | Name | What people see: *Margo*. | Up to 64 characters. |
255262 | Handle | How it is mentioned: `@margo`. | 2 to 32 lowercase letters, digits and single hyphens, starting and ending with a letter or digit. Unique in the workspace. Never `g1t` or another reserved name. |
256263 | Title | *QA Engineer*. | Up to 60 characters. |
257−| Team | One of the workspace's [teams](/guides/teams/), or a department label such as *QA* when it is on no team. | A department label is up to 40 characters. |
258−| Role | The line lists show: *QA Engineer on the QA team*. Made from the title and team unless you write it. | Up to 120 characters. |
264+| Role | The line lists show: *QA Engineer*. Its title unless you write it. | Up to 120 characters. |
259265 | Responsibilities | What it answers for, one per line. | 2 to 8, each up to 160 characters, or none yet. |
260266 | Job | Its instructions: how it works and what good looks like. | Up to 8,000 characters. |
261267 | Personality | A preset, plus free text that refines the voice. | Free text up to 1,000 characters. |
269275 Every change to an agent is saved as a new **version**, so what it ran
270276 with is never lost.
271277
272−<Aside type="note" title="Teams">
273−When **Team** names one of the workspace's teams, that is the agent's
274−**home team**: it shows on the team's page, and lists group it there. A
275−team's maintainers can add it to other teams too. Every turn, it is told
276−who is on its teams, who leads them and who owns what; see
277−[what agents are told](/guides/people-and-teams/#what-agents-are-told).
278−Hearing a team's mentions and review requests, the way a person on the
279−team does, comes next. <Soon />
280−</Aside>
278+## Teams
279+
280+An agent is on teams exactly the way a person is: through the team's
281+membership. Nothing on the agent itself names a team, so an agent can be on
282+as many teams as a person can, or none.
281283
284+| To | Where | Who |
285+| --- | --- | --- |
286+| Add it to teams as you make it | **Add to teams**, on the drafted card or the full form | Owners for any team; a team's maintainers for theirs |
287+| Add it to a team, or take it off | Its **Profile** tab or its People profile, under **Teams**: **Add to team** and **Remove** | Owners, and the team's maintainers |
288+| The same, from the team | The team's **Members** page, under **Agents** (see [agents on a team](/guides/teams/#agents-on-a-team)) | Owners, and the team's maintainers |
289+
290+A personal agent is on no team; once an owner
291+[promotes](#promote-a-personal-agent) it, add it to teams like anyone.
292+
293+The Agents sidebar groups specialists by the teams they are on, each team
294+by name: an agent on two teams shows under both, and agents on none show
295+last, under **Not on a team**. Every turn, an agent is told who is on its
296+teams, who leads them and who owns what; see
297+[what agents are told](/guides/people-and-teams/#what-agents-are-told). A
298+team's [budget](/guides/agent-budgets/) caps what the agents on it spend
299+together. Hearing a team's mentions and review requests, the way a person
300+on the team does, comes next. <Soon />
301+
282302 ## Job and personality
283303
284304 An agent's **job** decides what it does. Its **personality** decides how it
390410 ## Agents know each other
391411
392412 Every agent, not only g1t, knows the team: each colleague's name, title,
393−team, responsibilities and status. It also knows the people and agents on
413+the teams it is on, responsibilities and status. It also knows the people and agents on
394414 its own [teams](/guides/teams/): who leads each, what each person owns,
395415 who reports to whom, who is around now and their local time, and who to
396416 ask when a person is needed. It is told this every turn; see
+4−4
9999
100100 | Card | What it shows |
101101 | --- | --- |
102−| **Responsible for** | Its responsibilities, from its [profile](/guides/agents/#title-team-and-responsibilities). |
103−| **Teams** | The teams it is on, with **Lead** and **Home team** where they apply. |
102+| **Responsible for** | Its responsibilities, from its [profile](/guides/agents/#title-and-responsibilities). |
103+| **Teams** | The teams it is on, with **Lead** where it leads one. Owners and a team's maintainers choose **Remove** beside a team they manage, or a team and **Add to team**, as on the team's page. A personal agent is on no team. |
104104 | **Who it works with** | The people on its teams, leads first. |
105105 | **Agents on its teams** | The other agents on those teams. |
106106 | **Access** | **Code**: what the person who asks it can read, and only what everyone in the conversation can see. **Storage**: coming. What it spent this month of its monthly budget. |
119119
120120 | | Where |
121121 | --- | --- |
122−| Add or remove an agent | The team's **People and agents** tab. See [agents on a team](/guides/teams/#agents-on-a-team). |
122+| Add or remove an agent | The team's **People and agents** tab, or **Teams** on the agent's profile. See [agents on a team](/guides/teams/#agents-on-a-team). |
123123 | Set the lead, channel and budget | The team's **Settings**. See [lead, channel and budget](/guides/teams/#lead-channel-and-budget). |
124124 | Storage for a team | Coming. |
125125
220220
221221 - [Teams](/guides/teams/): roles on repositories, mentions, review
222222 requests, and a team's lead, channel and budget.
223−- [Agents](/guides/agents/): hiring agents, and their home team.
223+- [Agents](/guides/agents/): hiring agents, and the teams they are on.
224224 - [Agent budgets](/guides/agent-budgets/): how a team's budget stacks
225225 with the others.
+11−11
123123 ## Agents on a team
124124
125125 The **People and agents** tab lists the team's **Agents** under its
126−people. An agent is on a team in one of two ways:
127−
128−| How | Badge | |
129−| --- | --- | --- |
130−| **Its home team** | **Home team** | The team its own profile names under **Team** (see [title, team and responsibilities](/guides/agents/#title-team-and-responsibilities)). To take it off, change its profile. |
131−| **Added** | None | A maintainer or an owner added it from the team's page. An agent can be on any number of teams this way. |
126+people. An agent is on a team exactly as a person is: a maintainer or an
127+owner adds it, and it stays until one of them takes it off. Nothing on the
128+agent itself names a team, and an agent can be on any number of teams.
132129
133130 To add or remove an agent:
134131
136133 `g1t.sh/<workspace>/-/teams/<team>`.
137134 2. Under **Agents**, choose one of the workspace's agents in **Add an
138135 agent**, and **Add**.
139−3. To take an added agent off, choose **Remove** beside it. An agent
140− whose home team it is has no **Remove**: change its profile instead.
136+3. To take an agent off, choose **Remove** beside it.
137+
138+You can do the same from the agent's side: under **Teams** on its
139+**Profile** tab in [Agents](/guides/agents/#teams) or on its
140+[People profile](/guides/people-and-teams/#an-agents-profile), and under
141+**Add to teams** when you make it. A personal agent is on no team.
141142
142143 An agent on a team works with the access of whoever asks it, not with the
143144 team's roles on repositories. Being on a team tells the agent about it
157158
158159 | Setting | What it is |
159160 | --- | --- |
160−| **Lead** | Someone on the team: a person or an agent. Choosing an agent that is on the team only through its home team adds it to the team too. When a person is needed, the team's agents ask the lead first. |
161+| **Lead** | Someone on the team: a person or an agent. Choosing one of the workspace's agents that isn't on the team adds it to the team too. When a person is needed, the team's agents ask the lead first. |
161162 | **Channel** | The team's channel in [Chat](/guides/chat/), by name. Leave it empty for none. |
162163 | **Budget for its agents, a month** | In dollars, such as `150`. Empty or `0` for no team budget. At most $1,000,000. |
163164 | **Storage level** | Coming. |
165166 Taking the lead off the team, or out of the workspace, leaves the team
166167 with no lead.
167168
168−The **budget** caps what the team's agents, added and home team alike,
169−spend together in a calendar month (UTC). When they reach it, its agents
169+The **budget** caps what the agents on the team spend together in a calendar month (UTC). When they reach it, its agents
170170 take no new work until the 1st, and say:
171171
172172 ```text
+2−2
4747
4848 4. **Create an agent**
4949
50− Open **Agents** in the dock and choose **New agent**. The templates are
51− grouped by department. Pick one, such as **QA Engineer**, and keep its
50+ Open **Agents** in the dock and choose **New agent**. Under the box, pick
51+ a role, such as **QA Engineer**, and keep its
5252 defaults: you hire Margo, with a title, responsibilities, a personality
5353 and sensible model limits. Choose
5454 **Another name** if you'd like someone else. Set a monthly budget, such as
+81−79
1−import { GripVertical, Pencil, Plus, Trash2, X } from "lucide-react";
1+import { Check, GripVertical, Pencil, Plus, Trash2, X } from "lucide-react";
22 import { useState } from "react";
33
44 import type { AgentRouting, ModelTier, SubagentDef } from "@g1t/contracts";
1212 const FIELD =
1313 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
1414
15−/** The departments an agent can sit in when it is on no team, as the role gallery groups them. */
16−export const DEPARTMENTS = ["Engineering", "QA", "Operations", "Docs", "Product", "Customer Support", "Sales"];
15+/**
16+ * The teams to add a new agent to as it is made: the workspace's teams the
17+ * person manages, as chips to press. Membership is the team's, as anyone's
18+ * is; with `name`, the chosen teams post as that field, one each.
19+ */
20+export function TeamPicker({
21+ teams,
22+ value,
23+ onChange,
24+ name,
25+}: {
26+ teams: { slug: string; name: string }[];
27+ /** Controlled: the chosen slugs; otherwise the picker keeps its own. */
28+ value?: string[];
29+ onChange?: (next: string[]) => void;
30+ name?: string;
31+}) {
32+ const [own, setOwn] = useState<string[]>([]);
33+ const chosen = value ?? own;
34+ const set = onChange ?? setOwn;
35+ if (!teams.length) {
36+ return <p className="text-[0.8125rem] text-muted">No teams you can add agents to yet. Owners and a team&apos;s maintainers add agents to it.</p>;
37+ }
38+ return (
39+ <div>
40+ <div role="group" aria-label="Add to teams" className="flex flex-wrap gap-1.5">
41+ {teams.map((team) => {
42+ const on = chosen.includes(team.slug);
43+ return (
44+ <button
45+ key={team.slug}
46+ type="button"
47+ aria-pressed={on}
48+ onClick={() => set(on ? chosen.filter((slug) => slug !== team.slug) : [...chosen, team.slug])}
49+ className={`inline-flex h-9 items-center gap-1.5 rounded-full border px-3 text-[0.8125rem] transition-colors sm:h-8 ${
50+ on ? "border-accent/50 bg-accent/10 text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg"
51+ }`}
52+ >
53+ {on ? <Check size={13} className="text-accent" /> : <Plus size={13} />}
54+ {team.name}
55+ </button>
56+ );
57+ })}
58+ </div>
59+ {name && chosen.map((slug) => <input key={slug} type="hidden" name={name} value={slug} />)}
60+ </div>
61+ );
62+}
1763
1864 /**
19− * An agent's role: its title, the team it is on (or a department, when it
20− * is on none), and what it is responsible for, as a list to add to, edit
21− * and reorder.
65+ * An agent's role: its title and what it is responsible for, as a list to
66+ * add to, edit and reorder. Making one, the teams to add it to as well:
67+ * teams are memberships, changed on the team or the agent's profile after.
2268 */
2369 export function RoleFields({
2470 title,
25− team,
26− department,
2771 responsibilities,
28− teams,
72+ joinable,
2973 errors,
3074 locked,
3175 personal = false,
3276 }: {
33− /** A personal agent: on no team, so no team to choose. */
77+ /** A personal agent: on no team. */
3478 personal?: boolean;
3579 title: string;
36− team: string | null;
37− department: string;
3880 responsibilities: string[];
39− teams: { slug: string; name: string }[];
81+ /** Making an agent: the teams the person may add it to. Null on a profile, where its Teams section does that. */
82+ joinable?: { slug: string; name: string }[] | null;
4083 errors: Record<string, string>;
4184 locked?: boolean;
4285 }) {
4386 const [items, setItems] = useState<string[]>(responsibilities.length > 0 ? responsibilities : [""]);
44− const [onTeam, setOnTeam] = useState(team ?? "none");
4587 const [dragging, setDragging] = useState<number | null>(null);
4688 const move = (from: number, to: number) =>
4789 setItems((now) => {
5294 });
5395 return (
5496 <>
55− <div className="grid gap-5 sm:grid-cols-2">
56− <div>
57− <label htmlFor="title" className="mb-1.5 flex items-baseline justify-between text-sm font-medium text-fg-soft">
58− Title
59− {errors.title && <span className="text-xs font-normal text-danger">{errors.title}</span>}
60− </label>
61− <input
62− id="title"
63− name="title"
64− defaultValue={title}
65− readOnly={locked}
66− placeholder="QA Engineer"
67− className={`${FIELD} ${locked ? "cursor-not-allowed text-muted" : ""}`}
68− autoComplete="off"
69− data-1p-ignore
70− />
71− </div>
72− {personal ? (
73− <div>
74− <p className="mb-1.5 text-sm font-medium text-fg-soft">Team</p>
75− <p className="flex min-h-9 items-center text-[0.8125rem] text-muted">Personal agents join teams once an owner promotes them.</p>
76− </div>
77− ) : (
78− <div>
79− <label htmlFor="team" className="mb-1.5 block text-sm font-medium text-fg-soft">
80− Team
81− </label>
82− <Select name="team" value={onTeam} onValueChange={setOnTeam} disabled={locked}>
83− <SelectTrigger id="team">
84− <SelectValue />
85− </SelectTrigger>
86− <SelectContent>
87− <SelectItem value="none" description="Placed by a department label instead">
88− No team
89− </SelectItem>
90− {teams.map((t) => (
91− <SelectItem key={t.slug} value={t.slug}>
92− {t.name}
93− </SelectItem>
94− ))}
95− </SelectContent>
96− </Select>
97− </div>
98− )}
97+ <div className="max-w-sm">
98+ <label htmlFor="title" className="mb-1.5 flex items-baseline justify-between text-sm font-medium text-fg-soft">
99+ Title
100+ {errors.title && <span className="text-xs font-normal text-danger">{errors.title}</span>}
101+ </label>
102+ <input
103+ id="title"
104+ name="title"
105+ defaultValue={title}
106+ readOnly={locked}
107+ placeholder="QA Engineer"
108+ className={`${FIELD} ${locked ? "cursor-not-allowed text-muted" : ""}`}
109+ autoComplete="off"
110+ data-1p-ignore
111+ />
99112 </div>
100− {onTeam === "none" && (
113+ {joinable && (
101114 <div>
102− <label htmlFor="department" className="mb-1.5 flex items-baseline justify-between text-sm font-medium text-fg-soft">
103− Department
104− <span className="text-xs font-normal text-faint">Where it shows in the Agents sidebar</span>
105− </label>
106− <input
107− id="department"
108− name="department"
109− list="departments"
110− defaultValue={department}
111− readOnly={locked}
112− placeholder="QA"
113− className={`${FIELD} ${locked ? "cursor-not-allowed text-muted" : ""}`}
114− autoComplete="off"
115− data-1p-ignore
116− />
117− <datalist id="departments">
118− {DEPARTMENTS.map((d) => (
119− <option key={d} value={d} />
120− ))}
121− </datalist>
115+ <p className="mb-1.5 flex items-baseline justify-between gap-3 text-sm font-medium text-fg-soft">
116+ Add to teams
117+ <span className="text-xs font-normal text-faint">{personal ? "" : "Optional. Like adding a person."}</span>
118+ </p>
119+ {personal ? (
120+ <p className="text-[0.8125rem] text-muted">A personal agent is on no team. Once an owner promotes it, add it to teams like anyone.</p>
121+ ) : (
122+ <TeamPicker teams={joinable} name="teams" />
123+ )}
122124 </div>
123125 )}
124126 <div>
+1−1
4848 </Link>
4949 </h3>
5050 <p className="truncate text-xs text-muted">
51− {template.department ? `${template.department} · ` : ""}Suggests {template.display_name}
51+ Suggests {template.display_name}
5252 </p>
5353 </div>
5454 </div>
+48−65
1515 import { Input } from "./ui/input";
1616 import { EffortPicker } from "./effort";
1717 import { effortSetting } from "../lib/effort";
18−import { DEPARTMENTS, RoleFields, SubagentsField } from "./agent-role";
18+import { RoleFields, SubagentsField } from "./agent-role";
19+import { type AgentTeamRef, groupByTeam } from "../lib/people";
1920 import type { AgentsLayoutData } from "../routes/workspace/agents/layout";
2021
2122 /** The workspace's agents while an Agents page is open; undefined elsewhere. */
4849 }
4950
5051 /** An agent as the sidebar lists it: from the Agents pages' own data, or the shell's on any other page. */
51−type Listed = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "avatar" | "role" | "status" | "title" | "team" | "department"> & {
52+type Listed = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "avatar" | "role" | "status" | "title"> & {
5253 builtin?: boolean;
5354 avatar_seed?: string | null;
5455 scope?: WorkspaceAgent["scope"];
5556 personal_owner_id?: string | null;
5657 personal_owner?: string | null;
58+ /** The shell's list carries each agent's teams; the Agents pages' data has them apart. */
59+ teams?: AgentTeamRef[];
5760 };
5861
59−/** Where an agent sits in the org chart: its team, else its department. */
60−export function placeOf(agent: Pick<WorkspaceAgent, "team" | "department">): string {
61− if (agent.team) return agent.team.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
62− return agent.department?.trim() || "Unplaced";
63−}
64−
6562 /** "Margo · QA Engineer": how an agent reads wherever it shows. */
6663 export function nameAndTitle(agent: Pick<WorkspaceAgent, "display_name" | "title">): string {
6764 return agent.title ? `${agent.display_name} · ${agent.title}` : agent.display_name;
9087 }) {
9188 const data = useAgentsData();
9289 const agents: Listed[] | null = data?.agents ?? shellAgents;
90+ // The teams each agent is on, from team memberships; null when they couldn't be read.
91+ const teamsOf: Record<string, AgentTeamRef[]> | null = data
92+ ? data.teams
93+ : shellAgents?.every((agent) => agent.teams)
94+ ? Object.fromEntries(shellAgents.map((agent) => [agent.id, agent.teams ?? []]))
95+ : null;
9396 const live = data?.live ?? {};
9497 const orchestrator = agents?.find((agent) => isOrchestrator(agent)) ?? null;
9598 const specialists = (agents ?? []).filter((agent) => !isOrchestrator(agent) && agent.scope !== "personal");
194197 <>
195198 {orchestrator && <Group title="Orchestrator">{row(orchestrator)}</Group>}
196199 {yours.length > 0 && <Group title="Yours">{yours.map(row)}</Group>}
197− <p className="mt-4 px-2 text-[0.6875rem] leading-snug text-faint">Specialists are colleagues hired into a role. g1t hands them work.</p>
198− {[...orgChart(specialists)].map(([place, members]) => (
199− <Group key={place} title={`${place} · ${members.length}`} collapsible>
200− {members.map(row)}
201− </Group>
202− ))}
200+ <p className="mt-4 px-2 text-[0.6875rem] leading-snug text-faint">Specialists are colleagues hired into a role, on teams like anyone. g1t hands them work.</p>
201+ {teamsOf ? (
202+ groupByTeam(specialists, teamsOf).map((group) => (
203+ <Group key={group.key || "none"} title={`${group.label} · ${group.agents.length}`} collapsible>
204+ {group.agents.map(row)}
205+ </Group>
206+ ))
207+ ) : specialists.length > 0 ? (
208+ <Group title={`Specialists · ${specialists.length}`}>{[...specialists].sort((a, b) => a.display_name.localeCompare(b.display_name)).map(row)}</Group>
209+ ) : null}
203210 {specialists.length === 0 && <p className="px-2 py-1 text-xs text-faint">No specialists yet. Hire one into a role.</p>}
204211 {members.length > 0 && (
205212 <Group title={`Members' personal · ${members.length}`} collapsible initiallyOpen={false}>
225232 )}
226233 </nav>
227234 </div>
228− );
229−}
230−
231−/** Specialists by team or department, each group by name, the groups in the gallery's order. */
232−function orgChart(agents: Listed[]): Map<string, Listed[]> {
233− const groups = new Map<string, Listed[]>();
234− for (const agent of agents) groups.set(placeOf(agent), [...(groups.get(placeOf(agent)) ?? []), agent]);
235− const rank = (place: string) => {
236− const at = DEPARTMENTS.indexOf(place);
237− return at < 0 ? DEPARTMENTS.length : at;
238− };
239− return new Map(
240− [...groups]
241− .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
242− .map(([place, list]) => [place, list.sort((x, y) => x.display_name.localeCompare(y.display_name))]),
243235 );
244236 }
245237
271263
272264 /** The templates g1t ships, and a blank one, to start a new agent from. */
273265 export function TemplateGallery({ templates, chosen, onChoose }: { templates: AgentTemplate[]; chosen: string | null; onChoose: (id: string | null) => void }) {
274− // By department, in the order the departments are listed; any other after them.
275− const order = (department: string) => {
276− const at = DEPARTMENTS.indexOf(department);
277− return at < 0 ? DEPARTMENTS.length : at;
278− };
279− const groups = new Map<string, AgentTemplate[]>();
280− for (const template of [...templates].sort((a, b) => order(a.department) - order(b.department))) {
281− const key = template.department || "Other";
282− groups.set(key, [...(groups.get(key) ?? []), template]);
283− }
266+ // One grid in the order g1t lists them: a template is a role, never a team.
284267 return (
285268 <div className="space-y-8">
286− {[...groups].map(([department, list]) => (
287− <section key={department}>
288− <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">{department}</h2>
289− <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
290− {list.map((template) => (
291− <TemplateCard
292− key={template.id}
293− selected={chosen === template.id}
294− onClick={() => onChoose(template.id)}
295− name={template.display_name}
296− title={template.title}
297− seed={template.handle}
298− duties={template.responsibilities.slice(0, 3)}
299− />
300− ))}
301− </div>
302− </section>
303− ))}
304269 <section>
270+ <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">Roles</h2>
271+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
272+ {templates.map((template) => (
273+ <TemplateCard
274+ key={template.id}
275+ selected={chosen === template.id}
276+ onClick={() => onChoose(template.id)}
277+ name={template.display_name}
278+ title={template.title}
279+ seed={template.handle}
280+ duties={template.responsibilities.slice(0, 3)}
281+ />
282+ ))}
283+ </div>
284+ </section>
285+ <section>
305286 <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">Your own</h2>
306287 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
307288 <TemplateCard
310291 name="A new role"
311292 title="Start from nothing"
312293 blank
313− duties={["Give it a name, a title and a team", "Say what it is responsible for"]}
294+ duties={["Give it a name and a title", "Say what it is responsible for"]}
314295 />
315296 </div>
316297 </section>
457438 nameIdeas = [],
458439 locked = false,
459440 seed,
460− teams = [],
441+ joinable = null,
461442 spaces = [],
462443 hidden = {},
463444 personal = false,
465446 }: {
466447 /** A personal agent: on no team. */
467448 personal?: boolean;
449+ /** Making an agent: the teams the person may add it to ("Add to teams"). Left out on a profile. */
450+ joinable?: { slug: string; name: string }[] | null;
468451 /** Fields the form carries without showing: a drafted agent's scope, skills and face. */
469452 hidden?: Record<string, string>;
470453 /** What each effort level has cost this agent, when it has a history; a new agent has none. */
471454 effortCosts?: AgentEffortCosts | null;
472− /** The workspace's teams, to put it on one. */
473− teams?: { slug: string; name: string }[];
474455 /** The Docs spaces the person editing can read, for its required reading. */
475456 spaces?: { id: string; name: string; kind: string }[];
476457 draft: AgentDraft;
588569 {!locked && (
589570 <FormSection
590571 title="Role"
591− about="Hired into a role, not a task: a title, a team, and what it is responsible for. Shown as its name and title everywhere."
572+ about={
573+ joinable
574+ ? "Hired into a role, not a task: a title and what it is responsible for, and the teams it joins, like anyone. Shown as its name and title everywhere."
575+ : "Hired into a role, not a task: a title and what it is responsible for. Shown as its name and title everywhere."
576+ }
592577 >
593578 <RoleFields
594579 title={draft.title}
595− team={draft.team}
596− department={draft.department}
597580 responsibilities={draft.responsibilities}
598− teams={teams}
581+ joinable={joinable}
599582 errors={e}
600583 locked={locked}
601584 personal={personal}
+19−7
1010
1111 import { type AgentProposal, type AgentRedraft, type AgentTemplate, type DraftTurn, FOUNDATIONAL_SKILLS, type WorkspaceAgent } from "@g1t/contracts";
1212
13+import { TeamPicker } from "../agent-role";
1314 import { AgentAvatar, PixelCreature } from "../agent-avatar";
1415 import { Markdown } from "../markdown";
1516 import { Badge } from "../ui/badge";
233234 proposal,
234235 value,
235236 onChange,
237+ joinable = [],
238+ teams = [],
239+ onTeams,
236240 }: {
237241 slug: string;
238242 owner: boolean;
239243 proposal: AgentProposal;
240244 value: BuilderDefinition;
241245 onChange: (next: BuilderDefinition) => void;
246+ /** The teams the person may add it to; a workspace agent only. */
247+ joinable?: { slug: string; name: string }[];
248+ /** The teams chosen to add it to: memberships, posted with Create, never part of the draft. */
249+ teams?: string[];
250+ onTeams?: (next: string[]) => void;
242251 }) {
243252 const ideas = [proposal.definition.display_name, ...proposal.name_ideas];
244253 const [idea, setIdea] = useState(0);
308317 <div>
309318 <Label htmlFor="b-title">Title</Label>
310319 <input id="b-title" value={value.title ?? ""} onChange={(e) => set({ title: e.target.value })} placeholder="Release Manager" className={FIELD} autoComplete="off" />
311− </div>
312− <div>
313− <Label htmlFor="b-department" hint="Optional">
314− Department
315− </Label>
316− <input id="b-department" value={value.department ?? ""} onChange={(e) => set({ department: e.target.value })} placeholder="Engineering" className={FIELD} autoComplete="off" />
317320 </div>
318321 </div>
319322 </div>
320323
324+ {onTeams && (
325+ <Section title="Add to teams" about={value.scope === "personal" ? "A personal agent is on no team." : "Optional. It joins them like anyone, and you can change them on its profile or the team's page."}>
326+ {value.scope === "personal" ? null : <TeamPicker teams={joinable} value={teams} onChange={onTeams} />}
327+ </Section>
328+ )}
329+
321330 <Section title="Job" about="What it is responsible for and how it works. It reads this before every reply.">
322331 <textarea
323332 aria-label="Job"
621630 // ── Create ────────────────────────────────────────────────────────────────
622631
623632 /** Saves the drafted agent: its hello is waiting in a direct message. */
624−export function CreateDraft({ definition, onEditAll }: { definition: BuilderDefinition; onEditAll: () => void }) {
633+export function CreateDraft({ definition, teams = [], onEditAll }: { definition: BuilderDefinition; teams?: string[]; onEditAll: () => void }) {
625634 const navigation = useNavigation();
626635 const busy = navigation.state !== "idle" && navigation.formData?.get("intent") === "create_draft";
627636 const ready = definition.display_name.trim() && definition.instructions.trim() && definition.handle.trim();
629638 <Form method="post" className="flex flex-wrap items-center justify-end gap-2">
630639 <input type="hidden" name="intent" value="create_draft" />
631640 <input type="hidden" name="definition" value={JSON.stringify(definition)} />
641+ {teams.map((slug) => (
642+ <input key={slug} type="hidden" name="teams" value={slug} />
643+ ))}
632644 <button type="button" onClick={onEditAll} className={QUIET}>
633645 Edit all fields
634646 </button>
+0−2
257257 display_name: "name",
258258 handle: "handle",
259259 title: "title",
260− team: "team",
261− department: "department",
262260 role: "role",
263261 responsibilities: "responsibilities",
264262 subagents: "subagents",
+1−2
77 import { useChatSend } from "./actions";
88 import { AgentPill, StatusDot, statusLabel } from "./marks";
99 import { AgentAvatar } from "../agent-avatar";
10−import { placeOf } from "../agents-mode";
1110 import { BottomSheet } from "../mobile";
1211 import { PresenceSummary, WithPresence } from "../presence";
1312 import { isOrchestrator } from "../orchestrator";
168167 <span className="truncate">{agent.display_name}</span>
169168 <AgentPill />
170169 </p>
171− <p className="truncate text-xs text-muted">{g1t ? "Orchestrator" : `${agent.title || agent.role} · ${placeOf(agent)}`}</p>
170+ <p className="truncate text-xs text-muted">{g1t ? "Orchestrator" : agent.title || agent.role}</p>
172171 </div>
173172 </div>
174173 <div className="mt-3 space-y-2 text-[0.8125rem] text-muted">
+8−7
300300 },
301301 ];
302302
303−/** An example org chart, read like a company's: g1t comes with the workspace; each department's colleague was hired from a template. */
303+/** An example org chart, read like a company's: g1t comes with the workspace; each team's agent was hired from a template and added to the team. */
304304 const ORG: { team: string; who: string }[] = [
305305 { team: "Engineering", who: "Otto" },
306306 { team: "QA", who: "Margo" },
313313
314314 function OrgChart() {
315315 return (
316− <figure aria-label="An example org chart: g1t at the top, and one agent hired from a template in each department" className="rounded-2xl bg-surface p-5 ring-1 ring-line">
316+ <figure aria-label="An example org chart: g1t at the top, and one agent hired from a template on each team" className="rounded-2xl bg-surface p-5 ring-1 ring-line">
317317 <div className="flex items-center gap-3">
318318 <AgentAvatar agent={{ handle: "g1t", name: "g1t" }} size={28} />
319319 <p className="text-sm">
605605 Hire agents into roles, like colleagues
606606 </h2>
607607 <p className="mt-4 max-w-xl leading-7 text-muted">
608− Pick a role template, grouped by department, and hire in a click. For example, hire Margo from the QA
609− Engineer template. She gets a title, a team and broad responsibilities: review pull requests for risk,
610− write test plans, chase flaky checks. Every template comes with a name you can shuffle, a voice and
608+ Pick a role template and hire in a click. For example, hire Margo from the QA Engineer template. She
609+ gets a title and broad responsibilities: review pull requests for risk, write test plans, chase flaky
610+ checks. Add her to the QA team the way you add anyone, and she knows its people and works within its
611+ budget. Every template comes with a name you can shuffle, a voice and
611612 sensible limits. Her job decides what she does; her personality only changes how she sounds.
612613 </p>
613614 <p className="mt-4 max-w-xl leading-7 text-muted">
618619 </p>
619620 <Points
620621 points={[
621− "Role templates by department, with names to shuffle",
622− "Title, team and responsibilities",
622+ "Role templates, with names to shuffle",
623+ "A title, responsibilities, and teams like anyone",
623624 "Personality that changes the voice, never the rules",
624625 "Model routing with a floor, a ceiling and your own providers",
625626 "Monthly, daily and per-task budgets",
+6−2
104104 /** An agent as the shell lists it. */
105105 export type ShellAgent = Pick<
106106 WorkspaceAgent,
107− "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "role" | "status" | "title" | "team" | "department" | "builtin"
108−> & { scope?: WorkspaceAgent["scope"] };
107+ "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "role" | "status" | "title" | "builtin"
108+> & {
109+ scope?: WorkspaceAgent["scope"];
110+ /** The visible teams it is on (team memberships); absent when they couldn't be read. */
111+ teams?: { slug: string; name: string }[];
112+};
109113
110114 function SidebarLink({
111115 to,
+85−10
22 * Pieces of the teams pages: a team in a list, and its badges.
33 */
44 import { EyeOff, UsersRound } from "lucide-react";
5−import { Link, useOutletContext } from "react-router";
5+import { Form, Link, useOutletContext } from "react-router";
66
77 import { type Team, teamHandle } from "@g1t/contracts";
88
9−import type { PeopleAgent } from "../lib/people";
9+import type { AgentTeams, PeopleAgent, TeamChange } from "../lib/people";
1010 import { teamCounts, teamPath } from "../lib/teams";
11+import { ErrorText, SubmitButton } from "./ui";
1112 import { Badge } from "./ui/badge";
13+import { SelectField } from "./ui/select";
1214 import { TeamKindMark } from "./people";
1315
1416 /**
15− * What each page of a team gets from the team's layout: the team, the
16− * agents on it (added, then those whose home team it is), and which of
17− * them were added (the others are on it through their own profile).
17+ * What each page of a team gets from the team's layout: the team and the
18+ * agents on it, members as its people are.
1819 */
19−export type TeamContext = { team: Team; agents: PeopleAgent[]; added: string[] };
20+export type TeamContext = { team: Team; agents: PeopleAgent[] };
2021
2122 /** The team whose page this is. */
2223 export function useTeam(): Team {
2324 return useOutletContext<TeamContext>().team;
2425 }
2526
26−/** The agents on the team whose page this is, and which were added to it. */
27−export function useTeamAgents(): { agents: PeopleAgent[]; added: string[] } {
28− const { agents, added } = useOutletContext<TeamContext>();
29− return { agents, added };
27+/** The agents on the team whose page this is. */
28+export function useTeamAgents(): { agents: PeopleAgent[] } {
29+ const { agents } = useOutletContext<TeamContext>();
30+ return { agents };
3031 }
3132
3233 /** Secret, and the viewer's place in the team. */
7980 </li>
8081 );
8182 }
83+
84+/**
85+ * An agent's teams on its profile, as a person's are: each team it is on,
86+ * with Remove where the viewer manages the team, and Add to a team. Posts
87+ * `join-team` and `leave-team` to the page's action (lib/agent-teams.server.ts).
88+ */
89+export function AgentTeamsEditor({
90+ slug,
91+ name,
92+ teams,
93+ personal = false,
94+ change,
95+}: {
96+ slug: string;
97+ /** The agent's name, for the empty line. */
98+ name: string;
99+ /** Null when its teams couldn't be read. */
100+ teams: AgentTeams | null;
101+ /** A personal agent is on no team. */
102+ personal?: boolean;
103+ /** The last change's answer, for its error. */
104+ change?: TeamChange | null;
105+}) {
106+ if (personal) return <p className="text-sm text-faint">A personal agent is on no team. Once an owner promotes it, add it to teams like anyone.</p>;
107+ if (!teams) return <p className="text-sm text-faint">Its teams couldn&apos;t be read just now.</p>;
108+ const error = (team: string) => (change?.team === team && change.intent === "leave-team" ? change.error : null);
109+ return (
110+ <div className="space-y-3">
111+ {teams.on.length ? (
112+ <ul className="space-y-1 text-sm">
113+ {teams.on.map((team) => (
114+ <li key={team.slug}>
115+ <div className="flex min-h-9 flex-wrap items-center gap-2">
116+ <TeamKindMark people={team.people} agents={team.agents} />
117+ <Link to={teamPath(slug, team.slug)} className="min-w-0 truncate font-medium hover:text-accent">
118+ {team.name}
119+ </Link>
120+ {team.lead && <Badge tone="accent">Lead</Badge>}
121+ {team.can_manage && (
122+ <Form method="post" className="ml-auto">
123+ <input type="hidden" name="intent" value="leave-team" />
124+ <input type="hidden" name="team" value={team.slug} />
125+ <SubmitButton variant="quiet" match={{ intent: "leave-team", team: team.slug }} pending="Removing…">
126+ Remove
127+ </SubmitButton>
128+ </Form>
129+ )}
130+ </div>
131+ <ErrorText>{error(team.slug)}</ErrorText>
132+ </li>
133+ ))}
134+ </ul>
135+ ) : (
136+ <p className="text-sm text-faint">{name} isn&apos;t on a team yet.</p>
137+ )}
138+ {teams.addable.length > 0 && (
139+ <Form method="post" key={`teams:${teams.on.length}`} className="flex flex-col gap-2 sm:flex-row sm:items-center">
140+ <input type="hidden" name="intent" value="join-team" />
141+ <SelectField
142+ name="team"
143+ aria-label="Add to a team"
144+ defaultValue={teams.addable[0]!.slug}
145+ className="w-full sm:w-56"
146+ options={teams.addable.map((team) => ({ value: team.slug, label: team.name }))}
147+ />
148+ <SubmitButton variant="quiet" match={{ intent: "join-team" }} pending="Adding…">
149+ Add to team
150+ </SubmitButton>
151+ </Form>
152+ )}
153+ {change?.intent === "join-team" && <ErrorText>{change.error}</ErrorText>}
154+ </div>
155+ );
156+}
+1−2
2525 assert.equal(read.personality_preset, "crisp");
2626 assert.deepEqual(read.routing, { floor: null, ceiling: "small", providers: [], pinned: null }, "nothing but limits");
2727 assert.deepEqual(read.budget, { monthly_micros: 20_000_000, daily_micros: null, task_micros: null });
28− assert.equal(read.team, null, "a draft is on no team");
28+ assert.ok(!("team" in read) && !("department" in read), "a draft names no team: teams are added as memberships");
2929 assert.equal(read.scope, "personal");
3030 assert.equal(readDefinition("not json"), null);
3131 assert.equal(readDefinition("[1]"), null);
5757 handle: "juniper",
5858 display_name: "Juniper",
5959 title: "Release Manager",
60− department: "",
6160 instructions: "Cut releases.",
6261 responsibilities: ["One", "Two"],
6362 personality_preset: "crisp" as const,
+0−3
3838 handle: text("handle", 40),
3939 display_name: text("display_name", 64),
4040 title: text("title", 60),
41− department: text("department", 40),
42− team: null,
4341 role: "",
4442 instructions: text("instructions", 8000),
4543 responsibilities: list("responsibilities", 160, 8),
123121 plain("display_name", "Name");
124122 if (changes.handle !== undefined) rows.push({ field: "handle", label: "Handle", before: `@${agent.handle}`, after: `@${changes.handle}` });
125123 plain("title", "Title");
126− plain("department", "Department");
127124 if (changes.instructions !== undefined) {
128125 rows.push({ field: "instructions", label: "Job", before: agent.instructions, after: changes.instructions, lines: lineDiff(agent.instructions, changes.instructions) });
129126 }
+10−2
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { clampRouting, cleanHandle, dollarsField, microsFromDollars, readAgentForm } from "./agent-form.ts";
4+import { clampRouting, cleanHandle, dollarsField, microsFromDollars, readAgentForm, readTeams } from "./agent-form.ts";
55
66 function form(fields: Record<string, string>): FormData {
77 const data = new FormData();
2121 handle: "",
2222 title: "Release Manager",
2323 department: "Engineering",
24+ team: "ops",
2425 responsibility: "Cut releases",
2526 instructions: "Cut a release on Tuesdays.",
2627 personality_preset: "terse",
4445 assert.equal(read.input.capacity, 2);
4546 assert.equal(read.input.title, "Release Manager");
4647 assert.deepEqual(read.input.responsibilities, ["Cut releases"]);
47− assert.equal(read.input.team, null);
48+ assert.ok(!("team" in read.input) && !("department" in read.input), "teams are memberships, never part of the agent");
49+});
50+
51+test("Add to teams reads the teams chosen, by slug, once each", () => {
52+ const data = new FormData();
53+ for (const slug of ["qa", " Web ", "qa", "not a slug!"]) data.append("teams", slug);
54+ assert.deepEqual(readTeams(data), ["qa", "web"]);
55+ assert.deepEqual(readTeams(new FormData()), []);
4856 });
4957
5058 test("the agent form says what to fix", () => {
+9−10
6868 const display_name = String(form.get("display_name") ?? "").trim();
6969 const handle = cleanHandle(String(form.get("handle") ?? "") || display_name);
7070 const title = String(form.get("title") ?? "").trim();
71− // A team from the workspace's, or else a department label.
72− const team = String(form.get("team") ?? "").trim();
73− const department = String(form.get("department") ?? "").trim();
7471 const responsibilities = form
7572 .getAll("responsibility")
7673 .map((value) => String(value).trim())
111108 input: {
112109 handle,
113110 display_name,
114− // Made from the title and team by the agents service.
111+ // Its title, made by the agents service.
115112 role: "",
116113 title,
117− team: team && team !== "none" ? team : null,
118− department,
119114 responsibilities,
120115 subagents: subagents ?? [],
121116 reading,
188183 return { floor, ceiling };
189184 }
190185
186+/**
187+ * The teams a new agent joins as it is made (the form's "Add to teams"),
188+ * by slug: membership is the team's, never part of the agent.
189+ */
190+export function readTeams(form: FormData): string[] {
191+ return [...new Set(form.getAll("teams").map((value) => String(value).trim().toLowerCase()).filter((slug) => /^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)))].slice(0, 20);
192+}
193+
191194 /** What the form starts from: an agent being edited, a template, or nothing. */
192195 export type AgentDraft = Pick<
193196 WorkspaceAgent,
195198 | "display_name"
196199 | "role"
197200 | "title"
198− | "team"
199− | "department"
200201 | "responsibilities"
201202 | "subagents"
202203 | "reading"
215216 display_name: "",
216217 role: "",
217218 title: "",
218− team: null,
219− department: "",
220219 responsibilities: [],
221220 subagents: [],
222221 reading: [],
+57−0
1+/**
2+ * An agent's teams on its profiles: the teams it is on and the ones the
3+ * viewer may add it to, and adding or taking it off one. An agent is on
4+ * teams the way a person is, through team membership in identity, so this
5+ * is the team's own add and remove, from the agent's side.
6+ */
7+import type { PeopleDirectory, Team, User } from "@g1t/contracts";
8+
9+import type { AgentTeams, TeamChange } from "./people";
10+import { identity, workspaceAgents } from "./services.server";
11+
12+/** The teams `agentId` is on and the teams the viewer may add it to; null when identity didn't answer. */
13+export async function agentTeamsFor(viewer: User, slug: string, agentId: string, directory?: PeopleDirectory | null): Promise<AgentTeams | null> {
14+ const [listed, people] = await Promise.all([
15+ identity.listTeams(viewer, slug).catch(() => null),
16+ directory ? Promise.resolve(directory) : identity.peopleDirectory(viewer, slug).then((found) => (found.ok ? found.value : null), () => null),
17+ ]);
18+ if (!listed?.ok || !people) return null;
19+ const manage = new Map<string, Team>(listed.value.map((team) => [team.slug, team]));
20+ const on = people.teams
21+ .filter((team) => team.agent_ids.includes(agentId))
22+ .map((team) => ({
23+ slug: team.slug,
24+ name: team.name,
25+ people: team.people.length,
26+ agents: new Set(team.agent_ids).size,
27+ lead: team.lead?.kind === "agent" && team.lead.agent_id === agentId,
28+ can_manage: manage.get(team.slug)?.can_manage ?? false,
29+ }))
30+ .sort((a, b) => a.name.localeCompare(b.name));
31+ const addable = listed.value
32+ .filter((team) => team.can_manage && !on.some((row) => row.slug === team.slug))
33+ .map((team) => ({ slug: team.slug, name: team.name }))
34+ .sort((a, b) => a.name.localeCompare(b.name));
35+ return { on, addable };
36+}
37+
38+/**
39+ * `join-team` or `leave-team` from an agent's profile: the agent must be
40+ * one of the workspace's (identity knows agents only by id), and a
41+ * personal agent joins no team. Null for any other intent.
42+ */
43+export async function changeAgentTeam(form: FormData, viewer: User, slug: string, handle: string): Promise<TeamChange | null> {
44+ const intent = String(form.get("intent") ?? "");
45+ if (intent !== "join-team" && intent !== "leave-team") return null;
46+ const team = String(form.get("team") ?? "").trim().toLowerCase();
47+ if (!team) return { intent, team, error: "Choose a team." };
48+ const found = await workspaceAgents.get(slug, handle, viewer).catch(() => null);
49+ if (!found?.ok) return { intent, team, error: "That agent couldn't be found." };
50+ if (intent === "join-team" && found.value.scope === "personal") return { intent, team, error: "A personal agent is on no team." };
51+ const done =
52+ intent === "join-team"
53+ ? await identity.setTeamAgent(viewer, slug, team, found.value.id).catch(() => null)
54+ : await identity.removeTeamAgent(viewer, slug, team, found.value.id).catch(() => null);
55+ if (!done) return { intent, team, error: "Teams didn't answer. Try again in a moment." };
56+ return done.ok ? { intent, team, error: null } : { intent, team, error: done.error.message };
57+}
+7−15
33
44 import type { AgentTemplate } from "@g1t/contracts";
55
6−import { byDepartment, roleLine, routingWords, startPath, templateListings, templatePath, templatesPath } from "./agent-templates.ts";
6+import { roleLine, routingWords, startPath, templateListings, templatePath, templatesPath } from "./agent-templates.ts";
77
8−const template = (id: string, title: string, department = "Engineering"): AgentTemplate => ({
8+const template = (id: string, title: string): AgentTemplate => ({
99 id,
1010 display_name: title.split(" ")[0]!,
1111 handle: id,
1212 name_ideas: [],
1313 role: title,
1414 title,
15− department,
1615 responsibilities: ["Do the work"],
1716 subagents: [],
1817 instructions: "",
4443 assert.deepEqual(eng!.agents, []);
4544 });
4645
47−test("templates group by department, in the order they come", () => {
48− const groups = byDepartment(templateListings([template("a", "A One"), template("b", "B Two", "Sales"), template("c", "C Three"), template("d", "D Four", "")], null));
49− assert.deepEqual(
50− groups.map(([department, list]) => [department, list.map((l) => l.template.id)]),
51− [
52− ["Engineering", ["a", "c"]],
53− ["Sales", ["b"]],
54− ["Other", ["d"]],
55− ],
56− );
46+test("templates list in the order they come, each a role and never a team", () => {
47+ const listed = templateListings([template("a", "A One"), template("b", "B Two")], null);
48+ assert.deepEqual(listed.map((l) => l.template.id), ["a", "b"]);
49+ for (const { template: t } of listed) assert.ok(!("department" in t) && !("team" in t));
5750 });
5851
5952 test("a role and its model limits read as words", () => {
60− assert.equal(roleLine({ title: "QA Engineer", department: "Engineering" }), "QA Engineer · Engineering");
61− assert.equal(roleLine({ title: "QA Engineer", department: "" }), "QA Engineer");
53+ assert.equal(roleLine({ title: "QA Engineer", display_name: "Margo" }), "QA Engineer · Suggests Margo");
6254 assert.equal(routingWords({ floor: null, ceiling: null }), "Any model the work needs");
6355 assert.equal(routingWords({ floor: "large", ceiling: null }), "Large models or better");
6456 assert.equal(routingWords({ floor: null, ceiling: "large" }), "Up to large models");
+3−13
4848 }));
4949 }
5050
51−/** Listings by department, in the order the templates come, "Other" for none. */
52−export function byDepartment(listings: TemplateListing[]): [string, TemplateListing[]][] {
53− const departments = new Map<string, TemplateListing[]>();
54− for (const listing of listings) {
55− const key = listing.template.department || "Other";
56− departments.set(key, [...(departments.get(key) ?? []), listing]);
57− }
58− return [...departments];
59−}
60−
61−/** "Software Engineer · Engineering": a role as one line. */
62−export function roleLine(template: Pick<AgentTemplate, "title" | "department">): string {
63− return template.department ? `${template.title} · ${template.department}` : template.title;
51+/** "Software Engineer · Suggests Otto": a role as one line. A template names a role, never a team. */
52+export function roleLine(template: Pick<AgentTemplate, "title" | "display_name">): string {
53+ return `${template.title} · Suggests ${template.display_name}`;
6454 }
6555
6656 /** How a routing limit reads: "Any model the work needs", "Large models or better", "Up to large models", "Large to frontier models". */
+27−15
55
66 import {
77 type PeopleAgent,
8− agentIdsOn,
8+ NO_TEAM,
9+ agentsOn,
10+ groupByTeam,
11+ teamsByAgent,
912 budgetFromText,
1013 codeAccessWords,
1114 directoryEntries,
5659 avatar_seed: handle,
5760 title: "",
5861 role: "",
59− team: null,
60− department: "",
6162 status: "idle",
6263 responsibilities: [],
6364 builtin: false,
8384 repos_count: 0,
8485 }),
8586 team("design", "Design", { people: [{ username: "kai", role: "maintainer" }], repos_count: 2 }),
86− team("night-shift", "Night shift", { lead: { kind: "agent", agent_id: "agt_atlas" }, agent_ids: ["agt_atlas"] }),
87+ team("night-shift", "Night shift", { lead: { kind: "agent", agent_id: "agt_atlas" }, agent_ids: ["agt_atlas", "agt_sentinel", "agt_david"] }),
8788 ],
8889 base_permission: "read",
8990 can_manage: true,
9091 };
9192
9293 const agents = [
93− agent("agt_david", "david", { title: "CRM keeper", team: "sales" }),
94+ agent("agt_david", "david", { title: "CRM keeper" }),
9495 agent("agt_atlas", "atlas", { title: "Night watch" }),
95− agent("agt_sentinel", "sentinel", { title: "Error watch", team: "night-shift", responsibilities: ["Watches errors"] }),
96+ agent("agt_sentinel", "sentinel", { title: "Error watch", responsibilities: ["Watches errors"] }),
97+ agent("agt_pax", "pax", { title: "Billing" }),
9698 ];
9799
98−test("an agent is on the teams it was added to and its home team", () => {
99− assert.deepEqual(agentIdsOn(directory.teams[2]!, agents), ["agt_atlas", "agt_sentinel"]);
100− assert.deepEqual(
101− teamsOfAgent(directory.teams, agents[2]!).map((t) => t.slug),
102− ["night-shift"],
103− );
100+test("an agent is on the teams it is a member of, like anyone", () => {
101+ assert.deepEqual(agentsOn(directory.teams[2]!, agents).map((a) => a.id), ["agt_atlas", "agt_sentinel", "agt_david"]);
102+ assert.deepEqual(teamsOfAgent(directory.teams, agents[0]!).map((t) => t.slug), ["sales", "night-shift"]);
103+ assert.deepEqual(teamsOfAgent(directory.teams, agents[3]!), []);
104+});
105+
106+test("the Agents sidebar groups agents by their teams: under each team, and the rest last", () => {
107+ const teams = teamsByAgent(directory.teams);
108+ assert.deepEqual(teams.agt_david, [{ slug: "night-shift", name: "Night shift" }, { slug: "sales", name: "Sales" }]);
109+ const groups = groupByTeam(agents, teams).map((g) => [g.label, g.agents.map((a) => a.handle)]);
110+ assert.deepEqual(groups, [
111+ ["Night shift", ["atlas", "david", "sentinel"]],
112+ ["Sales", ["david"]],
113+ [NO_TEAM, ["pax"]],
114+ ]);
115+ assert.deepEqual(groupByTeam(agents.slice(3), {}).map((g) => g.label), [NO_TEAM]);
104116 });
105117
106118 test("the directory finds people and agents in one search", () => {
109121 assert.deepEqual(names("sales"), ["sofia", "jordan", "@david"]);
110122 assert.deepEqual(names("halcyon"), ["jordan"]);
111123 assert.deepEqual(names("errors"), ["@sentinel"]);
112− assert.deepEqual(names("", "agents"), ["@david", "@atlas", "@sentinel"]);
113− assert.deepEqual(names("night"), ["@atlas", "@sentinel"]);
124+ assert.deepEqual(names("", "agents"), ["@david", "@atlas", "@sentinel", "@pax"]);
125+ assert.deepEqual(names("night"), ["@david", "@atlas", "@sentinel"]);
114126 assert.equal(names("nobody here").length, 0);
115127 });
116128
123135 assert.deepEqual(sofia.agents.map((a) => a.handle), ["david"]);
124136 assert.deepEqual(sofia.reports.map((n) => n.person.username), ["jordan"]);
125137 // A team an agent leads sits apart, with its agents.
126− assert.deepEqual(chart.unled.map((u) => [u.team.slug, u.agents.map((a) => a.handle)]), [["night-shift", ["atlas", "sentinel"]]]);
138+ assert.deepEqual(chart.unled.map((u) => [u.team.slug, u.agents.map((a) => a.handle)]), [["night-shift", ["atlas", "sentinel", "david"]]]);
127139 });
128140
129141 test("no one goes missing from the org chart, even in a loop", () => {
+68−19
88 /** An agent as People's pages need it. */
99 export type PeopleAgent = Pick<
1010 WorkspaceAgent,
11− "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "title" | "role" | "team" | "department" | "status" | "responsibilities" | "builtin" | "spent_month_micros"
11+ "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "title" | "role" | "status" | "responsibilities" | "builtin" | "spent_month_micros"
1212 >;
1313
1414 /** Just what People's pages read of an agent, so loaders send no more. */
1515 export function peopleAgent(agent: PeopleAgent): PeopleAgent {
16− const { id, handle, display_name, avatar, avatar_seed, title, role, team, department, status, responsibilities, builtin, spent_month_micros } = agent;
17− return { id, handle, display_name, avatar, avatar_seed, title, role, team, department, status, responsibilities, builtin, spent_month_micros };
16+ const { id, handle, display_name, avatar, avatar_seed, title, role, status, responsibilities, builtin, spent_month_micros } = agent;
17+ return { id, handle, display_name, avatar, avatar_seed, title, role, status, responsibilities, builtin, spent_month_micros };
1818 }
1919
2020 /** Where a person's profile is. */
2727 return `/${workspace}/-/people/agents/${handle}`;
2828 }
2929
30−/** The agents on a team, by id: those added to it, then those whose home team it is. */
31−export function agentIdsOn(team: Pick<DirectoryTeam, "slug" | "agent_ids">, agents: readonly Pick<PeopleAgent, "id" | "team">[]): string[] {
32− const ids = [...team.agent_ids];
33− for (const agent of agents) if (agent.team === team.slug && !ids.includes(agent.id)) ids.push(agent.id);
34− return ids;
35−}
36−
37−/** The agents on a team, in order, as found among `agents`. */
38−export function agentsOn<A extends Pick<PeopleAgent, "id" | "team">>(team: Pick<DirectoryTeam, "slug" | "agent_ids">, agents: readonly A[]): A[] {
30+/** The agents on a team, in the order they were added, as found among `agents`. */
31+export function agentsOn<A extends Pick<PeopleAgent, "id">>(team: Pick<DirectoryTeam, "agent_ids">, agents: readonly A[]): A[] {
3932 const byId = new Map(agents.map((agent) => [agent.id, agent]));
40− return agentIdsOn(team, agents)
41− .map((id) => byId.get(id))
42− .filter((agent): agent is A => !!agent);
33+ return [...new Set(team.agent_ids)].map((id) => byId.get(id)).filter((agent): agent is A => !!agent);
4334 }
4435
4536 /** The teams a person is on. */
4738 return teams.filter((team) => team.people.some((person) => person.username === username));
4839 }
4940
50−/** The teams an agent is on: added to, or its home team. */
51−export function teamsOfAgent<T extends Pick<DirectoryTeam, "slug" | "agent_ids">>(teams: readonly T[], agent: Pick<PeopleAgent, "id" | "team">): T[] {
52− return teams.filter((team) => team.agent_ids.includes(agent.id) || team.slug === agent.team);
41+/** The teams an agent is on: its memberships, as a person's are. */
42+export function teamsOfAgent<T extends Pick<DirectoryTeam, "agent_ids">>(teams: readonly T[], agent: Pick<PeopleAgent, "id">): T[] {
43+ return teams.filter((team) => team.agent_ids.includes(agent.id));
44+}
45+
46+/** A team an agent is on, as the Agents sidebar groups by it. */
47+export type AgentTeamRef = { slug: string; name: string };
48+
49+/** Each agent's teams, by agent id, from teams with the agents on them; the teams by name. */
50+export function teamsByAgent(teams: readonly { slug: string; name: string; agent_ids: readonly string[] }[]): Record<string, AgentTeamRef[]> {
51+ const out: Record<string, AgentTeamRef[]> = {};
52+ for (const team of [...teams].sort((a, b) => a.name.localeCompare(b.name))) {
53+ for (const id of new Set(team.agent_ids)) (out[id] ??= []).push({ slug: team.slug, name: team.name });
54+ }
55+ return out;
5356 }
5457
58+/** The label for agents on no team, wherever agents are grouped by team. */
59+export const NO_TEAM = "Not on a team";
60+
61+/**
62+ * Agents grouped by the teams they are on, as the Agents sidebar shows
63+ * them: each team by name, an agent on two teams under both, and agents on
64+ * none last, under "Not on a team". Each group's agents by name.
65+ */
66+export function groupByTeam<A extends { id: string; display_name: string }>(agents: readonly A[], teamsOf: Record<string, readonly AgentTeamRef[]>): { key: string; label: string; agents: A[] }[] {
67+ const groups = new Map<string, { key: string; label: string; agents: A[] }>();
68+ const none: A[] = [];
69+ for (const agent of agents) {
70+ const teams = teamsOf[agent.id] ?? [];
71+ if (!teams.length) none.push(agent);
72+ for (const team of teams) {
73+ const group = groups.get(team.slug) ?? { key: team.slug, label: team.name, agents: [] };
74+ if (!group.agents.includes(agent)) group.agents.push(agent);
75+ groups.set(team.slug, group);
76+ }
77+ }
78+ const byName = (a: A, b: A) => a.display_name.localeCompare(b.display_name);
79+ const out = [...groups.values()].sort((a, b) => a.label.localeCompare(b.label)).map((group) => ({ ...group, agents: group.agents.sort(byName) }));
80+ if (none.length) out.push({ key: "", label: NO_TEAM, agents: none.sort(byName) });
81+ return out;
82+}
83+
5584 /** Whether `lead` is this person or this agent. */
5685 export function leads(lead: TeamLead | null, who: { username: string } | { id: string }): boolean {
5786 if (!lead) return false;
101130 if (kind !== "people") {
102131 for (const agent of agents) {
103132 const teams = teamsOfAgent(directory.teams, agent);
104− const fields = [agent.handle, agent.display_name, agent.title, agent.role, agent.department, ...agent.responsibilities, ...teams.map((team) => team.name), "agent"];
133+ const fields = [agent.handle, agent.display_name, agent.title, agent.role, ...agent.responsibilities, ...teams.map((team) => team.name), "agent"];
105134 if (matches(query, fields)) out.push({ kind: "agent", agent, teams });
106135 }
107136 }
212241 const found = blocks.find((block) => block.startsWith(`### ${teamName}\n`) || block.trim() === `### ${teamName}`);
213242 return found ? found.trim() : null;
214243 }
244+
245+/** A team an agent is on, as its profile lists it. */
246+export type AgentTeamRow = {
247+ slug: string;
248+ name: string;
249+ people: number;
250+ agents: number;
251+ lead: boolean;
252+ /** Whether the viewer may take it off (owners and the team's maintainers). */
253+ can_manage: boolean;
254+};
255+
256+export type AgentTeams = {
257+ on: AgentTeamRow[];
258+ /** Teams it isn't on that the viewer manages, to add it to. */
259+ addable: { slug: string; name: string }[];
260+};
261+
262+/** What adding an agent to a team, or taking it off, answers. */
263+export type TeamChange = { intent: "join-team" | "leave-team"; team: string; error: string | null };
+1−1
119119 "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
120120 "stars about public_links branch_drift tags last_commits languages contributors license releases release " +
121121 "stargazers starred commit_checks shortcuts spend person_budgets usage_report templates install_requests extension_installs " +
122− "people_directory team_agents team_context team_members github_visible_installations skill_library skill agent_skills last_visit"
122+ "people_directory team_agents team_context team_members github_visible_installations skill_library skill agent_skills last_visit team_agent_index"
123123 ).split(" "),
124124 );
125125
+24−24
3737 import { DOCK_COOKIE, SIDEBAR_COOKIE, pinsToShow, sidebarClosed } from "./lib/apps";
3838 import { savedPins } from "./lib/dock.server";
3939 import { StandaloneFrame } from "./components/standalone";
40−import { billing, chat, inbox, projects, workspaceAgents } from "./lib/services.server";
40+import { billing, chat, identity, inbox, projects, workspaceAgents } from "./lib/services.server";
41+import { teamsByAgent } from "./lib/people";
4142 import { unreadTotals } from "./lib/chat";
4243 import { countsFor, readableRepos } from "./lib/access.server";
4344 import { shortCache } from "./lib/cache.server";
271272 /** The workspace's agents, as the shell lists them; null when the agents service is slow or down. */
272273 async function agentsFor(slug: string, user: User): Promise<ShellData["agents"]> {
273274 const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), CHAT_BADGE_WAIT_MS));
274− // The workspace's agents and the person's own personal ones, for "Yours".
275− const read = workspaceAgents
276− .list(slug, user, { personal: "mine" })
277− .then((result) =>
278− result.ok
279− ? result.value
280− .filter((agent) => !agent.archived_at)
281− .map((agent) => ({
282− id: agent.id,
283− handle: agent.handle,
284− display_name: agent.display_name,
285− avatar: agent.avatar,
286− avatar_seed: agent.avatar_seed,
287− role: agent.role,
288− title: agent.title,
289− team: agent.team,
290− department: agent.department,
291− status: agent.status,
292− builtin: agent.builtin === true,
293− scope: agent.scope,
294− }))
295− : null,
296− )
275+ // The workspace's agents and the person's own personal ones, for "Yours",
276+ // with the teams each is on, for the Agents sidebar's groups.
277+ const read = Promise.all([workspaceAgents.list(slug, user, { personal: "mine" }), identity.teamAgentIndex(slug).catch(() => null)])
278+ .then(([result, index]) => {
279+ if (!result.ok) return null;
280+ const teams = index ? teamsByAgent(index) : null;
281+ return result.value
282+ .filter((agent) => !agent.archived_at)
283+ .map((agent) => ({
284+ id: agent.id,
285+ handle: agent.handle,
286+ display_name: agent.display_name,
287+ avatar: agent.avatar,
288+ avatar_seed: agent.avatar_seed,
289+ role: agent.role,
290+ title: agent.title,
291+ status: agent.status,
292+ builtin: agent.builtin === true,
293+ scope: agent.scope,
294+ ...(teams ? { teams: teams[agent.id] ?? [] } : {}),
295+ }));
296+ })
297297 .catch(() => null);
298298 return Promise.race([read, timeout]);
299299 }
+7−2
55 import { AgentsSidebar } from "../../../components/agents-mode";
66
77 import type { Route } from "./+types/layout";
8−import { workspaceAgents } from "../../../lib/services.server";
8+import { type AgentTeamRef, teamsByAgent } from "../../../lib/people";
9+import { identity, workspaceAgents } from "../../../lib/services.server";
910 import { requireUser, roleIn } from "../../../lib/session.server";
1011
1112 /**
2021 viewer_id: string;
2122 /** Whether the viewer may create an agent: owners always, members unless owners turned personal agents off. */
2223 may_create: boolean;
24+ /** The visible teams each agent is on, by agent id (team memberships); null when they couldn't be read. */
25+ teams: Record<string, AgentTeamRef[]> | null;
2326 };
2427
2528 export async function loader({ params, context, request }: Route.LoaderArgs): Promise<AgentsLayoutData> {
2831 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
2932 const slug = params.owner.toLowerCase();
3033 const owner = roleIn(viewer, slug) === "owner";
31− const [listed, live, policy] = await Promise.all([
34+ const [listed, live, policy, index] = await Promise.all([
3235 // Personal agents too: the viewer's own, and every member's for an owner.
3336 workspaceAgents.list(slug, viewer!, { personal: "all" }).catch(() => null),
3437 // Every live session counts, private ones too: a count says nothing about what it is.
3538 workspaceAgents.sessions(slug, viewer!, { status: "live", limit: 200 }).catch(() => null),
3639 owner ? Promise.resolve(null) : workspaceAgents.policy(slug, viewer!).catch(() => null),
40+ identity.teamAgentIndex(slug).catch(() => null),
3741 ]);
3842 const counts: Record<string, number> = {};
3943 for (const session of live?.ok ? live.value : []) counts[session.agent_id] = (counts[session.agent_id] ?? 0) + 1;
4246 viewer_id: viewer.id,
4347 may_create: owner || (policy?.ok ? policy.value.members_create_agents : true),
4448 live: counts,
49+ teams: index ? teamsByAgent(index) : null,
4550 agents: listed?.ok ? listed.value.filter((agent) => !agent.archived_at).sort((a, b) => a.display_name.localeCompare(b.display_name)) : null,
4651 };
4752 }
+19−8
88 import { AgentForm, TemplateGallery } from "../../../components/agents-mode";
99 import { type BuilderAnswer, CreateDraft, DescribeBox, ProposalCard, TryChat } from "../../../components/agents/builder";
1010 import { type BuilderDefinition, readDefinition } from "../../../lib/agent-builder";
11−import { type AgentDraft, BLANK_DRAFT, cleanHandle, readAgentForm } from "../../../lib/agent-form";
11+import { type AgentDraft, BLANK_DRAFT, cleanHandle, readAgentForm, readTeams } from "../../../lib/agent-form";
1212 import { channelPath } from "../../../lib/chat";
1313 import { page } from "../../../lib/meta";
1414 import { chat, docs, identity, workspaceAgents } from "../../../lib/services.server";
3636 templates,
3737 owner,
3838 mayCreate: owner || membersMay,
39− teams: teams?.ok ? teams.value.map((team) => ({ slug: team.slug, name: team.name })) : [],
39+ // The teams the viewer may add a new agent to: owners any, a maintainer theirs.
40+ joinable: teams?.ok ? teams.value.filter((team) => team.can_manage).map((team) => ({ slug: team.slug, name: team.name })) : [],
4041 spaces,
4142 };
4243 }
9091 if (!read.ok) return { errors: read.errors };
9192 input = read.input;
9293 }
93− const made = await workspaceAgents.create(slug, viewer, input).catch(() => null);
94+ // Its teams: memberships, added as it is made; a personal agent joins none.
95+ const made = await workspaceAgents.create(slug, viewer, input, { teams: input.scope === "personal" ? [] : readTeams(form) }).catch(() => null);
9496 if (!made) return { errors: { form: NO_ANSWER } };
9597 if (!made.ok) {
9698 const field = made.error.code === "conflict" ? "handle" : "form";
114116 handle: cleanHandle(name),
115117 display_name: name,
116118 title: template.title,
117− department: template.department,
118119 responsibilities: template.responsibilities,
119120 subagents: template.subagents,
120121 role: template.role,
132133 handle: d.handle,
133134 display_name: d.display_name,
134135 title: d.title ?? "",
135− department: d.department ?? "",
136136 responsibilities: d.responsibilities ?? [],
137137 instructions: d.instructions,
138138 personality_preset: d.personality_preset ?? "crisp",
153153 const [proposal, setProposal] = useState<AgentProposal | null>(null);
154154 const [definition, setDefinition] = useState<BuilderDefinition | null>(null);
155155 const [full, setFull] = useState(false);
156+ // "Add to teams" on the drafted card: memberships, posted with Create.
157+ const [joining, setJoining] = useState<string[]>([]);
156158 const handled = useRef<unknown>(null);
157159 useEffect(() => {
158160 if (drafter.state !== "idle" || !drafter.data || handled.current === drafter.data) return;
205207 intent="create"
206208 formKey={full ? `draft:${definition?.handle}` : (chosen ?? "blank")}
207209 nameIdeas={full && proposal ? [proposal.definition.display_name, ...proposal.name_ideas] : ideasOf(template)}
208− teams={personal ? [] : loaderData.teams}
210+ joinable={loaderData.joinable}
209211 personal={personal}
210212 spaces={loaderData.spaces}
211213 seed={full ? definition?.avatar_seed : undefined}
251253 {errors?.handle && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger">{errors.handle}</p>}
252254 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)]">
253255 <div className="min-w-0 space-y-4">
254− <ProposalCard slug={params.owner.toLowerCase()} owner={owner} proposal={proposal} value={definition} onChange={setDefinition} />
255− <CreateDraft definition={definition} onEditAll={() => setFull(true)} />
256+ <ProposalCard
257+ slug={params.owner.toLowerCase()}
258+ owner={owner}
259+ proposal={proposal}
260+ value={definition}
261+ onChange={setDefinition}
262+ joinable={loaderData.joinable}
263+ teams={joining}
264+ onTeams={setJoining}
265+ />
266+ <CreateDraft definition={definition} teams={definition.scope === "personal" ? [] : joining} onEditAll={() => setFull(true)} />
256267 </div>
257268 <div className="lg:sticky lg:top-4 lg:h-[calc(100dvh-13rem)] lg:self-start">
258269 <TryChat definition={definition} />
+30−6
1−import { ChevronRight, History } from "lucide-react";
1+import { ChevronRight, History, UsersRound } from "lucide-react";
22 import { useState } from "react";
33 import { Form, data, redirect, useNavigation, useOutletContext } from "react-router";
44
1212 import { TimeAgo } from "../../../components/ui";
1313 import { isOrchestrator } from "../../../components/orchestrator";
1414 import { readAgentForm } from "../../../lib/agent-form";
15−import { docs, identity, workspaceAgents } from "../../../lib/services.server";
15+import { docs, workspaceAgents } from "../../../lib/services.server";
16+import { agentTeamsFor, changeAgentTeam } from "../../../lib/agent-teams.server";
17+import { AgentTeamsEditor } from "../../../components/teams";
1618 import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
1719
18−/** The workspace's teams, to put the agent on one, and its saved versions. */
20+/** The teams it is on (and may join), and its saved versions. */
1921 export async function loader({ params, context, request }: Route.LoaderArgs) {
2022 const viewer = requireUser(context, request);
2123 const role = roleIn(viewer, params.owner);
2224 const [teams, versions, spaces, effortCosts] = await Promise.all([
23− identity.listTeams(viewer, params.owner).catch(() => null),
25+ workspaceAgents
26+ .get(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)
27+ .then((found) => (found.ok && found.value.scope !== "personal" ? agentTeamsFor(viewer, params.owner.toLowerCase(), found.value.id) : null))
28+ .catch(() => null),
2429 readOrNull(workspaceAgents.versions(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)),
2530 readingSpaces(params.owner.toLowerCase(), viewer),
2631 // What each effort level has cost it, beside the control.
2732 readOrNull(workspaceAgents.effortCosts(params.owner.toLowerCase(), params.handle.toLowerCase(), viewer)),
2833 ]);
2934 return {
30− teams: teams?.ok ? teams.value.map((team) => ({ slug: team.slug, name: team.name })) : [],
35+ teams,
36+ slug: params.owner.toLowerCase(),
3137 versions,
3238 spaces,
3339 effortCosts,
5258 const form = await request.formData();
5359 const handle = params.handle.toLowerCase();
5460 const intent = String(form.get("intent") ?? "");
61+ // Its teams: the team's own add and remove, from here.
62+ const changed = await changeAgentTeam(form, viewer, slug, handle);
63+ if (changed) return { teamChange: changed };
5564 // "Tell <name> what to change": drafted, shown, and saved only when they say so.
5665 if (intent === "redraft") {
5766 const drafted = await workspaceAgents.redraft(slug, handle, viewer, String(form.get("request") ?? "")).catch(() => null);
112121 <>
113122 {personal && <PersonalNotice agent={agent} owner={loaderData.owner} mine={mine} />}
114123 {mayChange && <RedraftBox agent={agent} />}
124+ {!isOrchestrator(agent) && (
125+ <section aria-labelledby="agent-teams" className="mb-8 rounded-xl border border-line bg-surface p-4">
126+ <h2 id="agent-teams" className="flex items-center gap-2 text-sm font-medium">
127+ <UsersRound size={14} className="text-faint" />
128+ Teams
129+ </h2>
130+ <p className="mt-0.5 mb-3 text-xs text-faint">On teams like anyone: owners and a team&apos;s maintainers add it and take it off.</p>
131+ <AgentTeamsEditor
132+ slug={loaderData.slug}
133+ name={agent.display_name}
134+ teams={loaderData.teams}
135+ personal={personal}
136+ change={actionData && "teamChange" in actionData ? actionData.teamChange : null}
137+ />
138+ </section>
139+ )}
115140 {actionData && "saved" in actionData && actionData.saved && (
116141 <p role="status" className="mb-6 rounded-lg border border-success/30 bg-success/10 px-4 py-2.5 text-sm text-success">
117142 Saved as version {agent.version}.
127152 intent="update"
128153 formKey={`${agent.id}:${agent.version}`}
129154 locked={isOrchestrator(agent)}
130− teams={personal ? [] : loaderData.teams}
131155 personal={personal}
132156 spaces={loaderData.spaces}
133157 effortCosts={loaderData.effortCosts}
+1−1
5959 <PixelCreature seed={template.handle} size={64} />
6060 <div className="min-w-0 grow basis-60">
6161 <h1 className="text-2xl font-semibold tracking-tight">{template.title}</h1>
62− <p className="mt-0.5 text-sm text-muted">{template.department ? `${template.department} · ` : ""}A template from g1t</p>
62+ <p className="mt-0.5 text-sm text-muted">A role template from g1t · Suggests {template.display_name}</p>
6363 </div>
6464 <StartAction listing={listing} slug={slug} owner={owner} className="h-9 px-4 text-sm" />
6565 </header>
+7−9
11 /**
2− * Agent templates: the starting points g1t provides for a new agent, by
3− * department, with the agents in the workspace that started from each. A
2+ * Agent templates: the roles g1t provides to start a new agent from, by
3+ * title, with the agents in the workspace that started from each. A
44 * template isn't installed: an owner starts an agent from one (a member,
55 * a personal agent) and configures it, and the agent is its own from then on.
66 */
1010 import type { Route } from "./+types/templates";
1111 import { TemplateCard } from "../../../components/agent-templates";
1212 import { EmptyState } from "../../../components/ui";
13−import { byDepartment, startPath, templateListings } from "../../../lib/agent-templates";
13+import { startPath, templateListings } from "../../../lib/agent-templates";
1414 import { loadTemplates } from "../../../lib/agent-templates.server";
1515 import { page } from "../../../lib/meta";
1616 import { requireUser, roleIn } from "../../../lib/session.server";
5757 <EmptyState title="Templates can't be shown right now">The agents service didn't answer. Reload in a minute.</EmptyState>
5858 ) : (
5959 <div className="space-y-10">
60− {/* One grid in department order: most departments have a single template. */}
60+ {/* One grid, in the order g1t lists the roles: a template names a role, never a team. */}
6161 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
62− {byDepartment(listings)
63− .flatMap(([, list]) => list)
64− .map((listing) => (
65− <TemplateCard key={listing.template.id} listing={listing} slug={slug} owner={owner} />
66− ))}
62+ {listings.map((listing) => (
63+ <TemplateCard key={listing.template.id} listing={listing} slug={slug} owner={owner} />
64+ ))}
6765 </div>
6866 {owner && (
6967 <section aria-label="Your own">
+22−6
1212 import { teamPath } from "../../../lib/teams";
1313 import { money } from "../../../lib/usage";
1414 import { identity, workspaceAgents } from "../../../lib/services.server";
15−import { getViewer, roleIn, unwrap } from "../../../lib/session.server";
15+import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
16+import { agentTeamsFor, changeAgentTeam } from "../../../lib/agent-teams.server";
17+import { AgentTeamsEditor } from "../../../components/teams";
1618
1719 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
1820 return page(args, { title: `${loaderData?.agent.display_name ?? params.handle} · People · ${params.owner} · g1t` });
3133 const agent = found.value;
3234 const agents = listed?.ok ? listed.value.map(peopleAgent) : [peopleAgent(agent)];
3335 const teams = teamsOfAgent(directory.teams, agent);
36+ // Its teams to change here, as a person's are changed on the team: Remove where the viewer manages one, and Add.
37+ const editable = agent.scope === "personal" ? null : await agentTeamsFor(viewer!, params.owner.toLowerCase(), agent.id, directory);
3438 // Everyone on its teams, each once, leads first.
3539 const names = new Set(teams.flatMap((team) => team.people.map((p) => p.username)));
3640 const leadNames = new Set(teams.flatMap((team) => (team.lead?.kind === "user" ? [team.lead.username] : [])));
4145 return {
4246 slug: params.owner.toLowerCase(),
4347 agent: { ...peopleAgent(agent), monthly_micros: agent.budget.monthly_micros },
48+ personal: agent.scope === "personal",
49+ editable,
4450 teams,
4551 teammates,
4652 agents,
4854 };
4955 }
5056
51−export default function AgentProfile({ loaderData }: Route.ComponentProps) {
52− const { slug, agent, teams, teammates, agents, told } = loaderData;
57+/** Adding the agent to a team, or taking it off one. */
58+export async function action({ request, params, context }: Route.ActionArgs) {
59+ assertSameOrigin(request);
60+ const viewer = requireUser(context, request);
61+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
62+ const changed = await changeAgentTeam(await request.formData(), viewer, params.owner.toLowerCase(), params.handle.toLowerCase());
63+ return changed ?? { intent: "join-team" as const, team: "", error: "Unknown request." };
64+}
65+
66+export default function AgentProfile({ loaderData, actionData }: Route.ComponentProps) {
67+ const { slug, agent, teams, teammates, agents, told, editable, personal } = loaderData;
5368 const otherAgents = teams
5469 .flatMap((team) => agentsOn(team, agents))
5570 .filter((other, index, all) => other.id !== agent.id && all.findIndex((a) => a.id === other.id) === index);
101116
102117 <div className="grid gap-4 md:grid-cols-2">
103118 <Card icon={<UsersRound size={14} />} title="Teams">
104− {teams.length ? (
119+ {editable || personal ? (
120+ <AgentTeamsEditor slug={slug} name={agent.display_name} teams={editable} personal={personal} change={actionData} />
121+ ) : teams.length ? (
105122 <ul className="space-y-2">
106123 {teams.map((team) => (
107124 <li key={team.slug} className="flex flex-wrap items-center gap-2">
110127 {team.name}
111128 </Link>
112129 {leads(team.lead, { id: agent.id }) && <Badge tone="accent">Lead</Badge>}
113− {agent.team === team.slug && <Badge>Home team</Badge>}
114130 </li>
115131 ))}
116132 </ul>
117133 ) : (
118− <Quiet>Not on a team yet. Add it to one from the team's page.</Quiet>
134+ <Quiet>Not on a team yet.</Quiet>
119135 )}
120136 </Card>
121137
+3−3
3434 const team = found.value;
3535 const ids = added?.ok ? added.value.map((agent) => agent.agent_id) : [];
3636 const all = listed?.ok ? listed.value.map(peopleAgent) : [];
37− return { team, agents: agentsOn({ slug: team.slug, agent_ids: ids }, all), added: ids };
37+ return { team, agents: agentsOn({ agent_ids: ids }, all) };
3838 }
3939
4040 export default function TeamLayout({ loaderData }: Route.ComponentProps) {
41− const { team, agents, added } = loaderData;
41+ const { team, agents } = loaderData;
4242 const base = teamPath(team.workspace, team.slug);
4343 const lead =
4444 team.lead?.kind === "user"
141141 </TabStrip>
142142 </header>
143143 <div className="pt-6">
144− <Outlet context={{ team, agents, added } satisfies TeamContext} />
144+ <Outlet context={{ team, agents } satisfies TeamContext} />
145145 </div>
146146 </div>
147147 );
+10−15
3030 ]);
3131 const all = listed?.ok ? listed.value.map(peopleAgent) : [];
3232 const ids = added?.ok ? added.value.map((agent) => agent.agent_id) : [];
33− const onTeam = agentsOn({ slug: team.slug, agent_ids: ids }, all);
33+ const onTeam = agentsOn({ agent_ids: ids }, all);
3434 // What the team's first agent is told about it, word for word: every agent on it is told the same about this team.
3535 const first = onTeam.find((agent) => !agent.builtin);
3636 const told = first ? await workspaceAgents.teamContext(params.owner, first.handle, viewer!).catch(() => null) : null;
7474
7575 export default function TeamMembers({ loaderData, actionData }: Route.ComponentProps) {
7676 const team = useTeam();
77− const { agents, added } = useTeamAgents();
77+ const { agents } = useTeamAgents();
7878 const { members, children, me, addable, told } = loaderData;
7979 const [, setParams] = useSearchParams();
8080 const manage = team.can_manage;
194194 ) : (
195195 <ul className="divide-y divide-line rounded-xl border border-line">
196196 {agents.map((agent) => {
197− const home = !added.includes(agent.id);
198197 return (
199198 <li key={agent.id} className="px-4 py-3">
200199 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
204203 <span className="ml-2 text-muted">{agent.title || agent.role}</span>
205204 </div>
206205 {leads(team.lead, { id: agent.id }) && <Badge tone="accent">Lead</Badge>}
207− {home ? (
208− <Badge>Home team</Badge>
209− ) : (
210− manage && (
211− <Form method="post">
212− <input type="hidden" name="intent" value="remove-agent" />
213− <input type="hidden" name="agent_id" value={agent.id} />
214− <SubmitButton variant="quiet" match={{ intent: "remove-agent", agent_id: agent.id }} pending="Removing…">
215− Remove
216− </SubmitButton>
217− </Form>
218− )
206+ {manage && (
207+ <Form method="post">
208+ <input type="hidden" name="intent" value="remove-agent" />
209+ <input type="hidden" name="agent_id" value={agent.id} />
210+ <SubmitButton variant="quiet" match={{ intent: "remove-agent", agent_id: agent.id }} pending="Removing…">
211+ Remove
212+ </SubmitButton>
213+ </Form>
219214 )}
220215 </div>
221216 <ErrorText>{rowError(agent.id)}</ErrorText>
+3−3
3333 const NO_LEAD = "none";
3434
3535 /**
36− * Its lead, channel and budget. An agent made lead that is on the team
37− * only through its home team is added to it first, as identity needs; a
38− * channel is found by its name in Chat, as the viewer sees it.
36+ * Its lead, channel and budget. An agent made lead is added to the team
37+ * first if it isn't on it, as identity needs; a channel is found by its
38+ * name in Chat, as the viewer sees it.
3939 */
4040 async function saveLeadChannelBudget(form: FormData, workspace: string, slug: string, user: Parameters<typeof identity.updateTeam>[0]) {
4141 const intent = "people";
+3−3
88 import { TeamRow } from "../../components/teams";
99 import { ButtonLink, EmptyState, notACredential } from "../../components/ui";
1010 import { filterTeams, splitTeams } from "../../lib/teams";
11−import { agentIdsOn } from "../../lib/people";
11+import { agentsOn } from "../../lib/people";
1212 import { identity, workspaceAgents } from "../../lib/services.server";
1313 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
1414
2424 const [teams, workspace, directory, agents] = await Promise.all([
2525 identity.listTeams(viewer, params.owner).then(unwrap),
2626 identity.getWorkspace(params.owner).catch(() => null),
27− // Which agents are on each team: added to it, or their home team.
27+ // Which agents are on each team: its agent members that are still here.
2828 identity.peopleDirectory(viewer, params.owner).catch(() => null),
2929 workspaceAgents.list(params.owner, viewer!).catch(() => null),
3030 ]);
3131 const listed = agents?.ok ? agents.value : [];
3232 const agentCounts = Object.fromEntries(
33− (directory?.ok ? directory.value.teams : []).map((team) => [team.slug, agentIdsOn(team, listed).length]),
33+ (directory?.ok ? directory.value.teams : []).map((team) => [team.slug, agentsOn(team, listed).length]),
3434 );
3535 // Who may create one is the workspace's to say (its settings).
3636 return { teams, agentCounts, slug: params.owner.toLowerCase(), canCreate: mayCreateTeams(workspace?.teamCreation, role) };
+3−1
442442 https://docs.g1t.sh/guides/teams/
443443
444444 A team can hold people, agents or both, with a lead, a Chat channel and a
445−monthly budget its agents share. Every reply and session step, an agent is
445+monthly budget its agents share. An agent is on teams the way a person is,
446+as a member that owners and the team's maintainers add and remove; nothing
447+on the agent names a team. Every reply and session step, an agent is
446448 told each visible team it is on: who leads it, each person's title, what
447449 they own, who they report to, whether they are around and their local
448450 time, and who to ask when a person is needed. Ask the person who owns
+48−3
581581 }
582582
583583 /// `agent_teams`: for the agents service. The visible teams an agent is
584−/// on in a workspace (added to, or its home team), each with everyone on
585−/// it, for what the agent is told every turn and its team budgets.
584+/// on in a workspace (its `team_agents` memberships), each with everyone
585+/// on it, for what the agent is told every turn and its team budgets.
586586 /// Returns `Vec<AgentTeam>`.
587587 #[derive(Debug, Default, Serialize, Deserialize)]
588588 pub struct AgentTeamsArgs {
589589 /// The workspace's slug.
590590 pub workspace: String,
591591 pub agent_id: String,
592− /// The team its profile names, by slug.
592+ /// Sent only by an agents service from before agents' teams were
593+ /// memberships, while a deploy rolls out: a team it named itself, by
594+ /// slug, counted as one it is on. Newer callers leave it out.
593595 #[serde(default)]
594596 pub home_team: Option<String>,
595597 }
596598
599+/// `team_agent_index`: for the agents service. A workspace's visible
600+/// teams, each with the agents on it, for the roster agents are told and
601+/// spend by team. Returns `Vec<TeamAgentsEntry>`.
602+#[derive(Debug, Default, Serialize, Deserialize)]
603+pub struct TeamAgentIndexArgs {
604+ /// The workspace's slug.
605+ pub workspace: String,
606+}
607+
608+/// A visible team and the agents on it, by id.
609+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
610+pub struct TeamAgentsEntry {
611+ pub slug: String,
612+ pub name: String,
613+ pub agent_ids: Vec<String>,
614+}
615+
616+/// `adopt_agent_teams`: for the agents service, once. Puts each agent on
617+/// the team it named itself when an agent carried its own team, matched
618+/// by slug in any case, in the workspace by id. Safe to repeat: an agent
619+/// already on the team stays as it is. Returns `Vec<AdoptedAgentTeam>`.
620+#[derive(Debug, Default, Serialize, Deserialize)]
621+pub struct AdoptAgentTeamsArgs {
622+ pub workspace_id: String,
623+ pub agents: Vec<AgentTeamClaim>,
624+}
625+
626+/// An agent and the team it named, by slug.
627+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
628+pub struct AgentTeamClaim {
629+ pub agent_id: String,
630+ pub team: String,
631+}
632+
633+/// What `adopt_agent_teams` did for one agent.
634+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
635+pub struct AdoptedAgentTeam {
636+ pub agent_id: String,
637+ pub team: String,
638+ /// `added`, `already` (on it before), or `no_team` (no team of that name).
639+ pub outcome: String,
640+}
641+
597642 /// A person on a team, as an agent on it is told of them.
598643 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
599644 pub struct RosterPerson {
+3−1
244244 teamAgents: (viewer, workspace, team) => call("team_agents", { viewer, workspace, team }),
245245 setTeamAgent: (actor, workspace, team, agentId) => call("set_team_agent", { actor, workspace, team, agent_id: agentId }),
246246 removeTeamAgent: (actor, workspace, team, agentId) => call("remove_team_agent", { actor, workspace, team, agent_id: agentId }),
247− agentTeams: (workspace, agentId, homeTeam) => call("agent_teams", { workspace, agent_id: agentId, home_team: homeTeam }),
247+ agentTeams: (workspace, agentId) => call("agent_teams", { workspace, agent_id: agentId }),
248+ teamAgentIndex: (workspace) => call("team_agent_index", { workspace }),
249+ adoptAgentTeams: (workspaceId, agents) => call("adopt_agent_teams", { workspace_id: workspaceId, agents }),
248250 // People; see people.ts.
249251 peopleDirectory: (viewer, workspace) => call("people_directory", { viewer, workspace }),
250252 setMemberProfile: (actor, workspace, username, changes) => call("set_member_profile", { actor, workspace, username, ...changes }),
+1−11
5151 budget_micros: number | null;
5252 /** Its own people. */
5353 people: TeamPersonRef[];
54− /** Agents added to it, by id. Agents whose home team it is are on it too (`agentsOnTeam`). */
54+ /** The agents on it, by id. */
5555 agent_ids: string[];
5656 repos_count: number;
5757 };
7777 peopleDirectory(viewer: User | null, workspace: string): Promise<Result<PeopleDirectory>>;
7878 /** A member's title and what they own (themselves or an owner), and their manager (owners). */
7979 setMemberProfile(actor: User, workspace: string, username: string, changes: MemberProfileChanges): Promise<Result<DirectoryPerson>>;
80−}
81−
82−/**
83− * The agents on a team, by id: those added to it, then those whose home
84− * team it is (their profile names it).
85− */
86−export function agentsOnTeam(team: { slug: string; agent_ids: readonly string[] }, agents: readonly { id: string; team: string | null }[]): string[] {
87− const ids = [...team.agent_ids];
88− for (const agent of agents) if (agent.team === team.slug && !ids.includes(agent.id)) ids.push(agent.id);
89− return ids;
9080 }
9181
9282 /** What a team is made of. */
+17−3
113113 agent_ids: string[];
114114 };
115115
116+/** A visible team and the agents on it (`team_agent_index`). */
117+export type TeamAgentsEntry = { slug: string; name: string; agent_ids: string[] };
118+
119+/** What `adopt_agent_teams` did for one agent: `added`, `already` on it, or `no_team` of that name. */
120+export type AdoptedAgentTeam = { agent_id: string; team: string; outcome: "added" | "already" | "no_team" };
121+
116122 /** How `update_team` takes a lead: `@username`, or `agent:<id>`; `""` for none. */
117123 export function leadInput(lead: { kind: "user"; username: string } | { kind: "agent"; agent_id: string } | null): string {
118124 if (!lead) return "";
132138 members_count: number;
133139 repos_count: number;
134140 child_teams_count: number;
135− /** Agents added to it. Agents whose home team it is are on it too. */
141+ /** Agents on it. */
136142 agents_count: number;
137143 lead: TeamLead | null;
138144 channel: TeamChannel | null;
234240 teamRepos(viewer: User | null, workspace: string, team: string): Promise<Result<TeamRepo[]>>;
235241 setTeamRepo(actor: User, workspace: string, team: string, owner: string, name: string, role: RepoRole): Promise<Result<TeamRepo>>;
236242 removeTeamRepo(actor: User, workspace: string, team: string, owner: string, name: string): Promise<Result<boolean>>;
237− /** The agents added to a team. Agents whose home team it is are on it too (`agentsOnTeam`). */
243+ /** The agents on a team: added to it, as people are. */
238244 teamAgents(viewer: User | null, workspace: string, team: string): Promise<Result<TeamAgent[]>>;
239245 /** Adds one of the workspace's agents, by id; check it is the workspace's first. Owners and maintainers. */
240246 setTeamAgent(actor: User, workspace: string, team: string, agentId: string): Promise<Result<TeamAgent>>;
241247 removeTeamAgent(actor: User, workspace: string, team: string, agentId: string): Promise<Result<boolean>>;
242248 /** For the agents service: the visible teams an agent is on, with everyone on each. */
243− agentTeams(workspace: string, agentId: string, homeTeam: string | null): Promise<AgentTeam[]>;
249+ agentTeams(workspace: string, agentId: string): Promise<AgentTeam[]>;
250+ /** For the agents service: the workspace's visible teams, each with the agents on it. */
251+ teamAgentIndex(workspace: string): Promise<TeamAgentsEntry[]>;
252+ /**
253+ * For the agents service, once: puts agents on the teams they named when
254+ * an agent carried its own team (by slug, any case), in the workspace
255+ * (by id). Safe to repeat: an agent already on the team stays as it is.
256+ */
257+ adoptAgentTeams(workspaceId: string, agents: { agent_id: string; team: string }[]): Promise<AdoptedAgentTeam[]>;
244258 userTeams(viewer: User | null, workspace: string, username: string): Promise<Result<Team[]>>;
245259 /** Each member's teams the viewer can see. Members only. */
246260 teamMemberships(viewer: User | null, workspace: string): Promise<Result<MemberTeams[]>>;
+15−16
9191 */
9292 avatar_seed: string;
9393 /**
94− * One line, as lists show it: "QA Engineer on the QA team". Made from
95− * the title and team (or department) when not written.
94+ * One line, as lists show it: "QA Engineer". Its title when not written.
9695 */
9796 role: string;
9897 /**
99− * Agents are hired into roles, not tasks: a title, a team, and broad
100− * responsibilities.
98+ * Agents are hired into roles, not tasks: a title and broad
99+ * responsibilities. The teams it is on are team memberships, as a
100+ * person's are (identity `team_agents`), never part of the agent.
101101 */
102102 title: string;
103− /** The team it is on, by slug, from the workspace's teams; null for none. */
104− team: string | null;
105− /** A label for where it works when it is on no team: "QA", "Sales". */
106− department: string;
107103 /** What it is responsible for: 2 to 8 short duties, or none yet. */
108104 responsibilities: string[];
109105 /**
201197 export type NewWorkspaceAgent = {
202198 handle: string;
203199 display_name: string;
204− /** Left out or empty: made from the title and team. */
200+ /** Left out or empty: its title. */
205201 role?: string;
206202 title?: string;
207− team?: string | null;
208− department?: string;
209203 responsibilities?: string[];
210204 subagents?: SubagentDef[];
211205 /** Docs spaces (by id) it reads first; at most 10. */
271265 };
272266
273267 /**
274− * A role to hire an agent into, by department. Agents get names, not job
275− * titles ("Margo", the QA Engineer).
268+ * A role to hire an agent into, by title. Agents get names, not job
269+ * titles ("Margo", the QA Engineer). A template never puts an agent on a
270+ * team.
276271 */
277272 export type AgentTemplate = {
278273 id: string;
283278 name_ideas: string[];
284279 role: string;
285280 title: string;
286− department: string;
287281 responsibilities: string[];
288282 subagents: SubagentDef[];
289283 instructions: string;
822816 get(workspace: string, handle: string, viewer: User): Promise<Result<WorkspaceAgent>>;
823817 /** Internal: by id, for the chat service resolving members. */
824818 byIds(ids: string[]): Promise<WorkspaceAgent[]>;
825− create(workspace: string, viewer: User, input: NewWorkspaceAgent): Promise<Result<WorkspaceAgent>>;
819+ /**
820+ * `teams`: the workspace's teams to add it to as it is made, by slug,
821+ * each one the viewer manages (owners and the team's maintainers). The
822+ * membership is the team's, as anyone's is; a personal agent joins none.
823+ */
824+ create(workspace: string, viewer: User, input: NewWorkspaceAgent, options?: { teams?: string[] }): Promise<Result<WorkspaceAgent>>;
826825 update(
827826 workspace: string,
828827 handle: string,
975974 list: (workspace, viewer, options) => call("list", { workspace, viewer, personal: options?.personal ?? null }),
976975 get: (workspace, handle, viewer) => call("get", { workspace, handle, viewer }),
977976 byIds: (ids) => call("by_ids", { ids }),
978− create: (workspace, viewer, input) => call("create", { workspace, viewer, input }),
977+ create: (workspace, viewer, input, options) => call("create", { workspace, viewer, input, teams: options?.teams ?? [] }),
979978 update: (workspace, handle, viewer, changes) => call("update", { workspace, handle, viewer, changes }),
980979 archive: (workspace, handle, viewer) => call("archive", { workspace, handle, viewer }),
981980 draft: (workspace, viewer, input) => call("draft", { workspace, viewer, description: input.description, scope: input.scope ?? null }),
+17−0
1+-- An agent is on teams the way a person is: through team membership in
2+-- identity (team_agents), added and removed on the team page or the
3+-- agent's profile (docs.g1t.sh/guides/people-and-teams/). The agent itself
4+-- carries no team and no department.
5+--
6+-- agents.team and agents.department stay as they are, unused: migrations
7+-- run before the code, so the code still serving during a deploy reads
8+-- and writes them for a minute or more. A later migration drops them once
9+-- no deployed code names them.
10+--
11+-- Each agent's old `team` moves to a membership once (src/team-move.ts,
12+-- run by the cron): a team of that slug in its workspace gets it as a
13+-- member, and a value that names no team is dropped and logged.
14+-- `team_moved_at` marks the agents it has seen, so it never runs twice for
15+-- one, and taking an agent off that team later stays taken off.
16+ALTER TABLE agents ADD COLUMN team_moved_at TEXT;
17+CREATE INDEX IF NOT EXISTS agents_team_to_move ON agents (id) WHERE team IS NOT NULL AND team <> '' AND team_moved_at IS NULL;
+2−1
2525 name_ideas: ["Wren", "Juniper", "Basil", "!!"],
2626 title: "Release Manager",
2727 department: "Engineering",
28+ team: "release",
2829 instructions: "You cut releases.\n- Read merged pull requests since the last tag.\n- Write the notes.",
2930 responsibilities: ["Cut a release every Tuesday", "Write the release notes", "Ask in #releases before tagging"],
3031 personality_preset: "friendly",
4344 assert.equal(definition.handle, "juniper");
4445 assert.equal(definition.display_name, "Juniper");
4546 assert.equal(definition.scope, "personal");
46− assert.equal(definition.team, null, "a draft is on no team");
47+ assert.ok(!("team" in definition) && !("department" in definition), "a draft names no team or department, whatever the model says");
4748 assert.deepEqual(definition.budget, { monthly_micros: 20_000_000, daily_micros: null, task_micros: 2_000_000 }, "$20 a month, $2 a session");
4849 assert.deepEqual(definition.routing, { floor: null, ceiling: "small", providers: [], pinned: null }, "a floor above the ceiling is dropped");
4950 assert.deepEqual(made.value.skills, ["documents", "code"], "unknown skills are left out");
+4−11
5151 /** Long enough for a full job, short enough to stay a quick call. */
5252 export const DRAFT_OUTPUT_TOKENS = 3000;
5353
54−const FIELD_LIMITS = { displayName: 64, title: 60, department: 40, duty: 160, instructions: 8000, personality: 1000 };
54+const FIELD_LIMITS = { displayName: 64, title: 60, duty: 160, instructions: 8000, personality: 1000 };
5555
5656 // ── Prompts ───────────────────────────────────────────────────────────────
5757
6868 "display_name": "a short, friendly first name for it, like Margo or Otto (not a job title)",
6969 "name_ideas": ["four or five other names that suit it"],
7070 "title": "its job title, like QA Engineer or Release Manager",
71− "department": "one or two words, like Engineering, Support, Sales, Operations",
7271 "instructions": "its job, in the second person: what it is responsible for, how it works step by step, what good looks like, and what it must never do. Markdown bullets are fine. 600 to 2500 characters.",
7372 "responsibilities": ["2 to 6 short duties, each under 120 characters"],
7473 "personality_preset": "crisp | friendly | socratic | terse",
9998 return {
10099 display_name: d.display_name,
101100 title: d.title,
102− department: d.department,
103101 instructions: d.instructions,
104102 responsibilities: d.responsibilities,
105103 personality_preset: d.personality_preset,
118116 `You edit an agent's definition in the ${workspace} workspace on g1t. Below is the agent as it is now, as JSON. A person with the right to change it asks for a change in words.`,
119117 "Make exactly the change they ask for, and nothing else: keep every other field as it is. Rewrite the instructions only where the request touches them, keeping the rest word for word.",
120118 builtin
121− ? "This is @g1t, the workspace's built-in orchestrator: its name, title and department are fixed, and its instructions are added to its fixed job. Change only instructions, personality, skills, model limits and budget."
119+ ? "This is @g1t, the workspace's built-in orchestrator: its name and title are fixed, and its instructions are added to its fixed job. Change only instructions, personality, skills, model limits and budget."
122120 : "",
123121 catalogLines(),
124122 `The agent now:\n${JSON.stringify(editableView(d), null, 2)}`,
239237 handle: freeHandle(display, context.taken),
240238 display_name: display,
241239 title: str(raw.title, FIELD_LIMITS.title) || "Assistant",
242− department: str(raw.department, FIELD_LIMITS.department),
243− team: null,
244240 role: "",
245241 responsibilities: duties.length === 1 ? [] : duties,
246242 instructions: str(raw.instructions, FIELD_LIMITS.instructions),
271267 export function redraftFrom(raw: Record<string, unknown> | null, before: Definition, builtin: boolean): Checked<{ changes: Partial<NewWorkspaceAgent>; summary: string }> {
272268 if (!raw) return { ok: false, message: "That change didn't come out right. Try saying it another way." };
273269 const changes: Partial<NewWorkspaceAgent> = {};
274− const text = (key: "display_name" | "title" | "department" | "instructions" | "personality", max: number) => {
270+ const text = (key: "display_name" | "title" | "instructions" | "personality", max: number) => {
275271 if (typeof raw[key] !== "string") return;
276272 const value = str(raw[key], max);
277− if (value !== before[key] && (value || key === "personality" || key === "department")) changes[key] = value;
273+ if (value !== before[key] && (value || key === "personality")) changes[key] = value;
278274 };
279275 if (!builtin) {
280276 text("display_name", FIELD_LIMITS.displayName);
281277 text("title", FIELD_LIMITS.title);
282− text("department", FIELD_LIMITS.department);
283278 if (Array.isArray(raw.responsibilities)) {
284279 const duties = strings(raw.responsibilities, FIELD_LIMITS.duty, 8);
285280 if (duties.length !== 1 && JSON.stringify(duties) !== JSON.stringify(before.responsibilities)) changes.responsibilities = duties;
381376 template: null,
382377 avatar_seed: null,
383378 title: null,
384− team: null,
385− department: null,
386379 responsibilities: null,
387380 subagents: null,
388381 faces: null,
+22−11
122122 assert.ok(chosen.ok && chosen.value.avatar_seed === "blob");
123123 });
124124
125−test("an agent is hired into a role: title, team or department, responsibilities", async () => {
125+test("an agent is hired into a role: a title and responsibilities, never a team", async () => {
126126 const { roleOf } = await import("./definition.ts");
127− const made = applyChanges(null, { handle: "margo", display_name: "Margo", title: "QA Engineer", department: "QA", instructions: "Test things.", responsibilities: ["Review pull requests", "Chase flaky checks"] }, TEMPLATE_IDS);
127+ const made = applyChanges(null, { handle: "margo", display_name: "Margo", title: "QA Engineer", instructions: "Test things.", responsibilities: ["Review pull requests", "Chase flaky checks"] }, TEMPLATE_IDS);
128128 assert.ok(made.ok);
129129 if (!made.ok) return;
130− assert.equal(made.value.role, "QA Engineer, QA", "the one-line role is made from the title and department");
131− const onTeam = applyChanges(made.value, { team: "QA-Core" }, TEMPLATE_IDS);
132− assert.ok(onTeam.ok && onTeam.value.role === "QA Engineer on the qa-core team", "a made role follows the title and team");
130+ assert.equal(made.value.role, "QA Engineer", "the one-line role is its title");
131+ assert.ok(!("team" in made.value) && !("department" in made.value), "teams are memberships, not part of the agent");
132+ const retitled = applyChanges(made.value, { title: "QA Lead" }, TEMPLATE_IDS);
133+ assert.ok(retitled.ok && retitled.value.role === "QA Lead", "a made role follows the title");
134+ const ignored = applyChanges(made.value, { team: "qa" } as never, TEMPLATE_IDS);
135+ assert.ok(ignored.ok && !("team" in ignored.value), "a team sent with a change is ignored");
133136 const written = applyChanges(made.value, { role: "Keeps us honest" }, TEMPLATE_IDS);
134137 assert.ok(written.ok);
135138 if (!written.ok) return;
136139 const kept = applyChanges(written.value, { title: "QA Lead" }, TEMPLATE_IDS);
137140 assert.ok(kept.ok && kept.value.role === "Keeps us honest", "a written role is kept");
138− assert.equal(roleOf({ title: "", team: null, department: "QA" }), "");
141+ assert.equal(roleOf({ title: "" }), "");
139142 assert.equal(applyChanges(null, { handle: "x1", display_name: "X", instructions: "y" }, TEMPLATE_IDS).ok, false, "a title or a role is needed");
140143 const bad = (changes: object) => applyChanges(made.value, changes, TEMPLATE_IDS).ok;
141144 assert.equal(bad({ responsibilities: ["Only one"] }), false, "2 to 8 duties");
142145 assert.equal(bad({ responsibilities: Array.from({ length: 9 }, (_, i) => `Duty ${i}`) }), false);
143146 assert.equal(bad({ responsibilities: [] }), true, "or none yet");
144− assert.equal(bad({ team: "not a slug!" }), false);
145− assert.equal(bad({ team: null }), true);
146147 });
147148
149+test("a role made from an agent's old team or department reads as its title", async () => {
150+ const { definitionOf } = await import("./store.ts");
151+ const row = (role: string, team: string | null, department: string) =>
152+ definitionOf({ handle: "margo", display_name: "Margo", title: "QA Engineer", role, team, department, routing: "{}", budget: "{}", autonomy: "{}" } as never);
153+ assert.equal(row("QA Engineer on the qa team", "qa", "").role, "QA Engineer");
154+ assert.equal(row("QA Engineer, QA", null, "QA").role, "QA Engineer");
155+ assert.equal(row("Keeps us honest", "qa", "QA").role, "Keeps us honest", "a written role stays");
156+ assert.ok(!("team" in row("x", "qa", "")), "the old columns are not part of the definition");
157+});
158+
148159 test("subagents: named, described, at most 8, never wider than their agent", () => {
149160 const base = applyChanges(null, { ...input, routing: { floor: "large", ceiling: "large" } }, TEMPLATE_IDS);
150161 assert.ok(base.ok);
172183 assert.ok(!customers.ok && customers.message === "Customer-facing agents aren't available yet.");
173184 });
174185
175−test("role templates by department, each with a title, duties and a subagent or two; David is back office", () => {
186+test("role templates by title, each with duties and a subagent or two, on no team; David is back office", () => {
176187 const byId = Object.fromEntries(TEMPLATES.map((t) => [t.id, t]));
177188 for (const template of TEMPLATES) {
178− assert.ok(template.title && template.department, template.id);
189+ assert.ok(template.title && template.role === template.title, template.id);
190+ assert.ok(!("department" in template) && !("team" in template), `${template.id} names a role, not a team`);
179191 assert.ok(template.responsibilities.length >= 2 && template.responsibilities.length <= 8, template.id);
180192 assert.ok(template.subagents.length >= 1 && template.subagents.length <= 2, template.id);
181193 }
182194 assert.equal(byId.sales.display_name, "David");
183195 assert.equal(byId.sales.title, "Sales Operations");
184− assert.equal(byId.sales.department, "Sales");
185196 assert.match(byId.sales.instructions, /never contact a customer/i);
186197 assert.match(byId.sales.instructions, /never promise roadmap/i);
187198 assert.match(byId.sales.instructions, /audience/);
+21−20
3434 export const DEFAULT_CAPACITY = 3;
3535 export const MAX_CAPACITY = 10;
3636
37−const LIMITS = { displayName: 64, role: 120, title: 60, department: 40, duty: 160, instructions: 8000, personality: 1000, providers: 10, pinned: 200 };
37+const LIMITS = { displayName: 64, role: 120, title: 60, duty: 160, instructions: 8000, personality: 1000, providers: 10, pinned: 200 };
3838 /** $100,000 in millionths: a cap above this is a typo. */
3939 const MAX_MICROS = 100_000_000_000;
4040
5454 /** What its generated avatar is drawn from. */
5555 avatar_seed: string;
5656 title: string;
57− team: string | null;
58− department: string;
5957 responsibilities: string[];
6058 subagents: SubagentDef[];
6159 faces: AgentFaces;
6563 skills_off: string[];
6664 };
6765
68−/** The one-line role a title and team (or department) make: "QA Engineer on the qa team". */
69−export function roleOf(d: Pick<Definition, "title" | "team" | "department">): string {
70− const title = d.title.trim();
71− if (!title) return "";
72− if (d.team) return `${title} on the ${d.team} team`;
73− return d.department.trim() ? `${title}, ${d.department.trim()}` : title;
66+/**
67+ * The one-line role its title makes: "QA Engineer". Which teams it is on
68+ * are team memberships (identity), never part of the agent.
69+ */
70+export function roleOf(d: Pick<Definition, "title">): string {
71+ return d.title.trim();
7472 }
7573
74+/**
75+ * The role an agent was given when it carried its own team or department,
76+ * made from them: "QA Engineer on the qa team", "QA Engineer, QA". A
77+ * stored role that is exactly this follows the title (store.ts).
78+ */
79+export function legacyRoleOf(title: string, team: string | null, department: string | null): string {
80+ const t = title.trim();
81+ if (!t) return "";
82+ if (team) return `${t} on the ${team} team`;
83+ return department?.trim() ? `${t}, ${department.trim()}` : t;
84+}
85+
7686 export const MAX_SUBAGENTS = 8;
7787
7888 export type Checked<T> = { ok: true; value: T } | { ok: false; message: string };
236246 template: null,
237247 avatar_seed: "",
238248 title: "",
239− team: null,
240− department: "",
241249 responsibilities: [],
242250 subagents: [],
243251 faces: "internal",
245253 skills_off: [],
246254 };
247255 const next: Definition = { ...from, skills_off: from.skills_off ?? [] };
248− // Whether the role was made from the title and team, so it follows them.
256+ // Whether the role was made from the title, so it follows it.
249257 const roleDerived = !from.role || from.role === roleOf(from);
250258 if (creating || changes.handle !== undefined) {
251259 const handle = checkHandle(changes.handle);
256264 ["display_name", "A display name", LIMITS.displayName, true],
257265 ["role", "The role", LIMITS.role, false],
258266 ["title", "The title", LIMITS.title, false],
259− ["department", "The department", LIMITS.department, false],
260267 // The built-in agent's instructions are added to its fixed job, and may be empty.
261268 ["instructions", "The instructions", LIMITS.instructions, !options.builtin],
262269 ["personality", "The personality", LIMITS.personality, false],
266273 const value = text(changes[key], what, max, required);
267274 if (!value.ok) return value;
268275 next[key] = value.value;
269− }
270− if (changes.team !== undefined) {
271− if (changes.team === null || changes.team === "") next.team = null;
272− else if (typeof changes.team !== "string" || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(changes.team.trim().toLowerCase())) {
273− return bad("A team is named by its slug.");
274− } else next.team = changes.team.trim().toLowerCase();
275276 }
276277 if (changes.responsibilities !== undefined) {
277278 const duties = responsibilitiesOf(changes.responsibilities);
278279 if (!duties.ok) return duties;
279280 next.responsibilities = duties.value;
280281 }
281− // A role left empty, or made from the title and team before, follows them.
282+ // A role left empty, or made from the title before, follows it.
282283 if (!next.role || (changes.role === undefined && roleDerived)) next.role = roleOf(next);
283284 if (!next.role) return bad("Give the agent a title or a one-line role.");
284285 if (changes.avatar_seed !== undefined) {
+39−21
5959 import { cardAction } from "./cards.ts";
6060 import { type SessionEnv, sweep } from "./sessions.ts";
6161 import { EFFORT_NAMES, checkDue, effortCostsOf, markResolved, outcomesSince, readRecommendations, recommendationRow, sinceWindow, toRecommendation } from "./recommend.ts";
62+import { moveAgentTeams } from "./team-move.ts";
6263 import { effortOf } from "./routing.ts";
6364 import * as views from "./views.ts";
6465 import { monthKey } from "./budget.ts";
154155 .first<Row>();
155156 }
156157
157− /** Whether `team` (a slug, or none) is one of the workspace's teams, as the person changing the agent sees them. */
158− private async teamExists(workspace: string, viewer: User, team: string | null): Promise<Result<null>> {
159− if (!team) return ok(null);
160− const found = await identityClient(this.env.IDENTITY)
161− .getTeam(viewer, workspace.toLowerCase(), team)
162− .catch(() => null);
163− return found?.ok ? ok(null) : fail("invalid", `${workspace} has no team called ${team}.`);
158+ /**
159+ * The teams a new agent joins as it is made, by slug: each must be one the
160+ * viewer manages (an owner, or the team's maintainer), as adding anyone
161+ * to a team asks. Membership lives on the team, in identity.
162+ */
163+ private async joinable(workspace: string, viewer: User, asked: unknown): Promise<Result<string[]>> {
164+ if (asked === undefined || asked === null) return ok([]);
165+ if (!Array.isArray(asked) || asked.some((slug) => typeof slug !== "string")) return fail("invalid", "Teams are a list of team slugs.");
166+ const slugs = [...new Set((asked as string[]).map((slug) => slug.trim().replace(/^@/, "").toLowerCase()).filter(Boolean))];
167+ if (slugs.length > 20) return fail("invalid", "Add a new agent to at most 20 teams.");
168+ const identity = identityClient(this.env.IDENTITY);
169+ const found = await Promise.all(slugs.map((slug) => identity.getTeam(viewer, workspace.toLowerCase(), slug).catch(() => null)));
170+ for (const [i, team] of found.entries()) {
171+ if (!team?.ok) return fail("invalid", `${workspace} has no team called ${slugs[i]}.`);
172+ if (!team.value.can_manage) return fail("forbidden", `Only owners and ${team.value.name}'s maintainers add agents to it.`);
173+ }
174+ return ok(slugs);
164175 }
165176
166177 private async handleTaken(workspaceId: string, handle: string, except: string | null): Promise<boolean> {
238249 return ok({ workspaceId: seen.value, scope: scope.scope, policy });
239250 }
240251
241− async create(a: { workspace: string; viewer: User | null; input: NewWorkspaceAgent }): Promise<Result<WorkspaceAgent>> {
252+ async create(a: { workspace: string; viewer: User | null; input: NewWorkspaceAgent; teams?: unknown }): Promise<Result<WorkspaceAgent>> {
242253 const allowed = await this.creatable(a.workspace, a.viewer, a.input?.scope);
243254 if (!allowed.ok) return allowed;
244255 const { workspaceId, scope, policy } = allowed.value;
263274 task_micros: given.task_micros !== undefined ? given.task_micros : start.task_micros,
264275 };
265276 const { scope: _scope, ...rest } = a.input ?? ({} as NewWorkspaceAgent);
266− // A personal agent is on no team: teams are shared, and it answers only its member.
267− const input = { ...rest, budget, ...(personal ? { team: null } : {}) };
277+ const input = { ...rest, budget };
268278 const checked = applyChanges(null, input, TEMPLATE_IDS);
269279 if (!checked.ok) return fail("invalid", checked.message);
270280 const definition = checked.value;
271− const team = await this.teamExists(a.workspace, viewer, definition.team);
272− if (!team.ok) return team;
281+ // A personal agent is on no team: teams are shared, and it answers only its member.
282+ const teams = personal ? ok([] as string[]) : await this.joinable(a.workspace, viewer, a.teams);
283+ if (!teams.ok) return teams;
273284 if (await this.handleTaken(workspaceId, definition.handle, null)) {
274285 return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`);
275286 }
287298 throw error;
288299 }
289300 this.audit(viewer, a.workspace, "create_agent", definition.handle, `Created ${personal ? "the personal agent " : ""}@${definition.handle} (version 1)`, "agents", personal ? "member" : "owner");
301+ // On its teams before it says hello, so it knows them.
302+ if (teams.value.length) {
303+ const identity = identityClient(this.env.IDENTITY);
304+ await Promise.all(
305+ teams.value.map((slug) =>
306+ identity
307+ .setTeamAgent(viewer, a.workspace.toLowerCase(), slug, id)
308+ .then((added) => (added.ok ? null : console.error("agents: a new agent was not added to its team", id, slug, added.error.message)))
309+ .catch((error: unknown) => console.error("agents: a new agent was not added to its team", id, slug, String(error))),
310+ ),
311+ );
312+ }
290313 this.defer(this.hello(a.workspace, workspaceId, id, viewer));
291314 const row = await this.row(workspaceId, definition.handle);
292315 return ok(toAgent(row!, new Date()));
619642 const before = definitionOf(row);
620643 const { scope: _scope, ...asked } = (a.changes ?? {}) as Partial<NewWorkspaceAgent>;
621644 // The built-in @g1t keeps who it is and its job; the rest is the workspace's.
622− // A personal agent stays on no team.
623− const allowed = row.builtin
624− ? builtinChanges(before, asked)
625− : { ok: true as const, value: isPersonal(row) && asked.team !== undefined ? { ...asked, team: null } : asked };
645+ const allowed = row.builtin ? builtinChanges(before, asked) : { ok: true as const, value: asked };
626646 if (!allowed.ok) return fail("invalid", allowed.message);
627647 const checked = applyChanges(before, allowed.value, TEMPLATE_IDS, { builtin: !!row.builtin });
628648 if (!checked.ok) return fail("invalid", checked.message);
629649 const definition = checked.value;
630650 if (JSON.stringify(definition) === JSON.stringify(before)) return ok(toAgent(row, new Date()));
631− if (definition.team !== before.team) {
632− const team = await this.teamExists(a.workspace, a.viewer!, definition.team);
633− if (!team.ok) return team;
634− }
635651 if (definition.handle !== before.handle && (await this.handleTaken(workspaceId, definition.handle, row.id))) {
636652 return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`);
637653 }
964980 batch.ackAll();
965981 },
966982
967− /** Every few minutes: routines that are due, session steps a desk lost, and once an hour the spend check. */
983+ /** Every few minutes: routines that are due, session steps a desk lost, the spend check, and the one-time team move. */
968984 async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
969985 const sessions = env as unknown as SessionEnv;
970986 ctx.waitUntil(
973989 sweep(sessions).catch((error: unknown) => console.error("agents: the session sweep failed", String(error))),
974990 // Spend less, keep quality: each workspace checked weekly (src/recommend.ts).
975991 checkDue(env.DB).catch((error: unknown) => console.error("agents: the spend check failed", String(error))),
992+ // Once: agents' own old teams become team memberships (src/team-move.ts); after that, one cheap read.
993+ moveAgentTeams(env.DB, (id, agents) => identityClient(env.IDENTITY).adoptAgentTeams(id, agents)).catch((error: unknown) => console.error("agents: moving agents' teams failed", String(error))),
976994 ]),
977995 );
978996 },
+5−7
6969 display_name: "Boss",
7070 role: "Does whatever",
7171 title: "",
72− team: "qa",
73− department: "x",
7472 responsibilities: [],
7573 subagents: [],
7674 template: "qa",
150148 assert.match(BUILTIN_NO_MODEL, /Integrations/);
151149 });
152150
153−test("the roster shows title, team and duties, so 'QA should look' reaches Margo", () => {
154− const margo: Specialist = { handle: "margo", display_name: "Margo", role: "QA", title: "QA Engineer", team: "qa", responsibilities: ["Review pull requests.", "Chase flaky checks"], status: "idle", spent_month_micros: 0, monthly_micros: 10_000_000 };
155− const david: Specialist = { handle: "david", display_name: "David", role: "Sales Operations, Sales", title: "Sales Operations", department: "Sales", status: "working", spent_month_micros: 0, monthly_micros: null };
151+test("the roster shows title, the teams it is on and duties, so 'QA should look' reaches Margo", () => {
152+ const margo: Specialist = { handle: "margo", display_name: "Margo", role: "QA", title: "QA Engineer", teams: ["QA", "Web"], responsibilities: ["Review pull requests.", "Chase flaky checks"], status: "idle", spent_month_micros: 0, monthly_micros: 10_000_000 };
153+ const david: Specialist = { handle: "david", display_name: "David", role: "Sales Operations", title: "Sales Operations", teams: [], status: "working", spent_month_micros: 0, monthly_micros: null };
156154 assert.equal(
157155 rosterLines([margo, david]),
158156 [
159− "- @margo: QA Engineer on the qa team. Does: Review pull requests; Chase flaky checks. idle; $0.00 of $10.00 this month.",
160− "- @david: Sales Operations, Sales. working; $0.00, no cap this month.",
157+ "- @margo: QA Engineer on QA and Web. Does: Review pull requests; Chase flaky checks. idle; $0.00 of $10.00 this month.",
158+ "- @david: Sales Operations. working; $0.00, no cap this month.",
161159 ].join("\n"),
162160 );
163161 });
+7−12
1212
1313 import { BUILTIN_AGENT_HANDLE, ORCHESTRATOR_TEMPLATE } from "../../../packages/contracts/src/workspace-agents.ts";
1414 import { type Checked, type Definition, DEFAULT_AUTONOMY, DEFAULT_BUDGET, DEFAULT_CAPACITY, DEFAULT_ROUTING } from "./definition.ts";
15+import { listOf } from "./teammates.ts";
1516 import { MAX_HAND_OFFS } from "./tools.ts";
1617
1718 export const BUILTIN_ROLE = "Your orchestrator: delegates to the team's agents, or does the work itself";
3233 template: ORCHESTRATOR_TEMPLATE,
3334 avatar_seed: BUILTIN_AGENT_HANDLE,
3435 title: "Orchestrator",
35− team: null,
36− department: "",
3736 responsibilities: [],
3837 subagents: [],
3938 faces: "internal",
4847 "display_name",
4948 "role",
5049 "title",
51− "team",
52− "department",
5350 "responsibilities",
5451 "subagents",
5552 "template",
7471 display_name: string;
7572 role: string;
7673 title?: string;
77− team?: string | null;
78− department?: string;
74+ /** The teams it is on, by name (identity's team memberships). */
75+ teams?: string[];
7976 responsibilities?: string[];
8077 status: AgentStatus;
8178 spent_month_micros: number;
9390
9491 const dollars = (micros: number) => `$${(Math.max(0, micros) / 1_000_000).toFixed(2)}`;
9592
96−/** Where a specialist sits: "QA Engineer on the qa team", "QA Engineer, QA", or its role. */
93+/** Where a specialist sits: "QA Engineer on QA and Web", or its title or role alone. */
9794 function placeLine(agent: Specialist): string {
98− const title = agent.title?.trim();
99− if (!title) return agent.role.replace(/\.$/, "");
100− if (agent.team) return `${title} on the ${agent.team} team`;
101− return agent.department?.trim() ? `${title}, ${agent.department.trim()}` : title;
95+ const title = agent.title?.trim() || agent.role.replace(/\.$/, "");
96+ return agent.teams?.length ? `${title} on ${listOf(agent.teams)}` : title;
10297 }
10398
10499 /**
105100 * The roster, one line per specialist, so g1t can route "QA should look"
106− * to the right one: `- @margo (Margo): QA Engineer, QA. Does: review pull
101+ * to the right one: `- @margo (Margo): QA Engineer on QA. Does: review pull
107102 * requests; write test plans. idle; $1.20 of $20.00 this month.`
108103 */
109104 export function rosterLines(specialists: Specialist[]): string {
+19−6
3131 * they show now. Null when identity can't be asked; presence is left out
3232 * when notify can't be (then no one reads as offline).
3333 */
34−export async function loadTeams(env: TeamsEnv, slug: string, workspaceId: string, agent: { id: string; team: string | null }): Promise<TeamsHere | null> {
34+export async function loadTeams(env: TeamsEnv, slug: string, workspaceId: string, agent: { id: string }): Promise<TeamsHere | null> {
3535 const teams = await identityClient(env.IDENTITY)
36− .agentTeams(slug, agent.id, agent.team)
36+ .agentTeams(slug, agent.id)
3737 .catch(() => null);
3838 if (!teams) return null;
3939 if (!teams.length) return { teams: [], agents: [], presence: [] };
4040 const userIds = [...new Set(teams.flatMap((team) => team.people.map((person) => person.user_id)))];
41− const slugs = teams.map((team) => team.slug);
4241 const added = [...new Set(teams.flatMap((team) => team.agent_ids))].slice(0, 90);
4342 const [presence, rows] = await Promise.all([
4443 env.NOTIFY && userIds.length ? notifyClient(env.NOTIFY).workspacePresence(slug, userIds).catch(() => null) : Promise.resolve(null),
4544 env.DB.prepare(
46− `SELECT id, handle, display_name, title, team FROM agents
45+ `SELECT id, handle, display_name, title FROM agents
4746 WHERE workspace_id = ?1 AND archived_at IS NULL AND scope = 'workspace'
48− AND (id IN (SELECT value FROM json_each(?2)) OR team IN (SELECT value FROM json_each(?3)))
47+ AND id IN (SELECT value FROM json_each(?2))
4948 ORDER BY builtin DESC, handle LIMIT 200`,
5049 )
51− .bind(workspaceId, JSON.stringify(added), JSON.stringify(slugs))
50+ .bind(workspaceId, JSON.stringify(added))
5251 .all<TeamAgentInfo>(),
5352 ]);
5453 return { teams, agents: rows.results, presence: presence ?? [] };
5554 }
5655
56+/**
57+ * The visible teams each of the workspace's agents is on, by agent id, as
58+ * team names: for the roster colleagues and @g1t are told. Empty when
59+ * identity can't be asked.
60+ */
61+export async function teamsOfAgents(env: { IDENTITY: ServiceBinding }, slug: string): Promise<Map<string, string[]>> {
62+ const out = new Map<string, string[]>();
63+ const teams = await identityClient(env.IDENTITY)
64+ .teamAgentIndex(slug)
65+ .catch(() => null);
66+ for (const team of teams ?? []) for (const id of team.agent_ids) out.set(id, [...(out.get(id) ?? []), team.name]);
67+ return out;
68+}
69+
5770 export type PortsEnv = {
5871 DB: D1Database;
5972 CHAT: ServiceBinding;
+4−4
116116 assert.match(fixedHello({ display_name: "Dot", handle: "dot", role: "" }, null), /^Hi! I'm Dot \(@dot\)\. Mention me/);
117117 });
118118
119−test("the prompt says the agent's title, team, duties, and that subagents work inside sessions", () => {
119+test("the prompt says the agent's title, its teams, duties, and that subagents work inside sessions", () => {
120120 const prompt = systemPrompt({
121121 ...base,
122− agent: { ...agent, title: "QA Engineer", team: "qa", responsibilities: ["Review pull requests", "Chase flaky checks"], subagents: [{ name: "flake-hunter", description: "Bisects flaky tests" }] },
122+ agent: { ...agent, title: "QA Engineer", teams: ["QA", "Release"], responsibilities: ["Review pull requests", "Chase flaky checks"], subagents: [{ name: "flake-hunter", description: "Bisects flaky tests" }] },
123123 });
124− assert.match(prompt, /You are Ship \(@ship\), the QA Engineer on the qa team, an agent/);
124+ assert.match(prompt, /You are Ship \(@ship\), the QA Engineer on QA and Release, an agent/);
125125 assert.match(prompt, /## Your responsibilities\n\n- Review pull requests\n- Chase flaky checks/);
126126 assert.match(prompt, /- flake-hunter: Bisects flaky tests/);
127127 assert.match(prompt, /use_subagent/);
146146 });
147147
148148 test("every agent knows its colleagues: consult, offer hand-offs, steer, no ping-pong", () => {
149− const prompt = systemPrompt({ ...base, colleagues: "- @margo: QA Engineer on the qa team. idle; $0.00, no cap this month." });
149+ const prompt = systemPrompt({ ...base, colleagues: "- @margo: QA Engineer on QA. idle; $0.00, no cap this month." });
150150 assert.match(prompt, /## Your colleagues\n\n- @margo: QA Engineer/);
151151 assert.match(prompt, /ask_colleague/);
152152 assert.match(prompt, /offer it; don't do it silently/);
+5−4
1010 import type { AskerAccess, PersonalityPreset } from "@g1t/contracts";
1111
1212 import type { Conversation, ConversationMember, SurfaceMessage } from "./surface.ts";
13+import { listOf } from "./teammates.ts";
1314
1415 /** How many messages a reply reads: the thread, or the latest of the DM or channel. */
1516 export const HISTORY_LIMIT = 30;
4748 personality_preset: PersonalityPreset;
4849 personality: string;
4950 title?: string;
50− team?: string | null;
51− department?: string;
51+ /** The teams it is on, by name (identity's team memberships). */
52+ teams?: string[];
5253 responsibilities?: string[];
5354 subagents?: { name: string; description: string }[];
5455 };
8788 return `${who} is ${role}; ${code}.`;
8889 }
8990
90−/** "the QA Engineer on the qa team, " or "", for the first line. */
91+/** "the QA Engineer on QA and Web, " or "", for the first line. */
9192 function placeOf(agent: PromptInput["agent"]): string {
9293 const title = agent.title?.trim();
9394 if (!title) return "";
94− const where = agent.team ? ` on the ${agent.team} team` : agent.department?.trim() ? ` in ${agent.department.trim()}` : "";
95+ const where = agent.teams?.length ? ` on ${listOf(agent.teams)}` : "";
9596 return `the ${title}${where}, `;
9697 }
9798
+15−12
3434 import { type Row, definitionOf, periods, selectAgents, toAgent } from "./store.ts";
3535 import { type SurfaceMessage, surfaceFor } from "./surface.ts";
3636 import { Audience } from "./audience.ts";
37−import { audiencePorts, loadTeams, toolPorts } from "./ports.ts";
37+import { audiencePorts, loadTeams, teamsOfAgents, toolPorts } from "./ports.ts";
3838 import { type ToolCall, type ToolPorts, ToolBox } from "./tools.ts";
3939 import type { Surface } from "./surface.ts";
4040 import type { Desk } from "./desk.ts";
8181 * An agent's colleagues: every agent of the workspace but itself that is
8282 * not archived (docs.g1t.sh/guides/agents/, "Agents know each other").
8383 */
84−async function team(db: D1Database, workspaceId: string, selfId: string, now: Date): Promise<Specialist[]> {
85− const rows = await db
86− // Personal agents are their members' own: no colleague of anyone.
87− .prepare(`${selectAgents("a.workspace_id = ?3 AND a.archived_at IS NULL AND a.id <> ?4 AND a.scope = 'workspace'")} ORDER BY a.builtin DESC, a.handle LIMIT 50`)
88− .bind(...periods(now), workspaceId, selfId)
89− .all<Row>();
84+async function team(db: D1Database, workspaceId: string, selfId: string, now: Date, teamsOf: Promise<Map<string, string[]>>): Promise<Specialist[]> {
85+ const [rows, onTeams] = await Promise.all([
86+ db
87+ // Personal agents are their members' own: no colleague of anyone.
88+ .prepare(`${selectAgents("a.workspace_id = ?3 AND a.archived_at IS NULL AND a.id <> ?4 AND a.scope = 'workspace'")} ORDER BY a.builtin DESC, a.handle LIMIT 50`)
89+ .bind(...periods(now), workspaceId, selfId)
90+ .all<Row>(),
91+ teamsOf,
92+ ]);
9093 return rows.results.map((row) => {
9194 const agent = toAgent(row, now);
9295 return {
9497 display_name: agent.display_name,
9598 role: agent.role,
9699 title: agent.title,
97− team: agent.team,
98− department: agent.department,
100+ teams: onTeams.get(agent.id) ?? [],
99101 responsibilities: agent.responsibilities,
100102 status: agent.status,
101103 spent_month_micros: agent.spent_month_micros,
346348 surface.typing(),
347349 delivery.hello ? Promise.resolve([]) : surface.history(HISTORY_LIMIT),
348350 delivery.hello ? Promise.resolve(null) : surface.conversation(),
349− loadTeams(env, slug, row.workspace_id, { id: row.id, team: row.team ?? null }).catch(() => null),
351+ loadTeams(env, slug, row.workspace_id, { id: row.id }).catch(() => null),
350352 ]);
351353 // A personal agent answers only its member, in the DM of the two of them (access.ts).
352354 const refused = personalRefusal(row, {
361363 const askerName = delivery.asker?.username ?? author?.name ?? null;
362364 // Every agent knows its colleagues; @g1t also steps up a tier to decide
363365 // who gets the work in a long thread.
364− const specialists = await team(db, row.workspace_id, row.id, now);
366+ const specialists = await team(db, row.workspace_id, row.id, now, teamsOfAgents(env, slug));
365367 const definition = definitionOf(row);
366368 // Its effort setting decides where the reply starts and how hard the model reasons.
367369 const plan = effortPlan(effortOf(definition.routing), row.builtin ? orchestratorTier(history.length, specialists.filter((a) => a.handle !== "g1t").length) : REPLY_TIER);
476478 sessionsHere(db, row.id, delivery.channel_id).catch(() => null),
477479 toolbox ? toolbox.recall(recallQuery(said), definition.reading ?? []) : Promise.resolve([]),
478480 // Its skills: named in the prompt, read with use_skill (skills.ts).
479− toolbox ? loadShelf(db, row.workspace_id, { id: row.id, skills_off: definition.skills_off }, teamSlugs(teamsHere, row.team ?? null)) : Promise.resolve([]),
481+ toolbox ? loadShelf(db, row.workspace_id, { id: row.id, skills_off: definition.skills_off }, teamSlugs(teamsHere)) : Promise.resolve([]),
480482 ]);
481483 toolbox?.useShelf(shelf, (skillId, version) => readVersion(db, skillId, version));
482484 const system = [
484486 agent: {
485487 ...definition,
486488 id: row.id,
489+ teams: teamsHere?.teams.map((t) => t.name) ?? [],
487490 // @g1t's job is fixed; what the workspace wrote is added to it.
488491 instructions: row.builtin ? orchestratorInstructions(specialists.filter((a) => a.handle !== "g1t"), definition.instructions) : definition.instructions,
489492 },
+9−9
4747 import { type MeterEnv, metered } from "./meter.ts";
4848 import { type RecallPlace, MAX_FACTS, cleanFact, memorySection, recall, scopeFor } from "./memory.ts";
4949 import { readPolicy } from "./policy.ts";
50−import { type PortsEnv, audiencePorts, loadTeams, toolPorts } from "./ports.ts";
50+import { type PortsEnv, audiencePorts, loadTeams, teamsOfAgents, toolPorts } from "./ports.ts";
5151 import { systemPrompt } from "./prompt.ts";
5252 import { loadShelf, skillsSection, teamSlugs } from "./skills.ts";
5353 import { readVersion } from "./skill-library.ts";
749749 const asker = { id: row.asked_by, username: row.asked_by_username };
750750 const current = row;
751751 // Its teams, from their pages: told every step, and their budgets apply.
752− const teamsHere = await loadTeams(env, slug, agent.workspace_id, { id: agent.id, team: agent.team ?? null }).catch(() => null);
752+ const teamsHere = await loadTeams(env, slug, agent.workspace_id, { id: agent.id }).catch(() => null);
753753
754754 const outcome = await metered(
755755 env,
811811 recall(db, agent.id, place).catch(() => []),
812812 toolbox ? toolbox.recall(recallQuery(asked, 800), definition.reading ?? []) : Promise.resolve([]),
813813 // Its skills: named in the prompt, read with use_skill (skills.ts).
814− toolbox ? loadShelf(db, agent.workspace_id, { id: agent.id, skills_off: definition.skills_off }, teamSlugs(teamsHere, agent.team ?? null)) : Promise.resolve([]),
814+ toolbox ? loadShelf(db, agent.workspace_id, { id: agent.id, skills_off: definition.skills_off }, teamSlugs(teamsHere)) : Promise.resolve([]),
815815 ]);
816816 toolbox?.useShelf(shelf, (skillId, version) => readVersion(db, skillId, version));
817− const [team, here] = await Promise.all([
817+ const [team, onTeams, here] = await Promise.all([
818818 db
819− .prepare("SELECT handle, display_name, role, title, team, department, responsibilities FROM agents WHERE workspace_id = ? AND archived_at IS NULL AND id <> ? AND scope = 'workspace' ORDER BY builtin DESC, handle LIMIT 50")
819+ .prepare("SELECT id, handle, display_name, role, title, responsibilities FROM agents WHERE workspace_id = ? AND archived_at IS NULL AND id <> ? AND scope = 'workspace' ORDER BY builtin DESC, handle LIMIT 50")
820820 .bind(agent.workspace_id, agent.id)
821− .all<{ handle: string; display_name: string; role: string; title: string; team: string | null; department: string; responsibilities: string }>(),
821+ .all<{ id: string; handle: string; display_name: string; role: string; title: string; responsibilities: string }>(),
822+ teamsOfAgents(env, slug),
822823 // Who reads what this session posts: said every step, as in a reply. A helper may not be a member: then not said.
823824 chatClient(env.CHAT)
824825 .conversationForAgent(slug, current.channel_id, agent.id, current.asked_by)
831832 display_name: a.display_name,
832833 role: a.role,
833834 title: a.title,
834− team: a.team,
835− department: a.department,
835+ teams: onTeams.get(a.id) ?? [],
836836 responsibilities: json<string[]>(a.responsibilities, []),
837837 status: "idle",
838838 spent_month_micros: 0,
842842 const access = json<AskerAccess | null>(current.asker, null);
843843 const system = [
844844 systemPrompt({
845− agent: { ...definition, id: agent.id },
845+ agent: { ...definition, id: agent.id, teams: teamsHere?.teams.map((t) => t.name) ?? [] },
846846 workspace: slug,
847847 channel: { kind: current.channel_kind === "dm" ? "dm" : "channel", name: current.channel_name },
848848 asker: { name: current.asked_by_username ?? "someone", display_name: null, access },
+13−4
4343 const WS = "wsp_acme";
4444 const at = new Date("2026-10-10T12:00:00Z");
4545
46+/** The teams each agent is on, by id: memberships, as identity holds them. */
47+const onTeams = new Map<string, string[]>();
48+
4649 async function addAgent(db: D1Database, id: string, handle: string, team: string | null = null): Promise<void> {
4750 await db
4851 .prepare(
49− `INSERT INTO agents (id, workspace_id, handle, display_name, role, instructions, routing, budget, autonomy, created_by, created_at, updated_at, team)
50− VALUES (?, ?, ?, ?, 'r', 'i', '{}', '{}', '{}', 'ana', ?, ?, ?)`,
52+ `INSERT INTO agents (id, workspace_id, handle, display_name, role, instructions, routing, budget, autonomy, created_by, created_at, updated_at)
53+ VALUES (?, ?, ?, ?, 'r', 'i', '{}', '{}', '{}', 'ana', ?, ?)`,
5154 )
52− .bind(id, WS, handle, handle[0]!.toUpperCase() + handle.slice(1), at.toISOString(), at.toISOString(), team)
55+ .bind(id, WS, handle, handle[0]!.toUpperCase() + handle.slice(1), at.toISOString(), at.toISOString())
5356 .run();
57+ onTeams.set(id, team ? [team] : []);
5458 }
5559
5660 type Repo = { id: string; full: string; default_branch: string; commits: Record<string, Record<string, string>> };
7781 };
7882 },
7983 blobs: async (_repoId, hashes) => hashes.map((hash) => ({ hash, data: blobs.get(hash) ?? null })),
80− agentTeams: async (agent) => (agent.team ? [{ slug: agent.team, name: agent.team.toUpperCase() }] : []),
84+ agentTeams: async (agentId) => (onTeams.get(agentId) ?? []).map((slug) => ({ slug, name: slug.toUpperCase() })),
85+ teamAgentIndex: async () => {
86+ const bySlug = new Map<string, string[]>();
87+ for (const [id, slugs] of onTeams) for (const slug of slugs) bySlug.set(slug, [...(bySlug.get(slug) ?? []), id]);
88+ return [...bySlug].map(([slug, agent_ids]) => ({ slug, agent_ids }));
89+ },
8190 audit: (action, name) => over.log?.push(`${action} ${name}`),
8291 ...over,
8392 };
+31−31
9999 /** Blobs as base64; null data for one missing or over 1 MB. */
100100 blobs(repoId: string, hashes: string[]): Promise<{ hash: string; data: string | null }[]>;
101101 /** The visible teams an agent is on. */
102− agentTeams(agent: { id: string; team: string | null }): Promise<{ slug: string; name: string }[]>;
102+ agentTeams(agentId: string): Promise<{ slug: string; name: string }[]>;
103+ /** The workspace's visible teams, each with the agents on it, by id. */
104+ teamAgentIndex(): Promise<{ slug: string; agent_ids: string[] }[]>;
103105 /** The workspace's audit log. */
104106 audit(action: string, name: string, message: string): void;
105107 };
733735 if (!mayChange(actor, scope, key)) {
734736 return fail("forbidden", scope === "team" ? "Only owners and the team's maintainers attach skills to it." : "Only the workspace's owners attach skills to agents and to every agent.");
735737 }
736− // At most SKILLS_PER_AGENT_MAX reach any one agent: counted for what this attachment adds to.
738+ // At most SKILLS_PER_AGENT_MAX reach any one agent, through the teams it is on (team
739+ // memberships, from identity): counted for each agent this attachment reaches.
740+ const index = await this.ctx.ports.teamAgentIndex().catch(() => []);
741+ const memberships = JSON.stringify(index.flatMap((team) => team.agent_ids.map((id) => [id, team.slug])));
742+ const which = scope === "workspace" ? "?3 = ?3" : scope === "team" ? "EXISTS (SELECT 1 FROM m WHERE m.agent_id = ag.id AND m.slug = ?3)" : "ag.id = ?3";
743+ const most = await this.db
744+ .prepare(
745+ `WITH m(agent_id, slug) AS (SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]') FROM json_each(?4))
746+ SELECT COALESCE(MAX(n), 0) AS n FROM (
747+ SELECT ag.id, COUNT(DISTINCT a.skill_id) AS n
748+ FROM agents ag
749+ JOIN skill_attachments a ON a.workspace_id = ag.workspace_id
750+ AND (a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ag.id) OR (a.scope = 'team' AND EXISTS (SELECT 1 FROM m WHERE m.agent_id = ag.id AND m.slug = a.target)))
751+ JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL
752+ WHERE ag.workspace_id = ?1 AND ag.archived_at IS NULL AND a.skill_id <> ?2 AND ${which}
753+ GROUP BY ag.id)`,
754+ )
755+ .bind(this.ctx.workspaceId, row.id, key, memberships)
756+ .first<{ n: number }>();
757+ // And what is attached where it lands already, for a team or workspace with no agents yet.
737758 const reach =
738− scope === "workspace"
739− ? "(a.scope = 'workspace' AND ?3 = ?3)"
740− : scope === "team"
741− ? "(a.scope = 'workspace' OR (a.scope = 'team' AND a.target = ?3))"
742− : "(a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ?3) OR (a.scope = 'team' AND a.target = (SELECT COALESCE(team, '') FROM agents WHERE id = ?3)))";
743− // And for each agent it reaches (by their home team): the most any one of them has already.
744− const which = scope === "workspace" ? "?3 = ?3" : scope === "team" ? "COALESCE(ag.team, '') = ?3" : "ag.id = ?3";
745− const [count, most] = await Promise.all([
746− this.db
747− .prepare(`SELECT COUNT(DISTINCT a.skill_id) AS n FROM skill_attachments a JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL WHERE a.workspace_id = ?1 AND a.skill_id <> ?2 AND ${reach}`)
748− .bind(this.ctx.workspaceId, row.id, key)
749− .first<{ n: number }>(),
750− this.db
751− .prepare(
752− `SELECT COALESCE(MAX(n), 0) AS n FROM (
753− SELECT ag.id, COUNT(DISTINCT a.skill_id) AS n
754− FROM agents ag
755− JOIN skill_attachments a ON a.workspace_id = ag.workspace_id
756− AND (a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ag.id) OR (a.scope = 'team' AND a.target = COALESCE(ag.team, '')))
757− JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL
758− WHERE ag.workspace_id = ?1 AND ag.archived_at IS NULL AND a.skill_id <> ?2 AND ${which}
759− GROUP BY ag.id)`,
760− )
761− .bind(this.ctx.workspaceId, row.id, key)
762− .first<{ n: number }>(),
763− ]);
759+ scope === "workspace" ? "(a.scope = 'workspace' AND ?3 = ?3)" : scope === "team" ? "(a.scope = 'workspace' OR (a.scope = 'team' AND a.target = ?3))" : "(a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ?3))";
760+ const count = await this.db
761+ .prepare(`SELECT COUNT(DISTINCT a.skill_id) AS n FROM skill_attachments a JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL WHERE a.workspace_id = ?1 AND a.skill_id <> ?2 AND ${reach}`)
762+ .bind(this.ctx.workspaceId, row.id, key)
763+ .first<{ n: number }>();
764764 if (Math.max(count?.n ?? 0, most?.n ?? 0) >= SKILLS_PER_AGENT_MAX) {
765765 return fail("invalid", `An agent has at most ${SKILLS_PER_AGENT_MAX} skills from the library, and ${scope === "workspace" ? "an agent" : label} would have more. Detach one first.`);
766766 }
834834 async agentSkills(handle: unknown): Promise<Result<AgentSkills>> {
835835 const key = String(handle ?? "").trim().replace(/^@/, "").toLowerCase();
836836 const agent = await this.db
837− .prepare("SELECT id, handle, team, skills_off FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL")
837+ .prepare("SELECT id, handle, skills_off FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL")
838838 .bind(this.ctx.workspaceId, key)
839− .first<{ id: string; handle: string; team: string | null; skills_off: string | null }>();
839+ .first<{ id: string; handle: string; skills_off: string | null }>();
840840 if (!agent) return fail("not_found", `There is no agent called @${key}.`);
841841 const off = new Set(json<string[]>(agent.skills_off, []));
842− const [teams, actor] = await Promise.all([this.ctx.ports.agentTeams({ id: agent.id, team: agent.team }).catch(() => (agent.team ? [{ slug: agent.team, name: agent.team }] : [])), this.actor()]);
842+ const [teams, actor] = await Promise.all([this.ctx.ports.agentTeams(agent.id).catch(() => []), this.actor()]);
843843 const teamNames = new Map(teams.map((t) => [t.slug, t.name]));
844844 const rows = await this.db
845845 .prepare(
+2−1
3838 return found?.ok ? { id: found.value.id, full: `${found.value.namespace}/${found.value.name}`, default_branch: found.value.defaultBranch } : null;
3939 },
4040 ...repoFiles(env),
41− agentTeams: async (agent) => (await identity.agentTeams(ctx.slug, agent.id, agent.team)).map((t) => ({ slug: t.slug, name: t.name })),
41+ agentTeams: async (agentId) => (await identity.agentTeams(ctx.slug, agentId)).map((t) => ({ slug: t.slug, name: t.name })),
42+ teamAgentIndex: async () => (await identity.teamAgentIndex(ctx.slug)).map((t) => ({ slug: t.slug, agent_ids: t.agent_ids })),
4243 audit,
4344 };
4445 return new Library({ db: ctx.db, workspaceId: ctx.workspaceId, slug: ctx.slug, viewer: { id: ctx.viewer.id, username: ctx.viewer.username, kind: ctx.viewer.kind }, owner: ctx.owner, ports });
+2−2
115115 const capped = shelfFrom([], many);
116116 assert.equal(capped.skills.filter((s) => s.kind === "library").length, SKILLS_PER_AGENT_MAX);
117117 assert.equal(capped.over, 5);
118− assert.deepEqual(teamSlugs({ teams: [{ slug: "qa" }, { slug: "web" }] as never, agents: [], presence: [] }, "qa"), ["qa", "web"]);
119− assert.deepEqual(teamSlugs(null, "qa"), ["qa"], "its home team when teams couldn't be read");
118+ assert.deepEqual(teamSlugs({ teams: [{ slug: "qa" }, { slug: "web" }, { slug: "qa" }] as never, agents: [], presence: [] }), ["qa", "web"]);
119+ assert.deepEqual(teamSlugs(null), [], "none when its teams couldn't be read");
120120 });
121121
122122 test("use_skill reads a skill the agent has: the playbook with what isn't here, files, and scripts never run", async () => {
+3−4
114114 return rows.results;
115115 }
116116
117−/** The teams whose skills reach an agent: those it is on, or its home team when they couldn't be read. */
118−export function teamSlugs(teams: TeamsHere | null, home: string | null): string[] {
119− if (teams) return [...new Set([...teams.teams.map((team) => team.slug), ...(home ? [home] : [])])];
120− return home ? [home] : [];
117+/** The teams whose skills reach an agent: those it is on; none when they couldn't be read. */
118+export function teamSlugs(teams: TeamsHere | null): string[] {
119+ return teams ? [...new Set(teams.teams.map((team) => team.slug))] : [];
121120 }
122121
123122 /** Everything an agent has this turn, read once. */
+13−11
55 import type { SubagentDef, WorkspaceAgent } from "@g1t/contracts";
66
77 import { agentStatus, budgetBlock, dayKey, monthKey } from "./budget.ts";
8−import { type Definition, DEFAULT_AUTONOMY, DEFAULT_BUDGET, DEFAULT_ROUTING, PRESETS, readJson } from "./definition.ts";
8+import { type Definition, DEFAULT_AUTONOMY, DEFAULT_BUDGET, DEFAULT_ROUTING, legacyRoleOf, PRESETS, readJson } from "./definition.ts";
99
1010 export type Row = {
1111 id: string;
2424 template: string | null;
2525 avatar_seed: string | null;
2626 title: string | null;
27− team: string | null;
28− department: string | null;
27+ /**
28+ * Unused columns from when an agent carried its own team and department.
29+ * Teams are memberships in identity now; `team` is read once, by the move
30+ * to them (team-move.ts), and `role` made from them follows the title.
31+ */
32+ team?: string | null;
33+ department?: string | null;
2934 responsibilities: string | null;
3035 subagents: string | null;
3136 faces: string | null;
6570 }
6671
6772 export function definitionOf(row: Row): Definition {
73+ const title = row.title ?? "";
74+ // A role made from the title and its old team or department is the title alone.
75+ const made = (row.team || row.department?.trim()) && row.role === legacyRoleOf(title, row.team ?? null, row.department ?? null);
6876 return {
6977 handle: row.handle,
7078 display_name: row.display_name,
71− role: row.role,
79+ role: made && title.trim() ? title.trim() : row.role,
7280 instructions: row.instructions,
7381 personality_preset: PRESETS.includes(row.personality_preset as Definition["personality_preset"])
7482 ? (row.personality_preset as Definition["personality_preset"])
8088 capacity: row.capacity,
8189 template: row.template,
8290 avatar_seed: row.avatar_seed || row.handle,
83− title: row.title ?? "",
84− team: row.team ?? null,
85− department: row.department ?? "",
91+ title,
8692 responsibilities: readList<string>(row.responsibilities),
8793 subagents: readList<SubagentDef>(row.subagents),
8894 faces: "internal",
164170 "template",
165171 "avatar_seed",
166172 "title",
167− "team",
168− "department",
169173 "responsibilities",
170174 "subagents",
171175 "faces",
189193 d.template,
190194 d.avatar_seed,
191195 d.title,
192− d.team,
193− d.department,
194196 JSON.stringify(d.responsibilities),
195197 JSON.stringify(d.subagents),
196198 d.faces,
+101−0
1+import assert from "node:assert/strict";
2+import { readFileSync, readdirSync } from "node:fs";
3+import { DatabaseSync } from "node:sqlite";
4+import { test } from "node:test";
5+
6+import type { AdoptedAgentTeam } from "@g1t/contracts";
7+
8+import { claimsByWorkspace, moveAgentTeams } from "./team-move.ts";
9+
10+/** D1 over node's SQLite with the service's migrations. */
11+function fakeD1(): D1Database {
12+ const db = new DatabaseSync(":memory:");
13+ const dir = new URL("../migrations/", import.meta.url);
14+ for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8"));
15+ const statement = (sql: string, params: unknown[] = []): any => ({
16+ sql,
17+ params,
18+ bind: (...values: unknown[]) => statement(sql, values),
19+ first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null,
20+ run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...(params as never[])).changes) } }),
21+ all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }),
22+ });
23+ return { prepare: (sql: string) => statement(sql) } as unknown as D1Database;
24+}
25+
26+const at = "2026-10-10T12:00:00Z";
27+
28+async function addAgent(db: D1Database, id: string, workspace: string, team: string | null, extra: { scope?: string; archived?: boolean } = {}): Promise<void> {
29+ await db
30+ .prepare(
31+ `INSERT INTO agents (id, workspace_id, handle, display_name, role, instructions, routing, budget, autonomy, created_by, created_at, updated_at, team, scope, archived_at)
32+ VALUES (?, ?, ?, ?, 'r', 'i', '{}', '{}', '{}', 'ana', ?, ?, ?, ?, ?)`,
33+ )
34+ .bind(id, workspace, id, id, at, at, team, extra.scope ?? "workspace", extra.archived ? at : null)
35+ .run();
36+}
37+
38+/** Identity's side: teams by workspace, and the memberships it holds. */
39+function identity(teams: Record<string, string[]>) {
40+ const on = new Set<string>();
41+ const calls: string[] = [];
42+ const adopt = async (workspaceId: string, agents: { agent_id: string; team: string }[]): Promise<AdoptedAgentTeam[]> => {
43+ calls.push(workspaceId);
44+ return agents.map(({ agent_id, team }) => {
45+ const slug = team.trim().toLowerCase();
46+ if (!(teams[workspaceId] ?? []).includes(slug)) return { agent_id, team, outcome: "no_team" };
47+ const key = `${workspaceId}/${slug}/${agent_id}`;
48+ if (on.has(key)) return { agent_id, team, outcome: "already" };
49+ on.add(key);
50+ return { agent_id, team, outcome: "added" };
51+ });
52+ };
53+ return { adopt, on, calls };
54+}
55+
56+test("only workspace agents that aren't archived join a team", () => {
57+ const claims = claimsByWorkspace([
58+ { id: "agt_a", workspace_id: "w1", team: "qa", scope: "workspace", archived_at: null },
59+ { id: "agt_b", workspace_id: "w1", team: "qa", scope: "personal", archived_at: null },
60+ { id: "agt_c", workspace_id: "w2", team: "sales", scope: null, archived_at: null },
61+ { id: "agt_d", workspace_id: "w2", team: "sales", scope: "workspace", archived_at: at },
62+ { id: "agt_e", workspace_id: "w2", team: " ", scope: "workspace", archived_at: null },
63+ ]);
64+ assert.deepEqual([...claims], [
65+ ["w1", [{ agent_id: "agt_a", team: "qa" }]],
66+ ["w2", [{ agent_id: "agt_c", team: "sales" }]],
67+ ]);
68+});
69+
70+test("agents' old teams become memberships once: matched by slug, the rest dropped, a second run does nothing", async () => {
71+ const db = fakeD1();
72+ await addAgent(db, "agt_margo", "w1", "QA");
73+ await addAgent(db, "agt_david", "w1", "sales");
74+ await addAgent(db, "agt_pax", "w1", "billing");
75+ await addAgent(db, "agt_otto", "w2", "qa");
76+ await addAgent(db, "agt_mine", "w1", "qa", { scope: "personal" });
77+ await addAgent(db, "agt_none", "w1", null);
78+ const fake = identity({ w1: ["qa", "sales"], w2: [] });
79+ const first = await moveAgentTeams(db, fake.adopt, new Date(at));
80+ assert.equal(first.added, 2);
81+ assert.deepEqual(first.dropped.map((d) => d.agent_id).sort(), ["agt_otto", "agt_pax"]);
82+ assert.equal(first.seen, 5, "the personal agent is marked without joining");
83+ assert.deepEqual([...fake.on].sort(), ["w1/qa/agt_margo", "w1/sales/agt_david"]);
84+ const second = await moveAgentTeams(db, fake.adopt, new Date(at));
85+ assert.deepEqual(second, { seen: 0, added: 0, already: 0, dropped: [] });
86+ assert.equal(fake.calls.length, 2, "identity is asked once per workspace, and never again");
87+});
88+
89+test("a workspace identity couldn't answer for is asked again next run", async () => {
90+ const db = fakeD1();
91+ await addAgent(db, "agt_margo", "w1", "qa");
92+ let fail = true;
93+ const fake = identity({ w1: ["qa"] });
94+ const adopt = async (id: string, agents: { agent_id: string; team: string }[]) => {
95+ if (fail) throw new Error("identity is down");
96+ return fake.adopt(id, agents);
97+ };
98+ assert.equal((await moveAgentTeams(db, adopt)).seen, 0);
99+ fail = false;
100+ assert.equal((await moveAgentTeams(db, adopt)).added, 1);
101+});
+77−0
1+/**
2+ * The one-time move of agents' own teams to team memberships
3+ * (migrations/0010_agent_team_memberships.sql). An agent is on teams the
4+ * way a person is, through identity's team_agents; agents made before
5+ * that named a team themselves, in `agents.team`.
6+ *
7+ * Every cron run takes the agents not moved yet (none, once it is done:
8+ * a partial index makes that one cheap read) and asks identity to put
9+ * each on the team its old value names, by slug in any case, in its own
10+ * workspace. A value that names no team is dropped and logged. Archived
11+ * and personal agents are marked without joining anything: neither is on
12+ * a team. Each agent is marked `team_moved_at` once identity answered for
13+ * its workspace, so a second run does nothing, and identity's insert is
14+ * idempotent besides.
15+ */
16+import type { AdoptedAgentTeam } from "@g1t/contracts";
17+
18+/** Agents read per run: the move finishes over a few runs in a large installation. */
19+export const MOVE_BATCH = 200;
20+
21+export type MoveRow = { id: string; workspace_id: string; team: string; scope: string | null; archived_at: string | null };
22+
23+/** What identity is asked, per workspace: the agents that may join, by the team each named. */
24+export function claimsByWorkspace(rows: readonly MoveRow[]): Map<string, { agent_id: string; team: string }[]> {
25+ const out = new Map<string, { agent_id: string; team: string }[]>();
26+ for (const row of rows) {
27+ if (row.archived_at || row.scope === "personal") continue;
28+ const team = row.team.trim();
29+ if (!team) continue;
30+ out.set(row.workspace_id, [...(out.get(row.workspace_id) ?? []), { agent_id: row.id, team }]);
31+ }
32+ return out;
33+}
34+
35+export type MoveReport = { seen: number; added: number; already: number; dropped: { agent_id: string; team: string }[] };
36+
37+/** `adopt` is identity's `adopt_agent_teams` (index.ts passes it). */
38+export async function moveAgentTeams(
39+ db: D1Database,
40+ adopt: (workspaceId: string, agents: { agent_id: string; team: string }[]) => Promise<AdoptedAgentTeam[]>,
41+ now: Date = new Date(),
42+): Promise<MoveReport> {
43+ const rows = await db.prepare(
44+ `SELECT id, workspace_id, team, scope, archived_at FROM agents
45+ WHERE team IS NOT NULL AND team <> '' AND team_moved_at IS NULL LIMIT ?`,
46+ )
47+ .bind(MOVE_BATCH)
48+ .all<MoveRow>();
49+ const report: MoveReport = { seen: 0, added: 0, already: 0, dropped: [] };
50+ if (!rows.results.length) return report;
51+ const claims = claimsByWorkspace(rows.results);
52+ // Agents identity is not asked about (archived, personal) are done as they are.
53+ const done = new Set(rows.results.filter((row) => !claims.get(row.workspace_id)?.some((c) => c.agent_id === row.id)).map((row) => row.id));
54+ for (const [workspaceId, agents] of claims) {
55+ try {
56+ const outcomes = await adopt(workspaceId, agents);
57+ for (const outcome of outcomes) {
58+ if (outcome.outcome === "added") report.added += 1;
59+ else if (outcome.outcome === "already") report.already += 1;
60+ else report.dropped.push({ agent_id: outcome.agent_id, team: outcome.team });
61+ }
62+ for (const agent of agents) done.add(agent.agent_id);
63+ } catch (error) {
64+ // Left unmarked: the next run asks again.
65+ console.error("agents: moving agents' teams to memberships failed for a workspace", workspaceId, String(error));
66+ }
67+ }
68+ if (done.size) {
69+ await db.prepare("UPDATE agents SET team_moved_at = ? WHERE id IN (SELECT value FROM json_each(?)) AND team_moved_at IS NULL")
70+ .bind(now.toISOString(), JSON.stringify([...done]))
71+ .run();
72+ }
73+ report.seen = done.size;
74+ for (const drop of report.dropped) console.log("agents: an agent's old team names no team, so it joins none", drop.agent_id, drop.team);
75+ if (report.seen) console.log("agents: moved agents' teams to memberships", JSON.stringify({ seen: report.seen, added: report.added, already: report.already, dropped: report.dropped.length }));
76+ return report;
77+}
+7−7
4040 person("sofia", { name: "Sofia Reyes", title: "Head of Sales", owns: ["the forecast"], manager: "chase", maintainer: true, timezone: "America/Chicago" }),
4141 person("jordan", { name: "Jordan Lee", title: "Account Executive", owns: ["Halcyon", "Bluebird"], manager: "sofia" }),
4242 ],
43− agent_ids: [],
43+ agent_ids: ["agt_david"],
4444 };
4545
4646 const here = (presence: PresenceEntry[]): TeamsHere => ({
4747 teams: [sales],
4848 agents: [
49− { id: "agt_david", handle: "david", display_name: "David", title: "CRM keeper", team: "sales" },
50− { id: "agt_pax", handle: "pax", display_name: "Pax", title: "Billing", team: null },
49+ { id: "agt_david", handle: "david", display_name: "David", title: "CRM keeper" },
50+ { id: "agt_pax", handle: "pax", display_name: "Pax", title: "Billing" },
5151 ],
5252 presence,
5353 });
5454
55−test("an agent is on the teams it was added to and its home team", () => {
56− const agents = here([]).agents;
57− assert.deepEqual(agentIdsOn(sales, agents), ["agt_david"]);
58− assert.deepEqual(agentIdsOn({ ...sales, agent_ids: ["agt_pax"] }, agents), ["agt_pax", "agt_david"]);
55+test("an agent is on the teams it was added to, and only those", () => {
56+ assert.deepEqual(agentIdsOn(sales), ["agt_david"]);
57+ assert.deepEqual(agentIdsOn({ ...sales, agent_ids: ["agt_pax", "agt_pax"] }), ["agt_pax"]);
58+ assert.deepEqual(agentIdsOn({ ...sales, agent_ids: [] }), []);
5959 });
6060
6161 test("an agent is told who leads, who owns what and who is around", () => {
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.