Skip to content
Merged
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
63 changes: 63 additions & 0 deletions src/__tests__/scope-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { filterEventsByScope } from '../team-push.js';
import type { DashboardEvent } from '../types.js';

function makeEvent(cwd: string | undefined, sessionId = 's1'): DashboardEvent {
return { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId, tool: 'claude', cwd };
}

describe('filterEventsByScope', () => {
const events: DashboardEvent[] = [
makeEvent('/Users/jeff/project-a', 's1'),
makeEvent('/Users/jeff/project-a/src', 's2'),
makeEvent('/Users/jeff/other-work', 's3'),
makeEvent('/Users/jeff/project-b', 's4'),
makeEvent(undefined, 's5'),
];

it('returns all events when no filter is provided', () => {
expect(filterEventsByScope(events)).toEqual(events);
expect(filterEventsByScope(events, {})).toEqual(events);
});

it('filters to projectRoot (exact match and subdirectories)', () => {
const result = filterEventsByScope(events, { projectRoot: '/Users/jeff/project-a' });
expect(result.map((e) => e.sessionId)).toEqual(['s1', 's2']);
});

it('projectRoot with trailing slash works the same', () => {
const result = filterEventsByScope(events, { projectRoot: '/Users/jeff/project-a/' });
expect(result.map((e) => e.sessionId)).toEqual(['s1', 's2']);
});

it('excludeProjectRoots removes matching events and keeps the rest', () => {
const result = filterEventsByScope(events, { excludeProjectRoots: ['/Users/jeff/project-a'] });
expect(result.map((e) => e.sessionId)).toEqual(['s3', 's4', 's5']);
});

it('excludeProjectRoots with multiple roots', () => {
const result = filterEventsByScope(events, {
excludeProjectRoots: ['/Users/jeff/project-a', '/Users/jeff/project-b'],
});
expect(result.map((e) => e.sessionId)).toEqual(['s3', 's5']);
});

it('events with undefined cwd are kept by excludeProjectRoots', () => {
const result = filterEventsByScope(events, { excludeProjectRoots: ['/Users/jeff/project-a'] });
expect(result.find((e) => e.sessionId === 's5')).toBeDefined();
});

it('events with undefined cwd are excluded by projectRoot', () => {
const result = filterEventsByScope(events, { projectRoot: '/Users/jeff/project-a' });
expect(result.find((e) => e.sessionId === 's5')).toBeUndefined();
});

it('does not match partial directory name prefixes', () => {
const evts = [
makeEvent('/Users/jeff/project-ab', 'x1'),
makeEvent('/Users/jeff/project-a', 'x2'),
];
const result = filterEventsByScope(evts, { projectRoot: '/Users/jeff/project-a' });
expect(result.map((e) => e.sessionId)).toEqual(['x2']);
});
});
2 changes: 1 addition & 1 deletion src/__tests__/scope-isolation-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const CLI = path.join(ROOT, 'dist', 'index.js');

const PROJECT_TITLE = 'Project Deployment Timeout Fix';
const USER_TITLE = 'User Deployment Timeout Fix';
const SKIP_MSG = '检测到 project scope,已跳过 user scope';
const SKIP_MSG = 'project scope detected, skipped user scope';

interface RunResult {
code: number | null;
Expand Down
30 changes: 11 additions & 19 deletions src/digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,14 +401,14 @@ export async function generateDigest(options: GlobalOptions): Promise<void> {
// Learnings
const { recent: recentLearnings, total: totalLearnings } = await getRecentLearnings(repoPath);
if (recentLearnings.length > 0) {
console.log(`📚 本周新增 Learnings: ${recentLearnings.length}`);
console.log(`📚 New Learnings This Week: ${recentLearnings.length}`);
for (const learning of recentLearnings) {
console.log(` • ${learning.title}`);
}
console.log('');
}
if (totalLearnings > 0) {
console.log(`📊 知识库总量: ${totalLearnings} learnings`);
console.log(`📊 Knowledge base total: ${totalLearnings} learnings`);
console.log('');
}

Expand Down Expand Up @@ -436,36 +436,28 @@ export async function generateDigest(options: GlobalOptions): Promise<void> {
// Session autonomy — Human Intervention metric (Issue #34)
const interventions = summarizeInterventions(teamStats);
if (interventions) {
console.log('🤖 会话自主性 (Human Intervention):');
console.log('🤖 Session Autonomy (Human Intervention):');
console.log(
` 团队均值: ${interventions.avgPerSession.toFixed(2)} 次干预/会话 ` +
`(${interventions.totalSessions} 会话, ${interventions.totalInterventions} 次干预)`,
` Team avg: ${interventions.avgPerSession.toFixed(2)} interventions/session ` +
`(${interventions.totalSessions} sessions, ${interventions.totalInterventions} interventions)`,
);
console.log(
` 分解: 中断 ${interventions.interrupt} · 拒绝 ${interventions.toolReject} · 纠偏 ${interventions.correction}`,
` Breakdown: interrupt ${interventions.interrupt} · reject ${interventions.toolReject} · correction ${interventions.correction}`,
);
console.log(' 干预率排行 (高 → 低, 越低自主性越强):');
for (const r of interventions.ranked.slice(0, 10)) {
console.log(` • ${r.username}: ${r.rate.toFixed(2)}/会话 (${r.total} 次 / ${r.sessions} 会话)`);
}
console.log('');
}

// Conversation turns + token usage (Issue #75)
const conversation = summarizeConversation(teamStats);
if (conversation) {
const t = conversation.tokens;
console.log('💬 对话量与 Token 用量:');
console.log(` 人工对话总轮数: ${conversation.totalPrompts}`);
console.log('💬 Conversation & Token Usage:');
console.log(` Total human prompts: ${conversation.totalPrompts}`);
console.log(
` Token 总量: ${formatTokenCount(conversation.totalTokens)} ` +
`(输入 ${formatTokenCount(t.input)} · 输出 ${formatTokenCount(t.output)} · ` +
`缓存读 ${formatTokenCount(t.cacheRead)} · 缓存写 ${formatTokenCount(t.cacheCreation)})`,
` Total tokens: ${formatTokenCount(conversation.totalTokens)} ` +
`(input ${formatTokenCount(t.input)} · output ${formatTokenCount(t.output)} · ` +
`cache read ${formatTokenCount(t.cacheRead)} · cache write ${formatTokenCount(t.cacheCreation)})`,
);
console.log(' Token 用量排行 (高 → 低):');
for (const r of conversation.ranked.slice(0, 10)) {
console.log(` • ${r.username}: ${formatTokenCount(r.tokens)} tokens (${r.prompts} 轮对话)`);
}
console.log('');
}

Expand Down
22 changes: 18 additions & 4 deletions src/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1093,18 +1093,32 @@ export async function pull(options: GlobalOptions): Promise<void> {
// 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.
if (!options.dryRun) {
try {
const { reportUsageToTeam } = await import('./team-push.js');
const { truncateUsageAfterReport, readUsageEvents } = await import('./usage-tracker.js');
const targets: Array<{ repoPath: string; username: string }> = [];
if (projectConfig) targets.push({ repoPath: projectConfig.repo.localPath, username: projectConfig.username });
if (userConfig && userConfig.repo.kind !== 'http') targets.push({ repoPath: userConfig.repo.localPath, username: userConfig.username });
const targets: Array<{ repoPath: string; username: string; opts: { skipTruncate: true; projectRoot?: string; excludeProjectRoots?: string[] } }> = [];
if (projectConfig) {
targets.push({
repoPath: projectConfig.repo.localPath,
username: projectConfig.username,
opts: { skipTruncate: true, projectRoot: projectConfig.projectRoot },
});
}
if (userConfig && userConfig.repo.kind !== 'http') {
targets.push({
repoPath: userConfig.repo.localPath,
username: userConfig.username,
opts: { skipTruncate: true, excludeProjectRoots: projectConfig?.projectRoot ? [projectConfig.projectRoot] : [] },
});
}

const eventCount = (await readUsageEvents()).length;
for (const t of targets) {
try {
await reportUsageToTeam(t.repoPath, t.username, { skipTruncate: true });
await reportUsageToTeam(t.repoPath, t.username, t.opts);
} catch (e) {
log.error(`Auto-report to ${t.repoPath} skipped: ${(e as Error).message}`);
}
Expand Down
42 changes: 37 additions & 5 deletions src/team-push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { readEvents, aggregateSessionMetrics } from './dashboard-collector.js';
import { createGit, pushRepoDirectly, pullRepo, resetToCleanMaster } from './utils/git.js';
import { writeFile, readFileSafe, ensureDir, pathExists, listFiles, readJson, writeJson } from './utils/fs.js';
import { log } from './utils/logger.js';
import type { UserStats, UserInterventionStats, SessionMetrics, TokenUsage } from './types.js';
import type { UserStats, UserInterventionStats, SessionMetrics, TokenUsage, DashboardEvent } from './types.js';
import { VOTES_LOCAL_DIR, emptyTokenUsage, addTokenUsage } from './types.js';

/** Snapshot of already-reported per-session intervention counts (idempotency basis). */
Expand Down Expand Up @@ -262,6 +262,33 @@ function hasPromptTokenDelta(d: PromptTokenDelta): boolean {
|| d.tokens.cacheRead > 0 || d.tokens.cacheCreation > 0;
}

/**
* Filter dashboard 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).
*/
export function filterEventsByScope(
events: DashboardEvent[],
opts?: { projectRoot?: string; excludeProjectRoots?: string[] },
): DashboardEvent[] {
if (!opts) return events;
if (opts.projectRoot) {
const root = opts.projectRoot.replace(/\/$/, '');
const prefix = root + '/';
return events.filter((e) => e.cwd === root || e.cwd?.startsWith(prefix));
}
if (opts.excludeProjectRoots && opts.excludeProjectRoots.length > 0) {
const normalized = opts.excludeProjectRoots.map((r) => r.replace(/\/$/, ''));
const prefixes = normalized.map((r) => r + '/');
return events.filter((e) => {
if (!e.cwd) return true;
return !normalized.some((r, i) => e.cwd === r || e.cwd!.startsWith(prefixes[i]));
});
}
return events;
}

/**
* Auto-report usage data to team repo during pull.
* Merges new events with existing stats to preserve historical data.
Expand All @@ -271,15 +298,17 @@ function hasPromptTokenDelta(d: PromptTokenDelta): boolean {
export async function reportUsageToTeam(
repoPath: string,
username: string,
options?: { skipTruncate?: boolean },
options?: { skipTruncate?: boolean; projectRoot?: string; excludeProjectRoots?: string[] },
): Promise<void> {
try {
const events = await readUsageEvents();
const filesToPush: string[] = [];

// Fold the local dashboard event log into per-session metrics once, then derive
// both the intervention delta and the prompt-count/token delta from it.
const dashboardEvents = await readEvents();
// Filter by scope so project repos only receive project sessions and vice versa.
const allDashboardEvents = await readEvents();
const dashboardEvents = filterEventsByScope(allDashboardEvents, options);
const metrics = aggregateSessionMetrics(dashboardEvents);

const currentInterventions = new Map(
Expand Down Expand Up @@ -379,12 +408,15 @@ export async function reportUsageToTeam(
log.debug(`Reported ${events.length} usage events to team repo (kept local copy)`);
}
// Success — advance the reported snapshots so we don't re-count.
// Merge (not overwrite) because each scope only touches its own sessions.
if (hasInterventions) {
await writeReportedInterventions(nextReported);
const existingIv = await readReportedInterventions();
await writeReportedInterventions({ ...existingIv, ...nextReported });
log.debug(`Reported intervention delta (${interventionDelta.sessions} new sessions) to team repo`);
}
if (hasPromptTokens) {
await writeReportedPromptTokens(nextReportedPromptTokens);
const existingPt = await readReportedPromptTokens();
await writeReportedPromptTokens({ ...existingPt, ...nextReportedPromptTokens });
log.debug(`Reported prompt/token delta (${promptTokenDelta.prompts} prompts) to team repo`);
}
if (!hasUsage && !hasInterventions && !hasPromptTokens) {
Expand Down
Loading