Skip to content

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

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

scpia wants to merge 8 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. The file is read from the team repo's fetched default branch (origin/<default>), not from the clone's working tree: a local edit, deletion or unpushed commit in the clone changes nothing, and a copy only the working tree has is absent. An HTTP source, or a repo with no fetched default branch, reads its working tree as before.
  • 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 — 11477 passed, 2 failed. Both failures are src/__tests__/submodule-checkout.test.ts (hook.teamai-post-checkout.command), which fail the same way on upstream/main (0da2be7) 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.

Codex follow-up, built from 1c754f0: a legacy role-less user config, roles.yaml declaring hai, no members.yaml.

$ teamai roles list --json 2>&1
{
  "roles": ["hai"],
  "primaryRole": "hai",
  "additionalRoles": [],
  "source": "local",
  "assignmentFile": null
}

Nothing but the JSON on either stream. teamai roles list without --json still opens with ℹ [dry-run] Would migrate legacy teamai config to default role profile: hai, and config.yaml gains no primaryRole either way.

Review follow-up, built from 009d7bd: manifest/members.yaml is read from origin/<default>. Fixture: a team repo assigning alice: [frontend], cloned as a workspace's team repo.

$ teamai roles list --json                       # baseline
{"roles":["frontend"],...,"source":"assigned"}
$ printf 'members:\n  alice: [backend]\n' > .teamai/team-repo/manifest/members.yaml   # edit the clone's copy
$ teamai roles list --json
{"roles":["frontend"],...,"source":"assigned"}
$ rm .teamai/team-repo/manifest/members.yaml     # delete it
$ teamai roles list --json
{"roles":["frontend"],...,"source":"assigned"}
$ teamai roles set backend
✖ Roles are assigned in manifest/members.yaml in the team repo. Yours: frontend. ...
exit=1

Then alice: [backend] committed in the team repo and one plain teamai pull: Team repo: 1 file(s) changed, and roles list --json reports ["backend"].

Unit: 8 cases added to role-assignments.test.ts against a real clone (edit, deletion, unpushed commit, working-tree-only file, fetch without checkout, empty file at the ref, HTTP source nested in a clone, repo without a remote). E2E: 1 case added to assigned-roles-1186 (5 pass).

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.

…gnated-maintainers-assign-roles-ins

# Conflicts:
#	docs/guide/distribution.md
#	docs/guide/zh-CN/distribution.md
@SaulMoro

Copy link
Copy Markdown
Collaborator

@scpia Codex left a remaining finding on clearing stale roleUnresolved. Mind taking a look?

A broken manifest/roles.yaml at load marks a role-less config roleUnresolved. When pull then fetches a fixed manifest and a members.yaml, refreshRoleAssignment applied the assigned role but kept the flag, so activeRoleIds still returned [] and role-scoped hooks, MCP entries and env were skipped on that pull. The refresh now drops the flag when it resolves an assignment, the state a load reaches, where the legacy migration is skipped once an assignment exists.
@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] Suppress migration notices in JSON mode — src/roles-cmd.ts:193: With a role-less user config, a declared hai role, and no members.yaml, the dry-run load emits “Would migrate legacy teamai config…” before the JSON, breaking consumers that parse the command output as JSON. Pass suppressMigrationNotice: true for --json.

All three earlier findings are resolved: refresh preserves config identity, all init paths apply assignments before reconciliation, and refresh clears stale roleUnresolved.

The description documents sufficient testing: 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.

@SaulMoro

Copy link
Copy Markdown
Collaborator

@scpia Codex left a new finding on suppressing the migration notice in JSON mode, and the branch now conflicts with main. Mind taking a look?

@SaulMoro
SaulMoro requested a review from jeff-r2026 October 21, 2026 07:54
@scpia

scpia commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

@SaulMoro i am on it :)

Nils Schiller added 2 commits October 21, 2026 10:14
…encent#1186)

The dry-run load previewed the legacy role migration before the JSON,
so a hook parsing the output broke. Suppress the notice for --json only.
…gnated-maintainers-assign-roles-ins-2

# Conflicts:
#	src/pull.ts
@github-actions

Copy link
Copy Markdown

No new findings in the specified diff.

All four earlier findings are resolved: refresh preserves config identity, all init paths apply assignments before reconciliation, refresh clears stale roleUnresolved, and JSON mode suppresses migration notices.

The PR description documents sufficient testing, including E2E results and representative built real-CLI runs. Extra provider/agent coverage is explicitly deferred to CI; the older commit named in part of the record is not blocking.

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

@SaulMoro

SaulMoro commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

@jeff-r2026 a short take to help with your review and your decision on whether to merge this feature.

Value. Yes. The PR makes a member's roles team data that changes through the forge's review, and teamai keeps no access list of its own. An unlisted member gets no roles instead of every namespace. roles list --json gives team hooks a supported way to read the effective roles instead of parsing teamai's config. #1186 comes from a team that already limits its agents by role with a PreToolUse hook. The bot's findings are resolved at 1c754f0, and the branch merges cleanly.

Gap. #1186 sets a narrow aim: an agent can no longer change its own role "with one command or one file write". Two one-step paths still do it. I found both by reading the code at 1c754f0 and did not run them.

  • loadRoleAssignments (src/role-assignments.ts) reads manifest/members.yaml from the clone's working tree. When the file is missing, the roles come from the local config again. So rm <team-repo>/manifest/members.yaml makes teamai roles set work again. A local edit to the file also counts.
  • The key is the username in the member's own config (src/config.ts:126). Writing username: <a member with wider roles> into that config gives the agent that member's roles.

Suggestions, smallest first.

  1. For git-backed team repos, read the file from the fetched remote ref (git show <remote>/<default>:manifest/members.yaml) instead of the working tree. A local edit, a deletion, or an unpushed commit then stops counting. Only what the forge merged counts. The change is a few lines in loadRoleAssignments.
  2. As a follow-up, on forge providers, compare username with the forge's identity during pull, and treat a mismatch as an unlisted member. The providers already have whoami helpers (ghAuthWhoami, gitlabWhoami, and others). The plain git provider stays as the docs describe it.

Neither change needs a new command or a new config key.

@scpia could you add suggestion 1 to this PR? It closes the first path, and it fits the PR's own rule that only absence of the file hands role selection back. Suggestion 2 can be a separate issue. The merge decision is Jeff's.

…nt#1186)

The assignments were read from the clone's working tree, so one local
edit, deletion or unpushed commit changed a member's roles. Read them
from origin/<default> as last fetched instead: only what the forge merged
counts, and a copy only the working tree has is absent.

An HTTP source has no git of its own and sits inside whatever repository
holds the config, so it keeps reading its materialized files. So does a
repo with no fetched default branch to prefer.
@scpia

scpia commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

@SaulMoro suggestion 1 is in at 009d7bd.

loadRoleAssignments now reads manifest/members.yaml from origin/<default> as last fetched (getHeadCommit to resolve the ref, getFileContentAtRev with a ./-relative path so single-repo mode resolves inside .teamai/). A local edit, a deletion or an unpushed commit in the clone changes nothing, and a file only the working tree has is absent. Verified with the built CLI; record in the description.

Decisions worth a look:

  • Pinned clones (pull --team-ref) also read origin/<default>, not the pinned ref, so pinning to a branch of one's own is not a way around it either. roles.yaml is still read from the checkout, since that is the manifest whose namespaces get delivered.
  • No fetched default branch (HTTP source, a repo without a remote, a clone of an empty remote): the working tree is read as before. There is nothing the forge merged to prefer, and failing closed there would strip every role from a fresh team with no manifest at all. An HTTP source is gated explicitly on repo.kind: its files sit inside whatever repository holds the config, so a git probe would have answered for the project's clone and read the file as absent.
  • Cost: one to two extra git spawns per config load (default-branch lookup is cached per path).

Suggestion 2 (whoami check on pull) left for a separate issue, as you proposed.

@github-actions

Copy link
Copy Markdown

No new findings in the specified diff.

All four earlier findings are resolved: refresh preserves config identity, all init paths apply assignments before reconciliation, refresh clears stale roleUnresolved, and JSON mode suppresses migration notices.

The PR description documents sufficient testing, including E2E results and representative built real-CLI runs. Extra provider/agent coverage is explicitly deferred to CI; older commits named in part of the record are not blocking.

Only the specified diff was reviewed against the trusted base review criteria. No PR code was run, built, or installed.

@SaulMoro

Copy link
Copy Markdown
Collaborator

@scpia thanks, 009d7bd cleanly closes suggestion 1:

  • loadRoleAssignments reads ./manifest/members.yaml from origin/<default> (getHeadCommit + getFileContentAtRev), with ./ keeping single-repo .teamai/ relative resolution working and pinned clones (--team-ref) still bound to the default branch.
  • repo.kind === 'http' and repos without a fetched default branch fall back to the working tree as expected, and an empty members.yaml on origin/<default> still fails closed via emptyManifestError.

@jeff-r2026 suggestion 1 from my note above is now in at 009d7bd and verified by unit + E2E tests. Ready for your review and merge decision (suggestion 2 — checking username against the forge's whoami on pull — remains a separate follow-up).

@scpia

scpia commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Shall I open a issue regarding the follow-up?

@SaulMoro

Copy link
Copy Markdown
Collaborator

@scpia if you want to start working on it right away, feel free to skip the issue and build the fix directly. If you finish before @jeff-r2026 reviews and merges this PR, you can push the commit right here; if this PR gets merged first, you can open a follow-up PR with it.

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

3 participants