Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 1 | /** |
| 2 | * People: a workspace's directory of everyone in it, people and agents, | |
| 3 | * and each member's place in it: a title, who they report to, and what | |
| 4 | * they own. Kept by identity beside membership; mirrors | |
| 5 | * `crates/contracts/src/people.rs`. Agents come from the agents service, | |
| 6 | * which teams name by id. | |
| 7 | */ | |
| 8 | import type { User } from "./identity"; | |
| 9 | import type { Result } from "./result"; | |
| 10 | import type { TeamChannel, TeamLead, TeamRef, TeamRole, TeamVisibility } from "./teams"; | |
| 11 | ||
| 12 | /** The longest title. */ | |
| 13 | export const MAX_TITLE_LENGTH = 80; | |
| 14 | /** The most things one person owns, as their profile lists them, and the longest of them. */ | |
| 15 | export const MAX_OWNS = 8; | |
| 16 | export const MAX_OWNS_LENGTH = 60; | |
| 17 | ||
| 18 | /** One member, as the directory, their profile and the org chart show them. */ | |
| 19 | export type DirectoryPerson = { | |
| 20 | user_id: string; | |
| 21 | username: string; | |
| 22 | display_username?: string; | |
| 23 | name: string | null; | |
| 24 | avatar: string | null; | |
| 25 | bio: string | null; | |
| 26 | location: string | null; | |
| 27 | pronouns: string | null; | |
| 28 | /** An IANA time zone, for their local time. */ | |
| 29 | timezone: string | null; | |
| 30 | role: "owner" | "member"; | |
| 31 | /** Their title in this workspace. */ | |
| 32 | title: string | null; | |
| 33 | /** Who they report to, by username. */ | |
| 34 | manager: string | null; | |
| 35 | /** What they own: a few short phrases. */ | |
| 36 | owns: string[]; | |
| 37 | joined_at: string; | |
| 38 | }; | |
| 39 | ||
| 40 | export type TeamPersonRef = { username: string; role: TeamRole }; | |
| 41 | ||
| 42 | /** A team as the directory and the org chart need it. */ | |
| 43 | export type DirectoryTeam = { | |
| 44 | slug: string; | |
| 45 | name: string; | |
| 46 | description: string | null; | |
| 47 | visibility: TeamVisibility; | |
| 48 | parent: TeamRef | null; | |
| 49 | lead: TeamLead | null; | |
| 50 | channel: TeamChannel | null; | |
| 51 | budget_micros: number | null; | |
| 52 | /** Its own people. */ | |
| 53 | people: TeamPersonRef[]; | |
| 54 | /** Agents added to it, by id. Agents whose home team it is are on it too (`agentsOnTeam`). */ | |
| 55 | agent_ids: string[]; | |
| 56 | repos_count: number; | |
| 57 | }; | |
| 58 | ||
| 59 | export type PeopleDirectory = { | |
| 60 | people: DirectoryPerson[]; | |
| 61 | teams: DirectoryTeam[]; | |
| 62 | /** What members get on every repository. */ | |
| 63 | base_permission: "none" | "read" | "write" | "admin"; | |
| 64 | /** Whether the viewer owns the workspace: they set anyone's title, manager and what they own. */ | |
| 65 | can_manage: boolean; | |
| 66 | }; | |
| 67 | ||
| 68 | /** What changes on a member's profile; the rest stays. `manager: ""` clears it (owners only). */ | |
| 69 | export type MemberProfileChanges = { | |
| 70 | title?: string; | |
| 71 | manager?: string; | |
| 72 | owns?: string[]; | |
| 73 | }; | |
| 74 | ||
| 75 | export interface PeopleClient { | |
| 76 | /** Everyone in a workspace and its teams, as the viewer may see them. Members only. */ | |
| 77 | peopleDirectory(viewer: User | null, workspace: string): Promise<Result<PeopleDirectory>>; | |
| 78 | /** A member's title and what they own (themselves or an owner), and their manager (owners). */ | |
| 79 | 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; | |
| 90 | } | |
| 91 | ||
| 92 | /** What a team is made of. */ | |
| 93 | export type TeamKind = "mixed" | "people" | "agents" | "empty"; | |
| 94 | ||
| 95 | export function teamKind(people: number, agents: number): TeamKind { | |
| 96 | if (people && agents) return "mixed"; | |
| 97 | if (people) return "people"; | |
| 98 | if (agents) return "agents"; | |
| 99 | return "empty"; | |
| 100 | } | |
| 101 | ||
| 102 | /** "2 people and 1 agent", "People only", "Agents only", "No one yet". */ | |
| 103 | export function teamKindLabel(people: number, agents: number): string { | |
| 104 | const kind = teamKind(people, agents); | |
| 105 | if (kind === "people") return "People only"; | |
| 106 | if (kind === "agents") return "Agents only"; | |
| 107 | if (kind === "empty") return "No one yet"; | |
| 108 | return `${people} ${people === 1 ? "person" : "people"} and ${agents} ${agents === 1 ? "agent" : "agents"}`; | |
| 109 | } | |
| 110 | ||
| 111 | /** What someone owns, as identity keeps it: trimmed, each once (ignoring case), at most eight. */ | |
| 112 | export function cleanOwns(owns: readonly string[]): string[] { | |
| 113 | const out: string[] = []; | |
| 114 | for (const raw of owns) { | |
| 115 | const phrase = raw.split(/\s+/).filter(Boolean).join(" ").slice(0, MAX_OWNS_LENGTH).replace(/[.,;]+$/, "").trim(); | |
| 116 | if (!phrase || out.some((kept) => kept.toLowerCase() === phrase.toLowerCase())) continue; | |
| 117 | out.push(phrase); | |
| 118 | if (out.length === MAX_OWNS) break; | |
| 119 | } | |
| 120 | return out; | |
| 121 | } |