Skip to content
1 change: 1 addition & 0 deletions docs/designs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ One document per feature design, in English. The status column says how far each
| Implemented | [GitCode provider](gitcode-provider.md) | #361 |
| Implemented | [Model profiles](model-profiles.md) — `teamai models` | — |
| Implemented | [Team env by directory](env-by-directory.md) — each directory's shell gets the env of the scope that governs it | #1018 |
| Implemented | [Assigned roles](assigned-roles.md) — `manifest/members.yaml` decides a member's roles | #1186 |
| Implemented | [Workspace children](workspace-children.md) — a workspace also delivers what the repos set up inside it select | #913 |
| Implemented | [Skill serving](skill-serving.md) — built-in skill content shipped with the CLI (`teamai skill get`) | #678 |
| Implemented, living | [Team secrets](team-secrets.md) — describes only what the current version does | #875, #879 |
Expand Down
76 changes: 76 additions & 0 deletions docs/designs/assigned-roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Assigned roles

> Status: Implemented — `manifest/members.yaml` decides a member's roles when it exists (#1186).

## Problem

A member's roles were a local setting. `teamai roles set <role>` checked the id against `manifest/roles.yaml` and wrote `primaryRole`/`additionalRoles` into that member's own config; nothing said who may hold which role. A member with no role received every namespace.

That is fine while roles only pick what gets delivered. A team that also uses the effective roles as a bound for its agents — a `PreToolUse` hook that lets the agent of a `frontend` member change `frontend/` and shared files, and nothing else — gets a different property: the agent can lift its own bound by the same means the member has. It can run `teamai roles set`, write the config file, or clear the role and remove the filter altogether. Blocking the command in a hook is string matching on shell commands, and the config is a plain YAML file in the member's home that the agent's shell can write.

## Design

Role assignments become team data, reviewed like any other change in the team repo, and the local setting stops deciding while they exist.

`manifest/members.yaml`, next to `roles.yaml`:

```yaml
version: 1
members:
alice: [frontend]
bob: [backend, ki]
default: [] # roles of a member this file does not list
```

The first role listed is the primary role, the rest are additional. Who may change the file is the forge's decision, as it already is for `roles.yaml`: protected branch, merge request, code owners for `manifest/`. teamai keeps no access list of its own — the people allowed to assign roles are the code owners of that file.

### One overlay, every reader

The roles are overlaid onto the member's config where it is loaded (`applyRoleAssignment` in `config.ts`, beside the legacy role migration it precedes). Every existing reader of `primaryRole`/`additionalRoles` — pull, push placement, the namespace filter, the per-entry role scope, the dashboard, child workspaces — then sees the team's assignment without knowing it was assigned.

The overlay is runtime-only (`LocalConfig.roleAssignment`). `serializeLocalConfig` writes the member's own on-disk selection back, so a save by any other command (`projects set`, a tag subscription) never freezes a copy of an assignment into the config file. A stale copy that outlives the assignment is the thing the file exists to prevent.

### Fail closed

With assignments in place, nothing falls back to an unfiltered sync:

- `activeRoleIds` returns `[]`, not `null`: a member assigned no roles matches no role-scoped entry, where `null` means "no role filter".
- `resolveOwnResourceNamespaces` keeps its three unfiltered fallbacks closed, so such a member receives the shared root only.
- A `members.yaml` that exists but cannot be read or parsed leaves this run with no roles, loudly. Only absence of the file returns role selection to the member.
- The file is read from the team repo's fetched default branch (`origin/<default>`), not from the clone's working tree. A local edit, a deletion or an unpushed commit in the clone changes nothing, and a copy only the working tree has is absent: only what the forge merged counts. Where no fetched default branch exists — an HTTP source, a repo without a remote — the working tree is all there is.
- An assigned role `roles.yaml` does not declare is dropped with a warning, rather than failing every command for that member over one stale line. If that leaves no roles — including when `roles.yaml` is missing entirely — the member holds none.

### Resolved again where a load cannot reach

`refreshRoleAssignment` re-resolves the assignment on a config already in memory. It **mutates** that config rather than returning a copy: pull keys `teamEnvs` and `instructions` on config identity, and reconciles hooks, MCP and env on the object its caller holds, so a replacement would reach none of them.

Two callers:

- `pull`. The assignment lives in the repo `pull` itself refreshes, and the config is loaded before that refresh. Resolved only at load time, the first pull after a reassignment would deliver the previous roles and record the new revision as synced, so the fast path would skip every pull after it until `--force`. `pullForScope` therefore re-resolves right after the team repo moves, which also picks up an assignments file that just arrived or just went away.
- `init`. It builds the config in memory and hands it straight to the hook, MCP and env reconciliation, before any load can overlay it. Without this the config reaches them role-less, and a role-less config matches every role-scoped entry: a member assigned `frontend` would have `backend`'s hooks installed at init. The assignment is applied after the role profile and before that reconciliation; nothing of it is written, since the save keeps the member's own selection, which an assignment leaves empty.

In a workspace with children (#913) every delivered member holds the workspace's assignment: a child cannot select a role, so the selection it recorded is not one to honour.

### What the member sees

- `teamai roles set` refuses, naming the file and the roles held, and exits non-zero.
- `teamai roles list` prints the effective roles and says where they come from. `--json` reports `{ roles, primaryRole, additionalRoles, source, assignmentFile }` with `source` one of `assigned` (listed), `default` (not listed), `local` (no such file) or `none`. A team hook reads that instead of parsing teamai's config file, whose path and shape are teamai's own.
- `teamai init` skips the role prompt and says which roles the team assigned; `--role` is refused.
- The dashboard's Distribution page shows the roles read-only; the role save and preview APIs refuse with 409. Active projects stay each workspace's own choice.

### Identity

The member key is the `username` in that member's teamai config, which `teamai init` records from the git provider (`gitlabWhoami`, `ghAuthWhoami`, ...). No whoami call happens per command: it would put a network round trip on every load, with nothing useful to do when it fails.

Two member keys that differ only by case are refused when the file loads, since a forge that folds usernames would make them one person.

An HTTP source (`init --repo <url>`, `username: http-consumer`) has no forge identity, so such a consumer resolves as an unlisted member and holds `default`.

## What this is not

A boundary against a determined process running as the member. The agent runs as that member and can edit the config the username comes from, or patch teamai itself. The narrower aim: a role change is a reviewed team decision with a history, and an agent can no longer change its own role with one command or one file write.

## Not included

- `sharing.roles.required` in `teamai.yaml` (#1096). The presence of `members.yaml` already decides fail-closed delivery.
- A cached forge whoami check as a stronger binding of the key to the member's token.
36 changes: 35 additions & 1 deletion docs/guide/distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,43 @@ teamai pull

`repo.localPath` accepts home-relative paths such as `~/.teamai/team-repo`, including the native `~\.teamai\team-repo` form on Windows.

## Assigning roles in the team repo

By default each member picks their own role and can change it at any time. A team that also uses the effective roles as a limit — a hook that lets a `frontend` member's agent change `frontend/` and shared files only — wants that assignment to be a reviewed change in the team repo. `manifest/members.yaml` makes it one:

```yaml
# manifest/members.yaml
version: 1
members:
alice: [frontend]
bob: [backend, ki]
default: [] # roles of a member this file does not list
```

The first role listed is the member's primary role, the rest are additional. While the file exists:

- A member's effective roles are the ones assigned here. `teamai roles set` refuses, and so does the dashboard's Distribution page, which shows the roles instead of offering them.
- A member the file does not list holds `default` — no roles unless you say otherwise — and receives shared resources only, rather than the unfiltered sync a role-less member gets without this file.
- Who may assign roles is your forge's decision, as it already is for `roles.yaml`: a protected branch, a merge request, code owners for `manifest/`. teamai keeps no access list of its own.

The member key is the username in the member's own teamai config, which `teamai init` records from the git provider. Deleting the file hands role selection back to each member. teamai reads it from the team repo's fetched default branch, not from the clone on the member's machine, so editing or deleting that copy changes nothing: only what your forge merged counts.

A reassignment takes effect on the next pull.

A team hook reads the effective roles without parsing any config file:

```bash
teamai roles list --json
# {"roles":["frontend"],"primaryRole":"frontend","additionalRoles":[],"source":"assigned","assignmentFile":"manifest/members.yaml"}
```

`source` is `assigned` (listed in the file), `default` (not listed), `local` (no such file, so the member's own selection) or `none`.

This is not a boundary against a determined process running as the member: the agent runs as that member and could edit the config the username comes from, or teamai itself. What it gives you is a role change that is a reviewed team decision with a history, instead of one command on a laptop.

## Restricting skills to some members

Namespaces only filter for members who have set a role: a member with no `primaryRole` syncs everything, so `manifest/roles.yaml` alone restricts no one. `teamai init` makes new members pick a role; members from before the manifest need `teamai roles set <role-id>` once. Their next pull withdraws teamai-delivered copies outside the new namespaces (locally modified copies are kept).
Namespaces only filter for members who have set a role: a member with no `primaryRole` syncs everything, so `manifest/roles.yaml` alone restricts no one. With `manifest/members.yaml` in place that gap is closed — an unlisted member receives shared resources only. `teamai init` makes new members pick a role; members from before the manifest need `teamai roles set <role-id>` once. Their next pull withdraws teamai-delivered copies outside the new namespaces (locally modified copies are kept).

To remind members who never set a role, enforce a rule that asks the agent to check `teamai roles list` and prompt them — enforced rules cannot be deleted:

Expand Down
36 changes: 35 additions & 1 deletion docs/guide/zh-CN/distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,43 @@ teamai pull

`repo.localPath` 支持 `~/.teamai/team-repo` 这样的用户目录相对路径,在 Windows 上也支持原生的 `~\.teamai\team-repo` 写法。

## 在团队仓库里分配角色

默认情况下,角色由成员自己设置,也可以随时改。如果团队还把生效角色当作边界——例如用 hook 让 `frontend` 成员的 agent 只能改 `frontend/` 和共享文件——那么这个分配应当是团队仓库里一次经过评审的变更。`manifest/members.yaml` 就是这个文件:

```yaml
# manifest/members.yaml
version: 1
members:
alice: [frontend]
bob: [backend, ki]
default: [] # 这个文件未列出的成员所持有的角色
```

列在第一位的是主角色,其余是附加角色。该文件存在时:

- 成员的生效角色就是这里分配的角色。`teamai roles set` 会被拒绝,dashboard 的 Distribution 页面也一样,只展示角色而不提供修改。
- 文件未列出的成员持有 `default`——除非另行声明即为无角色——并且只接收共享资源,而不是没有该文件时无角色成员得到的全量同步。
- 谁可以分配角色由代码平台决定,和 `roles.yaml` 一样:保护分支、合并请求、`manifest/` 的 code owner。teamai 自己不维护任何权限名单。

成员的键是该成员本地 teamai 配置里的用户名,由 `teamai init` 从 Git 平台记录。删除该文件后,角色选择重新回到每个成员手里。teamai 从团队仓库已拉取的默认分支读取该文件,而不是成员机器上的克隆副本,所以改写或删除本地副本不起作用:只有代码平台已合并的内容才算。

重新分配会在下一次 pull 时生效。

团队 hook 读取生效角色时不需要解析任何配置文件:

```bash
teamai roles list --json
# {"roles":["frontend"],"primaryRole":"frontend","additionalRoles":[],"source":"assigned","assignmentFile":"manifest/members.yaml"}
```

`source` 取值为 `assigned`(文件中列出)、`default`(未列出)、`local`(没有该文件,成员自己的选择)或 `none`。

这不是对以该成员身份运行的进程的硬边界:agent 以该成员身份运行,可以改写用户名所在的配置,也可以改 teamai 自身。它带来的是:角色变更成为一次有记录、经过评审的团队决定,而不是笔记本上的一条命令。

## 把技能限定给部分成员

Namespace 只对设置了角色的成员过滤:`primaryRole` 未设置的成员会同步全部技能,因此只有 `manifest/roles.yaml` 并不限定任何人。`teamai init` 会让新成员选择角色;manifest 之前入队的成员需要执行一次 `teamai roles set <role-id>`。之后的 pull 会收回新 namespace 之外、由 teamai 下发的副本(本地改过的保留)。
Namespace 只对设置了角色的成员过滤:`primaryRole` 未设置的成员会同步全部技能,因此只有 `manifest/roles.yaml` 并不限定任何人。有了 `manifest/members.yaml` 这个缺口就被补上——未列出的成员只接收共享资源。`teamai init` 会让新成员选择角色;manifest 之前入队的成员需要执行一次 `teamai roles set <role-id>`。之后的 pull 会收回新 namespace 之外、由 teamai 下发的副本(本地改过的保留)。

要提醒一直没设角色的成员,可以用强制规则让 agent 检查 `teamai roles list` 并催促设置——强制规则成员不可删除:

Expand Down
3 changes: 2 additions & 1 deletion skill-data/core/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ Generated: do not edit by hand. Regenerate with
- `teamai roles` — Manage team roles and resource namespaces
- `teamai roles init` — Create a roles manifest for the team repo (admin)
- `teamai roles list` — List all defined roles and your current role
- `teamai roles set <primary>` — Set your primary role (updates local config)
- `--json` — Print your effective roles and where they come from (assigned, default, local, none)
- `teamai roles set <primary>` — Set your primary role (updates local config; refused when manifest/members.yaml assigns roles)
- `--add <roles...>` — Additional roles to include
- `teamai roles add <id>` — Add a new role to the manifest (admin)
- `--namespaces <ns>` — Comma-separated resource namespaces (e.g. common,hai)
Expand Down
28 changes: 28 additions & 0 deletions src/__tests__/dashboard-distribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,32 @@ describe('dashboard distribution', () => {
expect((await rejection(saveRoles(config, { revision: stale, primaryRole: 'frontend' }))).status).toBe(409);
expect(await fse.pathExists(path.join(homeDir, '.teamai', 'config.yaml'))).toBe(false);
});

// The team assigns the roles (#1186): the page shows them, and a save here
// would be overlaid away on the next load.
describe('with roles assigned in the team repo', () => {
beforeEach(() => {
config.roleAssignment = { source: 'assigned', local: { additionalRoles: [] } };
});

it('says the roles are assigned, so the page can show them read-only', async () => {
expect((await readDistribution(config)).rolesAssigned)
.toEqual({ file: 'manifest/members.yaml', source: 'assigned' });
});

it('refuses a role save and a role preview, naming the file', async () => {
const save = await rejection(saveRoles(config, { revision: await revision(), primaryRole: 'backend' }));
expect(save.status).toBe(409);
expect(save.message).toContain('manifest/members.yaml');
expect((await rejection(previewDistribution(config, { primaryRole: 'backend' }))).status).toBe(409);
expect(await fse.pathExists(path.join(homeDir, '.teamai', 'config.yaml'))).toBe(false);
});

it('still saves the active projects, which each workspace still picks', async () => {
await saveProjects(config, { revision: await revision(), projects: ['pay'] });

const saved = YAML.parse(await fse.readFile(path.join(homeDir, '.teamai', 'config.yaml'), 'utf8'));
expect(saved.projects).toEqual(['pay']);
});
});
});
Loading
Loading