From 0cda8eb5da3a094e097f16e471f7e776c8a0f28f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 12:25:17 +0200 Subject: [PATCH 1/5] fix(hooks): run team hooks only where TeamAI is set up (#748) Hooks of a project-scope install live in HOME, so they fire in every project on the machine. With no config for the hook's cwd they ran anyway: the Stop share nudge (even with recall off), the TodoWrite recall nudge, and local capture of sessions and skill usage that another project's report later pushed to its team. Handlers that need a team now declare requiresConfig; the dispatcher drops them when neither a project nor a user config resolves. Only machine-level work runs there: update check, session-start pull, local agent, package pending hint. The legacy track paths skip recording the same way. --- CHANGELOG.md | 1 + docs/designs/skill-serving.md | 8 +- docs/product-overview.md | 2 +- docs/product-overview.zh-CN.md | 2 +- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- skill-data/setup/references/manage-admin.md | 3 +- src/__tests__/codex-stop-hint.test.ts | 8 ++ src/__tests__/hook-handlers.test.ts | 38 +++++++-- src/__tests__/usage-tracking.test.ts | 46 +++++++++++ src/hook-dispatch-cli.ts | 5 +- src/hook-handlers.ts | 87 ++++++++++++--------- src/usage-tracker.ts | 23 +++++- 13 files changed, 174 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7594c6ca..5b56bb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ 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; `teamai doctor` reports it (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)). - `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). - Cache GC now rejects partial integers such as `12abc`, decimals, zero and unsafe integers for `--max-bytes` and `--stale-days` before deleting anything. An invalid `TEAMAI_CACHE_MAX_BYTES` value falls back to the default 5 GB limit instead of using a numeric prefix. diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index 7888ca16..f2494994 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/product-overview.md b/docs/product-overview.md index baadea9e..a1c9d8b8 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 18c7370f..0294c9fa 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 1a1762d4..19f8b051 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -503,7 +503,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 and the local agent). 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)). @@ -947,7 +947,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 diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 013e756d..e9004bd2 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -475,7 +475,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 更新检查和本地 agent)。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 启用角色化 skills 后,`pull` 的 skills 同步来源会变成 `skills//` 中的内容,按 `primaryRole + additionalRoles` 展开对应的 namespace,拍平安装到本地各 AI 工具 skills 目录。`rules/`、`docs/` 仍然保持原有同步逻辑;`agents//` 按角色的 `agents` namespace 同步(见 [Agents 资源类型](#agents-资源类型))。`learnings/` 根目录对所有人共享,而 `learnings//` 子目录只对本目录激活的项目同步(见 [多项目](#多项目project-作为与-role-正交的维度))。 @@ -914,7 +914,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 的目录中也不会出现。 ### 搜索知识 diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index c8fe0ae3..4f30e665 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -130,7 +130,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 da14ca64..55f9e8f6 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__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index a0f12ab8..32d2d08c 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -411,17 +411,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 +675,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__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index 1b6ca7e4..c571b7da 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -39,9 +39,18 @@ import type { UsageEvent, UserStats } from '../types.js'; let tmpDir: string; const origHome = process.env.HOME; +/** 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 () => { @@ -277,6 +286,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', () => { diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index e5b7fb35..4ac210b4 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -409,8 +409,9 @@ 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 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). 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'); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index f28ee95c..97206461 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 ────────────────────────────────── @@ -233,10 +240,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 +253,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 +627,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 +647,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/usage-tracker.ts b/src/usage-tracker.ts index bbed0b35..08fd5fad 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -5,10 +5,12 @@ import { normalizeToolName } from './utils/tool-names.js'; import { getCopilotHome, 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'; /** Get the usage JSONL path (evaluated at call time to respect HOME changes in tests). */ function getUsagePath(): string { @@ -197,6 +199,17 @@ export async function skillExistsOnDisk(skillName: string): Promise { return false; } +/** + * The config of the scope a skill use at `cwd` belongs to: the project teamai + * is set up for there, else the user scope, else null. Null means teamai is not + * set up here, so nothing is recorded (#748). This is the resolution the hook + * dispatcher uses to pick which handlers run. + */ +export async function resolveUsageScope(cwd?: string): Promise { + const { detectProjectConfig, loadLocalConfig } = await import('./config.js'); + return (await detectProjectConfig(cwd ?? process.cwd())) ?? await loadLocalConfig(); +} + /** * Append a usage event to the local JSONL file. * Silently fails on I/O errors (disk full, permission denied, etc.) @@ -344,6 +357,8 @@ export async function track(rawToolName: string, toolInput: string, tool?: strin return; } + if (!(await resolveUsageScope())) return; + const event: UsageEvent = { skill: skillName, timestamp: new Date().toISOString(), @@ -374,7 +389,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,6 +437,8 @@ export async function trackFromStdin(toolArg?: string): Promise { return; } + if (!(await resolveUsageScope(resolveHookCwd(hookData)))) return; + const event: UsageEvent = { skill: skillName, timestamp: new Date().toISOString(), @@ -451,7 +468,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 +489,8 @@ export async function trackSlashCommand(toolArg?: string): Promise { return; } + if (!(await resolveUsageScope(resolveHookCwd(hookData)))) return; + for (const match of matches) { const skillName = match[1]; From 655834d0b92914a7f0e661fb041d376d8a037f3d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 12:35:03 +0200 Subject: [PATCH 2/5] fix(stats): keep skill usage in the scope that recorded it (#748) Every scope appended to one ~/.teamai/usage.jsonl, so whichever project pulled next reported every project's skills to its own team. Usage now goes to /usage.jsonl of the scope that resolves for the session's directory (detectProjectConfig(cwd) ?? loadLocalConfig(), as the dispatcher does). Each report reads and truncates only its own file; teamai stats shows the current scope. A machine's first user scope starts with an empty file: what it held cannot be attributed. --- CHANGELOG.md | 1 + docs/designs/team-intelligence-platform.md | 8 +-- docs/usage-guide.md | 7 ++- docs/usage-guide.zh-CN.md | 6 +- src/__tests__/git-kind-reports.test.ts | 56 +++++++++++++++-- src/__tests__/hook-handlers.test.ts | 3 + src/__tests__/http-repo-integration.test.ts | 29 +++++++++ src/__tests__/pull-scope-isolation.test.ts | 4 +- src/__tests__/usage-tracking.test.ts | 32 ++++++---- src/hook-handlers.ts | 12 ++-- src/init.ts | 20 +++++-- src/pull.ts | 25 ++++---- src/stats.ts | 5 +- src/team-push.ts | 6 +- src/usage-tracker.ts | 66 ++++++++++++++------- 15 files changed, 207 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b56bb69..a6122af2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ 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; `teamai doctor` reports it (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)). - `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). - Cache GC now rejects partial integers such as `12abc`, decimals, zero and unsafe integers for `--max-bytes` and `--stale-days` before deleting anything. An invalid `TEAMAI_CACHE_MAX_BYTES` value falls back to the default 5 GB limit instead of using a numeric prefix. diff --git a/docs/designs/team-intelligence-platform.md b/docs/designs/team-intelligence-platform.md index f7b7b04d..befe3b0f 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/usage-guide.md b/docs/usage-guide.md index 19f8b051..e677c185 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1685,8 +1685,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 e9004bd2..c3b4746e 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1637,8 +1637,10 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI ### 使用统计上报 Pull 对整批统计上报最多等待 5 秒,之后继续其他工作,上报任务仍会完成。 -超时后推送成功,仍会更新本地已上报快照;只有全部选中的目标确认成功后, -才清理对应使用事件。推送失败会保留事件。上报完成前继续持有相关同步锁, +超时后推送成功,仍会更新本地已上报快照。skill 使用按 scope 记录:写入会话所在 +目录对应的已配置 teamai 项目的数据目录(或 user scope),因此每个目标只上报 +自己的使用;未配置 teamai 的目录不记录。目标确认成功后才清理自己的使用事件, +推送失败会保留事件。上报完成前继续持有相关同步锁, 避免另一次 Pull 与尚未完成的上报竞争。 这仍是尽力上报,不提供崩溃恢复保证:远端推送成功与本地确认之间如果进程 diff --git a/src/__tests__/git-kind-reports.test.ts b/src/__tests__/git-kind-reports.test.ts index 9e4106e2..ec2ae4cc 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,48 @@ 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')!.handler; + await track.execute({ session_id: `s-${skill}`, cwd, tool_name: 'Skill', tool_input: { skill } }, 'claude'); + } + + async function reportedSkills(origin: string): Promise { + const stats = await simpleGit(origin).raw(['show', 'teamai-reports:stats/alice.yaml']); + return Object.keys((YAML.parse(stats) as { skills?: Record }).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-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 32d2d08c..c6920ed4 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -46,6 +46,9 @@ vi.mock('../usage-tracker.js', async () => { resolveSkillUse: actual.resolveSkillUse, extractSkillName: actual.extractSkillName, isValidSkillName: actual.isValidSkillName, + resolveUsageScope: vi.fn().mockResolvedValue({ + repo: { localPath: '/tmp/team-repo', remote: '' }, username: 'test', scope: 'user', additionalRoles: [], + }), appendUsageEvent: vi.fn().mockResolvedValue(undefined), updateKnownSkills: vi.fn().mockResolvedValue(undefined), }; diff --git a/src/__tests__/http-repo-integration.test.ts b/src/__tests__/http-repo-integration.test.ts index 0064fdfb..07e6e263 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 e35822f6..86347e9c 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__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index c571b7da..c39b96e4 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -32,13 +32,23 @@ 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( @@ -173,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'); @@ -183,8 +193,8 @@ 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(); expect(events).toHaveLength(2); @@ -225,8 +235,8 @@ describe('readUsageEvents', () => { 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); @@ -235,9 +245,9 @@ describe('truncateUsageAfterReport', () => { }); 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); @@ -638,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); @@ -656,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); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 97206461..1c319d6f 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -190,7 +190,7 @@ const dashboardReportHandler: HookHandler = { const trackHandler: HookHandler = { name: 'track', async execute(stdin, tool) { - const { resolveSkillUse, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); + const { resolveSkillUse, resolveUsageScope, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); const rawToolName = stdin.tool_name; if (typeof rawToolName !== 'string') return null; @@ -203,11 +203,13 @@ const trackHandler: HookHandler = { const resolved = resolveSkillUse(toolName, toolInput as Record); if (!resolved) return null; + const scope = await resolveUsageScope(resolveHookCwd(stdin)); + if (!scope) return null; await appendUsageEvent({ skill: resolved.skillName, timestamp: new Date().toISOString(), tool: resolved.source ?? tool, - }); + }, scope); await updateKnownSkills(resolved.skillName); return null; }, @@ -216,7 +218,7 @@ 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, resolveUsageScope, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); const prompt = stdin.prompt; if (typeof prompt !== 'string' || !prompt.startsWith('/')) return null; @@ -231,7 +233,9 @@ const trackSlashHandler: HookHandler = { const skillName = match[1]; if (!isValidSkillName(skillName)) return null; - await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool }); + const scope = await resolveUsageScope(resolveHookCwd(stdin)); + if (!scope) return null; + await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool }, scope); await updateKnownSkills(skillName); return null; }, diff --git a/src/init.ts b/src/init.ts index e2e0efe1..24575965 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 } from './roles.js'; import { loadProjectsManifest, listProjectIds } from './projects.js'; import { memberReadRoots, readMemberConfig, mergeMemberConfig } from './members.js'; @@ -327,6 +329,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(); +} + /** * 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 @@ -452,8 +466,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`); @@ -1596,8 +1609,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 eabdd31e..57a26712 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1962,11 +1962,11 @@ 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 { @@ -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 dbc4171e..30fea2b5 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -1,6 +1,6 @@ import YAML from 'yaml'; import path from 'node:path'; -import { readUsageEvents } from './usage-tracker.js'; +import { readUsageEvents, resolveUsageScope } from './usage-tracker.js'; import { readFileSafe } from './utils/fs.js'; import { loadLocalConfig, detectProjectConfig } from './config.js'; import { readEvents, aggregateSessionMetrics } from './dashboard-collector.js'; @@ -171,7 +171,8 @@ 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. + const events = await readUsageEvents((await resolveUsageScope()) ?? undefined); 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 c7f371ef..e75fcd0d 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,7 @@ export async function reportUsageToTeam( // only for callers that did not pass a config. try { - const events = await readUsageEvents(); + const events = await readUsageEvents(reportsConfig); // this scope's own skill usage (#748) const filesToPush: string[] = []; // Fold the local dashboard event log into per-session metrics once, then derive @@ -575,7 +575,7 @@ export async function reportUsageToTeam( // Success — truncate reported usage events (only if caller allows it) if (hasUsage && !options?.skipTruncate) { - await truncateUsageAfterReport(events.length); + 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 08fd5fad..0badf822 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -4,6 +4,7 @@ import { log } from './utils/logger.js'; import { normalizeToolName } from './utils/tool-names.js'; import { getCopilotHome, + getDataHome, SKILL_NAME_REGEX, type LocalConfig, type UsageEvent, @@ -12,9 +13,14 @@ import { ensureDir, readJson, writeJson, pathExists } from './utils/fs.js'; import { getUserHome } from './utils/home.js'; import { resolveHookCwd } from './utils/hook-cwd.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). No scope means the user scope + * (`~/.teamai`), the path every scope shared before. Evaluated at call time to + * respect HOME changes in tests. + */ +function getUsagePath(scope?: LocalConfig): string { + return path.join(scope ? getDataHome(scope) : path.join(getUserHome(), '.teamai'), 'usage.jsonl'); } /** Get the known-skills.json path (evaluated at call time to respect HOME changes in tests). */ @@ -41,7 +47,10 @@ function getKnownSkillsPath(): string { // [toolArg → toolSource; Read+SKILL.md → 'cursor'] // │ // ▼ -// appendFile(usage.jsonl, JSON line) +// [resolveUsageScope(cwd)] ──null──▶ skip (#748) +// │ +// ▼ +// appendFile(/usage.jsonl, JSON line) // │ // ▼ // updateKnownSkills(skill) → known-skills.json @@ -63,7 +72,7 @@ function getKnownSkillsPath(): string { // [extract & validate skill name after "/"] // │ // ▼ -// appendFile(usage.jsonl) + updateKnownSkills() +// appendFile(/usage.jsonl) + updateKnownSkills() // /** @@ -215,11 +224,12 @@ export async function resolveUsageScope(cwd?: string): Promise { +export async function appendUsageEvent(event: UsageEvent, scope: LocalConfig): Promise { try { - await ensureDir(path.dirname(getUsagePath())); + const usagePath = getUsagePath(scope); + 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}`); @@ -227,12 +237,22 @@ 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(): Promise { + await fs.promises.rm(getUsagePath(), { force: true }); +} + +/** + * Read all usage events from a scope's JSONL file (user scope when omitted). * Skips corrupted lines gracefully. */ -export async function readUsageEvents(): Promise { +export async function readUsageEvents(scope?: LocalConfig): Promise { try { - const content = await fs.promises.readFile(getUsagePath(), 'utf-8'); + const content = await fs.promises.readFile(getUsagePath(scope), 'utf-8'); const events: UsageEvent[] = []; for (const line of content.split('\n')) { const trimmed = line.trim(); @@ -256,17 +276,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, scope?: LocalConfig): Promise { try { - const content = await fs.promises.readFile(getUsagePath(), 'utf-8'); + const usagePath = getUsagePath(scope); + 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) { @@ -357,7 +378,8 @@ export async function track(rawToolName: string, toolInput: string, tool?: strin return; } - if (!(await resolveUsageScope())) return; + const scope = await resolveUsageScope(); + if (!scope) return; const event: UsageEvent = { skill: skillName, @@ -365,7 +387,7 @@ export async function track(rawToolName: string, toolInput: string, tool?: strin tool: tool ?? 'claude', }; - await appendUsageEvent(event); + await appendUsageEvent(event, scope); await updateKnownSkills(skillName); } @@ -437,7 +459,8 @@ export async function trackFromStdin(toolArg?: string): Promise { return; } - if (!(await resolveUsageScope(resolveHookCwd(hookData)))) return; + const scope = await resolveUsageScope(resolveHookCwd(hookData)); + if (!scope) return; const event: UsageEvent = { skill: skillName, @@ -445,7 +468,7 @@ export async function trackFromStdin(toolArg?: string): Promise { tool: toolSource, }; - await appendUsageEvent(event); + await appendUsageEvent(event, scope); await updateKnownSkills(skillName); } @@ -489,7 +512,8 @@ export async function trackSlashCommand(toolArg?: string): Promise { return; } - if (!(await resolveUsageScope(resolveHookCwd(hookData)))) return; + const scope = await resolveUsageScope(resolveHookCwd(hookData)); + if (!scope) return; for (const match of matches) { const skillName = match[1]; @@ -512,7 +536,7 @@ export async function trackSlashCommand(toolArg?: string): Promise { tool: toolArg ?? 'claude', }; - await appendUsageEvent(event); + await appendUsageEvent(event, scope); await updateKnownSkills(skillName); } } From 59d536800e12e57d451e02ff2a27b5b4e9498b6c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 12:52:01 +0200 Subject: [PATCH 3/5] fix(review): one scope resolver for hooks, usage and stats (#748) - resolveConfigForDir (config.ts) is the single resolution the dispatcher, the usage writers and readers, and teamai stats use. A host that sends no cwd (OpenClaw) resolves from the process cwd, and an unreadable project config yields null instead of falling back to the user scope. - readUsageEvents / truncateUsageAfterReport require a scope; no path reads the old shared file by default. readKnownSkills reads its scope's file. - Real-dispatch tests: no-config session leaves no trace, cwd-less host, unreadable project config. - Docs: machine-level handler list, data-directory-layout note. --- CHANGELOG.md | 2 +- docs/designs/data-directory-layout.md | 4 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/git-kind-reports.test.ts | 10 +- src/__tests__/hook-dispatch-scope.test.ts | 109 ++++++++++++++++++ src/__tests__/hook-handlers.test.ts | 6 +- src/__tests__/team-push-interventions.test.ts | 5 +- src/__tests__/usage-tracking.test.ts | 88 +++++++------- src/config.ts | 15 +++ src/hook-dispatch-cli.ts | 11 +- src/hook-handlers.ts | 18 +-- src/init.ts | 2 +- src/pull.ts | 2 +- src/stats.ts | 12 +- src/team-push.ts | 5 +- src/usage-tracker.ts | 65 +++++------ 17 files changed, 244 insertions(+), 114 deletions(-) create mode 100644 src/__tests__/hook-dispatch-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a6122af2..43e4a14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ 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; `teamai doctor` reports it (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 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 (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)). - `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 8338d435..c6d68aaa 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/usage-guide.md b/docs/usage-guide.md index e677c185..66644d57 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -503,7 +503,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 and the local agent). 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)). diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index c3b4746e..0bf766a7 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -475,7 +475,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 更新检查和本地 agent)。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-正交的维度))。 diff --git a/src/__tests__/git-kind-reports.test.ts b/src/__tests__/git-kind-reports.test.ts index ec2ae4cc..f720950b 100644 --- a/src/__tests__/git-kind-reports.test.ts +++ b/src/__tests__/git-kind-reports.test.ts @@ -655,13 +655,15 @@ describe('skill usage stays in the scope that recorded it (#748)', () => { } async function useSkill(cwd: string, skill: string): Promise { - const track = buildHandlerRegistry().find((r) => r.handler.name === 'track')!.handler; - await track.execute({ session_id: `s-${skill}`, cwd, tool_name: 'Skill', tool_input: { skill } }, 'claude'); + 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 = await simpleGit(origin).raw(['show', 'teamai-reports:stats/alice.yaml']); - return Object.keys((YAML.parse(stats) as { skills?: Record }).skills ?? {}).sort(); + 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 () => { diff --git a/src/__tests__/hook-dispatch-scope.test.ts b/src/__tests__/hook-dispatch-scope.test.ts new file mode 100644 index 00000000..32da9947 --- /dev/null +++ b/src/__tests__/hook-dispatch-scope.test.ts @@ -0,0 +1,109 @@ +/** + * 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 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 c6920ed4..95c34fce 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -46,9 +46,6 @@ vi.mock('../usage-tracker.js', async () => { resolveSkillUse: actual.resolveSkillUse, extractSkillName: actual.extractSkillName, isValidSkillName: actual.isValidSkillName, - resolveUsageScope: vi.fn().mockResolvedValue({ - repo: { localPath: '/tmp/team-repo', remote: '' }, username: 'test', scope: 'user', additionalRoles: [], - }), appendUsageEvent: vi.fn().mockResolvedValue(undefined), updateKnownSkills: vi.fn().mockResolvedValue(undefined), }; @@ -88,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', () => ({ diff --git a/src/__tests__/team-push-interventions.test.ts b/src/__tests__/team-push-interventions.test.ts index f46d1915..4cd44d43 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 c39b96e4..21f392f4 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -196,7 +196,7 @@ describe('appendUsageEvent', () => { 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'); @@ -205,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([]); }); @@ -217,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'); @@ -228,7 +228,7 @@ 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([]); }); }); @@ -238,9 +238,9 @@ describe('truncateUsageAfterReport', () => { 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([]); }); @@ -249,9 +249,9 @@ describe('truncateUsageAfterReport', () => { 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'); }); @@ -261,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'); }); @@ -270,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([]); }); @@ -350,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'); }); @@ -367,7 +367,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -379,7 +379,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -391,7 +391,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -407,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'); }); @@ -441,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'); @@ -460,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'); @@ -479,7 +479,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -495,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'); @@ -513,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'); }); @@ -530,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'); }); @@ -547,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'); }); @@ -564,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'); }); @@ -581,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'); }); @@ -598,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'); }); @@ -615,7 +615,7 @@ describe('trackFromStdin', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); }); @@ -801,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[] = [ @@ -982,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'); @@ -1001,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'); }); @@ -1018,7 +1018,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -1034,7 +1034,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -1046,7 +1046,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -1058,7 +1058,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -1075,7 +1075,7 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(); + const events = await readUsageEvents(userScope()); expect(events).toEqual([]); }); @@ -1107,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'); }); @@ -1124,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'); }); @@ -1141,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'); }); @@ -1162,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'], @@ -1183,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'], @@ -1204,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); @@ -1217,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'); }); @@ -1225,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 ad03deef..b46bf597 100644 --- a/src/config.ts +++ b/src/config.ts @@ -292,6 +292,21 @@ 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. + */ +export async function resolveConfigForDir(dir?: string): Promise { + let unreadable = false; + const project = await detectProjectConfig(dir ?? process.cwd(), () => { 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/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index 4ac210b4..d9e3f744 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -411,10 +411,11 @@ export async function hookDispatchCli( // 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). 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'); + // (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 { @@ -423,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 1c319d6f..2c7e40d1 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -190,7 +190,8 @@ const dashboardReportHandler: HookHandler = { const trackHandler: HookHandler = { name: 'track', async execute(stdin, tool) { - const { resolveSkillUse, resolveUsageScope, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); + 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; @@ -203,13 +204,13 @@ const trackHandler: HookHandler = { const resolved = resolveSkillUse(toolName, toolInput as Record); if (!resolved) return null; - const scope = await resolveUsageScope(resolveHookCwd(stdin)); - if (!scope) 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, - }, scope); + }, config); await updateKnownSkills(resolved.skillName); return null; }, @@ -218,7 +219,8 @@ const trackHandler: HookHandler = { const trackSlashHandler: HookHandler = { name: 'track-slash', async execute(stdin, tool) { - const { isValidSkillName, resolveUsageScope, 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; @@ -233,9 +235,9 @@ const trackSlashHandler: HookHandler = { const skillName = match[1]; if (!isValidSkillName(skillName)) return null; - const scope = await resolveUsageScope(resolveHookCwd(stdin)); - if (!scope) return null; - await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool }, scope); + 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; }, diff --git a/src/init.ts b/src/init.ts index 24575965..afaa9304 100644 --- a/src/init.ts +++ b/src/init.ts @@ -338,7 +338,7 @@ async function saveUserScopeConfig(localConfig: LocalConfig): Promise { const firstUserScope = !(await pathExists(getUserConfigPath())); await ensureDir(getTeamaiHomeDir()); await saveLocalConfig(localConfig); - if (firstUserScope) await discardUnattributedUsage(); + if (firstUserScope) await discardUnattributedUsage(localConfig); } /** diff --git a/src/pull.ts b/src/pull.ts index 57a26712..4eb55349 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1972,7 +1972,7 @@ export async function pull( 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. diff --git a/src/stats.ts b/src/stats.ts index 30fea2b5..2e08325a 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -1,8 +1,8 @@ import YAML from 'yaml'; import path from 'node:path'; -import { readUsageEvents, resolveUsageScope } from './usage-tracker.js'; +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,8 +171,10 @@ export interface ShowStatsOptions { * Merges local unreported events with reported team stats for a complete view. */ export async function showStats(options: ShowStatsOptions = {}): Promise { - // The same scope loadReportedStats reads, so local and reported totals match. - const events = await readUsageEvents((await resolveUsageScope()) ?? undefined); + // 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 e75fcd0d..80f63dc6 100644 --- a/src/team-push.ts +++ b/src/team-push.ts @@ -403,7 +403,8 @@ export async function reportUsageToTeam( // only for callers that did not pass a config. try { - const events = await readUsageEvents(reportsConfig); // this scope's own skill usage (#748) + // 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,7 +575,7 @@ export async function reportUsageToTeam( restoreStats = undefined; // Success — truncate reported usage events (only if caller allows it) - if (hasUsage && !options?.skipTruncate) { + if (hasUsage && reportsConfig && !options?.skipTruncate) { await truncateUsageAfterReport(events.length, reportsConfig); log.debug(`Reported ${events.length} usage events to team repo`); } else if (hasUsage) { diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index 0badf822..8e6d3d7c 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -12,15 +12,15 @@ import { 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'; /** * The usage JSONL of one scope: `/usage.jsonl`, so each scope reports - * only the skills used where it is set up (#748). No scope means the user scope - * (`~/.teamai`), the path every scope shared before. Evaluated at call time to + * only the skills used where it is set up (#748). Evaluated at call time to * respect HOME changes in tests. */ -function getUsagePath(scope?: LocalConfig): string { - return path.join(scope ? getDataHome(scope) : path.join(getUserHome(), '.teamai'), 'usage.jsonl'); +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). */ @@ -47,7 +47,7 @@ function getKnownSkillsPath(): string { // [toolArg → toolSource; Read+SKILL.md → 'cursor'] // │ // ▼ -// [resolveUsageScope(cwd)] ──null──▶ skip (#748) +// [resolveConfigForDir(cwd)] ─null─▶ skip (#748) // │ // ▼ // appendFile(/usage.jsonl, JSON line) @@ -208,25 +208,14 @@ export async function skillExistsOnDisk(skillName: string): Promise { return false; } -/** - * The config of the scope a skill use at `cwd` belongs to: the project teamai - * is set up for there, else the user scope, else null. Null means teamai is not - * set up here, so nothing is recorded (#748). This is the resolution the hook - * dispatcher uses to pick which handlers run. - */ -export async function resolveUsageScope(cwd?: string): Promise { - const { detectProjectConfig, loadLocalConfig } = await import('./config.js'); - return (await detectProjectConfig(cwd ?? process.cwd())) ?? await loadLocalConfig(); -} - /** * Append a usage event to the local JSONL file. * Silently fails on I/O errors (disk full, permission denied, etc.) * to avoid disrupting the AI coding session. */ -export async function appendUsageEvent(event: UsageEvent, scope: LocalConfig): Promise { +export async function appendUsageEvent(event: UsageEvent, config: LocalConfig): Promise { try { - const usagePath = getUsagePath(scope); + const usagePath = getUsagePath(config); await ensureDir(path.dirname(usagePath)); const line = JSON.stringify(event) + '\n'; await fs.promises.appendFile(usagePath, line, 'utf-8'); @@ -242,17 +231,20 @@ export async function appendUsageEvent(event: UsageEvent, scope: LocalConfig): P * which project each event came from; the new user scope must not report it * to its team (#748). */ -export async function discardUnattributedUsage(): Promise { - await fs.promises.rm(getUsagePath(), { force: true }); +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 (user scope when omitted). + * Read all usage events from a scope's JSONL file. * Skips corrupted lines gracefully. */ -export async function readUsageEvents(scope?: LocalConfig): Promise { +export async function readUsageEvents(config: LocalConfig): Promise { try { - const content = await fs.promises.readFile(getUsagePath(scope), '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(); @@ -276,9 +268,9 @@ export async function readUsageEvents(scope?: LocalConfig): Promise { +export async function truncateUsageAfterReport(reportedCount: number, config: LocalConfig): Promise { try { - const usagePath = getUsagePath(scope); + 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) { @@ -320,8 +312,9 @@ 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: this directory's scope usage.jsonl (unreported events since last truncation) + const config = await resolveConfigForDir(); + const events = config ? await readUsageEvents(config) : []; for (const event of events) { skills.add(event.skill); } @@ -378,8 +371,8 @@ export async function track(rawToolName: string, toolInput: string, tool?: strin return; } - const scope = await resolveUsageScope(); - if (!scope) return; + const config = await resolveConfigForDir(); + if (!config) return; const event: UsageEvent = { skill: skillName, @@ -387,7 +380,7 @@ export async function track(rawToolName: string, toolInput: string, tool?: strin tool: tool ?? 'claude', }; - await appendUsageEvent(event, scope); + await appendUsageEvent(event, config); await updateKnownSkills(skillName); } @@ -459,8 +452,8 @@ export async function trackFromStdin(toolArg?: string): Promise { return; } - const scope = await resolveUsageScope(resolveHookCwd(hookData)); - if (!scope) return; + const config = await resolveConfigForDir(resolveHookCwd(hookData)); + if (!config) return; const event: UsageEvent = { skill: skillName, @@ -468,7 +461,7 @@ export async function trackFromStdin(toolArg?: string): Promise { tool: toolSource, }; - await appendUsageEvent(event, scope); + await appendUsageEvent(event, config); await updateKnownSkills(skillName); } @@ -512,8 +505,8 @@ export async function trackSlashCommand(toolArg?: string): Promise { return; } - const scope = await resolveUsageScope(resolveHookCwd(hookData)); - if (!scope) return; + const config = await resolveConfigForDir(resolveHookCwd(hookData)); + if (!config) return; for (const match of matches) { const skillName = match[1]; @@ -536,7 +529,7 @@ export async function trackSlashCommand(toolArg?: string): Promise { tool: toolArg ?? 'claude', }; - await appendUsageEvent(event, scope); + await appendUsageEvent(event, config); await updateKnownSkills(skillName); } } From e21f3bd6c5eee48c2e4fe2fe169f618e56a866fe Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 12:55:00 +0200 Subject: [PATCH 4/5] fix(review): scope wording in CHANGELOG and readKnownSkills (#748) --- CHANGELOG.md | 2 +- src/usage-tracker.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43e4a14f..cd1ecdd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ 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 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 (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 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 (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)). - `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/src/usage-tracker.ts b/src/usage-tracker.ts index 8e6d3d7c..3cd14bb6 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -312,7 +312,8 @@ export async function updateKnownSkills(skillName: string): Promise { export async function readKnownSkills(): Promise> { const skills = new Set(); - // Source 1: this directory's scope usage.jsonl (unreported events since last truncation) + // 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) { From 454be758e9fe449fabd6dfc5d48110e00abdafbd Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 13:23:57 +0200 Subject: [PATCH 5/5] fix(review): gate legacy contribute-check and tolerate a missing cwd (#748) The hidden 'teamai contribute-check' command, still called by hooks of older installs, had no config gate, so it nudged in projects without teamai. It now resolves the scope like the dispatcher and skips when none resolves. resolveConfigForDir no longer throws for a directory that does not exist (simple-git refuses it); a hook naming a deleted worktree falls back to the user scope. --- CHANGELOG.md | 2 +- src/__tests__/contribute-check-e2e.test.ts | 20 +++++++++++++++++++- src/__tests__/hook-dispatch-scope.test.ts | 9 +++++++++ src/config.ts | 8 ++++++-- src/contribute-check.ts | 7 +++++++ 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1ecdd3..b51c89dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ 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 (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 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)). - `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/src/__tests__/contribute-check-e2e.test.ts b/src/__tests__/contribute-check-e2e.test.ts index 9b1261ba..e0078416 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__/hook-dispatch-scope.test.ts b/src/__tests__/hook-dispatch-scope.test.ts index 32da9947..02b3eb9f 100644 --- a/src/__tests__/hook-dispatch-scope.test.ts +++ b/src/__tests__/hook-dispatch-scope.test.ts @@ -95,6 +95,15 @@ describe('hook runs and the scope they belong to (#748)', () => { 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'); diff --git a/src/config.ts b/src/config.ts index b46bf597..a65e949c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -298,11 +298,15 @@ export async function resolveDataHomeForScope(scope: Scope, projectRoot?: string * 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. + * 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(dir ?? process.cwd(), () => { unreadable = true; }); + const project = await detectProjectConfig(target, () => { unreadable = true; }); if (project) return project; return unreadable ? null : loadLocalConfig(); } diff --git a/src/contribute-check.ts b/src/contribute-check.ts index 1af35b69..5531b9d8 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';