Skip to content

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

Merged
SaulMoro merged 9 commits into
Tencent:mainfrom
scpia:scpia/feat-let-designated-maintainers-assign-roles-ins
Oct 25, 2026
Merged

SaulMoro merged 9 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. The checkout record keeps the selection it delivered, and the revision fast path compares the roles in it — which ones, and whether the team assigned them: a workspace pinned to a tag does not move when main reassigns, so the revision alone would say "Already synced" and leave the new skills undelivered until --force.
  • 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 on the branch (18e8cdd, on top of upstream/main 6ebb31c) — 15396 passed, 67 failed. The failures are session-scoring, workflow-scoring, workflow-handoff, session-target-id (four files; judge tests that spawn the claude CLI, which exits 1 in this environment) and the two submodule-checkout cases; the same files fail identically on bare upstream/main 6ebb31c here, so they are environmental and unchanged by this PR. npx tsc --noEmit, npm run lint, npm run build and npm run test:e2e -- assigned-roles-1186 (7 pass) are clean on the rebased branch.

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

Fast path, built from 2fa1994: e2e/assigned-roles-1186 gained a case that pins a workspace to a tag (pull --team-ref pin-1186, unlisted member, teamai only delivered), commits dave: [backend] on main, and runs one plain pull. With the src/pull.ts change reverted and rebuilt, that case fails on not to contain 'Already synced'; with it, the pull does a full sync and delivers backend-only, and roles list --json reports ["backend"]. Unit: pull-skip-sync.test.ts gained the matching case (record selection differs, HEAD unchanged, full sync).

Fast path, assignment presence, built from 11f5574: the selection records rolesAssigned, and the gate compares only the roles part (roles + that flag). e2e/assigned-roles-1186 gained a case that removes members.yaml on main, pins a workspace to a tag (every namespace delivered), restores the file with the member unlisted, and pulls once: with the flag left out of the key it fails on not to contain 'Already synced'; with it the pull syncs, removes both role skills and roles list --json reports roles: [], source: default. The fixture skills carry frontmatter, so a withdrawal removes the copy rather than keeping it as unverifiable. Unit cases in pull-skip-sync.test.ts: roles differ → sync; roles became assigned → sync; only projects differ in a hand-edited config → still skips. The last one is what the earlier whole-selection compare broke in CI (push-active-namespaces, two cases, now passing locally).

roles list --json warnings, built from 18e8cdd: the load's log lines go to stderr for --json (setStderrOnly, put back afterwards). Fixture: members.yaml assigning alice: [frontend, ops] with only frontend declared, then a malformed members.yaml committed on main and fetched.

$ teamai roles list --json 2>/dev/null          # stdout
{"roles":["frontend"],"primaryRole":"frontend","additionalRoles":[],"source":"assigned","assignmentFile":"manifest/members.yaml"}
$ teamai roles list --json 2>&1 >/dev/null      # stderr
⚠ manifest/members.yaml assigns ops, which manifest/roles.yaml does not declare. Ignoring it; ...

With the malformed file: stdout {"roles":[],"primaryRole":null,...,"source":"default"}, both ⚠ lines on stderr. Both stdouts parse. Unit case in roles-assigned-cmd.test.ts: a warning emitted during the load lands on console.error, stdout parses, and the route is restored after the command.

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.
@scpia
scpia force-pushed the scpia/feat-let-designated-maintainers-assign-roles-ins branch from d12dbd9 to c045288 Compare October 17, 2026 13:13
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.
@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.

@SaulMoro

Copy link
Copy Markdown
Collaborator

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

@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 :)

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

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

@SaulMoro

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

@scpia this now conflicts with main in src/init.ts, after several init changes landed (#1263 among them). Could you rebase onto current main so it is ready when @jeff-r2026 reviews it? Nothing else is open from my side.

Nils Schiller and others added 6 commits October 24, 2026 17:27
…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.
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.
…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.
…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 force-pushed the scpia/feat-let-designated-maintainers-assign-roles-ins branch from 009d7bd to 34e42d3 Compare October 24, 2026 15:40
@scpia

scpia commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

@SaulMoro rebased onto current main (6ebb31c), now 34e42d3. Linear: six commits, no merge commits, force-pushed with lease.

Conflicts were import lines only (src/pull.ts, src/init.ts: refreshRoleAssignment beside the imports main changed) plus the placement of the new guide section after main's new repo.localPath paragraph in both languages. The three init paths still call promptForRoleProfile / refreshRoleAssignment as before (#1263's changes kept).

On the rebased branch: tsc, lint, build, and test:e2e -- assigned-roles-1186 (5) pass. The unit suite's 67 failures here are the judge tests that spawn the claude CLI plus the two submodule-checkout cases; bare upstream/main fails the same 67 in this environment, so that is local, not the PR. Details in the description.

@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] Invalidate the sync cache when assignments change independently of HEAD — src/pull.ts:1407: In a workspace pinned to a tag, fetching an origin/main reassignment changes the effective roles without changing the checked-out revision. Refresh updates the config, but the revision fast path still skips resource delivery, leaving the newly assigned skills absent until pull --force. Include the effective assignment in the persisted sync cache key.

All four earlier findings are resolved: config identity is preserved, init applies assignments before reconciliation, refresh clears roleUnresolved, and JSON mode suppresses migration notices.

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

Only the specified diff and its relevant context were read. No PR code was run, built, or installed.

…changed (Tencent#1186)

A reassignment in manifest/members.yaml arrives with the fetch. A pinned
workspace reads it from origin/<default>, so its checked-out revision does
not move, and the fast path reported "Already synced" with the newly
assigned skills undelivered until --force. A local selection change clears
lastPullRev; an assignment has no command to do that. Compare the record's
own delivered selection instead. A record without one, from an older CLI,
reads as unchanged.
@scpia

scpia commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Codex's fast-path finding is real and fixed at 2fa1994.

Confirmed: the "Already synced" gate compared revision, team ref, children fingerprint, targets and places, never the roles. In a workspace pinned to a tag, a reassignment on main arrives with the fetch while HEAD stays put, so the pull skipped delivery.

Fix: the checkout record already stores selection (deliverySelection(localConfig), written on both the full sync and the fast path, read by workspace children). The gate now compares it to the current selection after refreshRoleAssignment, alongside the children fingerprint. A record without one (older CLI) reads as unchanged, so nothing resyncs on upgrade. No new state key.

Tests: an e2e case pins a workspace to a tag, assigns the member on main, pulls once; it fails on Already synced with the pull.ts change reverted and passes with it. A unit case in pull-skip-sync covers the same gate. Record in the description.

@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] Include assignment presence in the sync cache — src/pull.ts:1758: In a tag-pinned workspace with no local role and no declared hai role, adding members.yaml with default: [] changes delivery from unfiltered to shared-only. However, deliverySelection() omits roleAssignment, so the recorded selection still matches. Plain pull reports “Already synced” and retains previously delivered role-scoped skills until --force. Persist and compare the assignment/filtering state too.

The four earlier non-cache findings are resolved. The earlier pinned-workspace reassignment scenario is fixed when the role list changes; the filtering-state transition above remains uncovered.

The description documents sufficient testing, including E2E and built real-CLI records. Additional provider/agent coverage is explicitly deferred to CI; older recorded commits are not blocking.

Only the specified diff and its relevant context were read against the trusted base criteria. No PR code was run, built, or installed.

@SaulMoro SaulMoro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during triage.

Checked at 2fa1994 with the built CLI against a build of the merge-base (6ebb31c, current main). Assigned roles, unlisted members on default, reading from origin/<default> instead of the clone's working tree, and leaving the overlay out of the saved config all behave as described. The tag-pinned reassignment fixed in 2fa1994 works when the role list changes. tsc, lint and the PR's own test files pass. Three things block.

[P1 blocking] The E2E check is red, and the cause is this PR. E2E (fork-safe, no credentials, shard 1/3) fails two cases in push-active-namespaces.test.ts, both sends an edit back to the project it came from under --role, and a new skill to the role (missing-projects-manifest and unknown-active-project contexts), with ENOENT on .claude/skills/a-skill/SKILL.md at line 1173. Locally the file is 91/93 on this head and 93/93 on the merge-base. The new selection comparison at src/pull.ts:1758 now treats the project switch from svc-a to svc-b as a changed selection. The pull therefore runs a full sync and correctly withdraws the svc-a copy the test then tries to edit. The new behavior is right. The fixture should make its edit before the switch and pull, keeping the assertions that the edit goes back to its original project and that the new skill follows --role.

[P1 blocking] Adding members.yaml with default: [] does not reach a tag-pinned member who had no role. This is the CI review's latest finding, reproduced with the built CLI. The workspace is pinned to a tag, the member has no local role, and the team declares no hai role. After the first pull it holds backend-only, frontend-only and shared-only. Main then adds members.yaml with default: []. A plain pull prints Already synced and keeps all three skills, while roles list --json reports "roles": [] and "source": "default". pull --force removes them. deliverySelection() serializes the same role-less selection in both states, because it does not record that an assignment applies. Rolling out members.yaml is exactly when this happens, and the PR's point is that the member cannot keep what the team withholds. One fix: add rolesAssigned: z.boolean().optional() to DeliverySelectionSchema, set it from !!localConfig.roleAssignment, and decide how a record without the field compares.

[P1 blocking] roles list --json prints warnings on stdout ahead of the JSON. src/roles-cmd.ts:184-194 loads the overlay before writing JSON, and the overlay's warnOnce goes through the normal logger to stdout. Both cases below exit 0 with empty stderr, and stdout does not parse:

members.yaml assigns a role roles.yaml does not declare:
⚠ manifest/members.yaml assigns unknown, which manifest/roles.yaml does not declare. Ignoring it; fix one file or the other in the team repo.
{ "roles": [], "source": "assigned", ... }

members.yaml malformed (members as a list):
⚠ manifest/members.yaml could not be read: Invalid members manifest: members: Expected object, received array
⚠ Roles are assigned in manifest/members.yaml while that file exists, so this run holds none. ...
{ "roles": [], "source": "default", ... }

A hook that runs JSON.parse on stdout fails in exactly the cases the PR says should still report the no-role state. Setting setStderrOnly(true) before autoDetectInit in JSON mode and restoring it afterwards, as other JSON commands do, keeps the warnings and leaves stdout parseable. The legacy hai migration notice is already fixed. A listed member and an unlisted member both give clean JSON.

The CI review at this head reports one [P2 non-blocking], the sync-cache finding above. It says the four earlier findings are resolved, and so is the earlier fast-path finding when the role list changes. We agree with its diagnosis and with the resolutions; we rate the cache finding blocking for the reason above. It read only the diff, so it did not catch the red E2E job or the JSON output. CI jobs at this head: E2E (fork-safe, no credentials, shard 1/3) and its aggregate E2E (fork-safe, no credentials) fail as described above; the rest pass, and the GitHub-provider job is skipped.

No other open PR implements role assignment. Not checked here: the GitLab/GitHub provider fixtures, Windows and Linux runs, and the dashboard in a browser (its API was checked).

…not (Tencent#1186)

The checkout record's selection now says whether manifest/members.yaml
assigned the roles: a role-less member goes from every namespace to the
shared root when the file appears, with no role list changing and no
revision moving in a pinned checkout.

Compare only the roles part of the selection. The rest changes through
commands that clear lastPullRev, and comparing it all made a config edited
by hand resync where it kept the fast path before, which the
push-active-namespaces e2e relies on.
@scpia

scpia commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Both addressed at 11f5574.

Codex, assignment presence. Confirmed: deliverySelection carried the role list but not whether it was assigned, so a role-less member's switch from every namespace to the shared root (the file appearing with default: []) matched the recorded selection. The selection now records rolesAssigned, and the gate compares roles plus that flag. E2E: pinned workspace, file removed then restored on main, one plain pull; fails on Already synced without the flag in the key, syncs with it.

CI, push-active-namespaces shard 1/3. My own doing in the previous commit: comparing the whole selection made a pull resync when the test switched projects by editing the config file directly, where real commands clear lastPullRev and a hand edit kept the fast path before. The gate now compares only the roles part, which is what members.yaml can change without a command. Both failing cases pass locally; a unit case pins the hand-edit behaviour.

One note for the record: the fixture's withdrawn skills are kept with the existing "local changes or unpushed files (or could not be verified)" warning, the same line the description's first real-CLI run showed on a plain reassignment and on a local roles set change with no members.yaml. Pre-existing withdrawal behaviour, not touched here; the e2e asserts the sync ran and judged them.

@github-actions

Copy link
Copy Markdown

No new findings in the specified diff.

All six earlier findings are resolved: config identity is preserved, init applies assignments before reconciliation, refresh clears roleUnresolved, JSON mode suppresses migration notices, and the sync cache compares both effective roles and assignment presence.

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

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

@scpia

scpia commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

@SaulMoro one CI note on 11f5574: Lint & Test (Node 20 on ubuntu-latest) failed on a single test outside this PR, and I cannot rerun it from a fork.

session-sync-pull.test.ts > SyncManager.gitPull > merges a diverged session index and rebuilds it (pull.rebase=false) failed with ENOTEMPTY: directory not empty, rmdir .../b/.git/objects/pack: the test's afterEach rmSync racing a git process still writing the pack in the fixture repo. The same job passed on the previous head, Node 24 on ubuntu-latest passes the same suite on this head, the branch touches no session files, and the test passes locally in every full run here. All three e2e shards are green on this head, including the push-active-namespaces cases that failed before.

Could you rerun the failed job? Nothing else is open from my side.

@SaulMoro

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

@scpia Thanks. Re-checked at 11f5574. Two of the three blocking findings are fixed:

  • push-active-namespaces.test.ts passes 93/93 locally, and E2E shard 1/3 is green.
  • The tag-pinned default: [] case: 2fa1994 printed Already synced and kept the role skills. This head removes them. The assignment-presence and reassignment cases from the CI review both deliver now.

[P1 blocking] still open: roles list --json writes warnings to stdout ahead of the JSON. Nothing since 2fa1994 touches src/roles-cmd.ts. When members.yaml assigns a role roles.yaml does not declare, or is malformed, stdout still starts with the ⚠ lines and the command exits 0. A listing without a problem prints clean JSON. setStderrOnly(true) around the load in JSON mode, as doctor does at src/doctor.ts:835, would fix it.

  • [P2 non-blocking] The member guide (EN and zh-CN, lines 89-90) still says to run teamai roles set hai, which an assigned team refuses. skill-data/setup/references/manage-admin.md does not mention members.yaml.
  • [P3 nit] The withdrawal case in assigned-roles-1186 accepts the Kept skill warning. Its fixture skill has no frontmatter, so withdrawal keeps the copy and the case never shows a removal. A fixture with frontmatter would.

The CI review at this head reports no new findings and calls all earlier findings resolved. We agree except for the JSON output above: it read only the diff, and its "JSON mode suppresses migration notices" covers the old notice, not these warnings. Lint & Test (Node 20 on ubuntu-latest) failed on session-sync-pull.test.ts (ENOTEMPTY in afterEach). That test passes 27/27 locally at this head and at the base, and the PR touches no session code. I re-ran the failed job.

…ent#1186)

The load warns about an assigned role roles.yaml does not declare, or a
members.yaml it cannot read, and a hook parses stdout whole. Route every
log line to stderr for --json, as doctor --json does, and put the route
back afterwards.

The member guide and the admin skill now say that a team assigning roles
in manifest/members.yaml refuses roles set. The e2e skill fixture gains
frontmatter, so a withdrawal removes the copy instead of keeping it as
unverifiable, and the cases assert the removal.
@scpia

scpia commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

@SaulMoro all three at 18e8cdd.

  • [P1] roles list --json warnings on stdout: rolesList now wraps the JSON path in setStderrOnly(true) and puts the previous mode back in finally, so the load's ⚠ lines (undeclared assigned role, unreadable members.yaml) go to stderr and stdout is the JSON alone. Verified with the built CLI on both cases, record in the description; unit case added. Exit code stays 0: the JSON carries the state (source: default, roles: []), and a hook that fails on a non-zero exit would otherwise lose the answer.
  • [P2] guides: member-guide.md (EN + zh-CN) marks roles set hai as the non-assigned case and links the assigned-roles section; skill-data/setup/references/manage-admin.md gained a paragraph on members.yaml. No other roles set guidance left outside the sections that already cover it.
  • [P3] fixture: the e2e skills carry frontmatter now. The withdrawal case asserts ['teamai'] and the reassignment case ['backend-only', 'teamai'], with no kept-copy allowance. Both pass; 7/7.

Thanks for the rerun.

@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] Apply legacy-role resolution when assignments disappear — src/config.ts:185: With no local role or project filter and roles.yaml declaring hai, deleting members.yaml upstream and pulling restores an empty selection without running migrateLegacyRoleConfig. That pull delivers every role namespace, while the next config load resolves only hai. Apply the same legacy-role resolution before delivering the refreshed, assignment-free config.

All six earlier findings are resolved, including both sync-cache cases.

The description documents sufficient testing: E2E results and representative built real-CLI runs. Additional provider/agent coverage is explicitly deferred to CI; older recorded commits are not blocking.

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

@SaulMoro

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

@scpia Thanks, checked at 18e8cdd. Nothing blocks from my side now, so I'm dismissing my Request changes. Approval and merge are @jeff-r2026's call, as this adds a new data model.

  • roles list --json now sends the load warnings to stderr. With the built CLI, a members.yaml naming an undeclared role and a malformed members.yaml both give valid JSON on stdout, with the ⚠ lines on stderr and exit 0. A listed member and an unlisted member are unchanged.
  • The guides: the member guide (EN and zh-CN) and manage-admin.md cover the assigned case.
  • The e2e fixtures carry frontmatter, so the withdrawal and reassignment cases now assert removal.
  • roles-assigned-cmd.test.ts (8/8), assigned-roles-1186 with push-active-namespaces (100/100), tsc and lint pass.

The CI review at this head raises one [P2 non-blocking]. When members.yaml is deleted upstream and the team still declares hai, the pull that drops the assignment delivers every role namespace, because it skips the legacy-role resolution the next config load applies. I read this as plausible from the description but did not run it. It is worth a look before merge. The rest of its summary matches what I saw. CI at this head is still running.

@SaulMoro
SaulMoro dismissed their stale review October 24, 2026 19:58

The blocking findings are resolved at 18e8cdd; see the latest comment. Approval is the maintainer's call.

@scpia

scpia commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

@SaulMoro Anything open from my site?

@SaulMoro SaulMoro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jeff-r2026 Approving and merging this one.

It is opt-in. Every new path is gated on manifest/members.yaml existing in the team repo, so a team that does not add the file sees no change in pull, push, init, roles set or the dashboard; the only addition it can notice is roles list --json. It is also a two-way door: the assignment is a runtime overlay and nothing is written to member configs, so it reverts cleanly if we decide against it. It has real value for teams that bound their agents by role, as in #1186.

The open Codex P2 (deleting members.yaml upstream delivers every role namespace for one pull when the team declares hai; the next pull corrects it) can go in a follow-up.

@SaulMoro
SaulMoro merged commit 775c032 into Tencent:main Oct 25, 2026
18 checks passed
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