diff --git a/CHANGELOG.md b/CHANGELOG.md index cda12dc06..2fa0cbfc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,9 +29,9 @@ All notable changes to this project will be documented in this file. See [standa - The legacy `teamai dashboard-report` command no longer records dashboard events in a directory that never set up teamai. A current install writes only `teamai hook-dispatch`, whose dashboard-report handler declares `requiresConfig` and is dropped when no config resolves for the hook's `cwd`; the old subcommand stayed ungated, so a hook left behind by an earlier install kept recording events for every project it fired in, and those sessions were then reported by whichever scope pulled next. It now applies the same gate `teamai contribute-check` was given, asked about the session's `cwd` — or, for a host that sends none, the directory the hook runs in (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). - The lock behind `pull`, `push`, the reports and learnings worktrees, learnings publishing, migration, self-mode bootstrap and the update check no longer hands one lock to two live processes. Reclaiming a stale lock renamed over whatever file was there once it had judged the lock stale, and it judged live locks stale: one that had just been released (and could be re-created by a third process before the rename), one whose owner had created it but not yet written it, one owned by a process running as another user (for example a `sudo teamai` run), and one it could not read. Under 16 processes contending on one lock, about 1% of acquisitions overlapped another holder, enough for two pulls to report the same usage twice. Now only a lock whose owner is provably gone is reclaimed; a lock that vanished gets one more exclusive create, and a new lock is published with its content already in place (written to a temp file, then hard-linked to the lock name; a filesystem without hard links falls back to the previous create). A lock that names no owner (empty, partly written, unreadable) is never reclaimed: if a crash left one, `pull` and `push` report busy until it is removed, and a warning names the file. Migration skips the lock's temporary files, which a contending pull creates and removes while the copy runs. With the change, the same stress run shows no overlap (for [#760](https://github.com/Tencent/teamai-cli/issues/760)). -- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for hooks and skill usage an unreadable project config never falls back to the user scope; `teamai doctor` reports it. A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). +- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for team hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition (the session-start pull still resolves its project on its own). A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - Every team hook handler now works in the scope `hook-dispatch` resolved for the hook's `cwd`. The team correction keywords, votes and webhooks read their config again from the directory the hook process ran in, so when that `cwd` no longer existed (a deleted worktree) and the host started the hook inside another project, that project's keywords were applied, the session's votes were recorded under its member and pushed to its team, and its webhooks fired. The background handlers (session-start pull, webhooks, update check) also run again when that `cwd` no longer exists: on macOS and Linux their detached process was started in it, so the start failed silently and none of them ran. It now starts in the temp directory, as on Windows (for [#752](https://github.com/Tencent/teamai-cli/issues/752)). -- Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`/usage.jsonl`: the project partition, `/.teamai` in single-repo mode, `~/.teamai` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. A machine's first user scope starts with an empty file, since the events it held by then cannot be attributed; on a machine with only project scopes, events recorded before the upgrade stay unreported. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). +- Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`/usage.jsonl`: the project partition, or `/.teamai` for an install not yet migrated to one, and `~/.teamai/user-usage.jsonl` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. Events in `~/.teamai/usage.jsonl` name no project, whether an earlier release left them there or writes them again after a rollback, so they are never read or reported. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - `teamai init` no longer hangs without a terminal. When the provider had no session it spawned `gh auth login --web` (or `gf auth login`, `cnb login`) with inherited stdio and waited for a browser device flow that nobody could complete, about five minutes for GitHub, then exited with the provider's error and no hint of the missing credential. Each login now refuses up front when the run is not interactive and names the credential to prepare (`GITHUB_TOKEN` / `GH_TOKEN`, `CNB_TOKEN`, or for TGit a prior `gf auth login`, since a `TGIT_TOKEN` PAT is REST-API-only and cannot clone). A run is non-interactive when stdin is not a TTY or when `CI` or `TEAMAI_NONINTERACTIVE` is set, so an agent sandbox with a pseudo-terminal can declare itself unattended, and every prompt in the CLI follows the same rule. `git` also runs with its prompts closed in that case — `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=echo` and `GCM_INTERACTIVE=never`, each only where the caller set nothing — so a missing clone credential fails at once instead of waiting on a terminal prompt or an askpass or credential-manager dialog. `ssh` keeps its own settings: its batch flag is only reachable through `GIT_SSH_COMMAND`, which would override each repository's `core.sshCommand` (for [#711](https://github.com/Tencent/teamai-cli/issues/711)). - A `manifest/roles.yaml` that exists but does not parse now fails the pull for that scope instead of warning and syncing with no role filter at all, for a member with no role as much as for one with a role. The same applies to `init` and `push`, which each fell back to a guess at the namespaces when any error came out of the loader. The legacy role migration skips with a warning instead of failing, so every command, `pull` included, still loads the config and can fetch the fixed manifest; until it can run, the member holds no role rather than every role, so hooks, MCP servers and env variables scoped by `roles:` reach them no more than skills do. For a member with no active project that fallback meant an unfiltered sync, so a broken manifest delivered every namespace it was written to gate. Only an absent manifest still means "this team does not use roles"; an unreadable or empty file is an error, as it now is for `manifest/projects.yaml` too. `push` stops at its scan for such a manifest (exit 2) even with `--role `, since the scan needs it to tell which namespaces are the member's. - `teamai members list` and `teamai projects members` read the roster registered before the reports switch, so a team upgrading past the orphan-branch split no longer sees "No team members registered" while its `members/` still lives on the default branch. The default-branch copy becomes a read-only inherited root, the way learnings' already was: listed in union with the `teamai-reports` copy, with the branch copy winning when the same file exists on both; nothing is copied or deleted, and a cold `members list` still does not publish the reports branch. Member registration merges against the inherited copy too, so a re-init keeps the original `registeredAt` and projects. Fixes [#735](https://github.com/Tencent/teamai-cli/issues/735). diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 24f320b24..46df6a2f0 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -309,7 +309,10 @@ were removed. the getters still return `~/.teamai/...`, unchanged. 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 dashboard is likewise an A2 singleton +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. The dashboard is likewise an A2 singleton (events carry `cwd`/`sessionId`); "two projects' events don't mix" is satisfied by `getEventsPath()` reading `HOME` at call time, not by per-project dirs. diff --git a/docs/designs/team-intelligence-platform.md b/docs/designs/team-intelligence-platform.md index befe3b0fd..53771acf7 100644 --- a/docs/designs/team-intelligence-platform.md +++ b/docs/designs/team-intelligence-platform.md @@ -69,7 +69,7 @@ Transform TeamAI from a simple skill-sharing CLI into a **Team Intelligence Plat **Storage:** `~/.teamai/sessions/.md` 按月聚合。 #### 2. Skill Usage Tracker (Local) -**What:** PostToolUse hook 检测 Claude Code 的 Skill 工具调用,追加写入该 scope 的 `/usage.jsonl`(会话所在目录对应的已配置项目,否则 user scope 的 `~/.teamai/`;未配置 teamai 的目录不记录,#748)。 +**What:** PostToolUse hook 检测 Claude Code 的 Skill 工具调用,追加写入该 scope 的 `/usage.jsonl`(会话所在目录对应的已配置项目,否则 user scope 的 `~/.teamai/user-usage.jsonl`;未配置 teamai 的目录不记录,#748)。 **Data format (JSONL, one event per line):** ```jsonl @@ -95,7 +95,7 @@ Skill Usage Statistics: teamai pull │ ▼ -聚合 /usage.jsonl → stats/.yaml +聚合该 scope 的 usage 文件 → stats/.yaml │ ▼ reports worktree → git add → git commit → git push (teamai-reports) @@ -162,13 +162,15 @@ reports worktree → git add → git commit → git push (teamai-reports) │ recommend.ts │ │ ┌──────────────┐ │ digest.ts │ push --stats │ Local │ │ stats.ts │ push --sessions -│ / │◀───│ team-push.ts ──┼────▶(直接 commit, 无 MR) -│ usage.jsonl │ │ │ +│ ~/.teamai/ │◀───│ team-push.ts ──┼────▶(直接 commit, 无 MR) +│ usage* │ │ │ │ sessions/ │ └────────────────┘ │ .md │ └──────────────┘ ``` +\* skill 使用记录按 scope 存放:项目为 `/usage.jsonl`(项目分区),user scope 为 `~/.teamai/user-usage.jsonl`(#748);`sessions/` 仍是机器级别的 `~/.teamai/`。 + ## New Files | File | LOC | Purpose | diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ccfbab62e..7818681b8 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -533,7 +533,7 @@ teamai pull --dry-run # Dry run, no actual changes A manual `teamai pull` ends by running the `teamai doctor` checks and printing each one that failed, with its fix — including whether the skills it just reported syncing are readable on disk for every enabled tool. It prints nothing when they all pass, and the exit code is unchanged. The SessionStart hook path and `--dry-run` run no checks at all, so session startup stays as fast as before. Provider checks (`gh`/`gf` authentication) are left to `teamai doctor`: the pull just used the provider. -> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because the built-in hooks gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). A project config that exists but cannot be read counts as none, never as the user scope. Self single-repo mode keeps its hooks in the business repo so they travel on clone. +> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because the built-in hooks gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). For the team hooks and skill usage, a project config that exists but cannot be read counts as none, never as the user scope or as a lower-priority project config (such as a legacy `.teamai/config.yaml`) behind it; the session-start pull still resolves its project on its own. Self single-repo mode keeps its hooks in the business repo so they travel on clone. With role-based skills enabled, `pull`'s skill sync source becomes the contents of `skills//`, expanded according to `primaryRole + additionalRoles` and flattened into each local AI tool's skills directory. `rules/` and `docs/` keep their original sync behavior; `agents//` follows the role's `agents` namespaces (see [Agents Resource Type](#agents-resource-type)). `learnings/` at the root is shared with everyone, while `learnings//` subdirectories sync only for the directory's active projects (see [Multi-project](#multi-project-project-as-a-dimension-orthogonal-to-role)). diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index a953cdb7d..b79eeb188 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -498,7 +498,7 @@ teamai pull --dry-run # 试运行,不实际修改 手动执行 `teamai pull` 会在结束时运行 `teamai doctor` 的检查,并逐条打印失败项及其修复建议——包括它刚刚报告同步的 skill 是否真的落到每个启用工具的磁盘上、且可被读取。全部通过时不会有任何额外输出,退出码也不变。SessionStart hook 路径和 `--dry-run` 完全不运行检查,会话启动速度保持不变。托管平台相关的检查(`gh`/`gf` 认证)留给 `teamai doctor`:这次 pull 刚刚用过该平台。 -> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为内置 hooks 依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。存在但无法读取的 project 配置视为没有配置,而不会退回 user scope。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 +> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为内置 hooks 依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。对团队 hooks 和 skill 使用记录而言,存在但无法读取的 project 配置视为没有配置,而不会退回 user scope,也不会退回其后优先级更低的 project 配置(如旧的 `.teamai/config.yaml`);SessionStart 时的 pull 仍自行解析其项目。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 启用角色化 skills 后,`pull` 的 skills 同步来源会变成 `skills//` 中的内容,按 `primaryRole + additionalRoles` 展开对应的 namespace,拍平安装到本地各 AI 工具 skills 目录。`rules/`、`docs/` 仍然保持原有同步逻辑;`agents//` 按角色的 `agents` namespace 同步(见 [Agents 资源类型](#agents-资源类型))。`learnings/` 根目录对所有人共享,而 `learnings//` 子目录只对本目录激活的项目同步(见 [多项目](#多项目project-作为与-role-正交的维度))。 diff --git a/src/__tests__/e2e/report-timeout.test.ts b/src/__tests__/e2e/report-timeout.test.ts index 2971e2cd2..585372f04 100644 --- a/src/__tests__/e2e/report-timeout.test.ts +++ b/src/__tests__/e2e/report-timeout.test.ts @@ -37,7 +37,7 @@ function fixture(agent: keyof typeof agents, provider: string) { repo: { localPath: clone, remote, kind: 'git' }, username: 'alice', scope: 'user', updatePolicy: 'skip', enabledAgents: [agent], additionalRoles: [], })); - const usage = path.join(home, '.teamai/usage.jsonl'); + const usage = path.join(home, '.teamai/user-usage.jsonl'); const dashboard = path.join(home, '.teamai/dashboard'); const timestamp = new Date().toISOString(); const usageLine = JSON.stringify({ skill: 'review', tool: agent, timestamp }) + '\n'; diff --git a/src/__tests__/hook-dispatch-scope.test.ts b/src/__tests__/hook-dispatch-scope.test.ts index 26e52d007..96471372b 100644 --- a/src/__tests__/hook-dispatch-scope.test.ts +++ b/src/__tests__/hook-dispatch-scope.test.ts @@ -77,7 +77,7 @@ describe('hook runs and the scope they belong to (#748)', () => { await hook('stop', '*', { ...base, hook_event_name: 'Stop' }); expect(fs.existsSync(path.join(teamaiHome(), 'dashboard', 'events.jsonl'))).toBe(false); - expect(fs.existsSync(path.join(teamaiHome(), 'usage.jsonl'))).toBe(false); + expect(fs.existsSync(path.join(teamaiHome(), 'user-usage.jsonl'))).toBe(false); expect(fs.existsSync(path.join(teamaiHome(), 'sessions', 'sid-b.json'))).toBe(false); }); @@ -102,7 +102,7 @@ describe('hook runs and the scope they belong to (#748)', () => { await hook('post-tool-use', 'Skill', { session_id: 'sid-g', cwd: gone, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-g' } }); - expect(fs.readFileSync(path.join(teamaiHome(), 'usage.jsonl'), 'utf-8')).toContain('skill-g'); + expect(fs.readFileSync(path.join(teamaiHome(), 'user-usage.jsonl'), 'utf-8')).toContain('skill-g'); }); it('handlers follow the scope the dispatcher resolved, not the directory the hook process runs in (#752)', async () => { @@ -163,6 +163,22 @@ describe('hook runs and the scope they belong to (#748)', () => { await hook('post-tool-use', 'Skill', { session_id: 'sid-a', cwd: root, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-a' } }); - expect(fs.existsSync(path.join(teamaiHome(), 'usage.jsonl'))).toBe(false); + expect(fs.existsSync(path.join(teamaiHome(), 'user-usage.jsonl'))).toBe(false); + }); + + it('an unreadable partition config does not hand the project to its legacy .teamai config', async () => { + const root = gitRepo('project-a'); + const partition = await resolveProjectDataHome(root); + fs.mkdirSync(partition, { recursive: true }); + fs.writeFileSync(path.join(partition, 'config.yaml'), 'repo: [not: a, valid config\n'); + // A lower-priority legacy config that names another team. + const legacy = path.join(root, '.teamai'); + fs.mkdirSync(legacy); + fs.writeFileSync(path.join(legacy, 'config.yaml'), + `repo:\n localPath: ${path.join(legacy, 'team-repo')}\n remote: https://example.test/acme/other-team.git\nusername: tester\nscope: project\n`); + + await hook('post-tool-use', 'Skill', { session_id: 'sid-a', cwd: root, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-a' } }); + + expect(fs.existsSync(path.join(legacy, 'usage.jsonl'))).toBe(false); }); }); diff --git a/src/__tests__/http-repo-integration.test.ts b/src/__tests__/http-repo-integration.test.ts index 07e6e2638..0064fdfbe 100644 --- a/src/__tests__/http-repo-integration.test.ts +++ b/src/__tests__/http-repo-integration.test.ts @@ -60,35 +60,6 @@ describe('teamai init --http (read-only onboarding)', () => { }); }); -describe('user-scope init and leftover skill usage (#748)', () => { - const usagePath = () => path.join(tmpDir, '.teamai', 'usage.jsonl'); - const leftover = '{"skill":"from-another-project","timestamp":"2026-01-01T00:00:00Z","tool":"claude"}\n'; - - it('starts with no usage when the machine had no user scope before', async () => { - writeApiKey(); - // Recorded before any user scope existed: nothing says which project it came from. - fs.writeFileSync(usagePath(), leftover); - server = await startMockServer({ apiKey: API_KEY }); - - const { init } = await import('../init.js'); - await init({ http: server.url, force: true, scope: 'user' }); - - expect(fs.existsSync(usagePath()) ? fs.readFileSync(usagePath(), 'utf-8') : '').toBe(''); - }); - - it('keeps the usage of an existing user scope on re-init', async () => { - writeApiKey(); - server = await startMockServer({ apiKey: API_KEY }); - const { init } = await import('../init.js'); - await init({ http: server.url, force: true, scope: 'user' }); - fs.writeFileSync(usagePath(), leftover); - - await init({ http: server.url, force: true, scope: 'user' }); - - expect(fs.readFileSync(usagePath(), 'utf-8')).toBe(leftover); - }); -}); - describe('read-only protection (http kind)', () => { it('rejects teamai push', async () => { writeApiKey(); diff --git a/src/__tests__/team-push-interventions.test.ts b/src/__tests__/team-push-interventions.test.ts index 4cd44d432..64890fa01 100644 --- a/src/__tests__/team-push-interventions.test.ts +++ b/src/__tests__/team-push-interventions.test.ts @@ -91,7 +91,7 @@ describe('reportUsageToTeam — intervention reporting', () => { interventions: { interrupt: 1, toolReject: 0 }, tokens: { input: 10, output: 5, cacheRead: 0, cacheCreation: 0 } }, ]); - const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); + const usagePath = path.join(tmpDir, '.teamai', 'user-usage.jsonl'); fs.writeFileSync(usagePath, JSON.stringify({ skill: 'review', timestamp, tool: 'claude' }) + '\n'); return usagePath; } diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index 21f392f48..633908d5d 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -39,7 +39,7 @@ import type { LocalConfig, UsageEvent, UserStats } from '../types.js'; let tmpDir: string; const origHome = process.env.HOME; -/** The user scope seeded below; its usage file is `~/.teamai/usage.jsonl`. */ +/** The user scope seeded below; its usage file is `~/.teamai/user-usage.jsonl`. */ function userScope(): LocalConfig { return { repo: { localPath: path.join(tmpDir, '.teamai', 'team-repo'), remote: 'https://example.test/acme/team.git' }, @@ -185,7 +185,7 @@ describe('appendUsageEvent', () => { }; await appendUsageEvent(event, userScope()); - const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); + const usagePath = path.join(tmpDir, '.teamai', 'user-usage.jsonl'); const content = await fs.promises.readFile(usagePath, 'utf-8'); const parsed = JSON.parse(content.trim()); expect(parsed.skill).toBe('code-review'); @@ -210,7 +210,7 @@ describe('readUsageEvents', () => { }); it('skips corrupted JSONL lines', async () => { - const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); + const usagePath = path.join(tmpDir, '.teamai', 'user-usage.jsonl'); await fse.ensureDir(path.dirname(usagePath)); await fs.promises.writeFile( usagePath, @@ -224,7 +224,7 @@ describe('readUsageEvents', () => { }); it('handles empty file', async () => { - const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); + const usagePath = path.join(tmpDir, '.teamai', 'user-usage.jsonl'); await fse.ensureDir(path.dirname(usagePath)); await fs.promises.writeFile(usagePath, ''); @@ -257,6 +257,28 @@ describe('truncateUsageAfterReport', () => { }); }); +describe('usage recorded while every scope shared ~/.teamai/usage.jsonl (#748)', () => { + const sharedPath = () => path.join(tmpDir, '.teamai', 'usage.jsonl'); + const legacy = '{"skill":"from-another-project","timestamp":"2026-01-01T00:00:00Z","tool":"claude"}\n'; + + it('is not read back as the user scope\'s own usage after an upgrade', async () => { + // Written by an earlier release on a machine that already had a user scope. + await fs.promises.writeFile(sharedPath(), legacy); + + expect(await readUsageEvents(userScope())).toEqual([]); + // Reading usage (as `teamai stats` does) leaves the file alone. + expect(fs.readFileSync(sharedPath(), 'utf-8')).toBe(legacy); + }); + + it('is not read back when an earlier release writes it again after a rollback', async () => { + await appendUsageEvent({ skill: 'after-upgrade', timestamp: '2026-02-01T00:00:00Z', tool: 'claude' }, userScope()); + // Rolled back: the earlier release records every project's usage here again. + await fs.promises.appendFile(sharedPath(), legacy); + + expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['after-upgrade']); + }); +}); + describe('track', () => { it('tracks Skill tool calls', async () => { await track('Skill', JSON.stringify({ skill: 'code-review' })); @@ -302,7 +324,7 @@ describe('skill tracking where teamai is not set up (#748)', () => { }); async function expectNothingRecorded(): Promise { - expect(fs.existsSync(path.join(tmpDir, '.teamai', 'usage.jsonl'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, '.teamai', 'user-usage.jsonl'))).toBe(false); expect((await readKnownSkills()).size).toBe(0); } diff --git a/src/config.ts b/src/config.ts index eedd6a259..c4e88bac1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -336,8 +336,10 @@ export async function resolveDataHomeForScope(scope: Scope, projectRoot?: string * The config that governs a directory (default: the process cwd): the project * teamai is set up for there, else the user scope, else null — teamai is not * set up here. A project config that exists but cannot be read is null too, - * never the user scope: that project's hooks and usage must not reach the - * user-scope team (#748). The hook dispatcher and every usage reader and writer + * never the user scope, nor a lower-priority project config that detection + * reads after it (a legacy `.teamai/` behind a broken partition may name + * another team): that project's hooks and usage must not reach a team it may + * not belong to (#748). The hook dispatcher and every usage reader and writer * resolve through this, so they always agree on the scope. A directory that no * longer exists (a hook payload naming a deleted worktree) holds no project * config; git refuses to open it, so it is not asked. @@ -347,8 +349,8 @@ export async function resolveConfigForDir(dir?: string): Promise { unreadable = true; }); - if (project) return project; - return unreadable ? null : loadLocalConfig(); + if (unreadable) return null; + return project ?? loadLocalConfig(); } /** diff --git a/src/init.ts b/src/init.ts index aa07b117e..f87d665e9 100644 --- a/src/init.ts +++ b/src/init.ts @@ -17,7 +17,6 @@ import { toolRootRejection, type TeamaiConfig, getTeamaiHomeDir, - getUserConfigPath, REPORTS_BRANCH, type GlobalOptions, type LocalConfig, @@ -26,7 +25,6 @@ import { getConfigPath, } from './types.js'; import { getUserHome } from './utils/home.js'; -import { discardUnattributedUsage } from './usage-tracker.js'; import { describeRoles, listRoleIds, loadRolesManifest, RolesManifestNotFoundError } from './roles.js'; import { loadProjectsManifest, listProjectIds } from './projects.js'; import { memberReadRoots, readMemberConfig, mergeMemberConfig } from './members.js'; @@ -415,18 +413,6 @@ async function isInsideGitRepo(dir: string): Promise { } } -/** - * Save the user-scope config. A machine's first user scope starts with no skill - * usage: what `~/.teamai/usage.jsonl` holds by then cannot be attributed to it - * (#748). - */ -async function saveUserScopeConfig(localConfig: LocalConfig): Promise { - const firstUserScope = !(await pathExists(getUserConfigPath())); - await ensureDir(getTeamaiHomeDir()); - await saveLocalConfig(localConfig); - if (firstUserScope) await discardUnattributedUsage(localConfig); -} - /** * Git-free HTTP onboarding (issue #1). A read-only consumer only needs an API * key: no git auth, no clone, no member/reviewer push. Skills/rules/CLAUDE.md are @@ -568,7 +554,8 @@ export async function initHttp( if (scope === 'project') { await saveLocalConfigForScope(localConfig, scope, projectRoot); } else { - await saveUserScopeConfig(localConfig); + await ensureDir(getTeamaiHomeDir()); + await saveLocalConfig(localConfig); } log.success(`Local config saved to ${teamaiHome}/config.yaml`); @@ -1735,7 +1722,8 @@ export async function init(options: GlobalOptions & { log.debug('Generated .teamai/.gitignore for project scope'); } } else { - await saveUserScopeConfig(localConfig); + await ensureDir(getTeamaiHomeDir()); + await saveLocalConfig(localConfig); log.success(`Local config saved to ${getTeamaiHomeDir()}/config.yaml`); } diff --git a/src/pull.ts b/src/pull.ts index 0e4982045..5de3020c0 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -2011,7 +2011,8 @@ export async function pull( await reconcileCoAuthorAllScopes(reconcileUser, reconcileProject, options); // 4. Auto-report usage data to all active scopes. Skill usage lives in each - // scope's own `/usage.jsonl`, so each target reports and then + // scope's own file (`/usage.jsonl`, the user scope's + // `~/.teamai/user-usage.jsonl`), so each target reports and then // truncates only its own file. Dashboard sessions live in one shared file // and are filtered instead: project scope only gets sessions whose cwd is // under projectRoot; user scope excludes those sessions. diff --git a/src/team-push.ts b/src/team-push.ts index 80f63dc6b..fbb91efe8 100644 --- a/src/team-push.ts +++ b/src/team-push.ts @@ -49,7 +49,7 @@ interface PromptTokenDelta { // [git pull latest] ── get freshest remote state ── // │ // ▼ -// [read /usage.jsonl] ─has events?─▶ merge stats +// [read scope usage file] ─has events?─▶ merge stats // │ │ // ▼ ▼ // [stage pending votes from ~/.teamai/votes/] [write stats/.yaml] diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index 3cd14bb66..c58e1f95b 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -5,6 +5,7 @@ import { normalizeToolName } from './utils/tool-names.js'; import { getCopilotHome, getDataHome, + getTeamaiHomeDir, SKILL_NAME_REGEX, type LocalConfig, type UsageEvent, @@ -18,9 +19,17 @@ import { resolveConfigForDir } from './config.js'; * The usage JSONL of one scope: `/usage.jsonl`, so each scope reports * only the skills used where it is set up (#748). Evaluated at call time to * respect HOME changes in tests. + * + * The user scope records in `~/.teamai/user-usage.jsonl` instead: every scope + * used to record in `~/.teamai/usage.jsonl`, and an earlier release still does + * after a rollback, so what that file holds names no project. It is never + * read, so the user scope cannot report it to its team. */ function getUsagePath(config: LocalConfig): string { - return path.join(getDataHome(config), 'usage.jsonl'); + const dataHome = getDataHome(config); + const sharedDir = getTeamaiHomeDir(); + if (path.resolve(dataHome) !== path.resolve(sharedDir)) return path.join(dataHome, 'usage.jsonl'); + return path.join(sharedDir, 'user-usage.jsonl'); } /** Get the known-skills.json path (evaluated at call time to respect HOME changes in tests). */ @@ -50,7 +59,7 @@ function getKnownSkillsPath(): string { // [resolveConfigForDir(cwd)] ─null─▶ skip (#748) // │ // ▼ -// appendFile(/usage.jsonl, JSON line) +// appendFile(, JSON line) // │ // ▼ // updateKnownSkills(skill) → known-skills.json @@ -72,7 +81,7 @@ function getKnownSkillsPath(): string { // [extract & validate skill name after "/"] // │ // ▼ -// appendFile(/usage.jsonl) + updateKnownSkills() +// appendFile() + updateKnownSkills() // /** @@ -225,19 +234,6 @@ export async function appendUsageEvent(event: UsageEvent, config: LocalConfig): } } -/** - * Drop the user-scope usage file when a machine first gets a user scope. What - * it holds was recorded while every scope shared that file, so nothing says - * which project each event came from; the new user scope must not report it - * to its team (#748). - */ -export async function discardUnattributedUsage(userConfig: LocalConfig): Promise { - const usagePath = getUsagePath(userConfig); - if (!(await pathExists(usagePath))) return; - await fs.promises.rm(usagePath, { force: true }); - log.debug(`Discarded ${usagePath}: its events predate this user scope and name no project (#748)`); -} - /** * Read all usage events from a scope's JSONL file. * Skips corrupted lines gracefully.