Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/designs/team-intelligence-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
43 changes: 42 additions & 1 deletion src/__tests__/scope-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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']);
});
});
46 changes: 46 additions & 0 deletions src/__tests__/team-push-interventions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
3 changes: 2 additions & 1 deletion src/hook-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
},
Expand Down
25 changes: 20 additions & 5 deletions src/team-push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends ScopedEvent>(
events: T[],
opts?: { projectRoot?: string; excludeProjectRoots?: string[] },
): DashboardEvent[] {
): T[] {
if (!opts) return events;
if (opts.projectRoot) {
const root = scopeRoot(opts.projectRoot);
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<user>.yaml) ─────────
Expand Down
5 changes: 4 additions & 1 deletion src/usage-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -426,6 +427,7 @@ export async function trackFromStdin(toolArg?: string): Promise<void> {
skill: skillName,
timestamp: new Date().toISOString(),
tool: toolSource,
cwd: resolveHookCwd(hookData),
};

await appendUsageEvent(event);
Expand All @@ -451,7 +453,7 @@ export async function trackSlashCommand(toolArg?: string): Promise<void> {
return;
}

let hookData: { prompt?: string };
let hookData: { prompt?: string; cwd?: unknown; workspace_roots?: unknown };
try {
hookData = JSON.parse(raw);
} catch {
Expand Down Expand Up @@ -491,6 +493,7 @@ export async function trackSlashCommand(toolArg?: string): Promise<void> {
skill: skillName,
timestamp: new Date().toISOString(),
tool: toolArg ?? 'claude',
cwd: resolveHookCwd(hookData),
};

await appendUsageEvent(event);
Expand Down
Loading