Files
teamai-cli/docs/product-overview.zh-CN.md
Saul Moro 7c929a996b feat(env): team-declared secrets with member-local values (#875) (#880)
* refactor(entries): let a namespaced entry reader declare its own layout (#879)

An entry reader took its directory, file name, activation key and failure
wording from its EntryType. It can now declare them as an EntryLayout,
defaulting to entryLayout(type), which gives today's values. This lets a
later reader read env/secrets.yaml and env/<ns>/secrets.yaml activated by
resources.env. No behaviour change: env, hooks, MCP and models resolve and
report as before.

Part of #875.

* refactor(models): share the team values path and stdin reader with a second store

getTeamValuesPath takes the store directory (defaulting to models/teams)
and keeps its <team>-<hash>.json naming. The piped-stdin reader moves to
utils/prompt.ts as readStdin; the --api-key-stdin checks and messages stay
in the models command. No behaviour change.

Refs #879 (S2), #875

* feat(env): declare team secrets in env/secrets.yaml and show their state (#879)

A team repo can declare the secrets its members need, with no value, in
env/secrets.yaml and env/<ns>/secrets.yaml (key, optional description and
url). They resolve like env.yaml: active through resources.env, a namespace
entry replaces the root entry with the same key. The declarations are
absent, valid or failed; a broken file fails the secrets only, is reported
in secret wording by pull, env list and doctor, and env variables are still
delivered.

- env list and list env show each declared secret as environment or
  missing, never its value, --reveal included.
- doctor fails "Team secrets can be resolved" on a broken file, and its
  notes name env/secrets.yaml, not env/env.yaml, for an override or a key
  repeated in legacy mode (describeEntryNotes takes the reader's layout).
- push lists a changed secrets.yaml, in single-repo mode too.
- docs/designs/team-secrets.md and .zh-CN.md start here, with the #818
  boundary; usage guide, product overview, multi-project, management
  backend and the admin reference updated.

Part of #875.

* feat(env): declare a team secret with env add --secret (#879)

env add <key> [value] --secret [-d] [--url] [--role|--project] writes
env/secrets.yaml or env/<ns>/secrets.yaml with no value; a value is
rejected and never printed. env remove removes a declared secret when
env.yaml does not set the key, and --secret removes only the declaration
for a key both files carry. entryNamespaceFromFlags takes a layout so
the --role warning names secrets.yaml.

* feat(mcp): keep the entry an earlier pull wrote when a declared secret is missing (#879)

The session-start pull inherits the agent's environment, which often lacks
the member's shell export, so it removed the MCP entry the interactive pull
had written. A server whose only missing variables are declared secrets now
keeps its entry and ownership record; it is removed when it leaves mcp.yaml
or by removeAll. A failed secrets declaration keeps managed MCP state.

* feat(env): keep a member's value for a team secret and resolve it in MCP servers (#879)

teamai env set KEY (hidden prompt, --stdin, --from-env VAR) and env unset KEY
store a member's value per team repo in ~/.teamai/secrets/teams/, 0600,
accepting only keys the scope declares as secrets. ${VAR} in MCP servers
resolves a declared secret from that value, then from the member's own
environment, which leaves out values a teamai env.sh exported (Conflict 10).
A key declared as a secret and set in env.yaml resolves as the secret: its
repo value leaves env.sh, the env backup, both list renderers and doctor's
expected set (Conflict 13). A failed declaration leaves env.sh and the backup
as they are (Conflict 14). env list shows team.

* docs(env): document team secret values, storage and MCP resolution (#879)

* feat(env): set one value for a team secret for every team on the machine (#879)

env set/unset --global keep the value in ~/.teamai/secrets/machine.json.
Resolution becomes team value > machine value > the member's environment,
for MCP servers and env list (state `global`). In a scope --global still
accepts only a declared secret; outside any scope it accepts any valid key
and notes that no team declares it yet.

* docs(env): document the machine value for team secrets (#879)

* feat(mcp): keep project MCP configs with resolved values out of git (#882)

A project-scope MCP config that carries a resolved ${VAR} sat untracked
and unignored in the business repo, one `git add -A` from committing the
token. After the reconcile writes such a file and git would track it,
teamai lists its path in the clone's .git/info/exclude inside a marked
block (resolved via `git rev-parse --git-path`, so linked worktrees and
submodules work). The committed .gitignore is never touched; an ignored
path or a config with no resolved value adds nothing; dry runs write
nothing. Project-scope uninstall removes only teamai's block, and doctor
reports such a file git would still commit.

The hook sits after the appliers in reconcileMcpForConfig, outside
desiredMcpForTarget/applyJson/applyCodex, so it merges cleanly with #880.

* feat(env): name a missing team secret and the command that sets it (#879)

Interactive pull, mcp list, env list and doctor print one line per declared
secret with no value, naming the MCP servers that use it, `teamai env set KEY`
and the declared url. doctor prints it as a note and no longer fails the MCP
delivery check for a server skipped only for a missing declared secret. Pull
and doctor also note a kept entry that may hold an old value and a key
declared as a secret and set in env.yaml. The silent pull prints nothing.

The lines come from one envAdvisories() result that later pull notices extend.

* docs(env): document the missing-secret advisory in pull, doctor and the lists (#879)

* fix(uninstall): count the .git/info/exclude block in the removal plan (#882)

The plan now records whether the project's .git/info/exclude holds
teamai's MCP config block (gitExcludeBlock). It counts toward
isPlanEmpty, is listed in the summary and dry run, and gates the
removal, so a plan whose only teamai leftover is the block removes it
instead of reporting "Nothing to uninstall".

* feat(env): run a command with the directory's team env and secrets via env exec (#879)

* docs(env): document env exec, what the command can reach and the ANTHROPIC_* caveat (#879)

* fix(tests): isolate Claude config dir from model tests

(cherry picked from commit e567ac31e5)

* fix(env): warn when env add --secret updates a declaration an unknown key keeps undeclared (#879)

* feat(env): tell agents at session start which team secrets exist and to run their CLIs through env exec (#879)

* docs(env): teach agents to use team secrets through env exec and leave values to the member (#879)

* feat(env): resolve plain env variables in one order, with a member override (#879)

The environment no longer overrides an env.yaml variable in MCP servers,
env exec or env.sh. A member sets their value for a team with
`teamai env set KEY`; an interactive pull and doctor say when an export
differs and is ignored. env.sh exports a literal override and leaves out a
--from-env one; doctor's env delivery check expects the same.

* docs(env): document the variable order and the member override (#879)

* fix(mcp): keep the exclude block until every MCP config is clean (#882)

- uninstall keeps a repository's .git/info/exclude block while a config in
  it could not be parsed and still holds teamai servers, and warns
- uninstall finds and removes the block in nested repositories holding an
  MCP config, across every worktree
- the block opens at the last start marker, so an orphaned start never
  pairs with a later block's end and takes the member's lines
- doctor counts only servers the ownership manifest records, not a member's
  own server under a team name

* fix(env): discount an old env.sh export in every later command (#879)

A shell opened before a pull keeps the values env.sh exported then. Only
the process that rewrote env.sh discounted them, so the next command read
the team's old token as the member's own (Conflict 10). Each env.sh now
keeps env.sh.exports.json beside it: SHA-256 of KEY=VALUE for the last 20
values per key, mode 0600, never a value. memberEnvironment discounts any
recorded export, which replaces the in-memory snapshot.

* fix(uninstall): remove the exclude block only once its files are proven clean (#882)

A missing or unreadable managed-mcp.json made the MCP cleanup return early
without reporting anything, so uninstall removed the block while .mcp.json
still held the resolved token.

Uninstall now inspects every path the block protects after the cleanup. The
block goes only when each one is missing, or parses and holds none of the
team's servers that need a resolved ${VAR}. Anything it cannot check keeps
the block, with a warning naming the file. This replaces the leftInPlace
report from the reconcile, which the check subsumes.

* fix(mcp): protect every project MCP config holding a resolved value (#882)

Pull, doctor and uninstall each skipped a case they had not inspected and
treated it as safe. Now:

- pull lists a config in .git/info/exclude whether or not it delivered to
  it this run: a disabled or undetected tool's file, a team with automatic
  delivery off, an unreadable mcp.yaml (any teamai entry counts), a failed
  write to another tool's config, and a lost ownership manifest (the
  resolved value found in the file)
- doctor checks the same files, including one that does not parse, and
  counts a git error as a failure
- git check-ignore failing inside a repository is no longer read as "not
  tracked": the path is excluded anyway, or teamai warns with git's error
- uninstall also keeps the block while a file contains the value (8+
  characters, not a path or the login name) of a variable still set in the
  environment, which finds a server since dropped from mcp.yaml

* refactor(env): resolve a scope's env once per command (#879)

resolveTeamEnv (src/env-resolution.ts) reads env.yaml, secrets.yaml,
both value stores and the env.sh exports once. buildVarTable, the MCP
reconcile, envAdvisories, env exec, doctor and pull take that result:
an interactive pull resolves once per scope in its env stage and reuses
it for MCP and the advisories (was up to four secrets.yaml reads).

- Variable and secret values move out of resources/secrets.ts, which now
  holds declarations only (R3); one StoreResolution<V> and storeEntry
  (R2); resolveSecretDeclarations takes { active } instead of a
  tri-state array (R4); reportMissingSecrets lives in env-advisories (X1).
- ENV_KEY_RE moves to resources/env-key.ts, so env.ts imports
  SECRETS_LAYOUT statically (EV1).
- env list and `teamai list env` print one listing (env-listing.ts);
  a variable shows the value it resolves to and its source, team or
  env.yaml, like a secret (S1).
- A failed declaration is never "no secrets": the listings show no
  variable value then, --reveal included, and a server skipped for a
  missing variable is kept (S2).
- SecretState gains `unreadable` for a store that can't be read,
  handled with a never check (R1).

* fix(mcp): mcp list reports a broken secrets.yaml and exits 1 (#879)

A failed declaration read as no secrets, so every server variable came
from the raw environment, another team's token included, and mcp list
said 'all set' while pull kept MCP frozen. It now names the file, exits
1, and shows a server's variables as 'not resolved' (Conflict 14, C3).

* fix(env): env commands outside a scope say so and exit 1 (#879)

env list, add, remove, set and unset threw NotInitializedError with a
stack trace outside any teamai scope. They now print its message and
exit 1; env set/unset --global still work there.

* fix(env): env exec refuses a command without -- before it (#879)

Commander drops --, so `teamai env exec gh pr list --dry-run` read the
command's flag as teamai's and ran nothing. env exec now takes what was
typed after `exec`, and without -- before the command it says
"Put -- before the command: teamai env exec -- <command>" and exits 2.
teamai's own options may still come before --.

* feat(doctor): check that the member's secret values can be read (#879)

While teams/<team>.json or machine.json can't be read, every secret has
no value and MCP keeps what the last pull wrote, yet the MCP check
passed and only a pull warning said why. doctor now fails
'Your team secret values can be read' with the reason, for a scope whose
secrets or variables read those files.

* fix(env): name the unset variable a --from-env secret reads (#879)

The missing-secret line told the member to run `teamai env set KEY`,
which replaces the reference they chose. For a secret whose deciding
entry reads an unset variable it now says: KEY reads VAR, which is not
set. Set VAR, or run `teamai env set KEY [--global]` to replace the
reference.

* test(mcp): env unset keeps the entry, still owned, until a new value (#879)

env set, pull, env unset with nothing exported, pull: the entry keeps
the old value and the manifest still claims it, so the next value
replaces it instead of colliding with a user-owned server.

* refactor(env): expected env set/remove failures as values, one wording (#879)

- secretInput returns a tagged result; an unexpected stdin or prompt
  failure propagates instead of printing as a user error (E1).
- Every error path in env-commands uses fail() and invalidKeyMessage(),
  so each exits 1, env add's invalid key included (E2).
- removeSecret returns removed | absent | reported: env remove of a
  variable env.yaml lacks still says it is not found next to a broken
  secrets.yaml (E3).
- env add branches on --secret, not on a missing value (E4).
- env unset with declarations that can't be read no longer calls the key
  a variable (E5).
- "Secret is not declared" names the file and the next step (E6).
- Messages call the machine store "global value (every team on this
  machine)", matching --global and the env list label (R5).

* refactor(entries): EntryFailure always carries its layout (#879)

An optional layout fell back to the type's, so a failure site that forgot
it would word a broken secrets.yaml as env's. activeEntryNamespaces now
takes the layout and every failure sets it (N1).

* refactor(doctor): name entry resolution checks with their layout (#879)

The check names were looked up by the layout's message label, so a
wording change to a label would silently drop its doctor check. Each
entry set now carries its check name (DD1).

* refactor(secrets): report an unparsable value store by path only (#879)

- Drop src/utils/json-position.ts, a second JSON grammar kept only to
  print a line and column: a parse error now reports the path alone,
  which is what the spec asks (never the input), and a disagreement with
  JSON.parse can no longer leak the parser's quote (SS3).
- The unreadable-store error names what to check and the way out, and
  reads the error code without a cast (SS1).
- SecretStore is inferred from its schema (SS2).
- The design doc says an unreadable store leaves secrets 'unreadable'.

* test(secrets): typed fixtures instead of casts in the new tests (#879)

The LocalConfig and TeamaiConfig fixtures in env-exec, mcp-secrets and
secret-values are now checked against the types, so a new required
field breaks them instead of testing a shape production never has (T1).

* docs(env): env set takes variables, listings show sources, exec needs -- (#879)

- Usage guide: `env set` accepts an env.yaml variable without --global,
  not only a declared secret (D1/C2); `env exec` links the resolution
  order instead of "the order above", which the section never gave (C4).
- team-secrets.md intro states the variable order; a --from-env variable
  override leaves the key out of new shells entirely.
- Document the shared listing (variable source, `unreadable`, no values
  while the declarations fail), `mcp list`'s `not resolved`, the doctor
  check for an unreadable values file, and `env exec`'s exit 2 without --.

* fix(env): refuse env set/unset/list when the project config can't be read (#879)

Detection returned null for an unreadable project config, so env set fell
back to the user scope and stored the value for that team. Report the file
and why, exit 1, and write nothing, as pull and env exec do.

* fix(env-exec): exit 128 + signal for a command killed by SIGPIPE or SIGUSR1 (#879)

Re-raising SIGPIPE on teamai does nothing (Node ignores it) and SIGUSR1
starts the inspector, so teamai exited 0. Set the shell's exit code first
and re-raise only the signals Node ends on.

* fix(env-exec): don't send the command a second SIGINT on Ctrl-C (#879)

The terminal sends Ctrl-C and Ctrl-\ to the whole foreground process group,
so the command already has them; forwarding sent a second SIGINT, which
tools such as terraform take as force-quit. Ignore SIGINT and SIGQUIT while
the command runs and keep forwarding SIGTERM and SIGHUP.

* docs(env): exec signal handling and env set on an unreadable project config (#879)

* fix(mcp): judge MCP configs by disk and manifest, not current config (#882)

Pull, doctor and uninstall still decided "clean" from the current team
config in places. Now one function, resolvedValueEvidence, decides for all
three:

- a teamai-owned entry still in the file counts when its server has left
  mcp.yaml, as well as when it needs a resolved ${VAR} or mcp.yaml cannot
  be read (doctor no longer skips that case)
- targets include the built-in location of a tool the team dropped from
  toolPaths or moved
- doctor names a file two tools share once
- exclude updates take the existing acquireLock helper, re-read the file
  and write it atomically, so concurrent commands keep each other's paths
- uninstall inspects every worktree of each repository owning a block,
  including a nested repository's linked worktrees, and applies the
  manifest rule per worktree
- the kept-block warning names each file and why, such as the variable
  whose value matched

* fix(env): the env commands and env exec load config through --dry-run (#866)

#866 threaded { dryRun } through the loaders pull, push, status and list
use. The scope lookups this branch added did not take it, so
`teamai --dry-run env list|set|unset|add|remove` and `env exec --dry-run`
still persisted the legacy role migration, adopted a pre-#546 partition
or ran the self-mode bootstrap.

- resolveConfigForDir takes LoadOptions and passes them to both loaders.
- scopeHere/requireScope (env commands) and commandEnvironment (env exec)
  forward options.dryRun. Without the flag nothing changes: env exec still
  runs the migrations every command runs (spec #879 Conflict 12).

Five cases added to dry-run-load-path.test.ts; each failed before this
change (config.yaml rewritten, partition renamed).

* fix(secrets): name the team values file by the repo identity hash alone (#879)

Renaming team: in teamai.yaml changed <team>-<hash>.json and orphaned every
member's values. The secrets store now uses ~/.teamai/secrets/teams/<hash>.json;
the models key files keep their names. The path has not shipped, so there is
no migration.

* fix(env): keep a __proto__ env key through the store, MCP and env exec (#879)

ENV_KEY_RE accepts __proto__, but z.record dropped it from the value store,
assigning it on an ordinary object hit the inherited setter, and reading it
unset returned Object.prototype. The store, the MCP var table and the env exec
overlay are now built without a prototype (envTable), and the member's
environment and --from-env references are read as own keys (envValue).

* fix(env): env list loads config with --dry-run always (#879)

env list only reads, so like status and list since #866 it never migrates
the config it loads, with or without --dry-run. The dry-run load-path test
now runs env list without the flag, which is the case that used to migrate.

* revert: drop the #890 cherry-pick from this branch

2e15c3f6 was picked only to protect the local Claude config while this
branch's tests ran; #890 lands it on main on its own.

* fix(env): env exec applies no team env while the declarations fail (#879)

A legacy GITHUB_TOKEN in env.yaml plus a secrets.yaml that fails gave the
command the repo value, though the key may be a declared secret. Like
env.sh and the backup (Conflict 14), env exec now overlays nothing on a
failed declaration: the command gets the inherited environment, and
stderr names the failure.

* fix(fs): create the atomic-write temp file with the target mode (#879)

writeFileAtomic and writeJsonAtomic wrote the temp file with the umask's
default mode (0644 under umask 022) and narrowed it by chmod afterwards,
so a secret or model key was readable by other users until then. The temp
file is now opened exclusively with the target mode; the chmod stays for
the bits the umask removes.

* fix(env): env set --dry-run previews without asking for the value (#879)

`teamai --dry-run env set KEY` prompted for the value, or read stdin with
--stdin, before printing the preview, so it failed without a terminal. The
preview now comes first, and no value is read.

* fix(env): serialize env set and env unset on the values file (#879)

Two env set or env unset runs at once each read the store, changed it and
wrote it back, so the later write dropped the other's change. Both now go
through updateSecretStore, which takes <store>.lock (the acquireLock
helper), re-reads the file inside it and writes the result. A --dry-run
takes no lock.

* fix(env): keep a secret's stored value from becoming a variable override (#879)

Secrets and variable overrides share the team store. env set now records
kind: secret | variable from what the scope declares; variable resolution
uses only variable entries, secret resolution only secret ones, and an entry
without kind counts as a secret. env list flags an entry of the other kind
with the fix.

* fix(mcp): write an MCP config that holds a resolved value 0600 (#879)

writeJsonAtomic preserved an existing file's mode, so a 0644 .mcp.json or
~/.claude.json kept 0644 after teamai wrote a resolved secret into it. A
config holding a resolved ${VAR} value (a kept entry included) is now
written 0600; one without keeps its mode.

* fix(mcp): create the Codex config temp file 0600 (#879)

applyCodex wrote config.toml.<pid>.tmp with the umask's mode and chmodded it
after, so a resolved secret was briefly readable at 0644. Both Codex writes
now go through writeFileAtomic with mode 0600 (random temp name, created
with the mode, symlinked targets written through).

* docs(env): say env exec ignores a SIGINT or SIGQUIT sent to teamai alone (#879)

* fix(mcp): tighten an unchanged config that holds a resolved value to 0600 (#879)

* fix(env): mark what each env.sh exported so a value from one no scan finds is not the member's (#879)

* fix(env): pass on a SIGINT or SIGQUIT sent to env exec outside the terminal's foreground (#879)

* fix(env): keep marking what an env.sh exported before a rewrite dropped it (#879)

* docs(env): say a SIGINT sent to a foreground env exec alone is not passed on (#879)

* fix(secrets): name the team values file by the configured team repo URL, not teamai.yaml's repo: (#879)

A copied or hostile team repo could claim another team's repo: and receive
that team's stored values. The secrets file now hashes the URL from the
member's own config, normalized so ssh, https and credentialed forms match.
The models key store keeps its naming (#894).

* fix(env): remove what a teamai env.sh exported from env exec while the declarations fail (#879)

An invalid secrets.yaml left the inherited environment untouched, so a
shell that had sourced an env.sh still passed the repo's GITHUB_TOKEN to
the command. Every inherited value the member-environment rule discounts
is now removed, named by key on stderr; the member's own exports stay.

* fix(mcp): never write a declared secret into a project MCP config git tracks (#879)

.git/info/exclude (#882) stops git add, not a file git already tracks. For
such a file the server is skipped for that tool and an entry an earlier pull
wrote stays as it is; pull warns, mcp list shows it as withheld and doctor
fails the delivery check, each naming the file and git rm --cached.

* fix(env): leave no duplicate declaration of a key env add --secret or env remove edits (#879)

A key declared twice fails every read of secrets.yaml, and both commands
edited only the first declaration. env add --secret now updates the first
and removes the rest; env remove removes every one; both say how many.

* test: keep the real normalizeRepoUrlForCompare in utils/git mocks the secrets store reaches (#879)

* fix(env): keep the port in the URL that names a team's secrets file (#879)

normalizeRepoUrlForCompare drops explicit ports, so two team repos on one
host with different ports shared one values file and one team's secret
reached the other. The file is now named by the URL's scheme family,
lowercased host, non-default port and path; only credentials, the ssh user,
a trailing .git and slashes are dropped. The scp form and the ssh URL of a
repo still share a file; its ssh and https URLs no longer do.

The utils/git mocks that kept the real normalizeRepoUrlForCompare for the
store are no longer needed and are reverted.

* fix(mcp): skip the .git/info/exclude write while another command holds its lock (#882)

After the 2.5 s wait for the exclude file's lock, updateExclude wrote without
it, so two writers could drop each other's pattern and leave a plaintext MCP
config committable. It now writes nothing and reports 'locked': pull warns that
the file is not excluded yet and to run `teamai pull` again (doctor's exclude
check keeps reporting it meanwhile), and uninstall keeps the block and warns.

* fix(uninstall): keep an exclude entry unless its MCP config is proven free of teamai's servers (#882)

Uninstall judged a protected file clean from the current mcp.yaml, manifest
and resolvable values, so with the manifest lost, the server gone from
mcp.yaml and its value unset, a plaintext token looked like the member's own
server and the exclusion went. It now fails closed and works per entry: a
pattern goes only when its file is gone, holds no server, or holds none of
teamai's servers with managed-mcp.json still there to say what teamai wrote.
A kept entry is named with its file, why, and how to clean it by hand, since
a rerun of uninstall finds no config after a full uninstall.

* fix(env): keep http and https team repos in separate secrets files (#879)

The store identity mapped https and http to one `http` family and dropped
each default port, so `http://host/team.git` and `https://host/team.git`
read one file: if the two endpoints serve different repos, one team got the
other's stored secrets. The identity now keeps the scheme, still dropping
443 and 80; the ssh forms (`ssh://`, `git+ssh://`, `ssh+git://`, scp) stay
one family.

* fix(env): strip teamai env.sh exports in env exec when the project config can't be read (#879)

With an unreadable project config, env exec passed the inherited
environment unchanged, so a value another scope's env.sh exported (a legacy
token, a member override) reached the command while the warning said no team
values were applied. It now removes what the member-environment rule
discounts (env.sh file, record or marker, the env.sh beside the unreadable
config included), keeps the member's own exports, and names the removed keys,
never values, as the failed-declaration path does. With no config at all the
inherited environment is still passed as is.

* fix(mcp): exclude a project MCP config from git before writing a resolved value into it (#882)

Pull listed the file in .git/info/exclude only after writing the plaintext,
and a failed exclusion only warned, so the secret-bearing file stayed
eligible for git add -A. The exclusion now comes first; when it cannot be
established (exclude file or .git/info not writable, lock held past the
wait, file already tracked, git error) the file is left as it was and the
warning names the reason and the fix.

* fix(mcp): report a server withheld from a file git would commit in mcp list and doctor (#882)

* fix(secrets): keep the ssh user in a team's values-file identity (#879)

alice@host:team.git and bob@host:team.git can be different repos in each
user's home; they no longer share one values file. The scp and ssh:// forms
of one user, host, port and path still match; http(s) credentials are still
dropped.

* fix(env): overlay and remove env exec keys case-insensitively on Windows (#879)

Windows environment names are case-insensitive, so a declared api_url left an
inherited API_URL in place (Node keeps the first name of a case-folded pair)
and a removed secret survived in another case. On win32 a key now replaces or
removes every case variant; elsewhere nothing changes.

* fix(mcp): report a tracked file on a dry run and a withheld server already installed (#882)

Backports #880's merge 0d9f7fa7: a dry run (doctor, mcp list) names a
tracked file before any pull has listed it, mcp list reports withheld for a
server an earlier pull installed, and doctor's withheld note carries the
exclusion's own fix instead of the pull --force advice.

* fix(mcp): name a tracked MCP config before an unwritable .git/info/exclude (#882)

A tracked file needs `git rm --cached` whatever else is wrong, so
ensureExcludedFromGit checks gitTracks before the writability check, on a
pull and a dry run alike, and lists nothing for it.

* fix(mcp): take a project MCP config's exclude line back out once it holds no resolved value (#882)

A pull that lists a config in .git/info/exclude and then writes no value
into it (it does not parse, a member's server holds the team's name, the
write fails) removes the line it added. After a pull or `teamai mcp
remove`, a line whose configs are proven clean in every worktree, by the
proof uninstall uses (moved to mcp-reconcile.ts), is removed under the
lock; one not proven clean stays. A config listed before its write is
listed again after it, so a concurrent uninstall that dropped the line
between the check and the write does not leave the value unprotected.

* fix(secrets): tell an scp path in the ssh user's home from an ssh:// path from the root (#879)

git@host:acme/team is relative to the ssh user's home, ssh://git@host/acme/team
is absolute; on a plain ssh host they can be different repos, yet they shared
one values file. An scp path starting with neither / nor ~ is now keyed as
~/path, the path ssh://host/~/path names; host:/abs and ssh://host/abs still
match. The scp form without a user (host:path) is now read as ssh too.

* fix(mcp): judge a project MCP config by the manifest as it stood before the pull rewrote it (#882)

A pull whose manifest was lost before it ran recreates managed-mcp.json
while reconciling, so the clean-file proof read the new record and took
the exclude line out of a file still holding a teamai server that left
mcp.yaml with its variable unset. The proof now uses this worktree's
manifest as read before the reconcile.

* fix(mcp): log a rolled-back exclude line at debug level (#882)

A line this pull added and took back out, because it wrote no resolved
value into the file, was reported as removed although the member never
saw it added. Only removing a line an earlier run added stays at info.

* fix(mcp): keep a shared exclude line while another worktree's config holds a server (#882)

A pull or `teamai mcp remove` judged every linked worktree's MCP config
with today's definitions and values. Once a server's ${VAR} became a
literal, and the value was no longer set, a pull in worktree A took
worktree B's stale token-bearing entry for clean and removed the shared
/.mcp.json line, so `git add -A` in B staged the token.

These commands now release a line only when the current worktree's file
passes the full proof and every other worktree's file is missing or holds
no MCP server. `teamai uninstall` keeps its full proof in each worktree.

* test(mcp): build the other worktree from the real temp path so the test checks what it names (#882)

* fix(env): strip teamai env.sh exports from env exec with no config or an HTTP scope (#879)

Neither path applies team values, yet both passed on whatever a sourced
teamai env.sh exported, another team's credentials included. Remove every
inherited value that is not the member's own, as the unreadable-config
path does, and name the removed keys on stderr.

* fix(secrets): name a team's values file by the full SHA-256 digest (#879)

The file name is the boundary between teams, and 40 bits let a hostile
team search for a repo URL whose name matches another team's file and
read its values. Nothing has shipped, so no migration.

* fix(env): name the env.sh provenance marker by the full SHA-256 of its data home (#879)

* test(push): push publishes the secrets.yaml env add --secret leaves (#879)

* fix(uninstall): list no worktrees for a project root that no longer exists (#882)

buildRemovalPlan now lists every worktree to find teamai's exclude blocks,
outside the MCP cleanup's try. simple-git throws synchronously for a missing
directory, so a project uninstall whose root is gone crashed; main's #878 test
caught it after the merge.

* fix(secrets): keep the URL query and fragment in a team's values file name (#879)

* fix(mcp): treat an empty, unparsable or tool-less managed-mcp.json as no record (#882)

The clean-file proof took any managed-mcp.json on disk as teamai's record,
so an empty or truncated one let a pull, `mcp remove` or uninstall judge a
file still holding a stale secret-bearing entry clean and drop its exclude
line. A file now counts as recorded only when the manifest parses and holds
an entry for that tool's file. A project record teamai empties stays as []
so a file left with only the member's own servers can still be released.

* fix(env): compare exported, recorded and marked keys case-insensitively on Windows (#879)

* fix(uninstall): list no worktrees for a project root that no longer exists (#879)

* fix(mcp): keep the exclude line of a config this pull wrote when a later step fails (#882)

* fix(mcp): read a check-ignore error as unsafe unless ls-files proves the config untracked (#882)

* fix(mcp): report withheld only for targets delivery would write the server to (#882)

* docs(mcp): describe the .git/info/exclude block in the setup skill and the stricter git check (#882)

* fix(mcp): keep the exclude line of an entry a pull wrote with a resolved value after its definition turns literal (#882)

* fix(mcp): judge a nested repository's linked worktree config by its sibling's tool (#882)

* feat(mcp): record the project MCP configs a pull wrote a resolved value to in managed-mcp-files.json (#882)

* fix(mcp): keep protecting a config a pull wrote under a toolPaths mapping the team has since changed (#882)

* fix(mcp): keep a config's exclude line past the pull that rebuilt its lost record (#882)

* test(mcp): pin today's exclude rules for a missing, corrupt or locked managed-mcp-files.json (#882)

* docs(mcp): describe managed-mcp-files.json and the configs it keeps protected (#882)

* refactor(mcp): keep the #882 record edits off the lines #880 changes (#882)

* test(mcp): keep excluding a config whose entry a pull kept for a missing declared secret (#875, #882)

* fix(mcp): protect a config an older teamai wrote under a mapping an earlier teamai.yaml made (#882)

A teamai from before managed-mcp-files.json kept no record of the path it
wrote a resolved value to. Once the team changed that toolPaths mapping, no
pull visited the file. The first pull on this version now reads every
mcpProject path the team repo's history of teamai.yaml mapped, once per
worktree: a file under the project root that no current mapping or record
reaches, and that holds a resolved value, is listed in .git/info/exclude and
recorded. A git error leaves the read for the next pull; a shallow clone
reads the history it has.

* fix(mcp): keep a rebuilt record from persisting without its note of the file's other servers (#882)

When a pull rebuilt a lost managed-mcp.json and could not note the other
servers in the file (managed-mcp-files.json locked, an I/O error), it still
wrote the rebuilt record, so no later pull knew the record was rebuilt and a
stale server's line could go. The same manifest write now marks those records
unnoted: the file counts as having no record, so it keeps its line while it
holds a server, and the next pull notes them and clears the mark.

* fix(mcp): take back a managed-mcp-files.json record for a config the pull then did not write (#882)

A pull records a config before writing a resolved value to it. When the
write failed or did not happen (the file does not parse), the record stayed,
and once the mapping changed a config of the member's own at that path was
kept excluded while it held any server. The pull now takes back a record it
added for a file it did not write, as it does the file's exclude line; the
settle after records it again if the file holds a resolved value anyway.

* docs(mcp): describe the teamai.yaml history read, the unnoted rebuilt record and the record a failed write takes back (#882)

* refactor(mcp): keep the r3 edits off the lines #880 changes (#882)

* fix(mcp): also protect a config an older teamai wrote under a built-in default it has since changed (#882)

* fix(mcp): replace a symlinked Codex config instead of writing into the file it links to (#875, #882)

* fix(env): drop what a teamai env.sh exported from env exec when env.yaml or the values file fails (#875)

* fix(env): on Windows, match a declared secret to an env.yaml variable in any case (#875)

* fix(mcp): judge a project MCP config under a symlinked directory where the write lands (#882)

The appliers replace the file itself (tmp + rename) but follow its
directories. Every git check now judges realFilePath(file), the one
resolver the release keying already used: a directory linked out of any
repository no longer withholds the servers on git's "not a git
repository", and a tracked file there is named with both paths, with a
git rm --cached that works (git refuses the path through the link).

* docs(mcp): describe how a config under a symlinked directory is kept out of git (#882)

* refactor(mcp): keep realFilePath next to existingAncestor, without an import cycle (#882)

* refactor(mcp): key each worktree's targets with realFilePath, the one rule for where a write lands (#882)

* fix(mcp): judge a config under an earlier teamai.yaml mapping as a recorded file, not by today's records (#882)

* fix(mcp): have doctor check the configs earlier teamai.yaml mappings reach until a pull reads them (#882)

* docs(mcp): describe how a config under an earlier teamai.yaml mapping is judged, and doctor's check of it (#882)

* fix(mcp): record a config under an earlier teamai.yaml mapping that git tracks, and judge it once git no longer does (#882)

* fix(mcp): keep judging a recorded config for a tool the team moved while another tool still maps it (#882)

* docs(mcp): describe the tracked config an earlier mapping reached, and a moved tool's config another tool still maps (#882)

* fix(mcp): find a config an older teamai wrote under an earlier mapping another tool maps today, and judge it by that tool's records (#882)

* docs(mcp): describe the history read's configs another tool maps today (#882)

* fix(mcp): prove a shared config clean only while every tool that wrote a resolved value there has its record (#882)

* fix(env): on Windows, set and unset a key under the name the scope declares, in any case typed (#875)

* fix(mcp): judge a built-in location no mapping reaches today as an earlier-mapped file, and a shared config no pull recorded by every tool mapping it (#882)

* docs(mcp): describe the built-in location of a moved or dropped tool, and a shared config no pull recorded (#882)

* fix(env): on Windows, match a secret's declaration and stored value in any case (#875)

* fix(mcp): name the ignore rule that re-includes a config teamai just listed, instead of saying git tracks it (#882)

* fix(mcp): judge a moved tool's built-in location another tool maps for that tool too, and hold a config's line while no managed-mcp.json claims its servers (#882)

* docs(mcp): describe the no-manifest rule, a moved tool's built-in location another tool maps, and a re-including ignore rule (#882)

* fix(env): key a team's values by its repo URL when the configured remote is only an alias (#875)

* fix(env): on Windows, recognise a secret's env.yaml value under another case of its name in the environment (#875)

* fix(mcp): on Windows, keep an inherited value under another case of a team variable's name out of MCP servers (#875)

* fix(env): on Windows, replace and remove every stored entry under another case of the key (#875)

* fix(mcp): keep a tool's record as it was when its config does not parse, and take back each tool a write that did not happen recorded (#882)

* fix(mcp): mark the records a pull with no managed-mcp.json writes as unnoted until the servers no record claims are noted (#882)

* fix(mcp): have doctor judge a record marked unnoted like a missing managed-mcp.json (#882)

* fix(mcp): judge a config tools of different formats share in each of their formats before releasing its line (#882)

* fix(mcp): note the servers no record claims in the file of a tool whose record a pull writes first, as with no managed-mcp.json at all (#882)

* fix(mcp): take back a tool managed-mcp-files.json recorded before a write unless its own records hold a resolved value there (#882)

* fix(mcp): treat an installed tool's missing record as lost at every pull and in doctor, not only an empty managed-mcp.json (#882)

* fix(mcp): tighten to 0600 every project config the protection pass keeps out of git, not only the ones a pull writes (#879)

* fix(mcp): on Windows, fill a placeholder from the variable of the same name in another case (#875)

* fix(mcp): note the unclaimed servers under every format of a shared config, and pin an uninstalled tool's leftover config (#882)

* fix(mcp): count an uninstalled tool's missing record when no installed tool maps its file, and name a re-including .gitignore rule on a dry run (#882)

* fix(mcp): on Windows, match a placeholder to its secret in any case in mcp list and the missing-secret notice (#875)

* fix(mcp): scope a shared config's claims to the tools reading the same key, and settle its notes on every format's view (#882)

* fix(env): keep a file:// repo's .git suffix in its values file identity: team and team.git are two directories (#875)

* fix(env): on Windows, update and remove an env.yaml variable typed in another case (#875)

* fix(mcp): keep suspect an uninstalled tool managed-mcp-files.json lists as a writer, though an installed tool maps the file (#882)
2026-09-30 15:02:30 +08:00

11 KiB
Raw Permalink Blame History

TeamAI 产品概览

English | 简体中文

本文介绍 TeamAI 的产品架构、Agent 支持范围和核心能力。安装与日常使用流程请参阅使用指南。


产品架构

Team Execution × Team Context (beta) × Team Improvement (beta):

层 要解决的问题 当前 CLI 中的体现
Team Execution 让每个 Agent 按团队的方式工作 init / pull / push,skills、rules、agents、hooks、MCP、env
Team Context (beta) 让每个 Agent 理解整个团队 recall、learnings、代码知识图谱、teamwiki...
Team Improvement (beta) 让每一次执行都成为团队能力的积累 基于摩擦信号的经验分享、sessions、digest、dashboard...

功能概览

各 Agent 的能力支持情况见 README 的 产品概览。

Git 托管平台 —— GitHub · GitLab · GitCode · CNB · TGit · 私有 Git 服务。

分发策略

管理员一次配置、随 teamai pull 分发给每位成员的团队级设置:

能力 命令 作用
项目(Projects) teamai projects 将工作目录绑定到一个或多个逻辑项目,使其同步该项目的 skills、knowledge 以及隔离的 learnings。与角色正交。
角色(Roles) teamai roles 定义「角色 → 命名空间」映射,让每位成员只同步与自身角色匹配的 skills。
标签(Tags) teamai tags 给 skills / rules 打标签,成员只订阅自己需要的标签。
订阅源(Sources) teamai source 订阅额外的 skill 仓库——其他团队的公开仓库,或本团队内的公共/共享仓库;已订阅的 skills 会在 pull 时自动同步。

learnings 隔离:仓库 learnings/ 根目录对所有人共享;learnings/<project-id>/ 为项目私有。详见使用指南。

Team Execution

One Team. One Harness. Every Agent.

TeamAI 把 skills、rules、docs、hooks 统一存放在共享 Git 仓库,通过「push → 评审合并 → pull」的流程分发到每位成员的本地 AI 工具,并支持订阅其他团队或公共仓库的 Harness。

工作原理

teamai push → 创建分支 + MR → reviewer 审批合并
                                    ↓
           SessionStart hook → teamai pull → 同步到本地 AI 工具

分发内容

每类资源分发到每个 Agent:

资源 团队仓库中的位置 备注
Skills skills/<name>/SKILL.md
Rules rules/*.md
Docs docs/、docs/<namespace>/ 项目基础文档,默认不全量加载(渐进式披露)。没有任何角色或项目声明的 docs/<dir>/ 对所有人共享
Agents agents/<name>.yaml、agents/<namespace>/<name>.yaml
Culture culture.md 团队使命、价值观与协作准则——注入各 Agent 的 CLAUDE.md / AGENTS.md,成为每次会话的行事底色
CLAUDE.md claudemd/*.md
Env env/env.yaml、env/<namespace>/env.yaml 通用环境变量、团队级开关;不要直接放密钥的值:密钥在 env/secrets.yaml 中只声明、不写值
Hooks hooks/hooks.yaml、hooks/<namespace>/hooks.yaml
MCP mcp/mcp.yaml、mcp/<namespace>/mcp.yaml
Packages teamai.yaml 目前只支持 npm 包和 Claude 插件
Models models/models.yaml、models/<namespace>/models.yaml 团队模型配置,适用于 Claude Code、Codex、OpenCode、CodeBuddy 和 WorkBuddy;只有运行 teamai models switch 后才会改动 Agent

Skills、rules、CLAUDE.md、agents、env、hooks、MCP、models 和 docs 也可以放在 <namespace>/ 子目录下,只同步给在 resources: 中列出它的角色和项目(rules 与 CLAUDE.md 列在 knowledge: 下)。namespace 中的条目会替换根目录中同名的条目;docs namespace 不替换任何内容。配置了角色或项目后,根目录的 skills 只通过标签订阅送达成员。

文件格式与完整工作流见使用指南。

Team Context (beta)

Every agent understands how the team works.

除了分发 Harness,TeamAI 还把团队沉淀的经验和代码结构组织成可检索的知识库,让 AI 在需要时自动召回。

自动经验沉淀

Session 结束时,Stop hook 按摩擦信号对 session 评分——这些信号表明本次 session 踩到了值得记录的东西:你打断或纠正了 AI、拒绝了某次工具调用,或 AI 反复重试出错的工具。又长又顺(工具调用很多但没有摩擦)的 session 不会触发;真正较劲过的 session 才会。达标后 AI 会显示如下英文提示:

[teamai] This session may contain a problem worth documenting: you interrupted the AI twice, the AI retried failing tools 8 times.

Task: Fix duplicate project-level Hook injection

Consider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`).

提示会列出实际触发它的非零摩擦信号;如果能取得首个任务摘要,还会在脱敏、单行化后附上任务上下文。share 工作流(teamai skill get share)自动总结 session 经验并推送到团队仓库。每个 session 最多提示一次。团队可在 teamai.yaml 设置 sharing.contributeHint.enabled: false 关闭该提示(成员可用本地配置 contributeHintEnabled 覆盖),Stop hook 的其余功能不受影响。该提示还需要开启 recall(默认关闭),因为它指向的工作流只在 recall 开启时提供。同理,只读 HTTP 源上,或 teamai 配置文件存在但无法加载时,该提示从不出现;在未配置 teamai 的目录中也不会出现。

团队知识检索

让 AI 在执行任务前自动检索团队积累的知识。该功能默认关闭,需显式开启——团队可在 teamai.yaml 设 sharing.recall.enabled: true 作为默认值,成员也可本地覆盖:

teamai recall enable     # 开启:部署 teamai-recall 子 agent + 注入引导规则
teamai recall disable    # 关闭:移除子 agent 和规则
teamai recall status     # 查看生效状态(团队默认 + 用户覆盖)

通过子 agent 检索:开启后 teamai pull 会把内置的 teamai-recall 子 agent 部署到各 AI 工具的 agents/ 目录。AI 在任务开始前调用它——由子 agent 提取关键词、执行检索、读取命中的源文件,最后返回结构化的团队知识摘要。subagent 会先做相关性预检(teamai recall --check),当任务与团队知识无关时直接跳过检索。子 agent 底层调用的仍是 teamai recall 命令,也可手动直接运行:

$ teamai recall "port conflict"
[1/2] MR review caught a port-conflict bug ★1 [user]
Author: member-a | Score: 18.5 | Tags: troubleshooting, networking

[2/2] Deployment configuration best practices [project]
Author: member-b | Score: 12.0 | Tags: deploy, config
Matched: conflict | Missing: port

代码知识图谱

teamai import 将源码仓库解析为 teamwiki/ 下的结构化图谱,实现结构感知的检索:

teamai import --from-repo https://github.com/org/repo
teamai import --from-org myorg              # 批量导入所有仓库
teamai codebase --extract /path/to/repo     # 本地提取到 teamwiki/
teamai codebase --deep-enrich --project my-service --output /path/to/repo # 从提取结果生成深度知识文档
teamai codebase --reconcile --output /path/to/repo # 将产品文档映射到代码页面
teamai codebase --lint --output /path/to/repo # 检查本地提取的图谱

只要 extract 发现了组件,就会写入 teamwiki/evidence/code/<project>/_manifest.json(包括跳过 AI 增强或增强没有产出的情况),因此 --deep-enrich 可以接着跑。

图谱存储组件、接口、配置和跨仓库依赖边。teamai recall 会将图谱增强命中与 learnings 放到同一相关性尺度上排序。 当召回命中 codebase 页面时,结果会附带一行 Sources:,列出相关源文件路径,供 agent 直接作为代码改动的入口,无需重新探索代码库。

依赖边来自两条并行的提取轨道,重叠时以 AST 结果优先:

  • AST 轨(TypeScript/JavaScript、Python、Go、Swift):使用 WASM 版 tree-sitter 解析器,将 import/require、调用点、以及 TS implements 子句解析为精确的文件到文件 DEPENDS_ON / REFERENCES / IMPLEMENTS 边(标记为 code-ast,带置信度权重)。
  • 启发式轨(所有语言,含 Java/Rust):基于正则的提取(标记为 code-heuristic),同时覆盖 AST 轨未支持的语言。

WASM 解析器是纯 JavaScript 依赖,无需任何原生编译工具链。若因任何原因加载失败,提取会降级到启发式轨并记录一条 AST_UNAVAILABLE gap。设置 TEAMAI_SKIP_AST=1 可强制仅使用启发式提取。

Team Improvement (beta)

Every execution makes the entire team smarter.

Maintenance

随着 skills 和知识积累,可以把团队不再使用的内容清掉。teamai recall maintenance 会归档低置信度 learnings,并标出过时的 skills、rules 和 docs,供清理或更新:

teamai recall maintenance --prune --dry-run      # 预览
teamai recall maintenance --prune --archive      # 归档无用 learnings
teamai recall maintenance --update-quality       # 为过时 skills / docs 生成更新草稿

洞察团队实际如何使用 AI 工具,也是把 session 中的摩擦转化为共享 Skill、Rule 和知识的起点:

能力 命令 呈现内容
用量(Usage) teamai digest 团队周报——近 7 天成功率、对话、活跃时长、估算成本、缓存与纠偏趋势,以及历史累计数据。
会话(Sessions) teamai session save 脱敏的单会话摘要(工具序列、对话轮次、干预次数),喂给周报的 Session Highlights。
看板(Dashboard) teamai dashboard 统一的 Overview / Team Execution / Team Context / Team Improvement 界面,保留本机实时会话、近 7 天趋势、每会话估算费用,支持中英文及日间/夜间/跟随系统主题。
知识库健康(KB Health) teamai dashboard → Team Context / Team Improvement 保留各类型覆盖率、高频召回与沉默条目、最近召回月份统计、作者贡献及维护控制台;完整 /kb-report 报告仍可访问。