diff --git a/CHANGELOG.md b/CHANGELOG.md index 7505b06d7..e6a02098e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes +- 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)). +- 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)). - `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 8338d435d..c6d68aaaf 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -298,7 +298,9 @@ were removed. **Functionization ≠ project-scoping.** All of these are class-A2 (machine-level): the getters still return `~/.teamai/...`, unchanged. The project-scoped equivalents -already route through `getDataHome()`. The dashboard is likewise an A2 singleton +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 (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/skill-serving.md b/docs/designs/skill-serving.md index 7888ca167..f2494994e 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -94,9 +94,11 @@ path (measured here from a 77-character one). blocks instead (`blockedBy: "config"`), since recall and the source are then unknown and the workflow would fail at `teamai contribute` — a project config too, which detection alone would skip in favour of the user config - (`findUnreadableProjectConfig`). The Stop-hook reminder follows the same rule. The Stop-hook share - reminder is gated the same way (`contributeHintAllowed`, `src/hook-handlers.ts`), - because it points at this command. The gate lives in one place: + (`findUnreadableProjectConfig`). The Stop-hook share reminder is gated the same + way (`contributeHintAllowed`, `src/hook-handlers.ts`), because it points at this + command, with one difference: with no config at all it stays silent. The hook + fires in every project on the machine, and a directory without teamai has no + team to share with (#748). The gate lives in one place: `resolveServableSkill` (`src/skill-content.ts`) is the only way to obtain a packaged skill outside that module, and it returns `blocked` instead of the skill, so a command cannot print a directory it never received. diff --git a/docs/designs/team-intelligence-platform.md b/docs/designs/team-intelligence-platform.md index f7b7b04d1..befe3b0fd 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 工具调用,追加写入 `~/.teamai/usage.jsonl`。 +**What:** PostToolUse hook 检测 Claude Code 的 Skill 工具调用,追加写入该 scope 的 `/usage.jsonl`(会话所在目录对应的已配置项目,否则 user scope 的 `~/.teamai/`;未配置 teamai 的目录不记录,#748)。 **Data format (JSONL, one event per line):** ```jsonl @@ -88,14 +88,14 @@ Skill Usage Statistics: ``` #### 3. Team Usage Aggregation -**What:** `teamai pull` 自动聚合本地 usage.jsonl 为 `stats/.yaml`;Git 仓库写入独立的 `teamai-reports` 分支(不创建 MR)。 +**What:** `teamai pull` 自动聚合当前 scope 的 usage.jsonl 为 `stats/.yaml`;Git 仓库写入独立的 `teamai-reports` 分支(不创建 MR)。 **Push flow:** ``` teamai pull │ ▼ -聚合 ~/.teamai/usage.jsonl → stats/.yaml +聚合 /usage.jsonl → stats/.yaml │ ▼ reports worktree → git add → git commit → git push (teamai-reports) @@ -162,7 +162,7 @@ reports worktree → git add → git commit → git push (teamai-reports) │ recommend.ts │ │ ┌──────────────┐ │ digest.ts │ push --stats │ Local │ │ stats.ts │ push --sessions -│ ~/.teamai/ │◀───│ team-push.ts ──┼────▶(直接 commit, 无 MR) +│ / │◀───│ team-push.ts ──┼────▶(直接 commit, 无 MR) │ usage.jsonl │ │ │ │ sessions/ │ └────────────────┘ │ .md │ diff --git a/docs/product-overview.md b/docs/product-overview.md index baadea9e0..a1c9d8b82 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -121,7 +121,7 @@ Task: Fix duplicate project-level Hook injection Consider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` -The hint names the non-zero friction signals that triggered it and, when available, includes a redacted, single-line summary of the first task. The `share` workflow (`teamai skill get share`) summarizes the session and pushes a learning document directly to the team repo. Each session is prompted at most once. Teams can switch the hint off with `sharing.contributeHint.enabled: false` in `teamai.yaml` (members: `contributeHintEnabled` in local config) while keeping the rest of the Stop hook. The hint also needs recall to be on (it is off by default), because the workflow it points at is served only then. +The hint names the non-zero friction signals that triggered it and, when available, includes a redacted, single-line summary of the first task. The `share` workflow (`teamai skill get share`) summarizes the session and pushes a learning document directly to the team repo. Each session is prompted at most once. Teams can switch the hint off with `sharing.contributeHint.enabled: false` in `teamai.yaml` (members: `contributeHintEnabled` in local config) while keeping the rest of the Stop hook. The hint also needs recall to be on (it is off by default), because the workflow it points at is served only then, and it never appears in a directory where teamai is not set up. ### Team Knowledge Recall diff --git a/docs/product-overview.zh-CN.md b/docs/product-overview.zh-CN.md index 18c7370f3..0294c9fa2 100644 --- a/docs/product-overview.zh-CN.md +++ b/docs/product-overview.zh-CN.md @@ -121,7 +121,7 @@ Task: Fix duplicate project-level Hook injection Consider running `/teamai share what this session taught me` to summarize what you learned and share it with your team (or run `teamai skill get share`). ``` -提示会列出实际触发它的非零摩擦信号;如果能取得首个任务摘要,还会在脱敏、单行化后附上任务上下文。`share` 工作流(`teamai skill get share`)自动总结 session 经验并推送到团队仓库。每个 session 最多提示一次。团队可在 `teamai.yaml` 设置 `sharing.contributeHint.enabled: false` 关闭该提示(成员可用本地配置 `contributeHintEnabled` 覆盖),Stop hook 的其余功能不受影响。该提示还需要开启 recall(默认关闭),因为它指向的工作流只在 recall 开启时提供。 +提示会列出实际触发它的非零摩擦信号;如果能取得首个任务摘要,还会在脱敏、单行化后附上任务上下文。`share` 工作流(`teamai skill get share`)自动总结 session 经验并推送到团队仓库。每个 session 最多提示一次。团队可在 `teamai.yaml` 设置 `sharing.contributeHint.enabled: false` 关闭该提示(成员可用本地配置 `contributeHintEnabled` 覆盖),Stop hook 的其余功能不受影响。该提示还需要开启 recall(默认关闭),因为它指向的工作流只在 recall 开启时提供;在未配置 teamai 的目录中也不会出现。 ### 团队知识检索 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 7037d3e78..9b421036e 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -529,7 +529,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). 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). 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. 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)). @@ -973,7 +973,7 @@ Teams that route knowledge sharing through their own review flow (for example, a Only the nudge is affected: friction scoring, `teamai contribute --file`, and `/teamai` keep working when invoked manually. -The reminder is also withheld while recall is off (the default until `sharing.recall.enabled: true` in `teamai.yaml`, or `teamai recall enable` on one machine): it points at the `share` workflow, and `teamai skill get share` refuses until recall is on. It never appears on a read-only HTTP source, where `share` refuses too. +The reminder is also withheld while recall is off (the default until `sharing.recall.enabled: true` in `teamai.yaml`, or `teamai recall enable` on one machine): it points at the `share` workflow, and `teamai skill get share` refuses until recall is on. It never appears on a read-only HTTP source, where `share` refuses too, nor in a directory where teamai is not set up. ### Searching knowledge @@ -1720,8 +1720,11 @@ On Windows, the update check, installation, and hook refresh run without opening By default, `teamai pull` commits session/usage stats into the team repo. Pull waits up to 5 seconds for the reporting batch, then continues its other work while reporting finishes. A late successful push still updates the local -reported snapshots. Usage events are removed only after every selected target -confirms success; failed pushes preserve them. The affected sync locks remain +reported snapshots. Skill usage is recorded per scope, in the data directory of +the project teamai is set up for where the session ran (or the user scope), so +each target reports only its own; a directory without teamai records none. A +target removes its usage events only after it confirms success; failed pushes +preserve them. The affected sync locks remain held until reporting finishes, preventing another pull from racing the report. This is best-effort reporting, not crash-safe delivery: termination between a diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 437d03f47..7d8666d94 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -495,7 +495,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 章节)。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 暂存的包提示)。存在但无法读取的 project 配置视为没有配置,而不会退回 user scope。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 启用角色化 skills 后,`pull` 的 skills 同步来源会变成 `skills//` 中的内容,按 `primaryRole + additionalRoles` 展开对应的 namespace,拍平安装到本地各 AI 工具 skills 目录。`rules/`、`docs/` 仍然保持原有同步逻辑;`agents//` 按角色的 `agents` namespace 同步(见 [Agents 资源类型](#agents-资源类型))。`learnings/` 根目录对所有人共享,而 `learnings//` 子目录只对本目录激活的项目同步(见 [多项目](#多项目project-作为与-role-正交的维度))。 @@ -934,7 +934,7 @@ teamai contribute --file /tmp/session.md --scope project 只影响提醒本身:摩擦评分、`teamai contribute --file` 和手动调用 `/teamai` 不受影响。 -未开启 recall 时(默认关闭;团队在 `teamai.yaml` 设置 `sharing.recall.enabled: true`,或单台机器运行 `teamai recall enable`)也不会显示这条提醒:提醒指向 `share` 工作流,而 recall 关闭时 `teamai skill get share` 会拒绝执行。只读 HTTP 源上这条提醒也从不出现,因为 `share` 同样会拒绝。 +未开启 recall 时(默认关闭;团队在 `teamai.yaml` 设置 `sharing.recall.enabled: true`,或单台机器运行 `teamai recall enable`)也不会显示这条提醒:提醒指向 `share` 工作流,而 recall 关闭时 `teamai skill get share` 会拒绝执行。只读 HTTP 源上这条提醒也从不出现,因为 `share` 同样会拒绝;在未配置 teamai 的目录中也不会出现。 ### 搜索知识 @@ -1664,8 +1664,10 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI ### 使用统计上报 Pull 对整批统计上报最多等待 5 秒,之后继续其他工作,上报任务仍会完成。 -超时后推送成功,仍会更新本地已上报快照;只有全部选中的目标确认成功后, -才清理对应使用事件。推送失败会保留事件。上报完成前继续持有相关同步锁, +超时后推送成功,仍会更新本地已上报快照。skill 使用按 scope 记录:写入会话所在 +目录对应的已配置 teamai 项目的数据目录(或 user scope),因此每个目标只上报 +自己的使用;未配置 teamai 的目录不记录。目标确认成功后才清理自己的使用事件, +推送失败会保留事件。上报完成前继续持有相关同步锁, 避免另一次 Pull 与尚未完成的上报竞争。 这仍是尽力上报,不提供崩溃恢复保证:远端推送成功与本地确认之间如果进程 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index 03e933824..d168ad08f 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -139,7 +139,8 @@ can do it, see `"$(teamai skill path core)/references/contribute-member.md"`.) ### Turn the sharing prompt on or off (admin) -The auto-share prompt is **on by default once recall is on**. To disable it team-wide, set this in +The auto-share prompt is **on by default once recall is on**, and only shows in directories set up +with teamai. To disable it team-wide, set this in `teamai.yaml` and `teamai push`: ```yaml diff --git a/src/__tests__/codex-stop-hint.test.ts b/src/__tests__/codex-stop-hint.test.ts index da14ca64f..55f9e8f63 100644 --- a/src/__tests__/codex-stop-hint.test.ts +++ b/src/__tests__/codex-stop-hint.test.ts @@ -17,6 +17,14 @@ describe('Codex Stop hint handoff with persisted session state', () => { beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-codex-stop-')); process.env.HOME = tmpHome; + // The nudge needs a team with recall on; without any config it stays silent (#748). + const teamRepo = path.join(tmpHome, '.teamai', 'team-repo'); + fs.mkdirSync(teamRepo, { recursive: true }); + fs.writeFileSync(path.join(teamRepo, 'teamai.yaml'), 'team: acme\nrepo: https://example.test/acme/team.git\nsharing:\n recall:\n enabled: true\n'); + fs.writeFileSync( + path.join(tmpHome, '.teamai', 'config.yaml'), + `repo:\n localPath: ${teamRepo}\n remote: https://example.test/acme/team.git\nusername: tester\nscope: user\n`, + ); }); afterEach(() => { if (originalHome === undefined) delete process.env.HOME; diff --git a/src/__tests__/contribute-check-e2e.test.ts b/src/__tests__/contribute-check-e2e.test.ts index 9b1261ba9..e0078416d 100644 --- a/src/__tests__/contribute-check-e2e.test.ts +++ b/src/__tests__/contribute-check-e2e.test.ts @@ -18,8 +18,16 @@ const SESSION_ID = 'e2e-test-session-001'; const RAW_GITHUB_TOKEN = 'ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const FIRST_TASK = `Fix auth retry for ${RAW_GITHUB_TOKEN}\nthen add regression coverage`; +/** A temp HOME with a user-scope install: the nudge only runs where teamai is set up (#748). */ function makeTmpHome(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-contribute-e2e-')); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-contribute-e2e-')); + const teamRepo = path.join(home, '.teamai', 'team-repo'); + fs.mkdirSync(teamRepo, { recursive: true }); + fs.writeFileSync( + path.join(home, '.teamai', 'config.yaml'), + `repo:\n localPath: ${teamRepo}\n remote: https://example.test/acme/team.git\nusername: tester\nscope: user\n`, + ); + return home; } /** Build a hook STDIN JSON payload with a session_id. */ @@ -155,6 +163,16 @@ describe('contribute-check E2E', () => { fs.rmSync(tmpHome, { recursive: true, force: true }); }); + it('stays silent where teamai is not set up (#748)', async () => { + fs.rmSync(path.join(tmpHome, '.teamai', 'config.yaml')); + writeEventsFile(tmpHome, buildRichSessionEvents(SESSION_ID)); + + const result = await runContributeCheck(tmpHome, makeStdinPayload(SESSION_ID)); + expect(result.code).toBe(0); + expect(result.stdout).toBe(''); + expect(readSessionState(tmpHome, SESSION_ID)).toBeNull(); + }); + it('standalone Codex Stop queues the hint without emitting incompatible JSON', async () => { writeEventsFile(tmpHome, buildRichSessionEvents(SESSION_ID)); const result = await runContributeCheck(tmpHome, makeStdinPayload(SESSION_ID), 'codex'); diff --git a/src/__tests__/git-kind-reports.test.ts b/src/__tests__/git-kind-reports.test.ts index 9e4106e24..f720950b7 100644 --- a/src/__tests__/git-kind-reports.test.ts +++ b/src/__tests__/git-kind-reports.test.ts @@ -13,6 +13,9 @@ import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree, up import { pushRepoDirectly } from '../utils/git.js'; import { reportUsageToTeam } from '../team-push.js'; import { listMembers } from '../members.js'; +import { resolveProjectDataHome, saveLocalConfigForScope } from '../config.js'; +import { buildHandlerRegistry } from '../hook-handlers.js'; +import YAML from 'yaml'; let tmp: string; let originalHome: string; @@ -35,8 +38,8 @@ async function configureGit(dir: string): Promise { await git.addConfig('user.name', 't'); } -async function seedBareOrigin(): Promise<{ origin: string; clone: string }> { - const seed = path.join(tmp, 'seed'); +async function seedBareOrigin(base = tmp): Promise<{ origin: string; clone: string }> { + const seed = path.join(base, 'seed'); fs.mkdirSync(seed, { recursive: true }); const seedGit = simpleGit(seed); await seedGit.init(['--initial-branch=main']); @@ -47,7 +50,7 @@ async function seedBareOrigin(): Promise<{ origin: string; clone: string }> { await seedGit.add(['.']); await seedGit.commit('init knowledge'); - const origin = path.join(tmp, 'origin.git'); + const origin = path.join(base, 'origin.git'); await simpleGit().clone(seed, origin, ['--bare']); const hook = path.join(origin, 'hooks', 'update'); fs.writeFileSync( @@ -63,7 +66,7 @@ exit 0 ); fs.chmodSync(hook, 0o755); - const clone = path.join(tmp, 'team-repo'); + const clone = path.join(base, 'team-repo'); await simpleGit().clone(origin, clone); await configureGit(clone); return { origin, clone }; @@ -629,3 +632,50 @@ describe('self-mode reports: shared stash', () => { expect(fs.readFileSync(path.join(clone, 'app.txt'), 'utf-8')).toBe('committed\n'); }); }); + +describe('skill usage stays in the scope that recorded it (#748)', () => { + /** A project initialized in project scope against its own team repo. */ + async function initProject(name: string): Promise<{ root: string; origin: string; config: LocalConfig }> { + const base = path.join(tmp, name); + const { origin, clone } = await seedBareOrigin(base); + fs.mkdirSync(path.join(base, 'project')); + const root = fs.realpathSync(path.join(base, 'project')); + const git = simpleGit(root); + await git.init(['--initial-branch=main']); + await configureGit(root); + await git.commit('init', { '--allow-empty': null }); + const config: LocalConfig = { + ...gitConfig(clone, origin), + scope: 'project', + projectRoot: root, + dataHome: await resolveProjectDataHome(root), + }; + await saveLocalConfigForScope(config); + return { root, origin, config }; + } + + async function useSkill(cwd: string, skill: string): Promise { + const track = buildHandlerRegistry().find((r) => r.handler.name === 'track'); + if (!track) throw new Error('track handler is not registered'); + await track.handler.execute({ session_id: `s-${skill}`, cwd, tool_name: 'Skill', tool_input: { skill } }, 'claude'); + } + + async function reportedSkills(origin: string): Promise { + const stats: unknown = YAML.parse(await simpleGit(origin).raw(['show', 'teamai-reports:stats/alice.yaml'])); + const skills = stats && typeof stats === 'object' && 'skills' in stats ? stats.skills : undefined; + return skills && typeof skills === 'object' ? Object.keys(skills).sort() : []; + } + + it("each project's report carries only its own skills, and one report does not consume the other's", async () => { + const a = await initProject('team-a'); + const c = await initProject('team-c'); + await useSkill(a.root, 'skill-a'); + await useSkill(c.root, 'skill-c'); + + await reportUsageToTeam(a.config.repo.localPath, 'alice', { projectRoot: a.root, selfConfig: a.config }); + expect(await reportedSkills(a.origin)).toEqual(['skill-a']); + + await reportUsageToTeam(c.config.repo.localPath, 'alice', { projectRoot: c.root, selfConfig: c.config }); + expect(await reportedSkills(c.origin)).toEqual(['skill-c']); + }); +}); diff --git a/src/__tests__/hook-dispatch-scope.test.ts b/src/__tests__/hook-dispatch-scope.test.ts new file mode 100644 index 000000000..02b3eb9f1 --- /dev/null +++ b/src/__tests__/hook-dispatch-scope.test.ts @@ -0,0 +1,118 @@ +/** + * Which scope a hook run belongs to (#748): the real dispatcher and handlers, + * observed through the files they leave in HOME. Only the machine-level + * handlers that reach the network or spawn processes are stubbed. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + // The detached background pass: run inline instead (bgOnly) so its writes are observable. + spawn: vi.fn(() => ({ on: vi.fn(), stdin: { on: vi.fn(), end: vi.fn((_: string, done: () => void) => done()) }, unref: vi.fn() })), +})); +vi.mock('../pull.js', () => ({ pull: vi.fn(async () => undefined) })); +vi.mock('../update.js', () => ({ doUpdate: vi.fn(async () => undefined) })); +vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: vi.fn(async () => null) })); + +const { hookDispatchCli } = await import('../hook-dispatch-cli.js'); +const { resolveProjectDataHome, saveLocalConfigForScope } = await import('../config.js'); + +let tmp: string; +let originalHome: string | undefined; +let originalCwd: string; + +beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-dispatch-scope-'))); + originalHome = process.env.HOME; + originalCwd = process.cwd(); + process.env.HOME = path.join(tmp, 'home'); + fs.mkdirSync(process.env.HOME); +}); + +afterEach(() => { + process.chdir(originalCwd); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +const teamaiHome = () => path.join(tmp, 'home', '.teamai'); + +/** Run one hook event the way a host does: foreground pass, then the background pass. */ +async function hook(event: string, matcher: string, payload: Record): Promise { + for (const bgOnly of [false, true]) { + const stdinFile = path.join(tmp, `stdin-${Date.now()}-${Math.random()}.json`); + fs.writeFileSync(stdinFile, JSON.stringify(payload)); + await hookDispatchCli(event, 'claude', matcher, { bgOnly, stdinFile }); + } +} + +function gitRepo(name: string): string { + const dir = path.join(tmp, name); + fs.mkdirSync(dir); + execFileSync('git', ['init', '-q'], { cwd: dir }); + return dir; +} + +function userScope(): void { + const teamRepo = path.join(teamaiHome(), 'team-repo'); + fs.mkdirSync(teamRepo, { recursive: true }); + fs.writeFileSync(path.join(teamaiHome(), 'config.yaml'), + `repo:\n localPath: ${teamRepo}\n remote: https://example.test/acme/user-team.git\nusername: tester\nscope: user\n`); +} + +describe('hook runs and the scope they belong to (#748)', () => { + it('a session in a directory without teamai leaves no trace', async () => { + const cwd = gitRepo('project-b'); + const base = { session_id: 'sid-b', cwd }; + await hook('session-start', '*', { ...base, hook_event_name: 'SessionStart' }); + await hook('prompt-submit', '*', { ...base, hook_event_name: 'UserPromptSubmit', prompt: 'hello' }); + await hook('post-tool-use', 'Skill', { ...base, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-b' } }); + await hook('post-tool-use', '*', { ...base, hook_event_name: 'PostToolUse', tool_name: 'Bash', tool_input: {} }); + 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(), 'sessions', 'sid-b.json'))).toBe(false); + }); + + it('a host that sends no cwd records into the project it runs in', async () => { + const root = gitRepo('project-a'); + const dataHome = await resolveProjectDataHome(root); + await saveLocalConfigForScope({ + repo: { localPath: path.join(dataHome, 'team-repo'), remote: 'https://example.test/acme/team-a.git' }, + username: 'tester', scope: 'project', projectRoot: root, additionalRoles: [], dataHome, + }); + // OpenClaw's plugin spawns hook-dispatch in the workspace without a cwd field. + process.chdir(root); + + await hook('post-tool-use', 'Skill', { session_id: 'sid-a', hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-a' } }); + + expect(fs.readFileSync(path.join(dataHome, 'usage.jsonl'), 'utf-8')).toContain('skill-a'); + }); + + it('a hook whose cwd no longer exists falls back to the user scope instead of failing', async () => { + userScope(); + const gone = path.join(tmp, 'deleted-worktree'); + + 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'); + }); + + it('a project whose config cannot be read records nothing, not even in the user scope', async () => { + userScope(); + const root = gitRepo('project-a'); + const dataHome = await resolveProjectDataHome(root); + fs.mkdirSync(dataHome, { recursive: true }); + fs.writeFileSync(path.join(dataHome, 'config.yaml'), 'repo: [not: a, valid config\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(teamaiHome(), 'usage.jsonl'))).toBe(false); + }); +}); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index a0f12ab86..95c34fceb 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -85,6 +85,9 @@ const mockAutoDetectInit = vi.fn().mockResolvedValue({ vi.mock('../config.js', async (importOriginal) => ({ ...(await importOriginal()), autoDetectInit: mockAutoDetectInit, + resolveConfigForDir: vi.fn().mockResolvedValue({ + repo: { localPath: '/tmp/team-repo', remote: '' }, username: 'test', scope: 'user', additionalRoles: [], + }), })); vi.mock('../utils/logger.js', () => ({ @@ -411,17 +414,19 @@ describe('hook-handlers registry', () => { expect(mockContributeCheckForSession).not.toHaveBeenCalled(); }); - it('contribute-check handler keeps hinting when there is no config at all', async () => { + it('contribute-check handler stays silent when there is no config at all (#748)', async () => { const { NotInitializedError } = await import('../config.js'); const registry = buildHandlerRegistry(); const handler = registry.find( (r) => r.event === 'stop' && r.handler.name === 'contribute-check', )!.handler; + // A directory without teamai has no team to share with. mockAutoDetectInit.mockRejectedValueOnce(new NotInitializedError('teamai is not initialized. Run `teamai init` first.')); - mockContributeCheckForSession.mockResolvedValueOnce({ hint: '[teamai] do share' }); + mockContributeCheckForSession.mockClear(); const result = await handler.execute({ session_id: 's5', cwd: '/x' }, 'claude'); - expect(result).toContain('do share'); + expect(result).toBeNull(); + expect(mockContributeCheckForSession).not.toHaveBeenCalled(); }); it('contribute-check handler stays silent when a config exists but cannot be loaded', async () => { @@ -673,12 +678,38 @@ describe('hook-handlers registry', () => { expect(names).toContain('local-agent-sync'); }); - it('filterHandlersForConfig keeps all handlers for git source and when uninitialized', () => { + it('filterHandlersForConfig keeps all handlers for git source', () => { const registry = buildHandlerRegistry(); const full = registry.length; expect(filterHandlersForConfig(registry, { repo: { kind: 'git' } } as never).length).toBe(full); expect(filterHandlersForConfig(registry, { repo: {} } as never).length).toBe(full); - expect(filterHandlersForConfig(registry, null).length).toBe(full); + }); + + it('filterHandlersForConfig drops the share nudge where teamai is not set up (#748)', () => { + const names = filterHandlersForConfig(buildHandlerRegistry(), null).map((r) => r.handler.name); + expect(names).not.toContain('contribute-check'); + }); + + it('only machine-level handlers run where teamai is not set up (#748)', () => { + // A new handler must decide: team handlers set requiresConfig, the rest join this list. + const names = new Set(filterHandlersForConfig(buildHandlerRegistry(), null).map((r) => r.handler.name)); + expect([...names].sort()).toEqual([ + 'local-agent-sync', + 'package-pending-hint', + 'pull', + 'update', + ]); + }); + + it('TodoWrite gets no recall nudge where teamai is not set up (#748)', async () => { + const registry = buildHandlerRegistry(); + expect(registry.some((r) => r.matcher === 'TodoWrite' && r.handler.name === 'todowrite-hint')).toBe(true); + const dispatcher = createDispatcher({ handlers: filterHandlersForConfig(registry, null) }); + + const result = await dispatcher.dispatch( + 'post-tool-use', 'TodoWrite', { session_id: 'td-748', tool_name: 'TodoWrite' }, 'claude', 'foreground', + ); + expect(result.output).toBeNull(); }); // ── Change 2: votes-sync nudge — marker guard removed, nudge every time declared===0 ── diff --git a/src/__tests__/http-repo-integration.test.ts b/src/__tests__/http-repo-integration.test.ts index 0064fdfbe..07e6e2638 100644 --- a/src/__tests__/http-repo-integration.test.ts +++ b/src/__tests__/http-repo-integration.test.ts @@ -60,6 +60,35 @@ 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__/pull-scope-isolation.test.ts b/src/__tests__/pull-scope-isolation.test.ts index e35822f6e..86347e9c4 100644 --- a/src/__tests__/pull-scope-isolation.test.ts +++ b/src/__tests__/pull-scope-isolation.test.ts @@ -203,7 +203,7 @@ describe('pull scope isolation (issue #73)', () => { await pull({ silent: true }); if (success) { expect(truncateUsageAfterReport).toHaveBeenCalledTimes(1); - expect(truncateUsageAfterReport).toHaveBeenCalledWith(1); + expect(truncateUsageAfterReport).toHaveBeenCalledWith(1, projectConfig); } else expect(truncateUsageAfterReport).not.toHaveBeenCalled(); }); @@ -237,7 +237,7 @@ describe('pull scope isolation (issue #73)', () => { await vi.advanceTimersByTimeAsync(0); if (success) { expect(truncateUsageAfterReport).toHaveBeenCalledTimes(1); - expect(truncateUsageAfterReport).toHaveBeenCalledWith(1); + expect(truncateUsageAfterReport).toHaveBeenCalledWith(1, projectConfig); } else expect(truncateUsageAfterReport).not.toHaveBeenCalled(); await pull({ silent: true }); diff --git a/src/__tests__/team-push-interventions.test.ts b/src/__tests__/team-push-interventions.test.ts index f46d1915a..4cd44d432 100644 --- a/src/__tests__/team-push-interventions.test.ts +++ b/src/__tests__/team-push-interventions.test.ts @@ -99,6 +99,7 @@ describe('reportUsageToTeam — intervention reporting', () => { it.each(['reports', 'legacy'])('finishes acknowledgement after the caller times out (%s)', async (backend) => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const usagePath = seedReport(); + const seeded = fs.readFileSync(usagePath, 'utf-8'); let finish!: (value: boolean) => void; let started!: () => void; const pushStarted = new Promise((resolve) => { started = resolve; }); @@ -126,7 +127,9 @@ describe('reportUsageToTeam — intervention reporting', () => { finish(true); expect(await operation).toBe(true); - expect(fs.readFileSync(usagePath, 'utf-8')).toBe(''); + // A caller without a scope config has no usage of its own to report (#748), + // so it leaves the user-scope file for the scope that owns it. + expect(fs.readFileSync(usagePath, 'utf-8')).toBe(backend === 'reports' ? '' : seeded); for (const name of ['interventions', 'prompt-tokens', 'daily-sessions']) { expect(JSON.parse(fs.readFileSync(path.join(dashboard, `reported-${name}.json`), 'utf-8')).slow).toBeDefined(); } diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index 1b6ca7e4b..21f392f48 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -32,16 +32,35 @@ import { aggregateUsage } from '../stats.js'; import { mergeStats } from '../team-push.js'; import { calculateSkillHealth, scoreToStars, calculateTeamHealth } from '../skill-health.js'; import { getRecommendations } from '../skill-recommend.js'; -import type { UsageEvent, UserStats } from '../types.js'; +import type { LocalConfig, UsageEvent, UserStats } from '../types.js'; // ─── Test helpers ────────────────────────────────────── let tmpDir: string; const origHome = process.env.HOME; +/** The user scope seeded below; its usage file is `~/.teamai/usage.jsonl`. */ +function userScope(): LocalConfig { + return { + repo: { localPath: path.join(tmpDir, '.teamai', 'team-repo'), remote: 'https://example.test/acme/team.git' }, + username: 'tester', + scope: 'user', + additionalRoles: [], + }; +} + +/** A user-scope install: skill usage is recorded only where teamai is set up. */ +async function seedUserConfig(): Promise { + await fse.outputFile( + path.join(tmpDir, '.teamai', 'config.yaml'), + `repo:\n localPath: ${path.join(tmpDir, '.teamai', 'team-repo')}\n remote: https://example.test/acme/team.git\nusername: tester\nscope: user\n`, + ); +} + beforeEach(async () => { tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-test-')); process.env.HOME = tmpDir; + await seedUserConfig(); }); afterEach(async () => { @@ -164,7 +183,7 @@ describe('appendUsageEvent', () => { timestamp: '2026-03-19T10:30:00Z', tool: 'claude', }; - await appendUsageEvent(event); + await appendUsageEvent(event, userScope()); const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); const content = await fs.promises.readFile(usagePath, 'utf-8'); @@ -174,10 +193,10 @@ describe('appendUsageEvent', () => { }); it('appends multiple events as separate lines', async () => { - await appendUsageEvent({ skill: 'tdd', timestamp: '2026-03-19T10:00:00Z', tool: 'claude' }); - await appendUsageEvent({ skill: 'code-review', timestamp: '2026-03-19T11:00:00Z', tool: 'claude' }); + await appendUsageEvent({ skill: 'tdd', timestamp: '2026-03-19T10:00:00Z', tool: 'claude' }, userScope()); + await appendUsageEvent({ skill: 'code-review', timestamp: '2026-03-19T11:00:00Z', tool: 'claude' }, userScope()); - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(2); expect(events[0].skill).toBe('tdd'); expect(events[1].skill).toBe('code-review'); @@ -186,7 +205,7 @@ describe('appendUsageEvent', () => { describe('readUsageEvents', () => { it('returns empty array for missing file', async () => { - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -198,7 +217,7 @@ describe('readUsageEvents', () => { '{"skill":"good","timestamp":"2026-01-01T00:00:00Z","tool":"claude"}\nNOT_JSON\n{"skill":"also-good","timestamp":"2026-01-02T00:00:00Z","tool":"claude"}\n', ); - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(2); expect(events[0].skill).toBe('good'); expect(events[1].skill).toBe('also-good'); @@ -209,30 +228,30 @@ describe('readUsageEvents', () => { await fse.ensureDir(path.dirname(usagePath)); await fs.promises.writeFile(usagePath, ''); - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); }); describe('truncateUsageAfterReport', () => { it('clears file when all events reported', async () => { - await appendUsageEvent({ skill: 'a', timestamp: '2026-01-01T00:00:00Z', tool: 'claude' }); - await appendUsageEvent({ skill: 'b', timestamp: '2026-01-02T00:00:00Z', tool: 'claude' }); + await appendUsageEvent({ skill: 'a', timestamp: '2026-01-01T00:00:00Z', tool: 'claude' }, userScope()); + await appendUsageEvent({ skill: 'b', timestamp: '2026-01-02T00:00:00Z', tool: 'claude' }, userScope()); - await truncateUsageAfterReport(2); + await truncateUsageAfterReport(2, userScope()); - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); it('keeps unreported events', async () => { - await appendUsageEvent({ skill: 'a', timestamp: '2026-01-01T00:00:00Z', tool: 'claude' }); - await appendUsageEvent({ skill: 'b', timestamp: '2026-01-02T00:00:00Z', tool: 'claude' }); - await appendUsageEvent({ skill: 'c', timestamp: '2026-01-03T00:00:00Z', tool: 'claude' }); + await appendUsageEvent({ skill: 'a', timestamp: '2026-01-01T00:00:00Z', tool: 'claude' }, userScope()); + await appendUsageEvent({ skill: 'b', timestamp: '2026-01-02T00:00:00Z', tool: 'claude' }, userScope()); + await appendUsageEvent({ skill: 'c', timestamp: '2026-01-03T00:00:00Z', tool: 'claude' }, userScope()); - await truncateUsageAfterReport(2); + await truncateUsageAfterReport(2, userScope()); - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].skill).toBe('c'); }); @@ -242,7 +261,7 @@ describe('track', () => { it('tracks Skill tool calls', async () => { await track('Skill', JSON.stringify({ skill: 'code-review' })); - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].skill).toBe('code-review'); }); @@ -251,21 +270,21 @@ describe('track', () => { await track('Bash', JSON.stringify({ command: 'ls' })); await track('Read', JSON.stringify({ path: '/tmp' })); - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); it('ignores invalid skill names', async () => { await track('Skill', JSON.stringify({ skill: '../../etc/passwd' })); - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); it('handles malformed JSON input', async () => { await track('Skill', 'not-json'); - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -277,6 +296,43 @@ describe('track', () => { }); }); +describe('skill tracking where teamai is not set up (#748)', () => { + beforeEach(async () => { + await fse.remove(path.join(tmpDir, '.teamai', 'config.yaml')); + }); + + async function expectNothingRecorded(): Promise { + expect(fs.existsSync(path.join(tmpDir, '.teamai', 'usage.jsonl'))).toBe(false); + expect((await readKnownSkills()).size).toBe(0); + } + + it('track records nothing', async () => { + await track('Skill', JSON.stringify({ skill: 'code-review' })); + await expectNothingRecorded(); + }); + + it('trackFromStdin records nothing', async () => { + const restore = mockStdin(JSON.stringify({ tool_name: 'Skill', tool_input: { skill: 'code-review' } })); + try { + await trackFromStdin(); + } finally { + restore(); + } + await expectNothingRecorded(); + }); + + it('trackSlashCommand records nothing', async () => { + await createFakeSkill('code-review'); + const restore = mockStdin(JSON.stringify({ prompt: '/code-review' })); + try { + await trackSlashCommand(); + } finally { + restore(); + } + await expectNothingRecorded(); + }); +}); + // ─── trackFromStdin tests ───────────────────────────── describe('trackFromStdin', () => { @@ -294,7 +350,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].skill).toBe('plan-eng-review'); }); @@ -311,7 +367,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -323,7 +379,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -335,7 +391,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -351,7 +407,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].skill).toBe('tdd'); }); @@ -385,7 +441,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].skill).toBe('tdd'); expect(events[0].tool).toBe('cursor'); @@ -404,7 +460,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].skill).toBe('code-review-expert'); expect(events[0].tool).toBe('cursor'); @@ -423,7 +479,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -439,7 +495,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].skill).toBe('plan-eng-review'); expect(events[0].tool).toBe('cursor'); @@ -457,7 +513,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].tool).toBe('claude'); }); @@ -474,7 +530,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].tool).toBe('claude-internal'); }); @@ -491,7 +547,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].tool).toBe('codebuddy'); }); @@ -508,7 +564,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].tool).toBe('codex-internal'); }); @@ -525,7 +581,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].tool).toBe('claude'); }); @@ -542,7 +598,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].tool).toBe('cursor'); }); @@ -559,7 +615,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); }); @@ -592,7 +648,7 @@ describe('readKnownSkills', () => { await updateKnownSkills('old-skill'); // Add a new event to usage.jsonl - await appendUsageEvent({ skill: 'new-skill', timestamp: '2026-03-20T10:00:00Z', tool: 'claude' }); + await appendUsageEvent({ skill: 'new-skill', timestamp: '2026-03-20T10:00:00Z', tool: 'claude' }, userScope()); const skills = await readKnownSkills(); expect(skills.has('old-skill')).toBe(true); @@ -610,7 +666,7 @@ describe('readKnownSkills', () => { await fs.promises.writeFile(knownPath, 'NOT_JSON!!!'); // Should still work with just usage.jsonl data - await appendUsageEvent({ skill: 'tdd', timestamp: '2026-03-20T10:00:00Z', tool: 'claude' }); + await appendUsageEvent({ skill: 'tdd', timestamp: '2026-03-20T10:00:00Z', tool: 'claude' }, userScope()); const skills = await readKnownSkills(); expect(skills.has('tdd')).toBe(true); @@ -745,7 +801,7 @@ describe('getRecommendations', () => { it('excludes skills after truncation when known-skills.json exists', async () => { // Simulate: track a skill, then report+truncate await track('Skill', JSON.stringify({ skill: 'tdd' })); - await truncateUsageAfterReport(1); // usage.jsonl is now empty + await truncateUsageAfterReport(1, userScope()); // usage.jsonl is now empty // But known-skills.json should still have 'tdd' const teamStats: UserStats[] = [ @@ -926,7 +982,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].skill).toBe('plan-eng-review'); expect(events[0].tool).toBe('claude'); @@ -945,7 +1001,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].skill).toBe('gstack:tdd'); }); @@ -962,7 +1018,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -978,7 +1034,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -990,7 +1046,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -1002,7 +1058,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -1019,7 +1075,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -1051,7 +1107,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].tool).toBe('claude-internal'); }); @@ -1068,7 +1124,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].tool).toBe('codex-internal'); }); @@ -1085,7 +1141,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].tool).toBe('claude'); }); @@ -1106,7 +1162,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(3); expect(events.map((e: UsageEvent) => e.skill).sort()).toEqual( ['code-review', 'plan-eng-review', 'tdd'], @@ -1127,7 +1183,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(2); expect(events.map((e: UsageEvent) => e.skill).sort()).toEqual( ['code-review', 'tdd'], @@ -1148,7 +1204,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); // Only 'tdd' should be tracked (twice — it appears twice in prompt) expect(events).toHaveLength(2); expect(events.every((e: UsageEvent) => e.skill === 'tdd')).toBe(true); @@ -1161,7 +1217,7 @@ describe('track with tool parameter', () => { it('uses provided tool parameter', async () => { await track('Skill', JSON.stringify({ skill: 'code-review' }), 'claude-internal'); - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].tool).toBe('claude-internal'); }); @@ -1169,7 +1225,7 @@ describe('track with tool parameter', () => { it('defaults to claude when tool not provided', async () => { await track('Skill', JSON.stringify({ skill: 'tdd' })); - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); expect(events[0].tool).toBe('claude'); }); diff --git a/src/config.ts b/src/config.ts index de838a811..0ec0bb627 100644 --- a/src/config.ts +++ b/src/config.ts @@ -301,6 +301,25 @@ export async function resolveDataHomeForScope(scope: Scope, projectRoot?: string return path.join(projectRoot, '.teamai'); } +/** + * 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 + * 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. + */ +export async function resolveConfigForDir(dir?: string): Promise { + const target = dir ?? process.cwd(); + if (!(await pathExists(target))) return loadLocalConfig(); + let unreadable = false; + const project = await detectProjectConfig(target, () => { unreadable = true; }); + if (project) return project; + return unreadable ? null : loadLocalConfig(); +} + /** * Told about a project-scope config file that exists but cannot be used, which * detection otherwise skips: `null` means "no project config here" to every diff --git a/src/contribute-check.ts b/src/contribute-check.ts index 1af35b69b..5531b9d87 100644 --- a/src/contribute-check.ts +++ b/src/contribute-check.ts @@ -709,6 +709,13 @@ export async function contributeCheck(toolArg?: string): Promise { log.debug('contribute-check: no STDIN data or no session ID'); return; } + // Hooks of older installs still call this command in every project; a + // directory without teamai has no team to share with (#748). + const { resolveConfigForDir } = await import('./config.js'); + if (!(await resolveConfigForDir(stdinData.cwd))) { + log.debug('contribute-check: teamai is not set up here, skipping'); + return; + } const { stopStdoutUnsupported } = await import('./utils/tool-names.js'); const tool = toolArg?.toLowerCase() ?? 'claude'; diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index e5b7fb357..d9e3f744c 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -409,11 +409,13 @@ export async function hookDispatchCli( const raw = stdinFile ? readStdinFile(stdinFile) : await readStdin(); const stdin = parseStdin(raw, event); - // Provider-config gate: HTTP-only teams must not receive git-provider-only - // hook prompts (contribute / mr-hint / votes). Prefer the project-scope - // config when the host tells us the working directory (#264), so - // filterHandlersForConfig can honour a project-level repo.kind. - const { loadLocalConfig, detectProjectConfig } = await import('./config.js'); + // Config gates: a directory without teamai runs no team handlers (#748), and + // HTTP-only teams must not receive git-provider-only hook prompts + // (contribute / mr-hint / votes). The project-scope config of the host's + // working directory wins (#264), so filterHandlersForConfig can honour a + // project-level repo.kind; a host that sends no cwd (OpenClaw) runs the + // hook in its workspace, so the process cwd stands in. + const { resolveConfigForDir } = await import('./config.js'); const cwd = resolveHookCwd(stdin); if (cwd) { try { @@ -422,7 +424,7 @@ export async function hookDispatchCli( log.debug(`hook-dispatch: chdir to ${cwd} failed: ${(e as Error).message}`); } } - const localConfig = (cwd ? await detectProjectConfig(cwd) : null) ?? await loadLocalConfig(); + const localConfig = await resolveConfigForDir(cwd); const handlers = filterHandlersForConfig(buildHandlerRegistry(), localConfig); const dispatcher = createDispatcher({ handlers }); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index f28ee95cb..2c7e40d1c 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -34,6 +34,13 @@ export interface HandlerRegistration { * (contribute / import-from-mr / votes push). See filterHandlersForConfig. */ gitOnly?: boolean; + /** + * Team handler: it only makes sense where teamai is set up. Hooks live in HOME + * even for a project-scope install, so they fire in every project on the + * machine; with no config for the hook's cwd these are filtered out at the + * dispatch boundary (#748). See filterHandlersForConfig. + */ + requiresConfig?: boolean; } // ─── Timeout constants ────────────────────────────────── @@ -184,6 +191,7 @@ const trackHandler: HookHandler = { name: 'track', async execute(stdin, tool) { const { resolveSkillUse, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); + const { resolveConfigForDir } = await import('./config.js'); const rawToolName = stdin.tool_name; if (typeof rawToolName !== 'string') return null; @@ -196,11 +204,13 @@ const trackHandler: HookHandler = { const resolved = resolveSkillUse(toolName, toolInput as Record); if (!resolved) return null; + const config = await resolveConfigForDir(resolveHookCwd(stdin)); + if (!config) return null; await appendUsageEvent({ skill: resolved.skillName, timestamp: new Date().toISOString(), tool: resolved.source ?? tool, - }); + }, config); await updateKnownSkills(resolved.skillName); return null; }, @@ -209,7 +219,8 @@ const trackHandler: HookHandler = { const trackSlashHandler: HookHandler = { name: 'track-slash', async execute(stdin, tool) { - const { extractSkillName, isValidSkillName, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); + const { isValidSkillName, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); + const { resolveConfigForDir } = await import('./config.js'); const prompt = stdin.prompt; if (typeof prompt !== 'string' || !prompt.startsWith('/')) return null; @@ -224,7 +235,9 @@ const trackSlashHandler: HookHandler = { const skillName = match[1]; if (!isValidSkillName(skillName)) return null; - await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool }); + const config = await resolveConfigForDir(resolveHookCwd(stdin)); + if (!config) return null; + await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool }, config); await updateKnownSkills(skillName); return null; }, @@ -233,10 +246,10 @@ const trackSlashHandler: HookHandler = { /** * Whether the share-learnings hint may be emitted at all. Resolved lazily per * hook run so a team can switch it off via teamai.yaml (or a member via local - * config) without re-injecting hooks. Falls back to enabled when there is no - * config at all, preserving pre-toggle behavior for half-initialized installs, - * where `teamai skill get share` serves too. A config that exists but cannot be - * loaded withholds it: `share` refuses there, so the nudge would lead nowhere. + * config) without re-injecting hooks. Withheld when there is no config at all: + * the hook fires in every project on the machine, and a directory without teamai + * has no team to share with (#748). A config that exists but cannot be loaded + * withholds it too: `share` refuses there, so the nudge would lead nowhere. * * Recall and a writable source gate it too: the hint routes to the `share` * workflow, and `teamai skill get share` refuses while recall is off or the @@ -246,14 +259,14 @@ const trackSlashHandler: HookHandler = { */ async function contributeHintAllowed(): Promise { const { isContributeHintEnabled, isRecallEnabled } = await import('./types.js'); - const { autoDetectInit, NotInitializedError } = await import('./config.js'); + const { autoDetectInit } = await import('./config.js'); try { const { localConfig, teamConfig } = await autoDetectInit(); return localConfig.repo?.kind !== 'http' && isContributeHintEnabled(localConfig, teamConfig) && isRecallEnabled(localConfig, teamConfig); - } catch (e) { - return e instanceof NotInitializedError ? isContributeHintEnabled({}, {}) : false; + } catch { + return false; } } @@ -620,17 +633,17 @@ export function buildHandlerRegistry(): HandlerRegistration[] { // on a slow network cannot delay session startup. Its own generous budget // (PULL_TIMEOUT_MS) — the shared 15s truncated the pull itself. { event: 'session-start', matcher: '*', handler: pullHandler, timeoutMs: PULL_TIMEOUT_MS, background: true }, - { event: 'session-start', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, - { event: 'session-start', matcher: '*', handler: mrHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true }, - { event: 'session-start', matcher: '*', handler: packageHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, + { event: 'session-start', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, + { event: 'session-start', matcher: '*', handler: mrHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true, requiresConfig: true }, + { event: 'session-start', matcher: '*', handler: packageHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, - { event: 'session-start', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, + { event: 'session-start', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, // Copilot emits SessionEnd after its final turn (not Stop), so the webhook // handler must run here too or those sessions emit no session-stop // notification (#702). Detached, mirroring the stop registration. - { event: 'session-end', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, - { event: 'session-end', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, + { event: 'session-end', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, + { event: 'session-end', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, // ─── Stop ───────────────────────────────────────── // votes-sync and contribute-check may return a hint the host injects back @@ -640,53 +653,57 @@ export function buildHandlerRegistry(): HandlerRegistration[] { // past the host's hook timeout (CodeBuddy kills hooks at ~10s regardless of // the declared timeout). { event: 'stop', matcher: '*', handler: updateHandler, timeoutMs: UPDATE_TIMEOUT_MS, background: true }, - { event: 'stop', matcher: '*', handler: votesSyncHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true }, - { event: 'stop', matcher: '*', handler: contributeCheckHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true }, - { event: 'stop', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, + { event: 'stop', matcher: '*', handler: votesSyncHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true, requiresConfig: true }, + { event: 'stop', matcher: '*', handler: contributeCheckHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true, requiresConfig: true }, + { event: 'stop', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, { event: 'stop', matcher: '*', handler: localAgentHandler, timeoutMs: LOCAL_AGENT_TIMEOUT_MS, background: true }, - { event: 'stop', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, + { event: 'stop', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, // ─── PostToolUse ────────────────────────────────── - { event: 'post-tool-use', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, - { event: 'post-tool-use', matcher: 'Skill', handler: trackHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, - { event: 'post-tool-use', matcher: 'TodoWrite', handler: todowriteHintHandler, timeoutMs: TODOWRITE_HINT_TIMEOUT_MS }, + { event: 'post-tool-use', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, + { event: 'post-tool-use', matcher: 'Skill', handler: trackHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, + { event: 'post-tool-use', matcher: 'TodoWrite', handler: todowriteHintHandler, timeoutMs: TODOWRITE_HINT_TIMEOUT_MS, requiresConfig: true }, { event: 'post-tool-use', matcher: '*', handler: localAgentHandler, timeoutMs: LOCAL_AGENT_TIMEOUT_MS, background: true }, - { event: 'post-tool-use', matcher: 'Skill', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true }, + { event: 'post-tool-use', matcher: 'Skill', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, // ─── UserPromptSubmit ───────────────────────────── - { event: 'prompt-submit', matcher: '*', handler: pendingHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true }, + { event: 'prompt-submit', matcher: '*', handler: pendingHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true, requiresConfig: true }, { event: 'prompt-submit', matcher: '*', handler: packagePendingHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, - { event: 'prompt-submit', matcher: '*', handler: trackSlashHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, - { event: 'prompt-submit', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, + { event: 'prompt-submit', matcher: '*', handler: trackSlashHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, + { event: 'prompt-submit', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'prompt-submit', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, ]; } /** - * Apply the provider-config gate to a handler registry. + * Apply the config gates to a handler registry. + * + * No config (localConfig === null) drops every `requiresConfig` handler. Hooks + * live in HOME even for a project-scope install, so they fire in every project + * on the machine; a directory without teamai must see no team prompts (#748). + * A config that fails to parse also reads as null (loadLocalConfig swallows + * parse errors), so a corrupted config withholds team prompts too; `teamai + * doctor` reports it. * * HTTP-only teams (localConfig.repo.kind === 'http') must not receive prompts - * for git-provider-only features. This drops every `gitOnly` handler when the - * team source is HTTP. When localConfig is null (teamai not initialized) or the - * source is git (kind === 'git' or undefined for backward compatibility), the - * full registry is returned unchanged. + * for git-provider-only features, so every `gitOnly` handler is dropped when the + * team source is HTTP. A git source (kind === 'git' or undefined for backward + * compatibility) keeps the full registry. * * The gate is keyed on teamai's own configured source, NOT on the current * working directory's git remote — an HTTP-only user working inside a - * github/tgit checkout must still see no git-only prompts. - * - * Fail-open by design: a null localConfig means either teamai is not - * initialized or the config failed to parse (loadLocalConfig swallows parse - * errors and returns null). In both cases the full registry is kept, so a - * corrupted config degrades to "all hooks run" rather than silently disabling - * them. This is intentionally NOT a hard security gate — HTTP write ops are - * still enforced at execution time by assertNotReadOnly(). + * github/tgit checkout must still see no git-only prompts. This is + * intentionally NOT a hard security gate — HTTP write ops are still enforced + * at execution time by assertNotReadOnly(). */ export function filterHandlersForConfig( registry: HandlerRegistration[], localConfig: LocalConfig | null, ): HandlerRegistration[] { - if (localConfig?.repo.kind === 'http') { + if (!localConfig) { + return registry.filter((reg) => reg.requiresConfig !== true); + } + if (localConfig.repo.kind === 'http') { return registry.filter((reg) => reg.gitOnly !== true); } return registry; diff --git a/src/init.ts b/src/init.ts index e11d01297..14a6f4674 100644 --- a/src/init.ts +++ b/src/init.ts @@ -11,6 +11,7 @@ import { ensureDir, writeFile, pathExists, expandHome, readFileSafe, remove } fr import { log, spinner } from './utils/logger.js'; import { getTeamaiHomeDir, + getUserConfigPath, REPORTS_BRANCH, type GlobalOptions, type LocalConfig, @@ -19,6 +20,7 @@ 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'; @@ -334,6 +336,18 @@ 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 @@ -462,8 +476,7 @@ export async function initHttp( if (scope === 'project') { await saveLocalConfigForScope(localConfig, scope, projectRoot); } else { - await ensureDir(getTeamaiHomeDir()); - await saveLocalConfig(localConfig); + await saveUserScopeConfig(localConfig); } log.success(`Local config saved to ${teamaiHome}/config.yaml`); @@ -1609,8 +1622,7 @@ export async function init(options: GlobalOptions & { log.debug('Generated .teamai/.gitignore for project scope'); } } else { - await ensureDir(getTeamaiHomeDir()); - await saveLocalConfig(localConfig); + await saveUserScopeConfig(localConfig); log.success(`Local config saved to ${getTeamaiHomeDir()}/config.yaml`); } diff --git a/src/pull.ts b/src/pull.ts index eabdd31e3..4eb553496 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1962,17 +1962,17 @@ export async function pull( // strips a trailer once the team drops the policy. await reconcileCoAuthorAllScopes(reconcileUser, reconcileProject, options); - // 4. Auto-report usage data to all active scopes. Events live in a single - // shared file (~/.teamai/usage.jsonl), so we report to each repo with - // skipTruncate=true first, then truncate once at the end. - // Scope filtering: project scope only gets sessions whose cwd is under - // projectRoot; user scope excludes those sessions. + // 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 + // 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. if (!options.dryRun && !pendingUsageReport) { pendingUsageReport = (async () => { try { const { reportUsageToTeam } = await import('./team-push.js'); const { truncateUsageAfterReport, readUsageEvents } = await import('./usage-tracker.js'); - const targets: Array<{ repoPath: string; username: string; opts: { skipTruncate: true; projectRoot?: string; excludeProjectRoots?: string[]; selfConfig?: LocalConfig } }> = []; + const targets: Array<{ repoPath: string; username: string; opts: { skipTruncate: true; projectRoot?: string; excludeProjectRoots?: string[]; selfConfig: LocalConfig } }> = []; // Per-target opt-out (teamai.yaml `usageReport: false`): a repo that // disables stat commits is dropped from the targets — e.g. teams // pulling from a read-only remote never accumulate unpushable commits. @@ -2004,22 +2004,19 @@ export async function pull( }); } - const eventCount = (await readUsageEvents()).length; - let allReported = true; + // Each scope keeps its own usage file (#748), so each target truncates + // only what it reported. Counted before the report: events appended + // meanwhile survive. A failed target keeps its events; a late success + // still truncates, even if pull has already stopped waiting. for (const t of targets) { + const eventCount = (await readUsageEvents(t.opts.selfConfig)).length; try { const reported = await reportUsageToTeam(t.repoPath, t.username, t.opts); - if (!reported) allReported = false; + if (reported && eventCount > 0) await truncateUsageAfterReport(eventCount, t.opts.selfConfig); } catch (e) { - allReported = false; log.error(`Auto-report to ${t.repoPath} skipped: ${(e as Error).message}`); } } - // A failed target must not lose its events. This also runs after a late - // success, even if pull has already stopped waiting for the report. - if (allReported && eventCount > 0 && targets.length > 0) { - await truncateUsageAfterReport(eventCount); - } } catch (e) { log.debug(`Auto-report skipped: ${(e as Error).message}`); } diff --git a/src/stats.ts b/src/stats.ts index dbc4171ef..2e08325a5 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -2,7 +2,7 @@ import YAML from 'yaml'; import path from 'node:path'; import { readUsageEvents } from './usage-tracker.js'; import { readFileSafe } from './utils/fs.js'; -import { loadLocalConfig, detectProjectConfig } from './config.js'; +import { resolveConfigForDir } from './config.js'; import { readEvents, aggregateSessionMetrics } from './dashboard-collector.js'; import { totalTokens, addTokenUsage, emptyTokenUsage } from './types.js'; import { attributeByRepo, timeAnalytics, renderHourSparkline } from './session-analytics.js'; @@ -49,7 +49,7 @@ export function aggregateUsage(events: UsageEvent[]): SkillStats[] { */ async function loadReportedStats(): Promise { try { - const config = await detectProjectConfig() ?? await loadLocalConfig(); + const config = await resolveConfigForDir(); if (!config) return null; // Non-HTTP: stats live on the teamai-reports orphan branch worktree. // Leftover stats/ on the default-branch clone is ignored. Read-only: never @@ -171,7 +171,10 @@ export interface ShowStatsOptions { * Merges local unreported events with reported team stats for a complete view. */ export async function showStats(options: ShowStatsOptions = {}): Promise { - const events = await readUsageEvents(); + // The same scope loadReportedStats reads, so local and reported totals match; + // a directory without teamai has no usage of its own (#748). + const config = await resolveConfigForDir(); + const events = config ? await readUsageEvents(config) : []; const localStats = aggregateUsage(events); const reported = await loadReportedStats(); const stats = mergeLocalAndReported(localStats, reported); diff --git a/src/team-push.ts b/src/team-push.ts index c7f371ef1..80f63dc6b 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 ~/.teamai/usage.jsonl] ──has events?──▶ merge stats +// [read /usage.jsonl] ─has events?─▶ merge stats // │ │ // ▼ ▼ // [stage pending votes from ~/.teamai/votes/] [write stats/.yaml] @@ -403,7 +403,8 @@ export async function reportUsageToTeam( // only for callers that did not pass a config. try { - const events = await readUsageEvents(); + // This scope's own skill usage (#748); a caller without a scope reports none. + const events = reportsConfig ? await readUsageEvents(reportsConfig) : []; const filesToPush: string[] = []; // Fold the local dashboard event log into per-session metrics once, then derive @@ -574,8 +575,8 @@ export async function reportUsageToTeam( restoreStats = undefined; // Success — truncate reported usage events (only if caller allows it) - if (hasUsage && !options?.skipTruncate) { - await truncateUsageAfterReport(events.length); + if (hasUsage && reportsConfig && !options?.skipTruncate) { + await truncateUsageAfterReport(events.length, reportsConfig); log.debug(`Reported ${events.length} usage events to team repo`); } else if (hasUsage) { log.debug(`Reported ${events.length} usage events to team repo (kept local copy)`); diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index bbed0b350..3cd14bb66 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -4,15 +4,23 @@ import { log } from './utils/logger.js'; import { normalizeToolName } from './utils/tool-names.js'; import { getCopilotHome, + getDataHome, SKILL_NAME_REGEX, + type LocalConfig, type UsageEvent, } from './types.js'; import { ensureDir, readJson, writeJson, pathExists } from './utils/fs.js'; import { getUserHome } from './utils/home.js'; +import { resolveHookCwd } from './utils/hook-cwd.js'; +import { resolveConfigForDir } from './config.js'; -/** Get the usage JSONL path (evaluated at call time to respect HOME changes in tests). */ -function getUsagePath(): string { - return path.join(getUserHome(), '.teamai', 'usage.jsonl'); +/** + * 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. + */ +function getUsagePath(config: LocalConfig): string { + return path.join(getDataHome(config), 'usage.jsonl'); } /** Get the known-skills.json path (evaluated at call time to respect HOME changes in tests). */ @@ -39,7 +47,10 @@ function getKnownSkillsPath(): string { // [toolArg → toolSource; Read+SKILL.md → 'cursor'] // │ // ▼ -// appendFile(usage.jsonl, JSON line) +// [resolveConfigForDir(cwd)] ─null─▶ skip (#748) +// │ +// ▼ +// appendFile(/usage.jsonl, JSON line) // │ // ▼ // updateKnownSkills(skill) → known-skills.json @@ -61,7 +72,7 @@ function getKnownSkillsPath(): string { // [extract & validate skill name after "/"] // │ // ▼ -// appendFile(usage.jsonl) + updateKnownSkills() +// appendFile(/usage.jsonl) + updateKnownSkills() // /** @@ -202,11 +213,12 @@ export async function skillExistsOnDisk(skillName: string): Promise { * Silently fails on I/O errors (disk full, permission denied, etc.) * to avoid disrupting the AI coding session. */ -export async function appendUsageEvent(event: UsageEvent): Promise { +export async function appendUsageEvent(event: UsageEvent, config: LocalConfig): Promise { try { - await ensureDir(path.dirname(getUsagePath())); + const usagePath = getUsagePath(config); + await ensureDir(path.dirname(usagePath)); const line = JSON.stringify(event) + '\n'; - await fs.promises.appendFile(getUsagePath(), line, 'utf-8'); + await fs.promises.appendFile(usagePath, line, 'utf-8'); log.debug(`Tracked skill: ${event.skill}`); } catch (e) { log.error(`Failed to write usage event: ${(e as Error).message}`); @@ -214,12 +226,25 @@ export async function appendUsageEvent(event: UsageEvent): Promise { } /** - * Read all usage events from the JSONL file. + * 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. */ -export async function readUsageEvents(): Promise { +export async function readUsageEvents(config: LocalConfig): Promise { try { - const content = await fs.promises.readFile(getUsagePath(), 'utf-8'); + const content = await fs.promises.readFile(getUsagePath(config), 'utf-8'); const events: UsageEvent[] = []; for (const line of content.split('\n')) { const trimmed = line.trim(); @@ -243,17 +268,18 @@ export async function readUsageEvents(): Promise { * Truncate the usage JSONL file, keeping only events after `afterTimestamp`. * Used after successful auto-report to keep the file small. */ -export async function truncateUsageAfterReport(reportedCount: number): Promise { +export async function truncateUsageAfterReport(reportedCount: number, config: LocalConfig): Promise { try { - const content = await fs.promises.readFile(getUsagePath(), 'utf-8'); + const usagePath = getUsagePath(config); + const content = await fs.promises.readFile(usagePath, 'utf-8'); const lines = content.split('\n').filter((l) => l.trim()); if (reportedCount >= lines.length) { // All lines were reported — clear file - await fs.promises.writeFile(getUsagePath(), '', 'utf-8'); + await fs.promises.writeFile(usagePath, '', 'utf-8'); } else { // Keep unreported lines const remaining = lines.slice(reportedCount).join('\n') + '\n'; - await fs.promises.writeFile(getUsagePath(), remaining, 'utf-8'); + await fs.promises.writeFile(usagePath, remaining, 'utf-8'); } log.debug(`Truncated usage.jsonl: removed ${reportedCount} reported events`); } catch (e) { @@ -286,8 +312,10 @@ export async function updateKnownSkills(skillName: string): Promise { export async function readKnownSkills(): Promise> { const skills = new Set(); - // Source 1: local usage.jsonl (unreported events since last truncation) - const events = await readUsageEvents(); + // Source 1: unreported events in the usage.jsonl of the scope governing the cwd + // (Source 2 below stays machine-wide; neither leaves the machine) + const config = await resolveConfigForDir(); + const events = config ? await readUsageEvents(config) : []; for (const event of events) { skills.add(event.skill); } @@ -344,13 +372,16 @@ export async function track(rawToolName: string, toolInput: string, tool?: strin return; } + const config = await resolveConfigForDir(); + if (!config) return; + const event: UsageEvent = { skill: skillName, timestamp: new Date().toISOString(), tool: tool ?? 'claude', }; - await appendUsageEvent(event); + await appendUsageEvent(event, config); await updateKnownSkills(skillName); } @@ -374,7 +405,7 @@ export async function trackFromStdin(toolArg?: string): Promise { return; } - let hookData: { tool_name?: string; tool_input?: Record }; + let hookData: { tool_name?: string; tool_input?: Record; cwd?: unknown }; try { hookData = JSON.parse(raw); } catch { @@ -422,13 +453,16 @@ export async function trackFromStdin(toolArg?: string): Promise { return; } + const config = await resolveConfigForDir(resolveHookCwd(hookData)); + if (!config) return; + const event: UsageEvent = { skill: skillName, timestamp: new Date().toISOString(), tool: toolSource, }; - await appendUsageEvent(event); + await appendUsageEvent(event, config); await updateKnownSkills(skillName); } @@ -451,7 +485,7 @@ export async function trackSlashCommand(toolArg?: string): Promise { return; } - let hookData: { prompt?: string }; + let hookData: { prompt?: string; cwd?: unknown }; try { hookData = JSON.parse(raw); } catch { @@ -472,6 +506,9 @@ export async function trackSlashCommand(toolArg?: string): Promise { return; } + const config = await resolveConfigForDir(resolveHookCwd(hookData)); + if (!config) return; + for (const match of matches) { const skillName = match[1]; @@ -493,7 +530,7 @@ export async function trackSlashCommand(toolArg?: string): Promise { tool: toolArg ?? 'claude', }; - await appendUsageEvent(event); + await appendUsageEvent(event, config); await updateKnownSkills(skillName); } }