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);