Skip to content

feat(roles): assign roles in manifest/members.yaml instead of per member (#1186) - #1191

Open
scpia wants to merge 3 commits into
Tencent:mainfrom
scpia:scpia/feat-let-designated-maintainers-assign-roles-ins
Open

scpia wants to merge 3 commits into
Tencent:mainfrom
scpia:scpia/feat-let-designated-maintainers-assign-roles-ins

Conversation

@scpia

@scpia scpia commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1186.

A member's roles were a local setting, so a team that uses the effective roles to bound its agents has a bound the agent can lift by the same means the member has. This makes the assignment team data, reviewed in the team repo, and takes the local setting out of the decision while it exists.

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 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.

How it works

  • One overlay, every reader. The assigned roles are overlaid onto the config where it is loaded (applyRoleAssignment in config.ts), so pull, push placement, the namespace filter, per-entry role scopes, the dashboard and child workspaces all see the assignment with no change of their own.
  • Nothing persisted. The overlay is runtime-only (LocalConfig.roleAssignment); serializeLocalConfig writes the member's own on-disk selection back, so a save by projects set or a tag subscription never freezes a copy of an assignment into the config file.
  • Fail closed. A member the file does not list holds default — no roles unless the team says otherwise — and receives shared resources only, instead of the unfiltered sync a role-less member gets today. A members.yaml that cannot be read, or that names a role roles.yaml does not declare, leaves fewer roles, never more. Only absence of the file hands role selection back.
  • Resolved again where a load cannot reach. refreshRoleAssignment re-resolves it on a config already in memory, mutating that config — pull keys teamEnvs/instructions on config identity and reconciles hooks, MCP and env on the object its caller holds, so a copy would reach none of them. pull calls it after it refreshes the team repo, so a reassignment takes effect on the pull that fetches it rather than being recorded as synced and skipped until --force. init calls it after the role profile and before its own hook, MCP and env reconciliation, which would otherwise see a role-less config and install every role's hooks.
  • roles set refuses, naming the file and the roles held, and exits non-zero. So does init --role and the dashboard's role editor (409; the page shows the roles read-only). Active projects stay each workspace's own choice.
  • roles list --json reports { roles, primaryRole, additionalRoles, source, assignmentFile }, source one of assigned / default / local / none, so a team hook reads the effective roles instead of parsing teamai's config file.

The identity is the username in the member's own config, recorded by init from the git provider. The docs say plainly what this is not: a boundary against a process running as the member, which can edit that config or patch teamai. What it is: a role change that is a reviewed team decision with a history.

Design note: docs/designs/assigned-roles.md. Guide: docs/guide/distribution.md + zh-CN.

Testing

Unit: npx vitest run — 10843 passed, 2 failed. Both failures are src/__tests__/submodule-checkout.test.ts (hook.teamai-post-checkout.command), which fail the same way on origin/main (fe3b906) in this environment, unchanged by this PR.

New tests: role-assignments.test.ts (19), roles-assigned-cmd.test.ts (6), 3 cases added to dashboard-distribution.test.ts, e2e/assigned-roles-1186.test.ts (4).

E2E: npm run test:e2e -- assigned-roles-1186 — 4 passed:

  • the assignment overrides the local role and roles set is refused;
  • a reassignment lands on the pull that fetches it;
  • an unlisted member receives no role namespace;
  • init applies only the assigned role's hooks and writes no role to the config. With the init-side resolution removed, this case applies the backend hook to a frontend-only member, so it fails for the reason it was written.

Real CLI, built from d12dbd9, git provider, Claude.

teamai init <url> --provider git --agent claude --scope project --force against a team repo assigning alice: [frontend], with hooks/frontend/hooks.yaml and hooks/backend/hooks.yaml both declared in roles.yaml:

ℹ Roles assigned to alice in manifest/members.yaml: frontend
ℹ Applying 1 team hook(s):
ℹ   [fe-stop] echo frontend-hook

No role prompt, the backend hook not applied, and the saved config carries no primaryRole.

Workspace scope, members.yaml assigning alice: [frontend] while the config says primaryRole: backend:

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

$ teamai roles set backend
✖ Roles are assigned in manifest/members.yaml in the team repo. Yours: frontend. To change them, open a merge request against that file, or ask a code owner of manifest/ to.
exit=1

$ teamai pull --force
✔ [workspace] Synced 1 skills (all updated)
$ ls .claude/skills
frontend-only  teamai

$ grep primaryRole .teamai/config.yaml   # the refused set and the pull left it alone
primaryRole: backend

Then, after committing alice: [backend] in the team repo, one plain teamai pull (no --force):

✔ [workspace] Team repo: 1 file(s) changed
✔ [workspace] Synced 1 skills (1 new, 0 updated)
    new: backend-only
⚠ [workspace] Kept skill "frontend-only" (claude): it has local changes or unpushed files not in the team repo (or could not be verified).

The "Kept skill" line is the existing withdrawal behavior for a copy whose bytes cannot be verified: a local teamai roles set frontend → backend change on the same fixture, with no members.yaml at all, prints exactly the same line.

Not covered locally, left to CI: the gitlab/github providers and the Codex / CodeBuddy / OpenCode agents.

Not included, and not needed for this: sharing.roles.required in teamai.yaml (#1096 — the presence of members.yaml already decides fail-closed delivery), and a cached forge whoami as a stronger binding of the member key to the token.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Propagate refreshed roles to post-pull reconciliation — src/pull.ts:1279: refreshRoleAssignment() returns a new object, but only pullForScope receives it. If Alice changes from frontend to backend, subsequent hook/MCP reconciliation still receives the caller’s frontend config, retaining frontend-scoped entries and omitting backend ones on that pull. The replacement also breaks the identity-keyed teamEnvs and instructions lookups. Update the caller-visible config or return and propagate the refreshed object.

  • [P1 blocking] Apply assigned roles before init installs hooks — src/init.ts:255: The assignment branch returns only { additionalRoles: [] }, leaving init’s in-memory config without primaryRole or roleAssignment. Init passes that config directly to reconcileHooksForInit; activeRoleIds() therefore returns null (unfiltered). With alice: [frontend], init installs backend-scoped hooks too; an unlisted member likewise receives role-scoped hooks instead of none. Apply the runtime assignment before init’s role-sensitive operations, without persisting it.

The PR description provides sufficient testing evidence: E2E results and a representative built real-CLI run. Extra provider/agent coverage is explicitly deferred to CI. No PR code was executed.

scpia pushed a commit to scpia/teamai-cli that referenced this pull request Oct 17, 2026
Tencent#1186)

Review of Tencent#1191 found two places the overlay did not reach.

`refreshRoleAssignment` returned a new config, and only `pullForScope` held
it. The hook, MCP and env stages that run after a scope reconcile the object
`pull` itself holds, and they look up `teamEnvs` and `instructions` by that
object's identity. So a reassignment reached the resource sync but not the
reconcilers — which kept the old role's hooks, MCP servers and env — and the
two maps were keyed on an object nothing looked up again. It now mutates the
config in place and returns nothing.

`init` builds its config in memory and hands it straight to that same
reconciliation, before any load can overlay it. The assignment branch of the
role prompt returned no role at all, so the config arrived role-less, and a
role-less config matches every role-scoped entry: a member assigned
`frontend` had `backend`'s hooks installed at init, and an unlisted member
received role-scoped hooks instead of none. Every init path now applies the
assignment after the role profile and before reconciliation. Nothing is
persisted: the save keeps the member's own selection, which an assignment
leaves empty.

The e2e case added for init applies `be-stop` to a frontend-only member
without the fix, so it fails for the reason it was written.
Nils Schiller added 3 commits October 17, 2026 15:12
…ber (Tencent#1186)

A member's roles were a local setting: `teamai roles set` wrote them into that
member's own config, and nothing said who may hold which role. For a team that
also uses the effective roles to bound its agents, that bound is one the agent
can lift by the same means the member has.

`manifest/members.yaml` makes the assignment team data, reviewed in the team
repo like `roles.yaml` is, and the forge decides who may change it:

    version: 1
    members:
      alice: [frontend]
      bob: [backend, ki]
    default: []

While the file exists, a member's effective roles are the ones assigned there.
The roles are overlaid onto the config where it is loaded, so every reader —
pull, push placement, the namespace filter, per-entry role scopes, the
dashboard, child workspaces — sees the assignment unchanged. The overlay is
runtime-only: a save by any other command writes the member's own selection
back, so no stale copy of an assignment is left in the config file.

Nothing falls back to an unfiltered sync any more: a member the file does not
list holds `default` and receives shared resources only, and a file that cannot
be read, or that names a role `roles.yaml` does not declare, leaves fewer roles
rather than more.

`roles set` refuses, naming the file; so does `--role` at init and the
dashboard's role editor. `roles list --json` reports the effective roles and
their source (`assigned`, `default`, `local`, `none`) for team hooks, which
otherwise parse teamai's config file. `pull` re-resolves the assignment after
it refreshes the team repo, so a reassignment takes effect on the pull that
fetches it instead of the one after.

This is not a boundary against a process running as the member; it makes a role
change a reviewed team decision with a history.
…ncent#1186)

The re-resolution of manifest/members.yaml sat inside the try that reports a
failed team repo fetch, so anything it threw was reported as "Pull failed"
and took the rest of the scope's pull with it. It belongs after that block:
the refresh runs only once the fetch succeeded, and a failure of its own is
not a failure to fetch.
Tencent#1186)

Review of Tencent#1191 found two places the overlay did not reach.

`refreshRoleAssignment` returned a new config, and only `pullForScope` held
it. The hook, MCP and env stages that run after a scope reconcile the object
`pull` itself holds, and they look up `teamEnvs` and `instructions` by that
object's identity. So a reassignment reached the resource sync but not the
reconcilers — which kept the old role's hooks, MCP servers and env — and the
two maps were keyed on an object nothing looked up again. It now mutates the
config in place and returns nothing.

`init` builds its config in memory and hands it straight to that same
reconciliation, before any load can overlay it. The assignment branch of the
role prompt returned no role at all, so the config arrived role-less, and a
role-less config matches every role-scoped entry: a member assigned
`frontend` had `backend`'s hooks installed at init, and an unlisted member
received role-scoped hooks instead of none. Every init path now applies the
assignment after the role profile and before reconciliation. Nothing is
persisted: the save keeps the member's own selection, which an assignment
leaves empty.

The e2e case added for init applies `be-stop` to a frontend-only member
without the fix, so it fails for the reason it was written.
@scpia
scpia force-pushed the scpia/feat-let-designated-maintainers-assign-roles-ins branch from d12dbd9 to c045288 Compare October 17, 2026 13:13
@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] Clear stale roleUnresolved after resolving an assignment — src/config.ts:185: If a role-less config loads with a malformed roles.yaml, migration sets roleUnresolved: true. When that pull fetches a corrected manifest and a new members.yaml assigning frontend, refresh sets primaryRole but retains the unresolved flag. activeRoleIds() therefore still returns [], omitting frontend-scoped hooks, MCP entries and env on that pull. Clear the flag when the assignment is successfully resolved.

Both earlier findings are resolved: refresh now mutates the caller’s config, and all three init paths apply the assignment before reconciliation.

The description supplies sufficient testing evidence, including E2E results and a representative built real-CLI run. Extra provider/agent coverage is explicitly deferred to CI.

Only the specified diff was reviewed; no PR code was run, built or installed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] Let designated maintainers assign roles instead of each member setting their own

2 participants