From ff7c7ad311c595610605d8b3e756d2733bed8fa2 Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:24:38 +0800 Subject: [PATCH] fix(usage): scope skill-usage events by cwd so one project's usage never reaches another team's stats Dashboard events already carry a cwd and are filtered by scope before a report, but skill-usage events do not, so ~/.teamai/usage.jsonl is aggregated whole and every project on the machine is shipped to whatever repo pull happens to be reporting to. Give UsageEvent an optional cwd, populate it from the hook payload via the existing resolveHookCwd helper (which already handles Cursor's workspace_roots), and filter usage with the same filterEventsByScope dashboard events use. That function is now generic over any event carrying a cwd, so both call sites share one set of path rules. Events written before this field have no cwd. A projectRoot report drops them, since they cannot be shown to belong to that project; excludeProjectRoots keeps them, since they cannot be shown to belong to an excluded one. pull reports to both scopes with complementary filters, so no event is lost from the local queue. Fixes #748 (bug 2). Bug 1 of that issue -- the Stop-hook nudge reaching projects that never initialized teamai -- is left to #747, which is already reworking the same gate. --- docs/designs/team-intelligence-platform.md | 8 +++- src/__tests__/scope-filter.test.ts | 43 ++++++++++++++++- src/__tests__/team-push-interventions.test.ts | 46 +++++++++++++++++++ src/hook-handlers.ts | 3 +- src/team-push.ts | 25 ++++++++-- src/types.ts | 13 ++++++ src/usage-tracker.ts | 5 +- 7 files changed, 134 insertions(+), 9 deletions(-) diff --git a/docs/designs/team-intelligence-platform.md b/docs/designs/team-intelligence-platform.md index f7b7b04d1..cd7b366a0 100644 --- a/docs/designs/team-intelligence-platform.md +++ b/docs/designs/team-intelligence-platform.md @@ -74,9 +74,15 @@ Transform TeamAI from a simple skill-sharing CLI into a **Team Intelligence Plat **Data format (JSONL, one event per line):** ```jsonl {"skill":"code-review","timestamp":"2026-03-19T10:30:00Z","tool":"claude"} -{"skill":"tdd","timestamp":"2026-03-19T10:35:00Z","tool":"claude"} +{"skill":"tdd","timestamp":"2026-03-19T10:35:00Z","tool":"claude","cwd":"/home/dev/project-a"} ``` +`cwd` records the project the hook ran in and is absent on events written +before it existed. Reports filter on it: a project-scope report keeps only +events under its `projectRoot`, and the user-scope report excludes those, so one +machine holding several projects never ships one project's usage to another +team's stats. + **Aggregation:** `teamai stats` 命令读取 JSONL 并聚合显示。 **CLI output:** diff --git a/src/__tests__/scope-filter.test.ts b/src/__tests__/scope-filter.test.ts index 17794855b..656bb4d43 100644 --- a/src/__tests__/scope-filter.test.ts +++ b/src/__tests__/scope-filter.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { filterEventsByScope } from '../team-push.js'; -import type { DashboardEvent } from '../types.js'; +import type { DashboardEvent, UsageEvent } from '../types.js'; function makeEvent(cwd: string | undefined, sessionId = 's1'): DashboardEvent { return { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId, tool: 'claude', cwd }; @@ -153,3 +153,44 @@ describe('filterEventsByScope', () => { }); }); }); + +// Skill-usage events carry a cwd for the same reason dashboard events do: a +// machine can hold several projects, and only the initialized one's usage may +// reach the team repo (#748). Without it the filter drops every event. +describe('filterEventsByScope with usage events (#748)', () => { + function makeUsage(cwd: string | undefined, skill = 'review'): UsageEvent { + return { skill, timestamp: new Date().toISOString(), tool: 'claude', cwd }; + } + + const usage: UsageEvent[] = [ + makeUsage('/Users/jeff/project-a', 'a-skill'), + makeUsage('/Users/jeff/project-a/src', 'b-skill'), + makeUsage('/Users/jeff/other-work', 'c-skill'), + makeUsage('/Users/jeff/project-b', 'd-skill'), + makeUsage(undefined, 'e-skill'), + ]; + + it('filters usage to projectRoot', () => { + const result = filterEventsByScope(usage, { projectRoot: '/Users/jeff/project-a' }); + expect(result.map((e) => e.skill)).toEqual(['a-skill', 'b-skill']); + }); + + it('excludeProjectRoots removes matching usage and keeps the rest', () => { + const result = filterEventsByScope(usage, { excludeProjectRoots: ['/Users/jeff/project-a'] }); + expect(result.map((e) => e.skill)).toEqual(['c-skill', 'd-skill', 'e-skill']); + }); + + it('applies the same Windows rules as dashboard events', () => { + const winUsage: UsageEvent[] = [ + makeUsage('C:\\Users\\jeff\\project-a', 'w1'), + makeUsage('C:\\Users\\jeff\\project-a\\src', 'w2'), + makeUsage('C:\\Users\\jeff\\other-work', 'w3'), + ]; + expect( + filterEventsByScope(winUsage, { projectRoot: 'c:/users/JEFF/Project-A' }).map((e) => e.skill), + ).toEqual(['w1', 'w2']); + expect( + filterEventsByScope(winUsage, { excludeProjectRoots: ['C:\\Users\\jeff\\project-a'] }).map((e) => e.skill), + ).toEqual(['w3']); + }); +}); diff --git a/src/__tests__/team-push-interventions.test.ts b/src/__tests__/team-push-interventions.test.ts index f46d1915a..fdadf744e 100644 --- a/src/__tests__/team-push-interventions.test.ts +++ b/src/__tests__/team-push-interventions.test.ts @@ -279,3 +279,49 @@ describe('reportUsageToTeam — preserve fields across partial reports (Issue #4 expect(pushRepoDirectly).not.toHaveBeenCalled(); }); }); + +describe('reportUsageToTeam — usage scope isolation (#748)', () => { + function seedMixedUsage(): string { + const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); + fs.mkdirSync(path.dirname(usagePath), { recursive: true }); + const base = { timestamp: new Date().toISOString(), tool: 'claude' }; + // project-a is the reporting project; project-b never initialized teamai + // there, so its skill usage must not reach the team repo. + fs.writeFileSync(usagePath, [ + { ...base, skill: 'team-review', cwd: '/p' }, + { ...base, skill: 'team-lint', cwd: '/p/src' }, + { ...base, skill: 'private-repo-skill', cwd: '/other-project' }, + { ...base, skill: 'legacy-no-cwd' }, + ].map((e) => JSON.stringify(e)).join('\n') + '\n'); + return usagePath; + } + + it('drops usage events from outside projectRoot', async () => { + seedMixedUsage(); + expect(await reportUsageToTeam(repoDir, 'me', { + projectRoot: '/p', + selfConfig: { ...gitConfig(), scope: 'project', projectRoot: '/p' } as LocalConfig, + })).toBe(true); + const stats = YAML.parse(fs.readFileSync(reportsStatsPath(), 'utf-8')); + expect(Object.keys(stats.skills).sort()).toEqual(['team-lint', 'team-review']); + expect(stats.skills['private-repo-skill']).toBeUndefined(); + }); + + it('excludes project sessions from the user-scope report', async () => { + seedMixedUsage(); + expect(await reportUsageToTeam(repoDir, 'me', { + excludeProjectRoots: ['/p'], + selfConfig: gitConfig(), + })).toBe(true); + const stats = YAML.parse(fs.readFileSync(reportsStatsPath(), 'utf-8')); + expect(Object.keys(stats.skills).sort()).toEqual(['legacy-no-cwd', 'private-repo-skill']); + expect(stats.skills['team-review']).toBeUndefined(); + }); + + it('keeps every event when no scope option is passed (backward compatible)', async () => { + seedMixedUsage(); + expect(await reportUsageToTeam(repoDir, 'me', { selfConfig: gitConfig() })).toBe(true); + const stats = YAML.parse(fs.readFileSync(reportsStatsPath(), 'utf-8')); + expect(Object.keys(stats.skills).sort()).toEqual(['legacy-no-cwd', 'private-repo-skill', 'team-lint', 'team-review']); + }); +}); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index f28ee95cb..85940141b 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -200,6 +200,7 @@ const trackHandler: HookHandler = { skill: resolved.skillName, timestamp: new Date().toISOString(), tool: resolved.source ?? tool, + cwd: resolveHookCwd(stdin), }); await updateKnownSkills(resolved.skillName); return null; @@ -224,7 +225,7 @@ const trackSlashHandler: HookHandler = { const skillName = match[1]; if (!isValidSkillName(skillName)) return null; - await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool }); + await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool, cwd: resolveHookCwd(stdin) }); await updateKnownSkills(skillName); return null; }, diff --git a/src/team-push.ts b/src/team-push.ts index c7f371ef1..a6d516fba 100644 --- a/src/team-push.ts +++ b/src/team-push.ts @@ -352,15 +352,27 @@ function isUnderScopeRoot(cwd: string, root: ScopeRoot): boolean { } /** - * Filter dashboard events by scope: + * Any local event that records the working directory it came from. + * + * Dashboard events and skill-usage events both carry an optional `cwd`; the + * scope rules below only ever read that one field, so they apply to both. + */ +type ScopedEvent = { cwd?: string }; + +/** + * Filter scope-carrying events by scope: * - projectRoot set: keep only events whose cwd is under that root. * - excludeProjectRoots set: exclude events whose cwd is under any listed root. * - Neither: return all events (backward-compatible). + * + * The semantics are deliberately shared by dashboard events and skill-usage + * events: both are written by hooks that run in whatever directory the AI tool + * was started in, and a machine can hold several projects at once (#748). */ -export function filterEventsByScope( - events: DashboardEvent[], +export function filterEventsByScope( + events: T[], opts?: { projectRoot?: string; excludeProjectRoots?: string[] }, -): DashboardEvent[] { +): T[] { if (!opts) return events; if (opts.projectRoot) { const root = scopeRoot(opts.projectRoot); @@ -403,7 +415,10 @@ export async function reportUsageToTeam( // only for callers that did not pass a config. try { - const events = await readUsageEvents(); + // Skill usage is scoped exactly like dashboard events: hooks write a cwd for + // both, and a machine can hold several projects, so an unfiltered report + // would ship one project's usage to another team's stats (#748). + const events = filterEventsByScope(await readUsageEvents(), options); const filesToPush: string[] = []; // Fold the local dashboard event log into per-session metrics once, then derive diff --git a/src/types.ts b/src/types.ts index 126f340c6..b390986e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1051,12 +1051,25 @@ export interface UsageEvent { skill: string; timestamp: string; tool: string; + /** + * Working directory the skill was used in, as reported by the AI tool's hook + * payload. Optional because events written before this field existed have no + * value, and because a hook may legitimately run without one. + * + * Reports filter on it so a machine holding several projects only ships the + * initialized project's usage (#748). A `projectRoot` report therefore drops + * an event with no cwd — it cannot be shown to belong to that project — while + * `excludeProjectRoots` keeps it, since it cannot be shown to belong to an + * excluded one either. + */ + cwd?: string; } export const UsageEventSchema = z.object({ skill: z.string().regex(SKILL_NAME_REGEX), timestamp: z.string(), tool: z.string(), + cwd: z.string().optional(), }); // ─── Stats YAML (team repo: stats/.yaml) ───────── diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index bbed0b350..4e7457d6f 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -9,6 +9,7 @@ import { } 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 { @@ -426,6 +427,7 @@ export async function trackFromStdin(toolArg?: string): Promise { skill: skillName, timestamp: new Date().toISOString(), tool: toolSource, + cwd: resolveHookCwd(hookData), }; await appendUsageEvent(event); @@ -451,7 +453,7 @@ export async function trackSlashCommand(toolArg?: string): Promise { return; } - let hookData: { prompt?: string }; + let hookData: { prompt?: string; cwd?: unknown; workspace_roots?: unknown }; try { hookData = JSON.parse(raw); } catch { @@ -491,6 +493,7 @@ export async function trackSlashCommand(toolArg?: string): Promise { skill: skillName, timestamp: new Date().toISOString(), tool: toolArg ?? 'claude', + cwd: resolveHookCwd(hookData), }; await appendUsageEvent(event);