diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa0cbfc..168d4058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes +- `teamai stats` no longer counts a session twice, and no longer counts another project's sessions. Its dashboard section added the whole machine's local `events.jsonl` metrics to the scope's already-reported totals from the team repo, so every session that a `pull` had reported — and that stays in the event log until compaction — was counted once by the team total and once again locally, and sessions whose `cwd` belonged to a different project were added to this scope's as well. It now filters the event log the way `teamai pull` reports it (a project scope keeps only the sessions under its own root; the user scope resolves no root, so nothing is excluded and the log passes through unfiltered) and adds only what that scope has not reported yet, derived from the same per-session `reported-*` snapshots the report path advances — so the local figure agrees with the team's instead of exceeding it. When the reported totals could not be read at all (no stats file, an unreadable one, a reports worktree that is not there), or when they exist but hold nothing yet, nothing is subtracted — the local `reported-*` snapshots are machine-global while the team file is per-scope, so a non-empty team total is what licenses trusting them, and a session the member can see happening is never hidden. The per-repo and by-hour breakdowns read that same filtered log, so they stay inside the scope; they answer a different question from the headline — what this machine's retained event log holds, per repo — and both the headings and the `--by-repo` / `--by-time` flag descriptions now name that source instead of presenting them as a split of the headline (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). - The legacy `teamai dashboard-report` command no longer records dashboard events in a directory that never set up teamai. A current install writes only `teamai hook-dispatch`, whose dashboard-report handler declares `requiresConfig` and is dropped when no config resolves for the hook's `cwd`; the old subcommand stayed ungated, so a hook left behind by an earlier install kept recording events for every project it fired in, and those sessions were then reported by whichever scope pulled next. It now applies the same gate `teamai contribute-check` was given, asked about the session's `cwd` — or, for a host that sends none, the directory the hook runs in (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). - The lock behind `pull`, `push`, the reports and learnings worktrees, learnings publishing, migration, self-mode bootstrap and the update check no longer hands one lock to two live processes. Reclaiming a stale lock renamed over whatever file was there once it had judged the lock stale, and it judged live locks stale: one that had just been released (and could be re-created by a third process before the rename), one whose owner had created it but not yet written it, one owned by a process running as another user (for example a `sudo teamai` run), and one it could not read. Under 16 processes contending on one lock, about 1% of acquisitions overlapped another holder, enough for two pulls to report the same usage twice. Now only a lock whose owner is provably gone is reclaimed; a lock that vanished gets one more exclusive create, and a new lock is published with its content already in place (written to a temp file, then hard-linked to the lock name; a filesystem without hard links falls back to the previous create). A lock that names no owner (empty, partly written, unreadable) is never reclaimed: if a crash left one, `pull` and `push` report busy until it is removed, and a warning names the file. Migration skips the lock's temporary files, which a contending pull creates and removes while the copy runs. With the change, the same stress run shows no overlap (for [#760](https://github.com/Tencent/teamai-cli/issues/760)). - 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 team hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition (the session-start pull still resolves its project on its own). 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)). diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 598e67b5..ad54033d 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -237,8 +237,8 @@ Generated: do not edit by hand. Regenerate with ## stats - `teamai stats` — Show local skill usage statistics - - `--by-repo` — Break usage down per repository - - `--by-time` — Show activity by hour of day + - `--by-repo` — Break the local event log down per repository + - `--by-time` — Show local event log activity by hour of day ## session diff --git a/src/__tests__/stats-scope.test.ts b/src/__tests__/stats-scope.test.ts new file mode 100644 index 00000000..63043870 --- /dev/null +++ b/src/__tests__/stats-scope.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import YAML from 'yaml'; +import { showStats } from '../stats.js'; +import { _setLogFilePath, _resetState } from '../utils/logger.js'; +import { resolveAnchors } from '../utils/git.js'; +import { resolvePartitionDir } from '../utils/partition.js'; +import { writeFile, ensureDir } from '../utils/fs.js'; +import type { DashboardEvent } from '../types.js'; + +// ─── showStats scope + double-count regression tests ─── +// +// `teamai stats` merged the WHOLE machine's local dashboard metrics into the +// scope's reported totals: reported sessions stay in events.jsonl until +// compaction, so every one was counted twice, and sessions belonging to other +// projects were added to this scope's totals. +// +// `teamai pull` reports the opposite way — filterEventsByScope plus a per-session +// reported snapshot — so the displayed total could never agree with the team's. +// These tests pin the display side to the same rules the report side uses. + +let tmpDir: string; +let originalHome: string; +let consoleLog: ReturnType; +let workspace: string; + +/** + * Resolve the event `cwd` values. `config.projectRoot` is the realpath'd + * workspace, so events must carry real absolute paths under it — a POSIX-style + * relative cwd would never match the Windows root and would be filtered out. + */ +function projectDirs(): { projectRoot: string; project: string; other: string } { + const projectRoot = workspace; + return { + projectRoot, + project: path.join(projectRoot, 'proj-a'), + // Outside the project root: a project scope keeps only the sessions under + // its own root, so this one belongs to a different scope entirely. + other: path.join(tmpDir, 'elsewhere', 'proj-b'), + }; +} + +let DIRS: { projectRoot: string; project: string; other: string }; + +const ZERO_TOKENS = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 }; +const SESSION_TOKENS = { input: 100, output: 50, cacheRead: 0, cacheCreation: 0 }; + +function git(cwd: string, args: string[]): void { + execFileSync('git', args, { cwd, stdio: 'pipe' }); +} + +/** A real git workspace, so config detection resolves a project scope. */ +function initWorkspace(): void { + workspace = path.join(tmpDir, 'workspace'); + fs.mkdirSync(workspace, { recursive: true }); + git(workspace, ['init', '-q']); + git(workspace, ['config', 'user.email', 'tester@example.test']); + git(workspace, ['config', 'user.name', 'tester']); + fs.writeFileSync(path.join(workspace, 'seed.txt'), 'seed'); + git(workspace, ['add', '.']); + git(workspace, ['commit', '-qm', 'seed']); +} + +/** Write the project-scope config into this workspace's partition. */ +async function seedProjectConfig(): Promise { + const anchors = await resolveAnchors(workspace); + if (!anchors) throw new Error('expected the seeded workspace to have git anchors'); + const partitionDir = await resolvePartitionDir(anchors.projectAnchor); + await ensureDir(partitionDir); + await writeFile( + path.join(partitionDir, 'config.yaml'), + [ + 'username: tester', + 'scope: project', + 'repo:', + ' kind: http', + ` localPath: ${path.join(tmpDir, '.teamai', 'team-repo')}`, + ' remote: https://example.test/acme/team.git', + 'additionalRoles: []', + '', + ].join('\n'), + ); +} + +/** Append raw dashboard events to the machine-wide events.jsonl. */ +async function appendEvents(events: DashboardEvent[]): Promise { + const eventsPath = path.join(tmpDir, '.teamai', 'dashboard', 'events.jsonl'); + await ensureDir(path.dirname(eventsPath)); + await fs.promises.appendFile(eventsPath, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); +} + +/** One full session: start, one prompt, end. */ +function session(sessionId: string, cwd: string): DashboardEvent[] { + const at = (h: number, m: number) => `2026-09-20T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00.000Z`; + return [ + { type: 'session_start', sessionId, cwd, tool: 'claude', timestamp: at(10, 0) }, + { type: 'prompt_submit', sessionId, cwd, tool: 'claude', timestamp: at(10, 1) }, + { type: 'session_end', sessionId, cwd, tool: 'claude', timestamp: at(10, 5), tokens: SESSION_TOKENS }, + ] as unknown as DashboardEvent[]; +} + +/** The team's copy of this member's reported totals. */ +async function writeReportedStats(stats: Record): Promise { + const statsDir = path.join(tmpDir, '.teamai', 'team-repo', 'stats'); + await ensureDir(statsDir); + await writeFile(path.join(statsDir, 'tester.yaml'), YAML.stringify(stats)); +} + +/** The local snapshot of what this machine already reported (idempotency basis). */ +async function writeReportedSnapshots( + interventions: Record, + promptTokens: Record, +): Promise { + const dir = path.join(tmpDir, '.teamai', 'dashboard'); + await ensureDir(dir); + await writeFile(path.join(dir, 'reported-interventions.json'), JSON.stringify(interventions)); + await writeFile(path.join(dir, 'reported-prompt-tokens.json'), JSON.stringify(promptTokens)); +} + +function statsOutput(): string[] { + return consoleLog.mock.calls.map((c) => String(c[0])); +} + +/** + * A user-scope config at `~/.teamai/config.yaml`, plus a project scope in + * `workspace`'s partition. `loadLocalConfig` never attaches `projectRoot`, so a + * user-scope run only sees the project's sessions if showStats resolves the + * project config separately. + */ +async function seedUserScopeWithProject(): Promise { + const userConfigDir = path.join(tmpDir, '.teamai'); + await ensureDir(userConfigDir); + await writeFile( + path.join(userConfigDir, 'config.yaml'), + [ + 'username: tester', + 'scope: user', + 'repo:', + ' kind: http', + ` localPath: ${path.join(tmpDir, '.teamai', 'team-repo')}`, + ' remote: https://example.test/acme/team.git', + 'additionalRoles: []', + '', + ].join('\n'), + ); + await seedProjectConfig(); +} + +/** Extract the trailing number of the `Sessions:` / `Conversation turns:` line. */ +function outputNumber(lines: string[], label: string): number { + const line = lines.find((l) => l.includes(label)); + if (!line) return Number.NaN; + const match = line.match(/(\d+)\s*$/); + return match ? Number(match[1]) : Number.NaN; +} + +/** Extract the session count from the `By Repo:` lines (e.g. ` 2 sess, ...`). */ +/** Total sessions across every `By Repo:` row (e.g. ` 2 sess, 1 turns, ...`). */ +function byRepoSessions(lines: string[]): number { + let total = 0; + let matched = false; + for (const line of lines) { + const match = line.match(/^\s+\S.*\s(\d+)\s+sess,\s*\d+\s+turns/); + if (match) { + total += Number(match[1]); + matched = true; + } + } + return matched ? total : Number.NaN; +} + +/** How many repo rows the breakdown printed. */ +function byRepoRowCount(lines: string[]): number { + return lines.filter((l) => /\s\d+\s+sess,\s*\d+\s+turns/.test(l)).length; +} + +/** Run showStats from inside the project workspace. */ +async function showStatsFromProject(options: { byRepo?: boolean } = {}): Promise { + const cwd = process.cwd(); + process.chdir(workspace); + try { + await showStats(options); + } finally { + process.chdir(cwd); + } + return statsOutput(); +} + +/** Run showStats from a directory with no project config of its own. */ +async function showStatsFromPlainDir(dir: string): Promise { + const cwd = process.cwd(); + process.chdir(dir); + try { + await showStats(); + } finally { + process.chdir(cwd); + } + return statsOutput(); +} + +beforeEach(() => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-stats-scope-'))); + originalHome = process.env.HOME ?? ''; + process.env.HOME = tmpDir; + _setLogFilePath(path.join(tmpDir, '.teamai', 'debug.log')); + consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + initWorkspace(); + DIRS = projectDirs(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.env.HOME = originalHome; + _resetState(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('showStats scope and idempotency', () => { + it('counts a reported session once, not twice', async () => { + await seedProjectConfig(); + await appendEvents(session('sess-1', DIRS.project)); + + // Already reported: the team yaml holds it, and the local snapshot says so. + await writeReportedStats({ + username: 'tester', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 1, + tokens: SESSION_TOKENS, + interventions: { sessions: 1, interrupt: 0, toolReject: 0, correction: 0 }, + }); + await writeReportedSnapshots( + { 'sess-1': { interrupt: 0, toolReject: 0, correction: 0 } }, + { 'sess-1': { prompts: 1, tokens: SESSION_TOKENS } }, + ); + + const out = await showStatsFromProject(); + + // Reported once in the team totals; nothing new locally to add. + expect(outputNumber(out, 'Sessions:')).toBe(1); + expect(outputNumber(out, 'Conversation turns:')).toBe(1); + }); + + it('adds only the sessions this scope has not reported yet', async () => { + await seedProjectConfig(); + await appendEvents([ + // Reported in an earlier pull. + ...session('sess-1', DIRS.project), + // New since that pull. + ...session('sess-2', DIRS.project), + ]); + + await writeReportedStats({ + username: 'tester', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 1, + tokens: SESSION_TOKENS, + interventions: { sessions: 1, interrupt: 0, toolReject: 0, correction: 0 }, + }); + await writeReportedSnapshots( + { 'sess-1': { interrupt: 0, toolReject: 0, correction: 0 } }, + { 'sess-1': { prompts: 1, tokens: SESSION_TOKENS } }, + ); + + const out = await showStatsFromProject(); + + // 1 already reported + 1 new = 2, not 3 (sess-1 must not be counted twice). + expect(outputNumber(out, 'Sessions:')).toBe(2); + expect(outputNumber(out, 'Conversation turns:')).toBe(2); + }); + + it('excludes sessions belonging to another project', async () => { + await seedProjectConfig(); + await appendEvents([ + ...session('sess-1', DIRS.project), + // A different project on the same machine. + ...session('sess-2', DIRS.other), + ]); + + await writeReportedStats({ + username: 'tester', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 0, + tokens: ZERO_TOKENS, + interventions: { sessions: 0, interrupt: 0, toolReject: 0, correction: 0 }, + }); + + const out = await showStatsFromProject(); + + // Only proj-a's session counts for proj-a. + expect(outputNumber(out, 'Sessions:')).toBe(1); + expect(outputNumber(out, 'Conversation turns:')).toBe(1); + }); + + it('applies no project exclusion in the user scope when no project resolves', async () => { + // A user-scope run from a plain directory: detectProjectConfig() finds no + // project here, exactly as `pull` sees it from the same directory, so the + // report path passes no exclusion list either. The display side matches. + await seedUserScopeWithProject(); + await appendEvents([ + ...session('sess-1', DIRS.project), + ...session('sess-2', DIRS.other), + ]); + + await writeReportedStats({ + username: 'tester', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 0, + tokens: ZERO_TOKENS, + interventions: { sessions: 0, interrupt: 0, toolReject: 0, correction: 0 }, + }); + + // Run from a plain directory, so no project config resolves for the cwd. + const plainDir = path.join(tmpDir, 'plain'); + fs.mkdirSync(plainDir, { recursive: true }); + const out = await showStatsFromPlainDir(plainDir); + + expect(outputNumber(out, 'Sessions:')).toBe(2); + expect(outputNumber(out, 'Conversation turns:')).toBe(2); + }); + + it('still shows local sessions when the team stats file is missing', async () => { + // The team totals could not be read (no stats file, an unreadable one, or a + // reports worktree that is not there). The local snapshot then says nothing + // about what the team holds, so subtracting it would hide a session the + // user can see happening. + await seedProjectConfig(); + await appendEvents(session('sess-1', DIRS.project)); + + // No writeReportedStats() — loadReportedStats() returns null. + await writeReportedSnapshots( + { 'sess-1': { interrupt: 0, toolReject: 0, correction: 0 } }, + { 'sess-1': { prompts: 1, tokens: SESSION_TOKENS } }, + ); + + const out = await showStatsFromProject(); + + + expect(outputNumber(out, 'Sessions:')).toBe(1); + expect(outputNumber(out, 'Conversation turns:')).toBe(1); + }); + + it('does not subtract a snapshot the team file never received', async () => { + // The local snapshots are machine-global while the team file is per-scope, + // so a snapshot can name a session this team never got — an empty team file + // with a populated snapshot. Subtracting anyway undercounts to nothing. + await seedProjectConfig(); + await appendEvents(session('sess-1', DIRS.project)); + + await writeReportedStats({ + username: 'tester', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 0, + tokens: ZERO_TOKENS, + interventions: { sessions: 0, interrupt: 0, toolReject: 0, correction: 0 }, + }); + await writeReportedSnapshots( + { 'sess-1': { interrupt: 0, toolReject: 0, correction: 0 } }, + { 'sess-1': { prompts: 1, tokens: SESSION_TOKENS } }, + ); + + const out = await showStatsFromProject(); + + expect(outputNumber(out, 'Sessions:')).toBe(1); + expect(outputNumber(out, 'Conversation turns:')).toBe(1); + }); + + it('keeps only the project sessions once the project config resolves', async () => { + // The same machine, run from inside the project: detectProjectConfig() now + // resolves it, so the project scope keeps only its own sessions and the + // other project's never reach its totals. + await seedUserScopeWithProject(); + await appendEvents([ + ...session('sess-1', DIRS.project), + ...session('sess-2', DIRS.other), + ]); + + await writeReportedStats({ + username: 'tester', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 0, + tokens: ZERO_TOKENS, + interventions: { sessions: 0, interrupt: 0, toolReject: 0, correction: 0 }, + }); + + const out = await showStatsFromProject(); + + expect(outputNumber(out, 'Sessions:')).toBe(1); + expect(outputNumber(out, 'Conversation turns:')).toBe(1); + }); + + it('does not count tokens of a session twice', async () => { + await seedProjectConfig(); + await appendEvents(session('sess-1', DIRS.project)); + + await writeReportedStats({ + username: 'tester', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 1, + tokens: SESSION_TOKENS, + interventions: { sessions: 1, interrupt: 0, toolReject: 0, correction: 0 }, + }); + await writeReportedSnapshots( + { 'sess-1': { interrupt: 0, toolReject: 0, correction: 0 } }, + { 'sess-1': { prompts: 1, tokens: SESSION_TOKENS } }, + ); + + const out = await showStatsFromProject(); + + // 100 input + 50 output, once — not doubled to 200/100. + expect(outputNumber(out, 'Tokens (total):')).toBe(150); + expect(outputNumber(out, 'Input:')).toBe(100); + expect(outputNumber(out, 'Output:')).toBe(50); + }); + + it('scopes the per-repo breakdown to this project, headline aside', async () => { + // Two sessions on disk, one already reported. The breakdown reads the + // scope's own event log, so it shows both — it answers "what happened in + // which repo on this machine", not "what is still owed to the team". + await seedProjectConfig(); + await appendEvents([ + ...session('sess-1', DIRS.project), + ...session('sess-2', DIRS.project), + ]); + + await writeReportedStats({ + username: 'tester', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 1, + tokens: SESSION_TOKENS, + interventions: { sessions: 1, interrupt: 0, toolReject: 0, correction: 0 }, + }); + await writeReportedSnapshots( + { 'sess-1': { interrupt: 0, toolReject: 0, correction: 0 } }, + { 'sess-1': { prompts: 1, tokens: SESSION_TOKENS } }, + ); + + const out = await showStatsFromProject({ byRepo: true }); + + // Headline: 1 reported + 1 unreported. + expect(outputNumber(out, 'Sessions:')).toBe(2); + // Breakdown: both local sessions, all of them this project's. + expect(byRepoSessions(out)).toBe(2); + }); + + it('reports the project scope against its own reported totals, excluding another project', async () => { + // The end-to-end shape the review asked for: a project scope with reported + // team totals, one reported session still in the log, one new session in + // this project, and one session belonging to a different project. + await seedProjectConfig(); + await appendEvents([ + ...session('sess-1', DIRS.project), + ...session('sess-2', DIRS.project), + ...session('sess-3', DIRS.other), + ]); + + await writeReportedStats({ + username: 'tester', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 300, + tokens: { input: 1000, output: 500, cacheRead: 0, cacheCreation: 0 }, + interventions: { sessions: 3, interrupt: 1, toolReject: 1, correction: 1 }, + }); + await writeReportedSnapshots( + { 'sess-1': { interrupt: 1, toolReject: 1, correction: 1 } }, + { 'sess-1': { prompts: 100, tokens: { input: 1000, output: 500, cacheRead: 0, cacheCreation: 0 } } }, + ); + + const out = await showStatsFromProject({ byRepo: true }); + + // 3 reported + sess-2 (new, this project). sess-3 belongs elsewhere. + expect(outputNumber(out, 'Sessions:')).toBe(4); + expect(outputNumber(out, 'Conversation turns:')).toBe(301); + // Only this project's rows reach the breakdown. + expect(byRepoRowCount(out)).toBe(1); + expect(byRepoSessions(out)).toBe(2); + }); + + it('keeps another project out of the per-repo breakdown', async () => { + await seedProjectConfig(); + await appendEvents([ + ...session('sess-1', DIRS.project), + ...session('sess-2', DIRS.other), + ]); + + await writeReportedStats({ + username: 'tester', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 0, + tokens: ZERO_TOKENS, + interventions: { sessions: 0, interrupt: 0, toolReject: 0, correction: 0 }, + }); + + const out = await showStatsFromProject({ byRepo: true }); + + // Only proj-a's session appears; the shared event log must not leak + // another project's rows into this scope's breakdown. + expect(byRepoRowCount(out)).toBe(1); + expect(byRepoSessions(out)).toBe(1); + }); +}); diff --git a/src/index.ts b/src/index.ts index 8d860e47..9b2926d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -818,8 +818,8 @@ program program .command('stats') .description('Show local skill usage statistics') - .option('--by-repo', 'Break usage down per repository') - .option('--by-time', 'Show activity by hour of day') + .option('--by-repo', 'Break the local event log down per repository') + .option('--by-time', 'Show local event log activity by hour of day') .action(async (cmdOpts) => { const { showStats } = await import('./stats.js'); await showStats({ byRepo: cmdOpts.byRepo, byTime: cmdOpts.byTime }); diff --git a/src/stats.ts b/src/stats.ts index 2e08325a..4eb4ae6c 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -4,6 +4,7 @@ import { readUsageEvents } from './usage-tracker.js'; import { readFileSafe } from './utils/fs.js'; import { resolveConfigForDir } from './config.js'; import { readEvents, aggregateSessionMetrics } from './dashboard-collector.js'; +import { getUserHome } from './utils/home.js'; import { totalTokens, addTokenUsage, emptyTokenUsage } from './types.js'; import { attributeByRepo, timeAnalytics, renderHourSparkline } from './session-analytics.js'; import { formatTokenCount } from './digest.js'; @@ -143,6 +144,53 @@ function aggregateDashboardStats(metrics: Map): Aggregat return { sessions: metrics.size, prompts, tokens, interrupt, toolReject, correction }; } +/** + * The local dashboard metrics this scope has NOT reported yet: the same + * per-session delta `teamai pull` pushes, so the displayed total is + * reported + unreported rather than reported + everything. + * + * The caller passes the scope's own metrics, already filtered the way the + * report path filters them. + */ +async function unreportedDashboardStats( + metrics: Map, +): Promise { + const { computeInterventionDelta, computePromptTokenDelta } = await import('./team-push.js'); + const { readJson } = await import('./utils/fs.js'); + const dashboardDir = path.join(getUserHome(), '.teamai', 'dashboard'); + + const interventions = (await readJson[1]>( + path.join(dashboardDir, 'reported-interventions.json'), + )) ?? {}; + const promptTokens = (await readJson[1]>( + path.join(dashboardDir, 'reported-prompt-tokens.json'), + )) ?? {}; + + const interventionDelta = computeInterventionDelta( + new Map([...metrics].map(([sid, m]) => [sid, { interrupt: m.interrupt, toolReject: m.toolReject, correction: m.correction }])), + interventions, + ); + const promptTokenDelta = computePromptTokenDelta(metrics, promptTokens); + + return { + sessions: interventionDelta.delta.sessions, + prompts: promptTokenDelta.delta.prompts, + tokens: promptTokenDelta.delta.tokens, + interrupt: interventionDelta.delta.interrupt, + toolReject: interventionDelta.delta.toolReject, + correction: interventionDelta.delta.correction, + }; +} + +/** + * Combine the scope's reported team totals with the local sessions it has not + * reported yet. + * + * `local` must already be the UNREPORTED delta for this scope, not the whole + * machine's metrics: reported sessions stay in events.jsonl until compaction, + * so adding the full local aggregate on top of the reported totals counted + * every one of them twice, and mixed in sessions belonging to other projects. + */ function mergeDashboardAndReported( local: AggregatedDashboardStats, reported: UserStats | null, @@ -179,13 +227,60 @@ export async function showStats(options: ShowStatsOptions = {}): Promise { const reported = await loadReportedStats(); const stats = mergeLocalAndReported(localStats, reported); - const dashboardEvents = await readEvents(); - const metricsMap = aggregateSessionMetrics(dashboardEvents); - const localDashboard = aggregateDashboardStats(metricsMap); + // Dashboard metrics follow the same scope rules `pull` reports with, so what + // is shown can agree with what the team holds: this scope's own sessions only, + // and only the part of them not already reported (reported sessions stay in + // events.jsonl until compaction, so counting the full local aggregate would + // count each one twice and pull in other projects' sessions). + // + // The project root is resolved on its own, exactly as `pull` does: it reads + // `detectProjectConfig()`, never the projectRoot of the scope config, because + // a user-scope config carries no projectRoot at all (the field is attached + // only when a PROJECT config is detected). Same call, same directory, same + // answer as the report path. + const { detectProjectConfig } = await import('./config.js'); + const projectConfig = await detectProjectConfig(); + const projectRoot = config?.scope === 'project' ? config.projectRoot : projectConfig?.projectRoot; + const scopeFilter = projectRoot + ? (config?.scope === 'project' + ? { projectRoot } + : { excludeProjectRoots: [projectRoot] }) + : undefined; + const { filterEventsByScope } = await import('./team-push.js'); + const scopedEvents = filterEventsByScope(await readEvents(), scopeFilter); + const metricsMap = aggregateSessionMetrics(scopedEvents); + // Only subtract what the team already holds. Two guards, because the local + // snapshots are machine-global while the team file is per user: + // + // - `reported` null (no stats file, an unreadable one, a reports worktree + // that is not there): the snapshot says nothing about what the team + // holds, and subtracting it would hide sessions the member can see. + // - `reported` present but empty: the team has received nothing yet, so a + // snapshot entry cannot describe something it holds. Subtracting anyway + // undercounts — down to "No usage data yet." with sessions on disk. + // + // Snapshots are written under the same lock as the team file, so a non-empty + // team total is what licenses trusting the snapshot. + const teamHasReported = !!reported && ( + (reported.interventions?.sessions ?? 0) > 0 + || (reported.prompts ?? 0) > 0 + || totalTokens(reported.tokens ?? emptyTokenUsage()) > 0 + ); + const localDashboard = config && teamHasReported + ? await unreportedDashboardStats(metricsMap) + : aggregateDashboardStats(metricsMap); const dashboard = mergeDashboardAndReported(localDashboard, reported); const hasDashboardData = dashboard.sessions > 0 || dashboard.prompts > 0 || totalTokens(dashboard.tokens) > 0; + // The optional breakdowns read the scope's own event log, which is a + // different question from the headline: the headline adds this machine's + // unreported sessions to totals that already include other machines and + // sessions compaction has since dropped, so the two are not expected to + // match number for number. What must hold is that the breakdown sees the + // same SCOPE — hence the shared filter — and never another project's rows. + const dashboardEvents = scopedEvents; + if (stats.length === 0 && !hasDashboardData) { console.log('No usage data yet.'); console.log('Usage tracking starts automatically via hooks.'); @@ -252,7 +347,7 @@ export async function showStats(options: ShowStatsOptions = {}): Promise { const repos = attributeByRepo(dashboardEvents); if (repos.length > 0) { console.log(''); - console.log('By Repo:'); + console.log('By Repo (local event log):'); console.log(''); const TOP_N = 15; const maxLen = Math.max(...repos.slice(0, TOP_N).map((r) => r.repo.length), 4); @@ -275,7 +370,7 @@ export async function showStats(options: ShowStatsOptions = {}): Promise { const ta = timeAnalytics(dashboardEvents); if (ta.totalEvents > 0) { console.log(''); - console.log('Activity by Hour (local time):'); + console.log('Activity by Hour (local event log):'); console.log(''); console.log(` 00h ${renderHourSparkline(ta.byHour)} 23h`); console.log(` Peak hour: ${String(ta.peakHour).padStart(2, '0')}:00`);