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 on the site: the directory (people and agents in one search), | |
| 3 | * profiles, teams of any mix, and the org chart. Types only from the | |
| 4 | * contracts, so this file runs under `node --test`. | |
| 5 | */ | |
| 6 | import type { DirectoryPerson, DirectoryTeam, PeopleDirectory, TeamLead, WorkspaceAgent } from "@g1t/contracts"; | |
| 7 | ||
| 8 | /** An agent as People's pages need it. */ | |
| 9 | export type PeopleAgent = Pick< | |
| 10 | WorkspaceAgent, | |
| 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. | 11 | "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "title" | "role" | "status" | "responsibilities" | "builtin" | "spent_month_micros" |
| 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. | 12 | >; |
| 13 | ||
| 14 | /** Just what People's pages read of an agent, so loaders send no more. */ | |
| 15 | export function peopleAgent(agent: PeopleAgent): PeopleAgent { | |
| 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. | 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 }; | |
| 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. | 18 | } |
| 19 | ||
| 20 | /** Where a person's profile is. */ | |
| 21 | export function personPath(workspace: string, username: string): string { | |
| 22 | return `/${workspace}/-/people/${username}`; | |
| 23 | } | |
| 24 | ||
| 25 | /** Where an agent's People profile is. */ | |
| 26 | export function agentPath(workspace: string, handle: string): string { | |
| 27 | return `/${workspace}/-/people/agents/${handle}`; | |
| 28 | } | |
| 29 | ||
| 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. | 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[] { | |
| 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. | 32 | const byId = new Map(agents.map((agent) => [agent.id, agent])); |
| 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. | 33 | return [...new Set(team.agent_ids)].map((id) => byId.get(id)).filter((agent): agent is A => !!agent); |
| 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. | 34 | } |
| 35 | ||
| 36 | /** The teams a person is on. */ | |
| 37 | export function teamsOfPerson<T extends Pick<DirectoryTeam, "people">>(teams: readonly T[], username: string): T[] { | |
| 38 | return teams.filter((team) => team.people.some((person) => person.username === username)); | |
| 39 | } | |
| 40 | ||
| 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. | 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; | |
| 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. | 56 | } |
| 57 | ||
| 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. | 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 | ||
| 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. | 84 | /** Whether `lead` is this person or this agent. */ |
| 85 | export function leads(lead: TeamLead | null, who: { username: string } | { id: string }): boolean { | |
| 86 | if (!lead) return false; | |
| 87 | if ("username" in who) return lead.kind === "user" && lead.username === who.username; | |
| 88 | return lead.kind === "agent" && lead.agent_id === who.id; | |
| 89 | } | |
| 90 | ||
| 91 | /** A person's name as pages show it. */ | |
| 92 | export function personName(person: Pick<DirectoryPerson, "username" | "name" | "display_username">): string { | |
| 93 | return person.name?.trim() || person.display_username || person.username; | |
| 94 | } | |
| 95 | ||
| 96 | /** Whether every word of `query` is somewhere in `fields`. */ | |
| 97 | export function matches(query: string, fields: readonly (string | null | undefined)[]): boolean { | |
| 98 | const words = query.toLowerCase().split(/\s+/).filter(Boolean); | |
| 99 | if (!words.length) return true; | |
| 100 | const text = fields.filter(Boolean).join(" ").toLowerCase(); | |
| 101 | return words.every((word) => text.includes(word)); | |
| 102 | } | |
| 103 | ||
| 104 | export type DirectoryKind = "everyone" | "people" | "agents"; | |
| 105 | ||
| 106 | export function directoryKind(value: string | null): DirectoryKind { | |
| 107 | return value === "people" || value === "agents" ? value : "everyone"; | |
| 108 | } | |
| 109 | ||
| 110 | /** One card in the directory: a person or an agent. */ | |
| 111 | export type DirectoryEntry = | |
| 112 | | { kind: "person"; person: DirectoryPerson; teams: DirectoryTeam[] } | |
| 113 | | { kind: "agent"; agent: PeopleAgent; teams: DirectoryTeam[] }; | |
| 114 | ||
| 115 | /** | |
| 116 | * Everyone matching `query`, people first by name, then agents (the | |
| 117 | * orchestrator first, then by name). People match on their name, title, | |
| 118 | * teams, what they own, where they are and their bio; agents on their | |
| 119 | * name, handle, title, role, teams and responsibilities. | |
| 120 | */ | |
| 121 | export function directoryEntries(directory: PeopleDirectory, agents: readonly PeopleAgent[], query: string, kind: DirectoryKind): DirectoryEntry[] { | |
| 122 | const out: DirectoryEntry[] = []; | |
| 123 | if (kind !== "agents") { | |
| 124 | for (const person of directory.people) { | |
| 125 | const teams = teamsOfPerson(directory.teams, person.username); | |
| 126 | const fields = [person.username, person.name, person.title, person.location, person.bio, ...person.owns, ...teams.map((team) => team.name), person.role === "owner" ? "owner" : null]; | |
| 127 | if (matches(query, fields)) out.push({ kind: "person", person, teams }); | |
| 128 | } | |
| 129 | } | |
| 130 | if (kind !== "people") { | |
| 131 | for (const agent of agents) { | |
| 132 | const teams = teamsOfAgent(directory.teams, agent); | |
| 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. | 133 | const fields = [agent.handle, agent.display_name, agent.title, agent.role, ...agent.responsibilities, ...teams.map((team) => team.name), "agent"]; |
| 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. | 134 | if (matches(query, fields)) out.push({ kind: "agent", agent, teams }); |
| 135 | } | |
| 136 | } | |
| 137 | return out; | |
| 138 | } | |
| 139 | ||
| 140 | /** One person in the org chart, with who reports to them and the agents of the teams they lead. */ | |
| 141 | export type OrgNode = { person: DirectoryPerson; agents: PeopleAgent[]; leads: DirectoryTeam[]; reports: OrgNode[] }; | |
| 142 | ||
| 143 | export type OrgChart = { | |
| 144 | /** Everyone at the top: no manager, or one who is no longer here. */ | |
| 145 | roots: OrgNode[]; | |
| 146 | /** Teams no person leads (an agent does, or no one), with their agents. */ | |
| 147 | unled: { team: DirectoryTeam; agents: PeopleAgent[] }[]; | |
| 148 | }; | |
| 149 | ||
| 150 | /** | |
| 151 | * Reporting lines, with each team's agents beside the person who leads it. | |
| 152 | * A loop (which identity refuses) would leave its people out of `roots`; | |
| 153 | * they are put back at the top, so no one is ever missing. | |
| 154 | */ | |
| 155 | export function orgChart(directory: PeopleDirectory, agents: readonly PeopleAgent[]): OrgChart { | |
| 156 | const known = new Set(directory.people.map((person) => person.username)); | |
| 157 | const byManager = new Map<string, DirectoryPerson[]>(); | |
| 158 | for (const person of directory.people) { | |
| 159 | if (person.manager && known.has(person.manager) && person.manager !== person.username) { | |
| 160 | byManager.set(person.manager, [...(byManager.get(person.manager) ?? []), person]); | |
| 161 | } | |
| 162 | } | |
| 163 | const placed = new Set<string>(); | |
| 164 | const node = (person: DirectoryPerson): OrgNode => { | |
| 165 | placed.add(person.username); | |
| 166 | const led = directory.teams.filter((team) => leads(team.lead, { username: person.username })); | |
| 167 | const theirAgents: PeopleAgent[] = []; | |
| 168 | for (const team of led) for (const agent of agentsOn(team, agents)) if (!theirAgents.includes(agent)) theirAgents.push(agent); | |
| 169 | const reports = (byManager.get(person.username) ?? []).filter((report) => !placed.has(report.username)).map(node); | |
| 170 | return { person, agents: theirAgents, leads: led, reports }; | |
| 171 | }; | |
| 172 | const roots = directory.people.filter((person) => !person.manager || !known.has(person.manager)).map(node); | |
| 173 | for (const person of directory.people) if (!placed.has(person.username)) roots.push(node(person)); | |
| 174 | const unled = directory.teams | |
| 175 | .filter((team) => !team.lead || team.lead.kind === "agent") | |
| 176 | .map((team) => ({ team, agents: agentsOn(team, agents) })) | |
| 177 | .filter((entry) => entry.agents.length > 0); | |
| 178 | return { roots, unled }; | |
| 179 | } | |
| 180 | ||
| 181 | /** Everyone who reports to `username`, directly. */ | |
| 182 | export function reportsOf(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] { | |
| 183 | return people.filter((person) => person.manager === username && person.username !== username); | |
| 184 | } | |
| 185 | ||
| 186 | /** Who `username` could report to: anyone but themselves and the people under them. */ | |
| 187 | export function managerChoices(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] { | |
| 188 | const below = new Set<string>([username]); | |
| 189 | let grew = true; | |
| 190 | while (grew) { | |
| 191 | grew = false; | |
| 192 | for (const person of people) { | |
| 193 | if (person.manager && below.has(person.manager) && !below.has(person.username)) { | |
| 194 | below.add(person.username); | |
| 195 | grew = true; | |
| 196 | } | |
| 197 | } | |
| 198 | } | |
| 199 | return people.filter((person) => !below.has(person.username)); | |
| 200 | } | |
| 201 | ||
| 202 | /** What someone owns, from a form's text: one per line or comma. */ | |
| 203 | export function ownsFromText(text: string): string[] { | |
| 204 | return text | |
| 205 | .split(/[\n,]+/) | |
| 206 | .map((phrase) => phrase.trim()) | |
| 207 | .filter(Boolean); | |
| 208 | } | |
| 209 | ||
| 210 | /** A monthly budget in dollars from a form, as micro-dollars; 0 for none; null when it isn't a number. */ | |
| 211 | export function budgetFromText(text: string): number | null { | |
| 212 | const value = text.trim().replace(/^\$/, "").replace(/,/g, ""); | |
| 213 | if (!value) return 0; | |
| 214 | const dollars = Number(value); | |
| 215 | if (!Number.isFinite(dollars) || dollars < 0) return null; | |
| 216 | return Math.round(dollars * 1_000_000); | |
| 217 | } | |
| 218 | ||
| 219 | /** The access to Code a member has, in words: their role, the base permission, and the teams that add to it. */ | |
| 220 | export function codeAccessWords( | |
| 221 | person: Pick<DirectoryPerson, "role">, | |
| 222 | base: PeopleDirectory["base_permission"], | |
| 223 | teams: readonly Pick<DirectoryTeam, "name" | "repos_count">[], | |
| 224 | ): { summary: string; through: string[] } { | |
| 225 | const summary = | |
| 226 | person.role === "owner" | |
| 227 | ? "Admin on every repository, as an owner" | |
| 228 | : base === "none" | |
| 229 | ? "Only repositories they're given a role on" | |
| 230 | : `${base[0]!.toUpperCase()}${base.slice(1)} on every repository, the workspace's base permission`; | |
| 231 | const through = teams | |
| 232 | .filter((team) => team.repos_count > 0) | |
| 233 | .map((team) => `Roles on ${team.repos_count} ${team.repos_count === 1 ? "repository" : "repositories"} through ${team.name}`); | |
| 234 | return { summary, through }; | |
| 235 | } | |
| 236 | ||
| 237 | /** The part of what an agent is told that is about one team: its heading and what follows, up to the next. */ | |
| 238 | export function teamBlock(text: string | null, teamName: string): string | null { | |
| 239 | if (!text) return null; | |
| 240 | const blocks = text.split(/\n(?=### )/); | |
| 241 | const found = blocks.find((block) => block.startsWith(`### ${teamName}\n`) || block.trim() === `### ${teamName}`); | |
| 242 | return found ? found.trim() : null; | |
| 243 | } | |
| 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. | 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 }; |