Status: P0 + P1 + P2 + P3 implemented (issue #374 complete). P1 shipped as PRs #397 / #402 / #406 / #414 / #417 (partition routing) and #439 (P1-3 auto-migration). P2 (self mode slimming, #455) and P3 (constant functionization +
status --all) are below.
teamai's machine-local data currently lives inside the business repository. In a
real checkout <repo>/.teamai/ measured 18 MB — a team-repo clone (12 MB),
downloaded skill resources (4.1 MB), a search index (1.8 MB), plus config, state,
env, token, and docs. This causes three concrete problems:
- Workspace residue. Machine data pollutes the business repo working tree.
- Worktree / subdirectory blindness. teamai only looked at the cwd's own
.teamai/config.yaml, so running from a subdirectory found nothing, and a git worktree (which does not carry gitignored.teamai/) had no config at all. - Cross-project mixing. Global singletons under
~/.teamai/(dashboard,sessions,votes,usage.jsonl, ...) are hardcoded to one location, so multiple projects' data is indistinguishable.
The end goal (P1+) is to move machine-local data to ~/.teamai/projects/<slug>/
so the business workspace has zero residue, partitioned per project.
Git source clones live at ~/.teamai/source-repos/<sha256(repo.trim())>/repo/. All aliases of the same URL share this clone, revision, and pull TTL under the source lifecycle mutex, so an older alias cannot overwrite refreshed shared content. Their sibling last-pull.json records successful clone/pull time for the 24-hour TTL, independent of installation manifests. Failed pulls retain only that repository's cached clone and do not advance its timestamp. source add, browse, list, and pull all resolve this same repository identity. Removal retains repository caches because other installations may still use them; older alias-scoped and name-only clones are neither adopted nor deleted. URL material is hashed rather than placed directly in cache paths.
Installation manifests live at ~/.teamai/sources/<name>/installations/<sha256([destinationRoot, teamCheckout])>.json. The identity uses absolute paths from resolveBaseDir(localConfig) and localConfig.repo.localPath; HOME, projects, worktrees, and separate team checkouts have independent records. New records also store destinationRoot, teamCheckout, and the repository URL hash (repositoryId) for physical-path coordination and manual review.
Pull plans all skill destinations before copying. A different or unidentified repository owning any overlapping physical target blocks that skill; installations of the same repository may share it. Existing copies from an unchanged repository survive a newly conflicting target. A conflicted repository replacement preserves its whole previous installation; a successful replacement records only newly deployed paths and releases old destinations. Dry-run checks the same conflicts without writing skills or installation manifests. The read-only destination resolver does not opportunistically delete identical Codex copies; previously recorded paths are reconciled during ownership-checked cleanup.
Stale cleanup and source remove release only the current record. Files remain while another active manifest owns the same canonical path or an overlapping parent/child path, including symlinked destinations. Foreign records only veto operations; they never authorize deletion. Removal retains caches and other records. If another destination already removed the subscription from a shared teamai.yaml, the current destination can still run source remove <name> using its scoped record without rewriting YAML. If an alias was re-added for a different repository, a stale or unidentified scoped installation authorizes only local cleanup; removal preserves the replacement subscription.
Local source tags use scoped provenance. Push also excludes skills whose current physical target belongs to another installation, and fails closed for skill publication when ownership tracking cannot be read safely. Legacy name-only installed.json files are never adopted as root/ownership evidence or deleted. Their names are conservatively excluded from push, with a warning naming the record; this can also hold back an unrelated same-named draft. Review and back up remaining deployed copies, and archive legacy tracking only after accounting for every affected destination; then pull active subscriptions to establish scoped records. There is no automatic destructive migration.
A missing or moved consumer checkout does not prove its deployed files are abandoned. Such records remain protective, and messages identify the owning manifest; new records retain the checkout path for review. Remove an installation from its original scope before moving/deleting its checkout when possible. Existing orphan records and legacy copies require manual review before retiring ownership; automatic orphan garbage collection is not implemented.
Git source add, browse/cache refresh, pull, and removal use the same machine-local lifecycle lock, re-reading state under it. A busy operation asks for a retry instead of bypassing another live owner; dry-run only checks lock state and reads existing caches, without cloning, pulling, or updating cache timestamps. Uncached previews report that skill contents cannot yet be inspected. Skill push is also withheld when a source transaction is active.
A successful source pull records only its current destinations and releases obsolete tool paths, including when the repository URL is unchanged. A valid source config with no publicSkills, an empty list, or no remaining published skill directories releases the previous installation. Files still owned by another installation remain protected; a missing or unreadable source config leaves the old installation untouched. Conflict-retained copies keep their previous records.
Public skill names and recorded skill identities must already be canonical (no normalization-changing segments, repeated separators, backslashes, or trailing slash), so alternate spellings cannot bypass team-skill priority. Canonical nested names also respect local-team and builtin skill directory ownership, including ancestor/descendant conflicts. When this protection leaves previous source files in place, pull/removal retains their scoped provenance and excludes their paths from push pending manual review; it does not silently adopt them as team content. Source removal preflights all foreign ownership records and deletion targets before editing shared configuration.
Ownership records must contain safe relative skill names and non-empty relative descendant paths, or absolute paths inside a tool's home outside the project (Hermes, OpenClaw, Copilot; #993); root-equivalent, escaping, and other absolute paths stop cleanup before configuration or files are changed.
All accepted source aliases, including .git, node_modules, and names ending in .pyc, participate in ownership, provenance, and push checks; resource-directory filters never hide their tracking.
If any skill in a scoped record lacks non-empty recorded destinations, pull and source removal keep the entire installation unchanged and require manual review, including for the same repository and during previews. Current tool paths cannot prove historical deployment locations, so they never authorize inferred cleanup, overwrites, or replacement claims. Review and back up the original source copies and unrelated local files before manually retiring the ambiguous record; changing tool paths or retrying source remove does not resolve missing history. Ambiguous scoped claims are quarantined across checkouts, so another team sharing HOME cannot publish the retained source bytes. This can temporarily hide unrelated same-named drafts until the record is reviewed; complete modern pins remain path-specific.
Older records with non-empty concrete plain paths remain supported. An ambiguous claim in another scoped installation or an unscoped legacy record also blocks source writes and cleanup for equal, parent, or child logical names before any mutation. Its warning identifies the record to review. This conservative name guard may block otherwise separate destinations because their historical location is unknown; it never invents deletion authority. Sources with unrelated names and complete recorded foreign paths retain their normal behavior.
After ownership preflight, source pull restores Codex's verified duplicate reconciliation: an unowned configured copy is removed only when it matches both the shared copy and incoming source. Different local copies, other installations, existing tracked paths, planned destinations, and repository inputs remain protected; dry-run never reconciles files. A symbolic-link skill root is copied as concrete contents only when it resolves to a child directory inside the source repository. Outside, dangling, or looping root aliases stop before deployment; ordinary in-repository aliases and tool-destination symlinks remain supported. Previously installed skill-root links retain their record and referent for manual review; a physical pin alone does not authorize deleting the link target. This does not migrate nested file symlinks.
Push excludes modern pinned source records by actual physical path only: an unrelated same-named local skill in another agent remains a candidate. Name-only quarantine is reserved for legacy or scoped records without complete physical ownership; malformed or incomplete pin metadata still stops skill push.
New scoped records pin each deployed path to its original physical destination, recorded after the copy. Before copying, withdrawing, or removing an existing installation, TeamAI checks every recorded destination; a changed, dangling, unreadable, or otherwise unverifiable symlink stops the operation before installed files, YAML, or the manifest change. Restore the original destination and retry, or manually review the retained files and record. Other installations protect the original pinned location, not a symlink's new target. Old scoped records without physical pins can authorize only explicitly recorded plain paths; existing symlinked paths (including symlinked roots) require manual review rather than guessed historical ownership. Newly installed stable symlink paths remain supported.
Canonical nested public names such as group/child remain supported. Pull refuses an ambiguous parent/child directory transition (including another installation of the same repository), or two distinct skill identities targeting the same physical directory, before copying any skill or changing the installation record. The warning requires manual review: back up the retained files, use source remove in the affected installation(s), then pull the new publication. When a retained parent contains another source's child directory, its own provenance also remains until that child installation releases ownership; remove the child installation first, then retry removal of the parent. No automatic subtree migration is attempted. Disjoint destination moves and exact-path sharing by the same repository and skill remain supported.
Status and skill inspection identify nested source installations by their recorded physical paths, without assigning that provenance to unrelated same-named copies. If scoped provenance cannot be read, status/skill inspection warns that source labels are incomplete; a local-only label is not proof of local ownership.
Do not run source installation/removal and push concurrently. The source mutex serializes source mutations, but push does not retain it through its entire staging/publication transaction; full cross-command snapshot isolation remains a limitation.
A Git repository has one identity and checkout paths with separate purposes:
projectAnchor = first entry of `git worktree list --porcelain`
→ a checkout or Git directory, SHARED by the repo and its worktrees.
→ the stable per-project identity; P1 keys machine data under
~/.teamai/projects/<slug(projectAnchor)>/ by it.
mainCheckout = first entry when it is a checkout, else the common dir's
core.worktree, else show-toplevel for a main separate-git-dir
checkout, else null
→ where agents read shared project hooks and Codex trust applies.
→ null means every checkout keeps its own hook files.
workspaceRoot = `git rev-parse --show-toplevel`
→ the CURRENT checkout, DISTINCT per worktree.
→ where project-scope AI-tool resources (skills/rules/agents,
tool config, CLAUDE.md) must be written.
→ an internal name for the checkout root, unrelated to the
`workspace` install scope (#1099).
All three are equal for a plain repository. An initialized submodule clone in
a superproject worktree inherits <common>/modules/<name> as its identity when
that original Git directory exists. Its checkout paths still refer to its own
clone, so shared configuration does not share delivery destinations (#1041).
Why resources must go to workspaceRoot, not projectAnchor: every AI tool
(Claude, Codex, CodeBuddy, OpenCode) discovers project resources by scanning up
from the launch directory to the current repository root. None of them follows
git-common-dir back to the main checkout, and gitignored files do not appear in
a fresh worktree. So resources have to land in the worktree the user is actually
working in.
What that means for "already synced". state.json sits in the shared
partition, so its lastPullRev and lastPullTargets say what the project last
synced, not what this checkout holds. A project-scope pull also records both per
checkout in lastPullByWorkspace, keyed by managedMcpWorkspaceId(workspaceRoot)
plus the inode and birth time of the checkout's .git entry (new each time a
worktree is created, so a worktree re-created at the same path gets its own key),
and takes the unchanged-repo fast path only when the shared lastPullRev and this
checkout's own revision and tool targets all match.
A worktree added after the
last pull therefore gets a full sync on its first pull, and two checkouts with
different tool directories no longer force a full sync on each other (#807).
The fast path also needs the entry's gitExcludePaths, what each writer of
the pull delivered into the checkout (the paths teamai's delivered git
exclude block lists, #915). An entry saved by an older CLI has none, so the
first pull after the upgrade is a full sync, whatever sharing.gitExclude says.
The entry's rootsSeeded says pull's tool-root seeding step completed or did
not apply, as a new linked worktree's post-checkout does, for a submodule clone
the superproject created (#1041). Pull leaves it absent when the original
checkout cannot be recovered, so the next pull retries. An entry without it,
older CLI's included, is seeded on the next pull, and a root the member removes
afterwards stays removed.
The block is the union of the gitExcludePaths of the live checkouts
(liveCheckoutRecords), less paths that hold a file outside another live
checkout's list; paths in another repository (a submodule, a nested clone, a tool
home under git) go to that repository's exclude file under delivered/<id>, the
partition's anchor hash. Skill writers (team, source, built-in, Codex's
.agents/skills, the local agent) report each file they wrote into a skill,
never its directory, which the git exclude module refuses to list, so a file
the member adds inside a delivered skill stays visible; a tracked file there
gets no line while the skill's other files keep theirs. The docs writer reports each doc file it wrote, never
an entry of the member's it kept nor a doc beneath one; with the mirror at
<root>/.teamai/docs, outside single-repo mode, the sync also keeps teamai's
block (!/docs/**) in <root>/.teamai/.ignore so ripgrep-based search finds
the excluded docs, and lists that file while the block is all it holds.
The teamai-only entry is judged anew on every pull,
fast path included, and replaces its previous list: the shared config files
teamai writes entries into (project MCP configs with no per-member place,
.codex/hooks.json, .opencode/opencode.json) that git does not track and that
hold only entries teamai's records own, so a file that takes in a member's entry
leaves the block on the next pull, which says so. On OpenCode V2 with teamai's
plugin current, the OpenCode MCP target is .opencode/teamai-mcp.json instead
(teamai's whole, always listed; its records carry file), and the pull takes
teamai's V1 entries out of the root opencode.json and .opencode/opencode.json
when git does not track them, keeping the member's entries there.
A .codex/hooks.json that is not teamai's alone (tracked, or holding a member's
entry; always in self mode) gets no team hooks while the flag is on: pull takes
teamai's entries out of it through its hook manifest and records the project's
Codex team hooks in ~/.teamai/codex-team-hooks.json (machine-wide, keyed by the
project identity, or by the checkout in self mode; each entry lists the checkouts
whose Codex file it stands for, so submodule clones sharing an identity keep
separate entries (#1041); locked by
codex-team-hooks.json.lock; a project whose directory is gone is dropped). The
same pull makes ~/.codex/hooks.json hold one
teamai hook-dispatch <Event> --tool codex --team-hooks entry per event any
recorded project has hooks for, with the largest timeout, before the built-ins;
at run time it resolves the project from the hook's cwd and runs that
project's hooks from the index.
A tool whose host reads no Claude hook file (OpenCode, Oh My Pi, Pi, OpenClaw,
Kiro, Hermes) keeps its team hooks, and the local agent's agent hooks for it,
in ~/.teamai/team-hooks/<tool>.json: a Claude-shaped file the same reconcile,
records and project gates write as a settings file. The tool's plugin,
extension or native hook entries run
teamai hook-dispatch <Event> --tool <tool> --team-hooks, which reads it.
Uninstalling a tool removes its file; the directory stays while a project
still uses it.
state.gitExcludeFiles records, per owner, the exclude
files holding its block. A pull nobody watches (session start, git hooks) keeps
what it could not say about the block in <dataHome>/git-exclude-notices.json:
its last failure to update it (cleared by the next update that succeeds) and
notices the next interactive pull says once and drops; doctor reads both.
pull --dry-run previews the block from what its writers would write, writing
nothing. A checkout whose config still loads from <workspace>/.teamai/ (the
migration kept the legacy layout, or a dry run previews one) does not read its
member gitExcludeEnabled: each worktree of that layout has its own config, and
they share one exclude file, so only the team's setting or the default applies.
uninstall reads state.gitExcludeFiles before it deletes the
partition and, under the partition's sync lock, removes every teamai block
from those files once it has deleted the files they hid; uninstall --agent
filters the tool's paths out of every entry's gitExcludePaths and syncs again.
Clearing lastPullRev still forces a full sync, which is how exclude, tags,
roles, projects, init and bootstrap apply their changes: the pull that finds
lastPullRev cleared resets every other checkout's entry to an empty rev,
which matches no revision, so each checkout does its own full sync (an older
CLI compares rev too, so it also misses the fast path). A new team revision
resets nothing, since a checkout recorded at an older revision already misses
the fast path.
push needs that entry too: before scanning, it syncs each rule
and skill the member never edited, and "never edited" means equal to the
version at a revision this checkout synced, not the shared lastPullRev
another checkout may have moved (#812). Rules of a tool with its own rules
format (every tool in RULE_FORMATS, rule-format.ts) compare bodies against those revisions, ignoring derived frontmatter,
and render refreshed copies in the tool's native format. Rule sync uses the same tool root as the
scanner, including COPILOT_HOME for user-scope Copilot instructions.
It checks isAgentExcluded before installation detection, so retained tool
directories do not authorize writes to rules excluded by the local configuration.
For Copilot updates that only change paths, it compares the entire local file
with the rendered recorded versions before refreshing applyTo, preserving
locally edited headers rather than overwriting them on a body match alone.
Each copy it writes, in any format, is recorded in the checkout's delivered
(#822), so the next pull does not keep it as the member's edit.
A placed agent, which push does not
sync, is held when the team file has changed since any of those revisions, or
since it was added if one of them predates it (#823).
That sync brings the
unedited copies up
to the team repo, so when push has refreshed the team repo it adds the
revision it synced to the entry's pushBaseRevs, newest first, even under
--dry-run, since the sync has already written the files, and even when the
sync stopped partway (it warns), since the copies it did not reach still match
an older base. If push cannot save that revision, it stops before scanning and
pushes nothing. The next push
accepts a copy at any of pushBaseRevs or at rev, so a copy the sync left
alone as edited is recognized again once the member undoes the edit, back to
whichever version a sync gave it.
The list keeps the 20 newest revisions; a
copy at an older one reads as an edit until the checkout pulls. Push never
moves the entry's rev: the pull fast path reads it, and the checkout still
lacks that revision's docs and agents. A reset entry keeps its bases, rev
included, in pushBaseRevs, and the next pull in the checkout rewrites the
entry without them.
A checkout with no entry (new, or last pulled by an older
CLI) syncs against the shared lastPullRev, which may be another checkout's or
cleared, so when the scan lists a team rule or skill as modified, push stops
before creating a branch and asks the member to save any edits to them and run
teamai pull in the checkout. A rule this machine placed (placedRules) is the
author's own copy and does not count; config-only pushes and new resources go
through.
Each project checkout's full sync records its root in its entry,
and every full sync drops the entries whose root is no longer a checkout of
the repository (it has no .git, or git rev-parse --git-common-dir there
names another common directory) or is one under another key, so a deleted or
re-created worktree's entry goes with the next full sync in any checkout. It
does not read git worktree list, which names the git directory instead of
the main checkout in a --separate-git-dir repo or a submodule (#993). An
entry an older CLI wrote without root is kept until its checkout's own full
sync adds one, and every entry is kept when git cannot name this checkout's
common directory.
A
state.json written before this field has no entry, so each checkout does one
full sync after the upgrade. The user scope's pull records its one checkout,
HOME, the same way, for push's bases alone: its fast path still reads the
shared fields, and an install with no entry yet (upgraded, and not fully
synced since) compares with lastPullRev and lastInheritedPullRev, which
only HOME's pulls move and either of which may have run last, so push does not
stop there; its first push creates the entry from lastPullRev, with
lastInheritedPullRev as a push base, and adds the revision its sync reached.
A project pull that inherits the user scope (inheritUserScope) moves HOME's
skills, rules and agents too, so it adds its revision to that entry's push
bases, creating the entry the same way if there is none, and leaves the entry's
rev alone. So does any pull whose docs mirror or submodule update fails, or
that holds an agent whose model cannot be resolved (#830), a cause the member
may fix without a new team revision: it leaves its revision marker for the
retry, but the skills, rules and agents it delivered are at the new revision.
A full pull that holds skills or agents on a
namespace collision writes its revision as rev but keeps the entry's earlier
bases as push bases, since the held copies stay at them. Like a full pull, an
inherited pull already synced at the team's revision writes nothing (#823).
The entry's agentModels records, by agent stem and tool, the model, effort
and resolution step each YAML agent copy received when pull last wrote it
(#830), and for an alias step the alias name, so a removed alias is noticed
even where it gave the tool no model. A copy pull keeps as the member's edit,
or holds because its model cannot be resolved, keeps its old entry.
The fast
path compares each agent's current resolution with it and redeploys only the
agents that differ, through the same ledger, so HOME's entry and each project
checkout's are separate; a copy the ledger keeps as the member's edit is
named, as a full sync names it, and its entry kept. A missing copy is
delivered, entry or not. An agent with no entry is also redeployed where an
alias replaces its model, which is what an older CLI wrote as is, and where
its copy still has the bytes delivered records but not the current render (an
older CLI's render, such as Claude extras in a Qoder copy).
Without a
delivered entry for the copy nothing tells that render from an edit, so it
is left alone.
Rules get the same treatment for a render change (#946): when a CLI upgrade
gives a tool its own rules format (a RuleFormat with previousRenders: Kiro,
Qoder, CodeBuddy, WorkBuddy, Oh My Pi, and JoyCode, which had Cursor's render), the fast
path rewrites each rule copy that still has the bytes delivered records but
is not the current render, records the new bytes, and of a copy without an
entry rewrites only one that is the team rule verbatim, which is what the older
CLI wrote. A copy the member changed is kept and named when teamai would now
deliver other bytes there.
A destination that moved (WorkBuddy's project rules,
from .workbuddy/rules to .codebuddy/rules) has no entry at its new path, so
the fast path first reclaims the old copies (LEGACY_RULE_DIRS), writes the
new path where it is missing, and records the change.
The flat names of Oh My Pi
and Kiro (RuleFormat.flat: <ns>/<name>.md became <ns>.<name>.md, in the
same directory) work the same
way through movedFrom: the copy at the old path is what lets the fast path
write the new one, with or without a delivered file; the old copy goes while
it has the recorded bytes or, with no entry, the team rule verbatim, and is
named otherwise, since the tool does not read it.
projectAnchor uses the first entry of git worktree list --porcelain rather than
dirname(git rev-parse --git-common-dir). Two traps make the git-common-dir route
wrong:
- With
git init --separate-git-dir, the common dir lives outside the checkout (e.g.gitdirs/proj.git), so its parent is a sharedgitdirs/— colliding across unrelated repos, and not the workspace either. --git-common-diralone returns a relative path (.git) in the main repo (only absolute inside a worktree), so it needs--path-format=absolute(git ≥ 2.31) just to be usable — and still hits the collision above.
git worktree list --porcelain lists the main worktree first, and every linked
worktree reports the same first entry, giving a shared-yet-distinct identity in all
cases. Both anchors are realpath-normalized so a symlinked prefix (macOS /tmp →
/private/tmp) does not make one checkout look like two.
slug(anchor) = <safe-path>-<sha256(normalized anchor) first 16 hex> — the whole
anchor path made filesystem-safe (leading separator dropped, separators and other
unsafe chars → -), so the directory name reads back to its project, mirroring
Claude Code's ~/.claude/projects/ naming: /Users/x/Project/app →
Users-x-Project-app-<hash>. The trailing hash is what guarantees uniqueness
(a /→- escape alone is not injective: /x/my-proj and /x/my/proj would
collide and silently merge two projects' plaintext env), and the prefix is
length-bounded so a deep path can never overflow NAME_MAX. The per-partition
anchor file stays the authoritative reverse lookup.
Because #546 changed the prefix without changing the hash, partitions written by
older teamai (<safe-basename>-<hash>) are adopted, not stranded: every seam
that resolves "this project's partition" (detection, init, migration) goes through
resolvePartitionDir, which computes the anchor's exact legacy name and ATOMICALLY
RENAMES the directory into the current name (same-parent metadata move — no data
copied, an interruption leaves either name intact). A partition that cannot be
renamed (read-only home) keeps serving under its legacy name; an authoritative
current-format partition is never clobbered by a leftover legacy one. status --all never renames (read-only) — it reports a legacy-named partition as
active (legacy name; renamed automatically on next command) instead of corrupt.
The rename alone is not enough: repo.localPath is stored in config.yaml as an
ABSOLUTE path to the team-repo clone (<oldPartition>/team-repo), so adoption
also rebases it onto the new directory — otherwise pull would read the team
config from a now-gone path and silently skip the sync (exit 0, "Team config not
found"). The rewrite is idempotent (a modern install's localPath already sits in
the canonical dir and is left untouched; an external clone outside the partition
is left untouched) and self-healing (it finishes an adoption that crashed between
the rename and the config rewrite) — the same repo.localPath rebase that
migrate.ts applies when moving a legacy .teamai/ into a partition.
A --dry-run detection adopts nothing: resolvePartitionDir(anchor, { dryRun })
returns the directory that holds the data now (the legacy name, where adoption
would rename it) and rewrites no config.
The rewrite is ATOMIC (same-dir temp file + rename, via writeFileAtomic). By
this point the legacy source has already been renamed away, so config.yaml is the
partition's only copy; a plain overwrite that failed partway (ENOSPC, EFBIG, a
crash mid-write) would truncate it with no way back. rename(2) is atomic, so a
failed write removes the temp file and leaves the original config.yaml intact —
the next command retries the (idempotent) rebase and converges.
P0 is deliberately structural: it establishes the primitive and fixes
discovery, WITHOUT relocating any data. The physical layout
(<projectRoot>/.teamai/, getTeamaiHome()) is unchanged, and the 61
resolveBaseDir() call sites are untouched — their divergence from the data home
is a P1 concern. This keeps P0 independently reviewable (issue R7).
-
Atomic locking —
src/update.tsacquireLock()/releaseLock(). The old lock was check-then-write (pathExists→writeFile): two racing processes could both observe "no lock" and both succeed, andreleaseLock()unconditionally deleted the file — including a lock another process later acquired. Rewritten to:-
Acquire with an atomic exclusive create: the payload is written to a private temp file and hard-linked to the lock name (
linkfails withEEXISTlikeO_CREAT|O_EXCL), so the lock never exists without its content (#760); a filesystem without hard links falls back towriteFile(path, payload, { flag: 'wx' }). Payload is JSON{ pid, startedAt, owner }with a randomownertoken. -
On
EEXIST, reclaim only a stale lock: one whose owner is provably gone (process.kill(pid,0)fails withESRCH). The reclaim is serialized behind an atomically-created reclaim sentinel and finished with an atomic rename-into-place, so concurrent reclaimers cannot each end up believing they hold the lock; a live holder returns "busy". Anything that cannot name a dead owner is held (#760): a lock that cannot be read (EACCES), an empty or partly written one (thewxfallback and older teamai open the file before writing), and a pid owned by another user (EPERM). A lock that names no owner, or cannot be read, stays until removed by hand if a crash left it, and a warning names it.
A lock that vanished before it could be read gets one more exclusive create instead (a third process may already have re-created it).
- Migration skips the locks' transient artifacts (
<lock>.<uuid>.tmp,.sentineland its temps,.new-<uuid>) along with the locks themselves. releaseLock()returns early when this process holds no owner token for the path, and otherwise deletes only when the on-diskownerstill matches the token this process recorded — never another process's lock.- Exit hygiene (#1027): a process that took at least one real lock registers a
synchronous
process.on('exit')sweep that removes whatever it still holds (same owner-verified removal, sync fs —exithandlers cannot await). This covers a hook host's hardprocess.exit(0)killing best-effort background work mid-lock; SIGKILL stays uncoverable by design.teamai doctorreports the remaining dead-owner locks (informational), andteamai doctor --fixremoves them through the same serializedacquireLock/releaseLockreclaim; ownerless and permission-denied locks are never touched. - Back-compatible with legacy plain-integer PID lock files.
The three call sites (
update.ts,bootstrap.ts,utils/reports-branch.ts) keep their signatures and all benefit.
-
-
Anchor primitive —
src/utils/git.tsresolveAnchors(cwd?). Returns{ workspaceRoot, projectAnchor, mainCheckout }, ornulloutside a git repo (callers fall back to cwd-based behavior). -
Subdirectory / worktree-aware discovery —
src/config.tsdetectProjectConfig(). When the cwd has no.teamai/config.yaml, it retries at the gitworkspaceRoot, so teamai runs from any subdirectory and resolves a worktree'sprojectRootto that worktree. -
Semantics —
resolveBaseDir()(src/types.ts) documented to return the workspace root; behavior unchanged.
- Concurrent
acquireLockon one path → exactly one winner; stale locks reclaimed; non-owner release is a no-op (src/__tests__/lock-atomic.test.ts). resolveAnchorson a real repo + realgit worktree add: shared anchor, distinct workspace (src/__tests__/anchors.test.ts).- Real CLI:
status/pullfrom a nested subdirectory detect project scope and deploy to the repo root; run inside a worktree, resources land in the worktree and the main checkout is untouched (src/__tests__/detect-subdir.test.ts+ manual run).
An install created before partitioning keeps its machine data in the business repo
at <workspaceRoot>/.teamai/. P1-2 routed NEW installs to the partition and reads
old installs through a legacy fallback; P1-3 moves a real legacy .teamai/ INTO the
partition on the next write command, so the workspace ends up with zero residue.
Trigger (src/migrate.ts, wired into the global preAction hook in index.ts):
- Only
init/pull/push/contribute, andimport --from-mr: the commands that write, and the two that queue learnings, which an unmigrated checkout would keep in its own.teamai/(#808).import's other modes leave the queue alone, andimport --cache-status --jsonprints only its JSON.contribute --scope user(the user install's queue) andimport --from-mr --output(drafts only) do not write this project's queue, so they do not migrate either. Read-only commands (status,recall, …) never move data. They can still read a valid legacy project config through the double-read fallback, but an existing unreadable project config is not treated as absent: scope resolution stops with a config error instead of falling back to another project's or the user scope (#899). hook-dispatchis excluded outright (viaTEAMAI_HOOK_SUBCOMMANDS): it is a high-frequency silent path and must never move 12 MB.--dry-run(the existing global flag) previews without writing.
Gate (planMigration, deliberately NOT detectProjectConfig — that
short-circuits on an existing partition and runs the self-heal bootstrap as a side
effect, both of which would mask the raw legacy state). Act iff:
- in a git repo (the partition only exists for git repos), AND
<workspaceRoot>/.teamai/config.yamlexists (self mode falls back to the partition's, see below), AND- the legacy config is
scope: project(user data never lives under.teamai/).
A kind: self config plans mode self whenever any entry the self migration
takes out (the machine data, the old queue, the old shared search index) is still
in the checkout's .teamai/. That .teamai/ is team knowledge committed to main,
so it is never copied whole or renamed: the self migration moves only those
entries into the partition (see P2 below). Self mode reads its scope and kind
from the partition's config.yaml when the checkout's is gone, so a relocation
interrupted after the config moved still finishes.
A partition config.yaml that
exists but that detection cannot read plans nothing, with a warning naming the
file, as for the other kinds below: the checkout's config is the only one that
still loads, and nothing leaves the checkout until the member fixes that file.
For any other kind, the plan's mode then depends on the partition: a full copy when
<partition>/config.yaml does not exist yet, or retire-only when it exists and
detection can read it (a prior run built the partition but was interrupted before
retiring the source — see Interrupt recovery). retire-only never re-copies onto the
authoritative partition; it only cleans up the leftover legacy dir, after settling
its queue by the partition's config (settleCheckoutQueue, below): into the partition
queue, or set aside, never into .teamai.bak, which a removed linked worktree takes
with it. A queue that cannot move keeps the legacy dir ('skipped'), so
contribute and import --from-mr stop on it and the next run tries again.
A partition
config.yaml that exists but that detection cannot read (it is empty or cannot be
opened, does not parse, does not validate, or is not scope: project) plans nothing:
the legacy dir holds the only config that still loads, so it stays in place (a warning
names the file) until the member fixes the partition file, and the next write command
then gets the retire-only cleanup. A partition dir with no config.yaml at all (say,
one moved aside by hand) plans nothing either, with a warning: the full copy replaces
the whole dir, so it would take that dir's data with it.
The full copy's re-check under
the lock in runMigration applies the same rules, warning and queue included.
A readable self partition over a legacy dir that holds self knowledge (a
teamai.yaml with mode: self) plans superseded instead of retire-only:
another checkout ran init --self, and this one checked out what it committed
next to its old install, so retiring the whole dir would take the knowledge too
(#808). Its machine entries (SUPERSEDED_ENTRIES: state, token, the env file,
the team-repo clone and its last-fetch.json stamp, indexes, report and usage data) move to a new
.teamai.bak[.N] with its own .gitignore (*), as step 5 keeps a retired dir;
the knowledge stays.
Then its queue is set aside as pending-learnings.<old kind>
(settleCheckoutQueue, below), and only then does config.yaml follow: it is what
tells the next run the queue is the old install's. A learning still queued at that
point (a contribute from a teamai older than the queue lock, below, running
beside the migration) keeps it in place ('skipped'), as retire-only keeps its dir. A move that fails partway leaves it in
place too. Either way the next run plans superseded again and moves what is left
(into the next free .teamai.bak.N). With the legacy config
gone, the next run plans the self branch, which finds nothing left to move.
Steps (runMigration) — copy → verify → atomic rename, so an interruption never
leaves data half-in-both-places:
0. Acquire <legacyDir>/.sync-lock (the exact lock an un-migrated pull/push contends
on, since their getDataHome still resolves to the legacy dir pre-migration).
Contention → skip this attempt (idempotent; the next write command retries).
1. Copy legacyDir → <partition>.staging (raw fse.copy, NOT copyDir — copyDir filters
out `.git` and would corrupt the team-repo clone). Skip reports-wt/learnings-wt/knowledge-wt
(disposable worktrees with absolute gitdirs — rebuilt on demand) and lock files.
2. Verify staging: config.yaml parses; if the source has team-repo/.git the copy must
too; every migratable top-level entry is present. Failure → discard staging, abort,
source untouched.
3. Atomic switch: fse.rename(staging → partition) (same-filesystem, atomic).
4. Write <partition>/anchor with the projectAnchor path — the slug's readable
prefix is lossy (path chars folded, length-bounded) and its hash is one-way,
so this file is the authoritative reverse lookup; it lives off the workspace.
5. Release the lock, then retire the source:
a. Drop a self-contained `.gitignore` (`*`) INTO legacyDir first. An old
install's `.teamai/` was often protected only by a repo-root rule matching
`.teamai/`, which does NOT match `.teamai.bak/` — so without this the rename
would expose the plaintext env/token to the next `git add`. Written before
the rename so the credentials are never in a non-ignored directory.
b. Rename legacyDir → the first FREE `.teamai.bak[.N]` name. An existing backup
(a prior migration's, or the user's own) is NEVER removed — it may hold
irreplaceable data — so we pick `.teamai.bak`, else `.teamai.bak.1`, …
The backup is NEVER auto-deleted: it is the manual rollback path.
Interrupt recovery: staging is a separate sibling dir, so a crash before step 3 leaves
the partition absent and the source intact — a rerun discards .staging/ and starts
clean. A crash between steps 3 and 5 leaves the partition built with the legacy dir
still present; the next write command's planMigration sees "readable partition AND
legacy lingers" and returns a retire-only plan that finishes the job — it retires
the leftover legacy dir to .teamai.bak/ WITHOUT re-copying onto the now-authoritative
partition. This closes the gap where the legacy dir (including its plaintext env)
would otherwise linger in the workspace forever, breaking the zero-residue guarantee.
The staged team-repo clone is smoke-checked (git rev-parse HEAD) before the rename,
so a partial/corrupt copy aborts with the source untouched rather than promoting a
broken clone. If a write command's migration fails, teamai prints a clean error and
exits non-zero (the source is intact, so a rerun retries safely) instead of surfacing
a raw async-hook rejection.
Downgrade is not supported — an older teamai treats a partitioned install as
uninitialized; .teamai.bak/ is the manual rollback. Flag prominently in release notes.
Before P2, self mode kept its class-A1 machine data (config, state, env backup,
search index, managed-mcp, the per-worktree resource cache) inside the business
repo at <repo>/.teamai/, alongside the class-B team knowledge that is committed
to main. A hand-maintained .gitignore blacklist kept git status clean — a
fragile arrangement (the per-worktree workspaces/ tree and the user-scope
managed-mcp.json were, in fact, never listed, so a self repo running MCP
reconcile or the local agent leaked them into the working tree).
P2 physically relocates the A1 data to the partition ~/.teamai/projects/<slug>/,
leaving .teamai/ with only class-B knowledge. The lever is the same as non-self
installs: attach a partition dataHome to the self LocalConfig, and every
getDataHome()-based write follows.
Invariant: getKnowledgeDir / repo.localPath stay <repo>/.teamai — that is
the class-B knowledge anchor, committed to main, and the ~230 path.join(localPath, …) call sites do not change. Learnings themselves left the default branch in
issue #485: new ones are written to learnings-wt/ (the teamai-learnings
branch) and queued in pending-learnings/ until they are published, while the
learnings already on main are read from where they are. Where those two live is
in "Self mode and linked worktrees (#808)" below; only the disposable
knowledge-wt/ (a detached checkout for knowledge PRs, removed after each use)
stays in the checkout's .teamai/.
-
init (
initSelfRepo): resolves the partition up front, attaches it asdataHome, and writes config/state there. The pre-P2 "retire the stale partition" step is gone — self now USES the partition, so there is nothing to retire. -
bootstrap (teammate fresh clone,
bootstrapSelfRepo): the "already initialized" check and the config write both target the partition (with a legacy fallback so a pre-P2 install is still recognized). -
detection seam (the delicate part): on a fresh clone the partition config does not exist yet, so partition-first misses. The legacy branch runs the self-heal bootstrap — which now writes the config into the PARTITION — then reads it back FROM the partition (
selfHealAndReadPartition). A pre-P2 install whose config still sits in<repo>/.teamaiis read via the legacy branch (double-read compat) until migration relocates it.A
--dry-rundetection (a command that forwards--dry-runto its loader, or a read-only one such asstatus,list,doctorormcp list, which loads this way unconditionally) previews the bootstrap instead (previewSelfBootstrap): it builds the config it would write, keeps it in memory, and prints[dry-run] Would bootstrap ...without locking, writing, injecting hooks or registering the member. It makes no provider auth call either (a stale token can sendauthenticate()into an interactive login), so the preview names the provider but not the username.initdetects withselfHeal: false(in itspreActionqueue check and ininitSelfRepo): it sets the project up itself, and a self-heal there would enable every tool in HOME before--agentchose them (#993). -
migration (
migrate.ts,mode: 'self'): self CANNOT use the git-mode whole directory copy→rename (that would carry the knowledge off and rename.teamaito.bak, breaking "knowledge on main"). Instead it selectively relocates the A1 whitelist (config.yaml, state.json, env.local, env.sh, managed-mcp.json, workspaces/) entry-by-entry, destination-first (copy to the partition, then delete the source), leaving class-B knowledge and the worktrees untouched and never renaming.teamai/. selfrepo.localPathis NOT rebased — it must keep pointing at the in-repo knowledge. It also drains the checkout's queue (pending-learnings/, see #808 below), and deletes the old sharedsearch-index.jsoninstead of moving it: self mode keeps one index per checkout underworkspaces/<id>/and rebuilds it.
Acceptance: after slimming, git status is clean (the A1 data is physically gone,
not merely ignored) and a teammate's fresh clone bootstraps into the partition.
Both scopes of one self-mode repository share those checkouts too (#1443). A
hand-written user-scope ~/.teamai/config.yaml next to the workspace install
would otherwise compute ~/.teamai/reports-wt while the partition computes
~/.teamai/projects/<slug>/reports-wt, so one scope could take the branch and
the other could never take it back. The loader attaches a runtime-only
branchHome — a side-branches/ subdirectory of the project partition,
resolved from the shared project anchor — to a kind: self config of either
scope; getWorktreeDir and the lock path beside it follow it, and every reader
goes through them. The subdirectory matters when a git-mode team clone lives in
the same partition: its checkouts are siblings of the clone, directly under the
partition, so the two installs never claim one path. The first upgraded pull
removes a checkout left in ~/.teamai/, in a legacy <checkout>/.teamai/, or
at the partition root from a pre-#1443 install, under the lock beside that old
checkout so a command still using it is never deleted from under.
Git checks a branch out in one worktree only. Self mode used to derive the
teamai-learnings and teamai-reports checkouts, and the queue, from
localPath, which is re-anchored to each checkout's .teamai/. So the first
checkout to create a side-branch checkout owned the branch, every other checkout
of the repo failed with 'teamai-learnings' is already used by worktree, and a
learning queued in a linked worktree was deleted with it (its .teamai/ is
ignored, so a plain git worktree remove takes it). The partition is shared by
every checkout, so that is where they live now:
~/.teamai/projects/<slug>/ one per project, every checkout
├── side-branches/ self mode's branchHome: getWorktreeDir → <partition>/side-branches/<dirname>
│ ├── learnings-wt/ for BOTH scopes (#1443); the locks sit beside each checkout
│ └── reports-wt/ a git-mode clone in this partition uses <partition>/<dirname> instead
├── pending-learnings/ pendingLearningsDir → <dataHome>/pending-learnings
├── managed-main-checkout-hooks.json legacy ownership, adopted into the original main checkout's workspace index (#1041)
│ team hooks teamai wrote ungated into the main checkout's Claude Code / Codex
│ settings, shared by every checkout (#955; the built-in hooks stay in HOME)
│ project toolPaths choose the files; Claude uses settings.local.json beside its settings file
│ Codex records event, matcher-group position and complete rendered entry; unique definitions recover moved entries
│ legacy ownership matches event/matcher/command uniquely, ignoring unrecorded timeout/context options
│ pre-#370 Codex ownership is imported from <main>/.teamai/managed-hooks.json before reconcile/removal
│ an un-migrated linked worktree injects through the main checkout's copy (<main>/.teamai when the main checkout
│ has no install), importing then retiring the records v0.22.0 kept in each linked checkout's data home; checkouts registered in the
│ manifest share ownership so removal keeps the shared entries until the last checkout removes them (#373)
│ registration is per tool; targeted uninstall releases only that tool and keeps the remaining tools' records
│ discovery respects enabledAgents/disabledAgents; excluded tools do not retain another checkout's shared hook
│ a shared partition config registers its linked worktrees even without checkout-local configs or prior hook injection
│ removing a legacy Codex copy shifts surviving checkout records only after matching their full entry at the old position
│ uninstall of a shared partition removes the selected tool's hooks and registrations for that whole installation before deleting its authority
│ the synthetic main-checkout manifest and empty .teamai directory go only after the last hook record is removed
│ uninstall also retires selected tool records for missing hook files after their last checkout releases ownership
│ removal releases one Claude entry per record, so an identical copy another checkout records stays
├── teamai.lock packageLockDir: installed package versions (#993; an older <root>/.teamai/teamai.lock is moved here, or copied while git tracks it,
│ and a <root>/.teamai/.gitignore holding only `teamai.lock` is removed outside self mode)
└── workspaces/<managedMcpWorkspaceId(root)>/
├── root the checkout's path, written by its full pulls; liveness is probed from it (#993)
├── managed-hooks.json getManagedHooksPath: team hooks teamai wrote into this checkout's own hook files (Copilot's
│ .github/hooks/; every tool in self mode). Until #993 it sat in <root>/.teamai/: the first pull
│ moves its Copilot records (all of them in self mode), and deletes it once empty and untracked;
│ doctor names a tracked one. Other records stay there for the pre-#370 import and legacy sweep.
│ Self mode with sharing.gitExclude on: Claude's records describe .claude/settings.local.json, the
│ tracked settings.json keeps only the built-ins; off, they describe settings.json again (#915)
├── managed-main-checkout-hooks.json Claude / Codex team hooks indexed by their actual target checkout (#1041),
│ with sibling registrations when Git supplies a main checkout. Inherited submodule
│ clones have separate target indexes; a null mainCheckout keeps ownership per workspace.
│ The original checkout adopts the legacy flat index; inherited clones never adopt it.
├── managed-mcp.json managedMcpManifestPath, one per checkout; Copilot placement is true for bare, false for keyed, absent when unproven
├── managed-mcp-files.json resolvedMcpFilesPath: project MCP configs teamai may have written a resolved ${VAR} to, and whether
│ the paths earlier teamai.yaml revisions mapped were read; one of those git tracks is marked tracked (#882);
│ for an HTTP team, the configs the local agent wrote a credential to
└── search-index.json getProjectSearchIndexPath, one per checkout
<checkout>/.teamai/ one per checkout: committed knowledge, knowledge-wt/
MCP configs and ownership must describe the same completed writes. Existing JSON local-agent installs keep the old record until the config write succeeds; uninstall keeps it until the entry is removed. A later manifest-write failure restores the previous config. Reconcile keeps one snapshot per config before any tool writes it and restores those snapshots if saving ownership or a later config write fails. File records added by that failed run are cleaned up before Git protection is checked against the restored configs. If restoration also fails, the command reports both failures and keeps credential files excluded.
For an HTTP workspace without project config, the resource cache and MCP records
remain under that workspace's .teamai/. With git exclude enabled, local-agent
lists its generated .gitignore and the existing MCP record files by exact path.
Member files stay visible, and tracked files are never excluded.
HTTP source removal keeps ~/.teamai/local-agent/config.json as {disabled:true},
without an endpoint or credentials, so legacy config and environment fallback
cannot reconnect. Failed agent-hook removals retain agent-hooks.json and report
exit code 1; removal can retry without an active source. So does
git-exclude.json while an exclude file it records still holds a teamai block
(one removal could not write, or the line of a copy it left on disk), so the
next removal or teamai uninstall finds it. HTTP initialization
replaces the disabled config to enable a source again.
Sync, detached plugin reconciliation and HTTP source removal share ~/.teamai/.local-agent-sync-lock, outside the
cache directory cleanup. A user-scope teamai uninstall removes the source the same way before its other steps. A sync runs a server-pushed
uninstall_teamai command while it holds the lock, and passes it on: that
uninstall's process, the sync's child, neither waits for the lock nor releases it. Each reloads config after acquiring the lock. Removal
waits up to 30 seconds for the current operation, disables before teardown, and
reports failure without teardown if the lock cannot be acquired. A hook sync
skips while the lock is held; plugin reconciliation waits up to 30 seconds.
git worktree add takes a path outside the repo, and the owning repo is still
the business repo, whose refs every checkout shares. The search index is keyed
per checkout, like managed MCP, because each checkout indexes its own branch's
docs, rules and skills; a shared index served whichever checkout rebuilt it last,
with paths into that checkout. The learnings they index are shared, though, so
when contribute or pull rebuilds one checkout's index it deletes the other
checkouts' workspaces/*/search-index.json in the partition, and each rebuilds
from its own roots on its next recall. Git and http mode keep their paths:
their checkouts and queue already sat beside the shared clone.
Upgrading from the per-checkout layout:
-
Queue. The self migration (
init/pull/push/contribute/import --from-mr, above) moves each file of<checkout>/.teamai/pending-learnings/into the partition queue, atomically, never overwriting: a file with the same content there is the one already moved, and one with different content stays in place with a warning. The partition's config decides, not the checkout's: afterinitswitched the project to another kind from another checkout, a checkout that has not migrated still sayskind: self, and its queue would publish to the new repository. It is set aside instead, as a mode switch does (below), topending-learnings.selfbeside the partition queue, with a warning naming it.A queue with no
config.yamlbeside it is one an older self install left after its config had moved to the partition; once the project serves another install no plan covers it, so every migrating command sets it aside the same way, as the self install's. Beside a legacyconfig.yamlthat cannot be read it stays, with a warning naming that file. A superseded git install's queue goes topending-learnings.gitthe same way, and a git or http checkout's queue takes the same step before retire-only retires the rest.A queue is the partition's only when both installs have the same kind and team repository (#823 item 13, below): a checkout's git install of another team repository has its queue set aside as
pending-learnings.git-<repo>. While the partition config cannot be read, the self migration keeps everything in the checkout, queue included, with a warning. Likewise, whenever a learning is still in the checkout's queue once it was settled (the queue lock was busy, or one was queued meanwhile), the self migration relocates nothing and reportsskipped, keepingconfig.yaml, as retire-only and superseded do: the next run settles it. It runs per checkout; the old directory is the "not done yet" marker.After the migration,
contributeandimport --from-mrstop with exit code 1 and save nothing whenever a learning queued now would still be kept in the checkout's.teamai/(queueKeptInCheckout): the migration stood down on the checkout's busy.teamai/.sync-lock, the data home is still that directory because the partition has no config detection can read, or an old queue there could not move. The message names the cause and the next step.init(except--scope user) stops the same way, before it writes anything: it would set the project up and leave that queue forgit worktree removeto delete. A linked worktree that only ever contributes or imports does not take a learning with it when removed. -
Queue lock (#823 item 11). A command loads its config long before it queues, and in between the migration can move that config (it retires the checkout's
.teamai/, or relocatesconfig.yaml) orinitcan switch the project's kind or team repository. Queue writes (savePendingLearning, whichcontributeandimport --from-mrboth use), the migration and the kind switch therefore share one lock per queue home, the directory holdingpending-learnings/:~/.teamai/locks/queue-<first 16 hex of sha256(realpath(home))>.lock.It lives outside the home because the migration renames a checkout's
.teamai/, and a writer waiting on a lock inside it would create the directory again; the sync lock is not reused because git pulls hold it for their whole run. A write holds the lock only around the file write: under it, it re-reads<dataHome>/config.yamland saves nothing when that file is gone, cannot be read or names another kind or team repository (changed).The migration holds the checkout's lock from after its sync lock to the end, through the rename or the last
config.yamlmove;settleCheckoutQueueholds the partition's around reading its install and moving the queue;initholds it around setting the queue aside and saving the new config. The publish lists the queue under the same lock and check, so a command whose install changed publishes nothing: the learnings stay for the install they were written for. Waits are bounded (30 x 100 ms): a write then exits 1 withAnother teamai command is moving this project's queued learnings (...). Nothing was saved., the migration returnsbusy, andinitexits 1 without saving the new config.Locks are taken in the order sync lock, checkout queue lock, partition queue lock, and a queue write holds no other lock. A teamai older than this takes no queue lock, so its
contributebeside a migration can still leave a learning in.teamai.bak. -
Old checkouts. Registrations are shared by every checkout, and one left in any checkout's
.teamai/blocks the shared one from all of them, so the migration cannot wait for a pull in the right checkout.ensureWorktreehandles it on its cold path, just beforegit worktree add: for every registration of the same branch at a path ending in/.teamai/<dirname>, it runsgit worktree removewithout--force. A clean one goes (its commits are on the branch, which the new checkout reuses), so acontributein a linked worktree works with no pull first.One with uncommitted changes stays: the side-branch step fails with an error naming the path and the next step (commit or move the changes, or delete the path by hand), and the queue keeps the learnings until then. What
import --from-mrin 0.25.0 to 0.26.0-beta.3 left there is not such a change (#823 item 7): it wrotelearnings/<YYYY-MM-DD>-<title>.mdwithsource_mrin the frontmatter and never committed it.publishQueuedLearningsfirst finds the branch's one registered checkout (git worktree listof the owning repo, so the shared one or the old.teamai/<dirname>, never another repository's), and queues each untracked file of exactly that shape, directly underlearnings/, into the active namespace withcontribute's name, then deletes the original. A file the branch (any namespace) or the queue already has, bysource_mror by content, is deleted without queueing. The branch is the tree oforigin/teamai-learnings, fetched first, never the checkout's tracked files: the old checkout is never synced again, so it may miss a teammate's later import of the same MR, or still track one origin has since deleted (#823 item 21).When that fetch fails, every such file stays where it is until a run can fetch, so a stale ref never queues a duplicate; when
git ls-remoteshows origin has no such branch (an offline first publish never pushed it), origin adds nothing and the files are queued. A file of that shape that cannot be read is skipped, never holding back the rest.The queue is read under the queue lock, and only while the data home's config still names the install the command loaded (
readPendingForInstall, the checklistPendingForInstallmakes): afterinitswitched the project to another team repository the queue holds that install's learnings, so while it does, or while the lock stays busy, every such file stays where it is. It runs only under the sync lock, before the queue is listed, and never on a dry run, which publishes nothing from the queue either andpullreports asWould publish N queued learning(s)(#823 item 20); any other file in the checkout is left alone.The error is also printed as a warning, because several callers treat a side-branch failure as non-fatal and log it at debug only; a silent (hook) run prints nothing.
refreshdoes not swallow it:recall maintenanceandrecall promotestop with exit code 1 and write nothing, since the shared checkout they would write into does not exist and the next publish would clear what they wrote there as a stale directory. Any other failure to create or sync the checkout (git worktree addrefusing a branch checked out at a path teamai does not know, say) makesrefreshreturnfailedwith the cause: readers use what is there, and maintenance and promote stop the same way, naming the cause. -
Lock. Every checkout of the repo now shares each side-branch checkout and its lock. A
refreshthat cannot take the lock (held, or not creatable) checks nothing and never creates, prunes or removes a checkout, which may be the holder's work in progress; it returnsbusywith the lock path, and a reader (members,projects members,digest,pull,statsandviz, throughreadableReportsWorktree) uses the local copy and never ensures it, after the same ownership probe asindexableVotesDir(below): another repository's copy is refused withForeignCheckoutError.A refresh that failed is tried once more, under the lock, and a second failure throws its cause: a reader never creates the checkout without the lock, which a writer that took it meanwhile may be creating (#823 item 15). The checkout there may be another repository's, or not created yet, so
recall maintenanceandrecall promotestop with exit code 1 and write nothing when either the reports lock (they rank by its votes) or the learnings lock is taken; the error names the lock and says to run the command again, or to check that the partition is writable. Their refresh fetches into the team clone, so it also takes the sync lock: while a pull or push holds it,recall maintenancestops the same way, and therecall promotelisting reads both checkouts as they are.
A git-mode install keeps the same checkouts and queue at the same partition
paths (beside <partition>/team-repo), so after a project switches mode the
checkout there may belong to the other repository. ensureWorktree accepts an
existing checkout only when its git rev-parse --git-common-dir, resolved,
matches the owning repo's. Otherwise it fails with an error naming the checkout,
the repository it belongs to and the command that removes it
(git -C <owner> worktree remove <checkout>); it never removes it itself. A
checkout git cannot open (rev-parse fails) is recreated only while the owning
repo still registers it: its .git file names a gitdir whose commondir leads
to the owning repo's git dir.
A gitdir without it proves nothing: git pruned the
registration, and a clone at the same path (the clone was deleted and cloned
again, as init does when it switches to another team repository) is not the
repository the checkout came from. That checkout, and any other path with a
.git (one whose repository was moved or deleted, or a .git directory), is refused with
the same error type, saying teamai cannot show whose it is, and is never
removed; a path with no .git (a partial leftover) is cleared as before.
The
refusal is a ForeignCheckoutError, which refresh does not swallow either,
because every path under the checkout is the other install's:
recall maintenance and recall promote stop with exit code 1 instead of
rewriting that team's learnings, and members and projects members stop the
same way on another repository's reports checkout. Every search-index build
(pull, contribute, and recall when it has no index) builds from
indexableLearningsRoots, which probes ownership and leaves out only that
checkout's learnings: the queue, docs, rules, skills and older learnings stay
recallable, and a plain recall runs no git. digest and the dashboard (viz:
its throwaway index and its promotion and prune candidates) use the same roots.
Promotion publishes the new knowledge entry from a disposable worktree based on
the freshly fetched default branch, for both separate team repositories and self
mode. Only the new entry is staged on a review branch; the user's active branch,
index and working files are left alone. The artifact push must succeed before
promoted_to is written to the learning. That marker is then published through
the existing learnings maintenance journal, so a failed marker push can retry on
the next pull without generating the knowledge entry again. A failed artifact
push leaves the learning eligible. Existing targets are refused, never replaced.
The marker keeps the learning out of the candidates only while a commit on
origin's default branch or on a teamai/push/* review branch has touched its
target, so renaming or deleting the merged entry later does not offer it again.
recall promote fetches both first, under the sync lock, and prunes the review
branches origin deleted, so a review closed unmerged, once its branch is
deleted, offers the learning again. While a pull or push holds the lock, the
listing skips the fetch and uses the last one. Promoting it again replaces the
marker. The dashboard applies the same rule to the refs as last fetched.
Those builds, and the reports checkout teamai recall feedback --negative counts
the team's upvotes from, take the votes directory from indexableVotesDir,
which runs the same probe on the reports checkout: another repository's votes
are left out, so no learning of this project carries that team's hotness.
With recall feedback --dry-run, config resolution stays read-only and the
command returns after previewing the requested feedback, before any vote read,
migration, lock, or reports-checkout probe. No vote count is predicted; ordinary
diagnostic logging still applies.
Negative feedback reads the team's votes without persisting a v1 → v2 upgrade there. A successful downvote is recorded in the scope's local votes and reaches the team's reports on sync.
The recall hook's vote judge, which starts no git process, reads the same answer
from git's files: the checkout's .git file names its gitdir, whose commondir
leads to the owning repository's git dir, compared (realpath'd) with this
project's. When they differ, or either cannot be read (a gitdir without
commondir included), the checkout's roots
leave the judge's allowed read roots; a path with no .git is judged as before.
The queue is the same partition directory in both modes, and its learnings were
written for the previous install's repository. A queue's owner is its install's
kind and team repository (repo.remote, compared as remotesMatch compares
remotes: credentials, protocol, scp or URL form, a trailing .git or / and
case do not count; #823 item 13).
When init changes the owner with learnings
still queued, it moves the queue, under the queue lock and together with saving
the new config (above), to pending-learnings.<previous kind>, or
pending-learnings.<kind>-<repo> when only the team repository changed (<repo>
is the compared form of the previous remote with / and the like as -, e.g.
pending-learnings.git-github.com-org-team-a), beside it (the first free name,
nothing is overwritten) and says how many learnings it set aside and where;
nothing is published to the new repository or deleted.
A previous config that
exists but cannot be read names no owner: the loaders return null for it as for
a fresh install, so init checks for the file, and with learnings queued moves
them to pending-learnings.unknown the same way, the warning naming that config;
queued or not, it drops the search indexes, as on an owner change.
When init is about to clone another owner's team repository where the old
install's clone was, or reuses a clone of it that an earlier init left there,
it settles the old install right then, before the clone or the refresh,
the same way (queue set aside, indexes dropped), and moves the old
config.yaml to the first free config.yaml.previous[.<n>] instead of saving
(#823 item 17). The save at the end still compares with the old install
loaded before the clone, finds nothing left to set aside, and writes the new
config.
An init that stops in between (an unknown --role, a busy queue lock, a
prompt left) leaves no config naming the old team beside the new clone, so every
command asks for teamai init; nothing is deleted. The rerun finds no
config.yaml, so it reads the last config.yaml.previous[.<n>] for the
settings a re-init carries forward (inheritUserScope, agents, tool roots).
A self install whose
business repository moved to another URL (renamed or transferred) is another
owner too: init sets its queue aside, and the warning names the directory to
move the files back from.
It also deletes every search index
in the data home (the root one and each workspaces/*/): they were built from
the other repository, and recall rebuilds a missing index. uninstall lists, before it asks,
how many unpublished learnings each queue in the data home holds, set-aside ones
included, so the member can publish or copy them first.
Every checkout keeps its workspaces/<id>/ (search index, managed MCP and
the MCP configs it wrote a resolved value to, resource cache) in the shared data home. A full pull writes its
checkout's path to the directory's root file, then removes the directories
whose root is no longer a checkout of the repository, probed as for the
lastPullByWorkspace entries above (#993). A directory without root (an
older CLI's, or one contribute, recall, viz, the MCP writers or the local agent
created before its checkout's first full pull) goes only when
git worktree list --porcelain -z names every checkout (no non-bare entry is
the common directory) and none of them, as git prints it or realpath'd, has its
id.
With --separate-git-dir, in a submodule, on git before 2.36, or when it
holds local-agent/, it stays until its checkout's own full pull writes
root. The fast path probes nothing.
import --from-mr queues its learning in pendingLearningsDir and publishes
it as contribute does (#823), so in self mode it lands in the partition queue
and needs no checkout before the extraction. Its possible-duplicate check reads
the queue and indexableLearningsRoots, with the active project namespaces,
never another repository's checkout.
The self migration that moves queued learnings into the partition drops every
checkout's workspaces/*/search-index.json: none of them has the moved
learnings, and recall rebuilds a missing index. It drops them whenever a
learning ends up in the partition queue, including one an interrupted run had
already moved there, so a retry after a crash does not leave them stale.
Functionization. A handful of top-level path constants were computed once at
module import: export const TEAMAI_HOME = path.join(getUserHome(), '.teamai') and
its derivatives (config/state/token/update-lock/session-logs/learnings/votes/
search-index). Because they froze at import, a test that later swapped HOME never
saw the new value — so HOME-based isolation silently failed (tests worked around
it with vi.resetModules() or vi.mock('../types.js')). P3 converts them to
call-time getters (getTeamaiHomeDir(), getUserVotesDir(), getSessionLogsDir(),
…), matching the existing getUserHome() / getDataHome() pattern, so isolation
just works. Seven consts that already had runtime getters and no live consumers
(TEAMAI_SOURCES_DIR, TEAMAI_USAGE_PATH, TEAMAI_KNOWN_SKILLS_PATH,
TEAMAI_PUSHIGNORE_PATH, CONTRIBUTE_SESSIONS_DIR, DASHBOARD_EVENTS_DIR/PATH)
were removed.
Functionization ≠ project-scoping. All of these are class-A2 (machine-level):
the getters still return ~/.teamai/..., unchanged, except
getUserVotesDir() (below). The project-scoped equivalents
already route through getDataHome(). Skill usage moved there too (#748):
usage.jsonl lives in each scope's getDataHome(), because one shared file let a
project's report carry every project's skills. The user scope records in
~/.teamai/user-usage.jsonl, not that old shared ~/.teamai/usage.jsonl, which
an earlier release still writes after a rollback; the shared file is never
read. Local votes followed for the same reason (#787): <dataHome>/votes/, and
~/.teamai/user-votes/ (getUserVotesDir()) for the user scope, so a scope
pushes only the votes cast where it is set up.
The old shared ~/.teamai/votes/
is never read, and its pending deltas are not pushed. The recall log sits beside
them (#884): <dataHome>/dashboard/recall.jsonl in every scope (dashboard/ is
already ignored in a workspace .teamai/), local and owner-only, never pushed, and pruned only by pull (30 days, then 5,000 lines). The dashboard stays an A2
singleton: teamai dashboard, session save and the contribute check read
across scopes; stats --by-repo reads only the current scope's events, as the
rest of stats does (#795).
Each event instead
carries dataHomeKey, a hash of the realpath'd getDataHome() of the scope the
hook resolved (#785; a hash, so a Copilot event still stores no path), and a
scope's report keeps the sessions whose first keyed event is its own, whole: a
Stop carries the whole transcript's totals, so a session that moved scope (a cd
mid-session) is reported once, where it started. A tool's own session ID is one
session whatever ends it records: claude --resume continues it, in a new
process, and its Stop carries the whole transcript.
A fallback ID (pid-…;
Copilot's is the parent PID) names one run up to its session_end or
process_exit, so a later run that reuses it is decided on its own; a second end with nothing
recorded since the first (the dashboard monitor's process_exit after
SessionEnd) belongs to the run it closed. A session_start on a fallback ID
(pid-…) whose monitorPid differs from its open run's begins a new run even
though nothing ended that one (a crash with no dashboard running), and that one
counts as the run closed before it; a tool's own
ID is not split this way, since Claude fires SessionStart again on resume, in a
new process, and its Stop carries the whole transcript.
The monitor's process_exit also
records processExitAfter, the last event it observed, and closes only that
run: an exit appended after the next run of the same ID began does not end it,
and one whose run compaction dropped is ignored. A dashboard started before
that field existed writes none. A dead process records nothing more, so such
an exit followed by more events of its fallback ID before the next start did
not end the open run: it belongs to the run closed before it, however late it
was appended. A tool's own session ID is
reported and snapshotted under the ID itself, as before, so a session resumed
after compaction dropped its events still reads what its scope reported.
The
scope that first reports it also appends the ID and its own data home key to
~/.teamai/dashboard/session-owners.jsonl (never a path, #666), and a session
recorded there is that scope's wherever it is resumed later, whatever the log
still holds, so another scope never reports its transcript again; the first
line for an ID wins, and the file grows by one line per such session, like the
snapshots. An earlier release kept only per-scope snapshots, so the file is
first written from them: a tool's own ID is the scope's whose snapshots show it
reported it with the greatest total (prompts, then tokens).
They show it when the shared snapshots (all three) hold none of it, or the scope is past their total: that release copied the shared file into every scope it ran in, so a copy, even the only one, shows nothing, and a tie names no owner.
When several scopes reported it (a session that release split per event), the owner's line also carries the credit of their parts, applied once as its baseline: a part whose daily entry shows it ended in a Stop holds the transcript's cumulative total, so the greatest such part counts once, while a part with no Stop counted its own prompts, which add; interruptions, rejections and tokens, from Stops, take the greatest, and corrections, counted per prompt, add.
Whether a part with no Stop came before another's cumulative Stop, which already counts it, is read from the session's transcript when it has one (Claude): it keeps every prompt in order with the directory it was typed in, so the Stop covers the first prompts and only the part's prompts after those add. With no transcript to place them they all add, which undercounts once but never sends a prompt again.
The scopes read are the user scope, every partition, and a project whose data home is in its workspace that a session still in the log leads to; each report also records the IDs of its own snapshots that have no owner yet and show it reported them (absent from the shared snapshot, or past its total there).
A session none of these reach
(a project whose data home is in its workspace, with no event left in the log)
is found by its transcript: hooks record transcriptPath on UserPromptSubmit,
Stop and SessionEnd (not SessionStart, whose path on a resume from another
project names a file that never exists; never Copilot's), and a Claude
transcript keeps its first cwd when resumed elsewhere, as a Codex rollout
keeps its session_meta and Copilot's own session log, found by the session ID
without storing its path, its session.start context.
So a tool's own session with no owner is the scope's that directory resolves to, when that scope's snapshots already hold it; else it is decided as before (a fork under a new ID, a tool whose transcript records no start).
A session main split across scopes
per event, whose events are still in the log with each part's dataHome, is
credited once with every part reported: for each scope, the shortest prefix of
its events whose metrics reach its snapshot, and the owner's entry is raised,
counter by counter, to at least the metrics of their union (a part may have
reported more time, tokens or costs with no more prompts), so parts counted before
any Stop carried the transcript's total are neither lost nor sent twice.
A Codex session (any Codex variant: codex, codex-internal, tcodex) is kept
per rollout, with or without a token record, and when
its tokens come from a thread-level counter that already spans rollouts (then
no rollout holds tokens of its own, nor does the prior rollout an entry from
before leaves); a rollout's prompts are its Stop's count or
else its submits. A
rollout's counters restart, and compaction drops its events, so its prompt-token
entry holds each rollout's reported prompts, tokens, interruptions, rejections,
corrections, active time and request costs under a hash of the rollout's path,
written with any delta, and whether it failed (an error, an interruption or a
correction).
The session's daily request costs sum its rollouts in the log. A rollout compaction has dropped keeps those totals in the session's prompt-token, intervention and daily sums (cache tokens from its tokens), and a failed one keeps the session unsuccessful, so a later rollout is reported in full and does not turn it into a success. An entry written before rollouts were kept is one total: an earlier release rewrote every session in the log on each report, so it covers the rollouts begun by the time its file was last written, or, earlier, when that report wrote the team stats file in this scope's reports checkout (after reading the log, before its push; the snapshot came after the push).
That is read before this report writes
anything; a seed keeps the
shared file's time, and teamai stats, which only reads, writes no seed). Those still in
the log consume it in order, as far as each had got by that time, what is left is the dropped rollouts', kept as one
prior rollout, and a rollout begun later is new.
Compaction also keeps a session whose tool process is still running, so a run
an exit from a dashboard before processExitAfter marked stopped keeps its
start, and its ID. A
fallback run is reported and snapshotted as <id>@<first event's timestamp>,
which does not change when compaction drops earlier runs.
A snapshot entry keyed by a bare fallback ID (written before) is the sum of the runs of that ID in the log at the earlier release's last report, and compaction keeps or drops the runs of an ID together. So the next time the scope reports, those runs consume the entry in log order, each taking up to its own totals of what is left; once the prompt-token entry is used up, the later runs were not reported and count as new sessions (the interventions and daily snapshots follow the prompt-token one, since their counts say nothing when they run out).
A run keeps its own
success and correction flags, since the sum's are no single run's, so an
adopted run changes no status total. The first run always
takes a share, as the entry means that release reported it. The entry is then
removed, so no later run of that ID reads it. Only an earlier release wrote bare
entries, and a seeded one may be another scope's, so a run whose first event
carries dataHomeKey (recorded by this release), and every later run of its ID,
takes none.
A session written before that field existed is attributed by its first
cwd: to the scope resolveConfigForDir resolves that directory to now, the
dispatcher's rule, so a nested clone under a project is not the project's; no
cwd, or one removed since, is no scope's. Inside git an event also carries projectAnchor, the repo's
stable identity, which all of its worktrees share (#809); a Copilot event, with no
cwd, carries none. stats --by-repo,
session save and the dashboard's Repository filter key a session by the last
anchor it recorded, else by its cwd, and the dashboard gives an event to the
project identified by its anchor, so a worktree counts as its repo, also after it
is removed. Checkout recovery uses mainCheckout, core.worktree, recorded
live checkout roots and Git's worktree list; it never exposes a Git directory as
a dashboard workspace or local-agent checkout (#1041).
A hook whose cwd no longer exists (a session that outlives its
worktree) would resolve to the user scope, or be dropped without one, so it
keeps the scope its session last recorded instead (#810): the config at that
event's projectAnchor, resolved through a live checkout when the anchor is a
Git directory, used only while the key of its getDataHome() is still the recorded
dataHomeKey. Its events and skill uses stay with the project, and the share
reminder's gate reads the project too. A project config there that
cannot be read records nothing, and with nothing recorded to match the hook
resolves from its cwd as before.
The recorded scope is read from events.jsonl, so it lasts as long
as the session's earlier events do (compaction keeps only active sessions),
and Copilot, whose events record no directory, has none to recover.
The snapshots of what was already reported are per scope
too (#786), because a session ID can recur in another scope (Copilot's fallback
ID is the parent PID):
<dataHome>/dashboard/reported-*.json, and ~/.teamai/dashboard/user-reported-*.json
for the user scope. The first time a scope needs one it seeds it from the shared
~/.teamai/dashboard/reported-*.json, so nothing reported before the upgrade is
sent again; after that it reads only its own.
That file summed every scope's
runs of an ID, so the runs of the whole log consume it in log order, whichever
scope each belongs to, and the seed keeps the shares of the scope's own runs,
under their run IDs; none goes to a run recorded
with a dataHome path: that release already kept per-scope snapshots, so a
shared entry under its ID is another scope's. An unmatched fallback entry is
dropped, so a later reuse of the PID cannot inherit it; a tool's own session ID
is copied whole, as before, so a session resumed after compaction dropped its
events is not sent again.
The shared file is no longer written, except by an earlier release after a rollback, so every scope seeds from what the machine had reported by then, never from another scope's later report. The seed holds a session's whole total, so a session still running at the upgrade goes on from the reported total, as before.
Every writer that may modify events.jsonl — each hook's append, and a
compaction that finds the log past its threshold or side files to fold —
takes events.jsonl.lock beside it (#804), the pattern the usage
file took for the same lost update (#803): compaction's
read → filter → temp-file → rename cannot drop an append that lands while it
runs, and two compactions cannot interleave their rewrites. A compaction
whose log is below the threshold and holds no side files does the common
case lock-free: one read, and it returns without touching the lock, so a
state directory that is being torn down concurrently never meets a lock
creation.
A hook append waits
up to ~250 ms, inside its foreground budget, and one that gives up records its
line in an events.pending-<uuid>.jsonl side file, with the file's mode, that
the next lock holder folds into the file before it writes — so an event is
late, never gone. Side files fold in their events' own time order, not
readdir's, and the compaction classifies sessions in time order too (the
order every reader rebuilds from), so a side file that outlived newer appends
cannot place an older event after them and re-mark a live session stopped.
The line carries a pendingId, so a fold never appends a side file twice (a
holder that died after appending it but before removing it leaves it for the
next one), two side files of identical events are both kept, and the id stays
in the raw file — a rewrite keeps it, as the usage file's does — until the
side file itself is gone, while no reader ever sees it: readEvents drops it.
A side file without its trailing
newline is still being written and waits for the next holder.
A compaction
waits up to ~5 s for a peer's rewrite, and skips — leaving the file as it is
for the next compaction — when a live holder outlasts the wait; a lock whose
owner is gone is reclaimed, and a rewrite's temp copy left by a killed
compaction (events.jsonl.<pid>.<hex>.tmp) is removed by the next one. The
side files and the lock live in ~/.teamai/dashboard/ beside the log, which
no repository tracks, so nothing needs adding to a workspace .gitignore.
anchor on save. Previously only migration wrote a partition's anchor
reverse-lookup file, so freshly-init'd partitions had none. saveLocalConfigForScope
now writes it whenever the config lands in a partition (via the shared
writeAnchorFile), so every partition can be resolved back to its project.
status --all. Extends the existing status command with an --all flag that
enumerates every partition under ~/.teamai/projects/ and marks each
active / ORPHAN (project path gone → safe to delete) / unknown / corrupt. The
verdict rests only on the anchor file — the shared project anchor the
partition is keyed by. The config's businessRepoRoot/projectRoot is read purely as
a display fallback: it is a persisted workspace path that may point at a linked
worktree, so its disappearance does not prove the shared partition is orphaned. A
partition with no anchor (e.g. one written before anchor-on-save) is therefore
unknown, never ORPHAN — we never recommend deleting data we cannot confirm is
dead.
teamai never auto-collects orphans (a renamed or deleted project leaves its
partition behind — a gc command is explicitly out of scope), so this is how a
user finds partitions safe to rm -rf by hand.
teamai migrate / gc / --revert commands; cross-project shared team-repo clone.
Downgrade to an older teamai after
P1 migration is not supported (.teamai.bak/ is the manual rollback path).
A member's values for their teams' declared secrets live in ~/.teamai/secrets/,
a class-A2 (machine-level) directory: teams/<hash>.json, one file per
team repo, named by the full SHA-256 hex digest (64 characters, never shortened) of the team repo URL in ~/.teamai/config.yaml (never
teamai.yaml's repo:), without the team name, so renaming team: keeps the values;
the hash covers the URL's scheme (the ssh forms count as one; https and http are two),
ssh user, host, non-default port and path (an scp-style path not starting with / or ~
is in the user's home, as ssh://host/~/path; ssh://host/path is from the root), so an
scp path in the home and the ssh:// path from the root, two ssh users' repos on one host, two
repos on one host with different ports, or repos behind http and https, get different files,
and machine.json, the values set with teamai env set --global for every team.
Every scope that uses the same team, and every worktree of it, reads the same file.
Each entry records whether it is a secret's value or the member's value for an
env.yaml variable (kind), so one is never used as the other.
It never goes to a partition or to <dataHome>, which in single-repo mode is inside
the business repo, and it is not ~/.teamai/env, which is already the user scope's
env backup file. Files are written atomically with mode 0600. Uninstalling a
project scope removes only its partition, so the values stay; uninstalling the user
scope removes ~/.teamai and them with it, but for the projects still set up on the
machine: their partitions, their teams' files, and machine.json unless each of those
teams' checkouts can be read and declares none stay (#1025). See Team secrets.
Beside each scope's env.sh, in <dataHome>, env.sh.exports.json records what
that env.sh has exported: per key, a SHA-256 of KEY=VALUE for the last 20
values, never a value, mode 0600. It is machine data like env.sh and is
removed with it. See Team secrets.