From d33ea0c5309f09b623a70331bab5f5ae66ee04c2 Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:32:57 +0800 Subject: [PATCH 1/8] fix(stats): count each session once and keep other projects out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showStats added the whole machine's local events.jsonl metrics to the scope's already-reported totals, so every session 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 another project were added to this scope's totals too. Filter the event log the way `pull` reports it (a project scope keeps only sessions under its own root, the user scope excludes them) and add only what the 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. The per-repo and by-hour breakdowns use the same filtered log, keeping them consistent with the headline numbers. --- CHANGELOG.md | 1 + src/__tests__/stats-scope.test.ts | 242 ++++++++++++++++++++++++++++++ src/stats.ts | 66 +++++++- 3 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/stats-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa0cbfc..c8d4f710 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 excludes them) 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. The per-repo and by-hour breakdowns use the same filtered log, keeping them consistent with the headline numbers (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/src/__tests__/stats-scope.test.ts b/src/__tests__/stats-scope.test.ts new file mode 100644 index 00000000..b717d543 --- /dev/null +++ b/src/__tests__/stats-scope.test.ts @@ -0,0 +1,242 @@ +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])); +} + +/** 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; +} + +/** Run showStats from inside the project workspace. */ +async function showStatsFromProject(): Promise { + const cwd = process.cwd(); + process.chdir(workspace); + 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); + }); +}); diff --git a/src/stats.ts b/src/stats.ts index 2e08325a..bf6af168 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, + ).delta; + const promptTokenDelta = computePromptTokenDelta(metrics, promptTokens).delta; + + return { + sessions: interventionDelta.sessions, + prompts: promptTokenDelta.prompts, + tokens: promptTokenDelta.tokens, + interrupt: interventionDelta.interrupt, + toolReject: interventionDelta.toolReject, + correction: interventionDelta.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,9 +227,23 @@ export async function showStats(options: ShowStatsOptions = {}): Promise { const reported = await loadReportedStats(); const stats = mergeLocalAndReported(localStats, reported); - const dashboardEvents = await readEvents(); + // 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). + const scopeFilter = config + ? { + ...(config.scope === 'project' && config.projectRoot ? { projectRoot: config.projectRoot } : {}), + ...(config.scope !== 'project' && config.projectRoot ? { excludeProjectRoots: [config.projectRoot] } : {}), + } + : undefined; + const { filterEventsByScope } = await import('./team-push.js'); + const dashboardEvents = filterEventsByScope(await readEvents(), scopeFilter); const metricsMap = aggregateSessionMetrics(dashboardEvents); - const localDashboard = aggregateDashboardStats(metricsMap); + const localDashboard = config + ? await unreportedDashboardStats(metricsMap) + : aggregateDashboardStats(metricsMap); const dashboard = mergeDashboardAndReported(localDashboard, reported); const hasDashboardData = dashboard.sessions > 0 || dashboard.prompts > 0 || totalTokens(dashboard.tokens) > 0; From efb16cc7da5d1d16986dd4fb84da2b5c7b7592e8 Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:15:24 +0800 Subject: [PATCH 2/8] fix(stats): align the breakdowns with the headline and resolve the project root independently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three points from the review of the double-count fix: 1. The project root is resolved on its own with detectProjectConfig(), the same call `pull` makes, instead of being read off the resolved scope config. A user-scope config carries no projectRoot — that field is attached only when a PROJECT config is detected — so the old expression could never populate the user scope's exclusion list. 2. `--by-repo` and `--by-time` now describe the same local part the headline adds to the team totals. They consumed the whole retained event log, so with a reported session still on disk the headline said "1 session, 300 tokens" while the breakdown said "2 sessions, 1.8K tokens" — two numbers from one command that could not both be right. unreportedDashboardStats now also returns the set of sessions the scope still owes the team, and the breakdowns filter to it. 3. The regression tests assert token totals, not only sessions and conversation turns, since over-counted tokens were half the bug. Verified through the built CLI in an isolated HOME: the headline and the per-repo breakdown now report the same sessions, turns and tokens. --- src/__tests__/stats-scope.test.ts | 182 +++++++++++++++++++++++++++++- src/stats.ts | 69 ++++++++--- 2 files changed, 232 insertions(+), 19 deletions(-) diff --git a/src/__tests__/stats-scope.test.ts b/src/__tests__/stats-scope.test.ts index b717d543..6cf1489d 100644 --- a/src/__tests__/stats-scope.test.ts +++ b/src/__tests__/stats-scope.test.ts @@ -124,6 +124,31 @@ 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)); @@ -132,10 +157,30 @@ function outputNumber(lines: string[], label: string): number { return match ? Number(match[1]) : Number.NaN; } +/** Extract the session count from the `By Repo:` lines (e.g. ` 2 sess, ...`). */ +function byRepoSessions(lines: string[]): number { + const line = lines.find((l) => /\d+\s+sess,\s*\d+\s+turns/.test(l)); + if (!line) return Number.NaN; + const match = line.match(/(\d+)\s+sess/); + return match ? Number(match[1]) : Number.NaN; +} + /** Run showStats from inside the project workspace. */ -async function showStatsFromProject(): Promise { +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 { @@ -239,4 +284,139 @@ describe('showStats scope and idempotency', () => { 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('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('reports the same sessions in the headline and the per-repo breakdown', async () => { + // Two sessions on disk: sess-1 already reported, sess-2 new. The headline + // counts 1 reported + 1 new = 2, and the breakdown must show the same one + // unreported session rather than both sessions still in the event log. + 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 }); + + expect(outputNumber(out, 'Sessions:')).toBe(2); + expect(byRepoSessions(out)).toBe(1); + }); + + it('keeps the breakdown from counting sessions the headline already reported', async () => { + // The team holds 3 sessions; only 1 is still in the local event log and it + // has already been reported. The headline must show 3 (reported) + 0 (new), + // and the breakdown must not present the local log as if it were extra. + await seedProjectConfig(); + await appendEvents(session('sess-1', DIRS.project)); + + await writeReportedStats({ + username: 'tester', + updatedAt: '2026-09-20T11:00:00.000Z', + skills: {}, + prompts: 100, + tokens: { input: 1000, output: 500, cacheRead: 0, cacheCreation: 0 }, + interventions: { sessions: 3, interrupt: 0, toolReject: 0, correction: 0 }, + }); + await writeReportedSnapshots( + { 'sess-1': { interrupt: 0, toolReject: 0, correction: 0 } }, + { 'sess-1': { prompts: 100, tokens: { input: 1000, output: 500, cacheRead: 0, cacheCreation: 0 } } }, + ); + + const out = await showStatsFromProject({ byRepo: true }); + + expect(outputNumber(out, 'Sessions:')).toBe(3); + // Not 1: the only session on disk was already reported, so there is no + // unreported session for the breakdown to present as extra activity. + expect(out.some((l) => l.includes('By Repo:'))).toBe(false); + }); }); diff --git a/src/stats.ts b/src/stats.ts index bf6af168..ef1cedd8 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -154,7 +154,7 @@ function aggregateDashboardStats(metrics: Map): Aggregat */ async function unreportedDashboardStats( metrics: Map, -): Promise { +): Promise<{ delta: AggregatedDashboardStats; unreportedSessions: Set }> { const { computeInterventionDelta, computePromptTokenDelta } = await import('./team-push.js'); const { readJson } = await import('./utils/fs.js'); const dashboardDir = path.join(getUserHome(), '.teamai', 'dashboard'); @@ -169,16 +169,33 @@ async function unreportedDashboardStats( const interventionDelta = computeInterventionDelta( new Map([...metrics].map(([sid, m]) => [sid, { interrupt: m.interrupt, toolReject: m.toolReject, correction: m.correction }])), interventions, - ).delta; - const promptTokenDelta = computePromptTokenDelta(metrics, promptTokens).delta; + ); + const promptTokenDelta = computePromptTokenDelta(metrics, promptTokens); + + // Sessions the scope still owes the team: one the snapshot has never seen, or + // one whose counts grew since it was last reported. + const unreported = new Set(); + for (const [sid, cur] of metrics) { + const prevIv = interventions[sid]; + const prevPt = promptTokens[sid]; + const ivGrew = !prevIv + || cur.interrupt > prevIv.interrupt + || cur.toolReject > prevIv.toolReject + || cur.correction > prevIv.correction; + const ptGrew = !prevPt || cur.prompts > prevPt.prompts; + if (ivGrew || ptGrew) unreported.add(sid); + } return { - sessions: interventionDelta.sessions, - prompts: promptTokenDelta.prompts, - tokens: promptTokenDelta.tokens, - interrupt: interventionDelta.interrupt, - toolReject: interventionDelta.toolReject, - correction: interventionDelta.correction, + delta: { + sessions: interventionDelta.delta.sessions, + prompts: promptTokenDelta.delta.prompts, + tokens: promptTokenDelta.delta.tokens, + interrupt: interventionDelta.delta.interrupt, + toolReject: interventionDelta.delta.toolReject, + correction: interventionDelta.delta.correction, + }, + unreportedSessions: unreported, }; } @@ -232,22 +249,38 @@ export async function showStats(options: ShowStatsOptions = {}): Promise { // 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). - const scopeFilter = config - ? { - ...(config.scope === 'project' && config.projectRoot ? { projectRoot: config.projectRoot } : {}), - ...(config.scope !== 'project' && config.projectRoot ? { excludeProjectRoots: [config.projectRoot] } : {}), - } + // + // 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 dashboardEvents = filterEventsByScope(await readEvents(), scopeFilter); - const metricsMap = aggregateSessionMetrics(dashboardEvents); - const localDashboard = config - ? await unreportedDashboardStats(metricsMap) + const scopedEvents = filterEventsByScope(await readEvents(), scopeFilter); + const metricsMap = aggregateSessionMetrics(scopedEvents); + const unreported = config ? await unreportedDashboardStats(metricsMap) : null; + const localDashboard = unreported + ? unreported.delta : aggregateDashboardStats(metricsMap); const dashboard = mergeDashboardAndReported(localDashboard, reported); const hasDashboardData = dashboard.sessions > 0 || dashboard.prompts > 0 || totalTokens(dashboard.tokens) > 0; + // The breakdowns describe the same local part the headline adds to the team + // totals, so they cannot present a session the headline already counted as + // reported as extra activity. Without this the headline and `--by-repo` + // disagreed whenever the event log still held a reported session. + const unreportedSessionIds = unreported ? unreported.unreportedSessions : new Set(metricsMap.keys()); + const dashboardEvents = scopedEvents.filter((e) => unreportedSessionIds.has(e.sessionId)); + if (stats.length === 0 && !hasDashboardData) { console.log('No usage data yet.'); console.log('Usage tracking starts automatically via hooks.'); From 5c27828a821eee7e1ac1a620e0be6430ed0b5aec Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:26:21 +0800 Subject: [PATCH 3/8] fix(stats): keep the breakdowns reading the scope's own event log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round filtered the breakdowns down to unreported sessions so they would match the headline. That was the wrong trade: the headline adds this machine's unreported sessions to totals that already include other machines and sessions compaction has dropped, so it can never equal a per-repo or per-hour view of the local log. Filtering made the breakdowns show neither a total nor a delta — a fully reported project vanished from `--by-repo` entirely. Restore the full scoped log for the breakdowns and say in the comment what question each answers. What both must share is the SCOPE, and the breakdown tests now pin that: removing the scope filter turns the cross-project case red, which the earlier assertion missed because it read only the first matching row. --- src/__tests__/stats-scope.test.ts | 60 ++++++++++++++++++------------- src/stats.ts | 51 +++++++++----------------- 2 files changed, 52 insertions(+), 59 deletions(-) diff --git a/src/__tests__/stats-scope.test.ts b/src/__tests__/stats-scope.test.ts index 6cf1489d..0055075b4 100644 --- a/src/__tests__/stats-scope.test.ts +++ b/src/__tests__/stats-scope.test.ts @@ -158,11 +158,23 @@ function outputNumber(lines: string[], label: string): number { } /** 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 { - const line = lines.find((l) => /\d+\s+sess,\s*\d+\s+turns/.test(l)); - if (!line) return Number.NaN; - const match = line.match(/(\d+)\s+sess/); - return match ? Number(match[1]) : Number.NaN; + 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. */ @@ -363,10 +375,10 @@ describe('showStats scope and idempotency', () => { expect(outputNumber(out, 'Output:')).toBe(50); }); - it('reports the same sessions in the headline and the per-repo breakdown', async () => { - // Two sessions on disk: sess-1 already reported, sess-2 new. The headline - // counts 1 reported + 1 new = 2, and the breakdown must show the same one - // unreported session rather than both sessions still in the event log. + 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), @@ -388,35 +400,33 @@ describe('showStats scope and idempotency', () => { const out = await showStatsFromProject({ byRepo: true }); + // Headline: 1 reported + 1 unreported. expect(outputNumber(out, 'Sessions:')).toBe(2); - expect(byRepoSessions(out)).toBe(1); + // Breakdown: both local sessions, all of them this project's. + expect(byRepoSessions(out)).toBe(2); }); - it('keeps the breakdown from counting sessions the headline already reported', async () => { - // The team holds 3 sessions; only 1 is still in the local event log and it - // has already been reported. The headline must show 3 (reported) + 0 (new), - // and the breakdown must not present the local log as if it were extra. + it('keeps another project out of the per-repo breakdown', async () => { await seedProjectConfig(); - await appendEvents(session('sess-1', DIRS.project)); + 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: 100, - tokens: { input: 1000, output: 500, cacheRead: 0, cacheCreation: 0 }, - interventions: { sessions: 3, interrupt: 0, toolReject: 0, correction: 0 }, + 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: 100, tokens: { input: 1000, output: 500, cacheRead: 0, cacheCreation: 0 } } }, - ); const out = await showStatsFromProject({ byRepo: true }); - expect(outputNumber(out, 'Sessions:')).toBe(3); - // Not 1: the only session on disk was already reported, so there is no - // unreported session for the breakdown to present as extra activity. - expect(out.some((l) => l.includes('By Repo:'))).toBe(false); + // 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/stats.ts b/src/stats.ts index ef1cedd8..615e485a 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -154,7 +154,7 @@ function aggregateDashboardStats(metrics: Map): Aggregat */ async function unreportedDashboardStats( metrics: Map, -): Promise<{ delta: AggregatedDashboardStats; unreportedSessions: Set }> { +): Promise { const { computeInterventionDelta, computePromptTokenDelta } = await import('./team-push.js'); const { readJson } = await import('./utils/fs.js'); const dashboardDir = path.join(getUserHome(), '.teamai', 'dashboard'); @@ -172,30 +172,13 @@ async function unreportedDashboardStats( ); const promptTokenDelta = computePromptTokenDelta(metrics, promptTokens); - // Sessions the scope still owes the team: one the snapshot has never seen, or - // one whose counts grew since it was last reported. - const unreported = new Set(); - for (const [sid, cur] of metrics) { - const prevIv = interventions[sid]; - const prevPt = promptTokens[sid]; - const ivGrew = !prevIv - || cur.interrupt > prevIv.interrupt - || cur.toolReject > prevIv.toolReject - || cur.correction > prevIv.correction; - const ptGrew = !prevPt || cur.prompts > prevPt.prompts; - if (ivGrew || ptGrew) unreported.add(sid); - } - return { - delta: { - sessions: interventionDelta.delta.sessions, - prompts: promptTokenDelta.delta.prompts, - tokens: promptTokenDelta.delta.tokens, - interrupt: interventionDelta.delta.interrupt, - toolReject: interventionDelta.delta.toolReject, - correction: interventionDelta.delta.correction, - }, - unreportedSessions: unreported, + sessions: interventionDelta.delta.sessions, + prompts: promptTokenDelta.delta.prompts, + tokens: promptTokenDelta.delta.tokens, + interrupt: interventionDelta.delta.interrupt, + toolReject: interventionDelta.delta.toolReject, + correction: interventionDelta.delta.correction, }; } @@ -266,20 +249,20 @@ export async function showStats(options: ShowStatsOptions = {}): Promise { const { filterEventsByScope } = await import('./team-push.js'); const scopedEvents = filterEventsByScope(await readEvents(), scopeFilter); const metricsMap = aggregateSessionMetrics(scopedEvents); - const unreported = config ? await unreportedDashboardStats(metricsMap) : null; - const localDashboard = unreported - ? unreported.delta + const localDashboard = config + ? await unreportedDashboardStats(metricsMap) : aggregateDashboardStats(metricsMap); const dashboard = mergeDashboardAndReported(localDashboard, reported); const hasDashboardData = dashboard.sessions > 0 || dashboard.prompts > 0 || totalTokens(dashboard.tokens) > 0; - // The breakdowns describe the same local part the headline adds to the team - // totals, so they cannot present a session the headline already counted as - // reported as extra activity. Without this the headline and `--by-repo` - // disagreed whenever the event log still held a reported session. - const unreportedSessionIds = unreported ? unreported.unreportedSessions : new Set(metricsMap.keys()); - const dashboardEvents = scopedEvents.filter((e) => unreportedSessionIds.has(e.sessionId)); + // 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.'); @@ -347,7 +330,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); From 5ff03ddd239092ae3ebfe9ffd089fe0d87ae1fd0 Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:27:04 +0800 Subject: [PATCH 4/8] fix(stats): subtract reported totals only when they were read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delta path keyed off `config` alone, so a scope whose team stats could not be read at all — no stats file yet, an unreadable one, a reports worktree that is not there — still had its local snapshot subtracted. The snapshot records what this machine pushed, not what the team holds, and with the reported side null it hid sessions the member could see happening, down to "No usage data yet." Require `reported` as well, so the local aggregate is shown when there is no team total to reconcile against. The changelog entry no longer claims the breakdowns match the headline; they read the same scoped log and answer a different question, and the heading says so. --- CHANGELOG.md | 2 +- src/__tests__/stats-scope.test.ts | 20 ++++++++++++++++++++ src/stats.ts | 6 +++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8d4f710..53bc076b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +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 excludes them) 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. The per-repo and by-hour breakdowns use the same filtered log, keeping them consistent with the headline numbers (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). +- `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 excludes them) 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), nothing is subtracted, so a session the member can see happening is not 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 are labelled as such rather than presented as a split of it (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/src/__tests__/stats-scope.test.ts b/src/__tests__/stats-scope.test.ts index 0055075b4..2608be52 100644 --- a/src/__tests__/stats-scope.test.ts +++ b/src/__tests__/stats-scope.test.ts @@ -325,6 +325,26 @@ describe('showStats scope and idempotency', () => { 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('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 diff --git a/src/stats.ts b/src/stats.ts index 615e485a..152c6fdc 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -249,7 +249,11 @@ export async function showStats(options: ShowStatsOptions = {}): Promise { const { filterEventsByScope } = await import('./team-push.js'); const scopedEvents = filterEventsByScope(await readEvents(), scopeFilter); const metricsMap = aggregateSessionMetrics(scopedEvents); - const localDashboard = config + // Only subtract what the team already holds. When the reported totals could + // not be read at all (no stats file yet, an unreadable one, a reports worktree + // that is not there), the local snapshot says nothing about what the team + // has, and subtracting it would hide sessions the user can see happening. + const localDashboard = config && reported ? await unreportedDashboardStats(metricsMap) : aggregateDashboardStats(metricsMap); const dashboard = mergeDashboardAndReported(localDashboard, reported); From af787cbe9692c01fb345c73394ca2aab44b5b2d9 Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:33:54 +0800 Subject: [PATCH 5/8] docs(stats): say which log the optional breakdowns read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `--by-repo` heading was changed to name the local event log, but the flag descriptions and the generated command reference still said "Break usage down per repository", and `--by-time` kept the old "(local time)" heading — so the two optional views disagreed with each other and with the changelog entry about them. Name the source in both flag descriptions, label the by-hour view the same way, and regenerate `commands.md` per AGENTS.md. --- CHANGELOG.md | 2 +- skill-data/core/references/commands.md | 4 ++-- src/index.ts | 4 ++-- src/stats.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bc076b..6e0996f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +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 excludes them) 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), nothing is subtracted, so a session the member can see happening is not 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 are labelled as such rather than presented as a split of it (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). +- `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 excludes them) 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), nothing is subtracted, so a session the member can see happening is not 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/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 152c6fdc..4887fba6 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -357,7 +357,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`); From 322c25a463def4d0e78ea8b55bf736e1df89811a Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:49:14 +0800 Subject: [PATCH 6/8] test(stats): cover the project scope against its reported totals The end-to-end shape the review asked for was missing: a project scope with reported team totals, one reported session still in the event log, one new session in this project, and one session belonging to a different project. It now asserts 3 reported + 1 new = 4 sessions, 301 turns, and a breakdown holding only this project's rows. Dropping the scope filter turns four tests red at once (the new one reporting 5 instead of 4), so the filter is pinned rather than assumed. --- src/__tests__/stats-scope.test.ts | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/__tests__/stats-scope.test.ts b/src/__tests__/stats-scope.test.ts index 2608be52..ddec921d 100644 --- a/src/__tests__/stats-scope.test.ts +++ b/src/__tests__/stats-scope.test.ts @@ -426,6 +426,40 @@ describe('showStats scope and idempotency', () => { 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([ From 069e79b8ce0600b6c2d3a1ed7bb0ecc984bc05b4 Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:56:38 +0800 Subject: [PATCH 7/8] fix(stats): do not subtract a snapshot the team file never received MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous guard only checked that the reported totals were readable. 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 alongside a populated snapshot. Subtracting then undercounts, down to "No usage data yet." with sessions sitting in the event log. Trust the snapshot only when the team total is non-empty: the report path writes the team file and advances the snapshot under the same lock, so a non-empty total is what licenses the subtraction. --- CHANGELOG.md | 2 +- src/__tests__/stats-scope.test.ts | 27 +++++++++++++++++++++++++++ src/stats.ts | 23 ++++++++++++++++++----- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0996f1..4f7c13d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +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 excludes them) 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), nothing is subtracted, so a session the member can see happening is not 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)). +- `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 excludes them) 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 an 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/src/__tests__/stats-scope.test.ts b/src/__tests__/stats-scope.test.ts index ddec921d..63043870 100644 --- a/src/__tests__/stats-scope.test.ts +++ b/src/__tests__/stats-scope.test.ts @@ -341,6 +341,33 @@ describe('showStats scope and idempotency', () => { 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); }); diff --git a/src/stats.ts b/src/stats.ts index 4887fba6..4eb4ae6c 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -249,11 +249,24 @@ export async function showStats(options: ShowStatsOptions = {}): Promise { const { filterEventsByScope } = await import('./team-push.js'); const scopedEvents = filterEventsByScope(await readEvents(), scopeFilter); const metricsMap = aggregateSessionMetrics(scopedEvents); - // Only subtract what the team already holds. When the reported totals could - // not be read at all (no stats file yet, an unreadable one, a reports worktree - // that is not there), the local snapshot says nothing about what the team - // has, and subtracting it would hide sessions the user can see happening. - const localDashboard = config && reported + // 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); From bf34934509eb3f40c2e89f29c0fc9aeaf73d390b Mon Sep 17 00:00:00 2001 From: ydflow <314143294+ydflow@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:25:15 +0800 Subject: [PATCH 8/8] docs(stats): correct the changelog's trust and scope claims The note inverted the guard's condition: the code trusts the reported snapshots only when the team total is NON-empty, while the wording said an empty total is what licenses trusting them. Say what the code does. It also claimed the user scope excludes project roots. A user config resolves only when detectProjectConfig() found no project, so there is no root to exclude and the log passes through unfiltered. State that instead of asserting an exclusion the branch cannot perform. No behaviour change; wording only. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f7c13d9..168d4058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +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 excludes them) 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 an 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)). +- `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)).