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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Resulting directory structure:

Independent git clones use the same reports split as single-repo mode: `members/` `sessions/` `votes/` `stats/` are written to the `teamai-reports` orphan branch (the checkout sits **beside** the clone, not inside it). Knowledge (`skills/` `rules/` `docs/` `learnings/` `teamai.yaml`) stays on the default branch. Leftover report files already on `main` are left in place and ignored.

In both modes, commands that only read reports (`members`, `digest`, `projects members`, `stats`, `viz`) never create or push the `teamai-reports` branch. `teamai pull` refreshes the reports checkout from `origin` before it rebuilds the search index (vote hotness) and skill recommendations.
In both modes, commands that only read reports (`members`, `digest`, `projects members`, `stats`, `viz`) never create or push the `teamai-reports` branch. `teamai pull` refreshes the reports checkout from `origin` before it rebuilds the search index (vote hotness) and skill recommendations. Report writers (session save `--push`, Stop-hook votes, member registration, auto-report) merge into origin's latest copy of the member's file first, so the same member reporting from two machines does not lose a session, vote, or stats entry.

Project machine-data (config, state, the team-repo clone, search index, MCP
manifests, resource cache) lives in a per-project partition under
Expand Down
2 changes: 1 addition & 1 deletion docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ teamai init https://github.com/yourorg/yourrepo

独立 git clone 与单仓模式使用同一套上报拆分:`members/` `sessions/` `votes/` `stats/` 写到 `teamai-reports` 孤儿分支(检出目录在 clone **旁边**,不嵌在 clone 里)。知识资产(`skills/` `rules/` `docs/` `learnings/` `teamai.yaml`)仍在默认分支。默认分支上已有的上报文件会留在原地并被忽略。

两种模式下,只读取上报数据的命令(`members`、`digest`、`projects members`、`stats`、`viz`)都不会创建或推送 `teamai-reports` 分支。`teamai pull` 在重建检索索引(投票热度)和技能推荐之前,会先从 `origin` 刷新上报检出。
两种模式下,只读取上报数据的命令(`members`、`digest`、`projects members`、`stats`、`viz`)都不会创建或推送 `teamai-reports` 分支。`teamai pull` 在重建检索索引(投票热度)和技能推荐之前,会先从 `origin` 刷新上报检出。写入上报(`session save --push`、Stop hook 投票、成员注册、自动上报)会先合并 `origin` 上该成员文件的最新副本,因此同一成员在两台机器上报时不会丢掉会话、投票或统计。

项目的机器数据(config、state、team-repo 克隆、搜索索引、MCP manifest、资源缓存)
存放在 `~/.teamai/projects/<slug>/` 下的按项目分区里,**不再**放进业务仓库,因此工作区
Expand Down
163 changes: 163 additions & 0 deletions src/__tests__/e2e/reports-writer-sync-561.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Built-CLI repro for #561: the same member on two machines must not lose
* session-save entries when machine A already has a reports worktree (from
* `teamai members`) that is stale relative to origin.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import YAML from 'yaml';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const cli = path.join(root, 'dist/index.js');
let sandbox: string;

function git(args: string[], cwd: string, env: NodeJS.ProcessEnv): string {
return execFileSync('git', args, { cwd, env, encoding: 'utf8', windowsHide: true });
}

function machineEnv(home: string): NodeJS.ProcessEnv {
return {
...process.env,
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: path.join(home, '.config'),
GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'),
GIT_CONFIG_NOSYSTEM: '1',
GIT_AUTHOR_NAME: 'alice',
GIT_AUTHOR_EMAIL: 'alice@example.invalid',
GIT_COMMITTER_NAME: 'alice',
GIT_COMMITTER_EMAIL: 'alice@example.invalid',
GIT_TERMINAL_PROMPT: '0',
FORCE_COLOR: '0',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'protocol.file.allow',
GIT_CONFIG_VALUE_0: 'always',
};
}

function runCli(home: string, args: string[], cwd: string): Promise<{ code: number | null; output: string }> {
const env = machineEnv(home);
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [cli, ...args], {
cwd,
env,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
const timer = setTimeout(() => {
child.kill();
reject(new Error(`CLI timed out\n${output}`));
}, 45_000);
const capture = (data: Buffer) => { output += data.toString(); };
child.stdout.on('data', capture);
child.stderr.on('data', capture);
child.on('error', (error) => { clearTimeout(timer); reject(error); });
child.on('close', (code) => {
clearTimeout(timer);
resolve({ code, output });
});
});
}

function seedSession(home: string, sessionId: string, timestamp: string): void {
const dashboard = path.join(home, '.teamai', 'dashboard');
fs.mkdirSync(dashboard, { recursive: true });
fs.appendFileSync(
path.join(dashboard, 'events.jsonl'),
`${JSON.stringify({ type: 'session_start', timestamp, sessionId, tool: 'claude', cwd: sandbox })}\n`,
);
}

afterEach(() => {
if (sandbox && path.dirname(sandbox) === os.tmpdir() && path.basename(sandbox).startsWith('teamai-561-')) {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});

describe('real CLI reports writer sync (#561)', () => {
it('keeps every session save from two machines of the same member', async () => {
if (!fs.existsSync(cli)) {
throw new Error(`CLI binary not found at ${cli}. Run "npm run build" first.`);
}

sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-561-'));
const origin = path.join(sandbox, 'origin.git');
const seed = path.join(sandbox, 'seed');
const homeA = path.join(sandbox, 'home-a');
const homeB = path.join(sandbox, 'home-b');
const env = machineEnv(homeA);

fs.mkdirSync(seed, { recursive: true });
fs.writeFileSync(path.join(seed, 'teamai.yaml'), YAML.stringify({
team: 'acme',
repo: origin,
provider: 'git',
}));
git(['init', '-q', '-b', 'main'], seed, env);
git(['add', '.'], seed, env);
git(['commit', '-q', '-m', 'fixture'], seed, env);
git(['clone', '-q', '--bare', seed, origin], sandbox, env);

const setupHome = (home: string) => {
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
fs.writeFileSync(
path.join(home, '.gitconfig'),
[
'[user]',
'\tname = alice',
'\temail = alice@example.invalid',
'[protocol "file"]',
'\tallow = always',
'',
].join('\n'),
);
const clone = path.join(home, '.teamai', 'team-repo');
git(['clone', '-q', origin, clone], sandbox, env);
git(['config', 'user.name', 'alice'], clone, machineEnv(home));
git(['config', 'user.email', 'alice@example.invalid'], clone, machineEnv(home));
git(['config', 'protocol.file.allow', 'always'], clone, machineEnv(home));
fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), YAML.stringify({
repo: { localPath: clone, remote: origin, kind: 'git' },
username: 'alice',
scope: 'user',
updatePolicy: 'skip',
enabledAgents: ['claude'],
additionalRoles: [],
}));
};

setupHome(homeA);
setupHome(homeB);

// Required precondition: machine A already has a reports worktree.
const members = await runCli(homeA, ['members'], sandbox);
expect(members.code, members.output).toBe(0);
expect(fs.existsSync(path.join(homeA, '.teamai', 'reports-wt', '.git'))).toBe(true);

const timestamp = '2026-09-16T12:00:00.000Z';
const save = (home: string, sessionId: string) =>
runCli(home, ['session', 'save', '--session-id', sessionId, '--push', '--force', '--scope', 'user'], sandbox);

seedSession(homeB, 'alice-b0', timestamp);
const b0 = await save(homeB, 'alice-b0');
expect(b0.code, b0.output).toBe(0);

seedSession(homeA, 'alice-a1', timestamp);
const a1 = await save(homeA, 'alice-a1');
expect(a1.code, a1.output).toBe(0);

seedSession(homeB, 'alice-b1', timestamp);
const b1 = await save(homeB, 'alice-b1');
expect(b1.code, b1.output).toBe(0);

const monthLog = git(['show', 'teamai-reports:sessions/alice/2026-09.md'], origin, env);
expect(monthLog).toContain('<!-- teamai:session alice-b0 -->');
expect(monthLog).toContain('<!-- teamai:session alice-a1 -->');
expect(monthLog).toContain('<!-- teamai:session alice-b1 -->');
}, 60_000);
});
22 changes: 21 additions & 1 deletion src/__tests__/git-kind-reports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import path from 'node:path';
import { simpleGit } from 'simple-git';

import { getReportsDir, REPORTS_WORKTREE_DIRNAME, type LocalConfig } from '../types.js';
import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree } from '../utils/reports-branch.js';
import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree, updateReports } from '../utils/reports-branch.js';
import { pushRepoDirectly } from '../utils/git.js';
import { reportUsageToTeam } from '../team-push.js';

Expand Down Expand Up @@ -408,6 +408,26 @@ describe('git-kind reports: refresh before reading (#557)', () => {
expect(await publish(machineB, 'members/alice.yaml', 'username: alice\n')).toBe(true);
expect(await originReportsFile(origin, 'members/alice.yaml')).toBe('username: alice\n');
});

it('merges a stale checkout of the same member onto origin instead of diverging', async () => {
const { origin, clone } = await seedBareOrigin();
const machineA = gitConfig(clone, origin);
const machineB = await cloneCheckout(origin, 'machine-b');

expect(await publish(machineA, 'stats/alice.yaml', 'n: 1\n')).toBe(true);
await ensureReportsWorktree(machineB);
expect(await publish(machineA, 'stats/alice.yaml', 'n: 2\n')).toBe(true);

const pushed = await updateReports(machineB, async (wt) => {
const statsPath = path.join(wt, 'stats', 'alice.yaml');
const current = Number(/n: (\d+)/.exec(fs.readFileSync(statsPath, 'utf-8'))![1]);
fs.writeFileSync(statsPath, `n: ${current + 10}\n`);
return { files: ['stats/alice.yaml'], message: '[teamai] Update usage stats for alice' };
});

expect(pushed).toBe(true);
expect(await originReportsFile(origin, 'stats/alice.yaml')).toBe('n: 12\n');
});
});

describe('self-mode reports: shared stash', () => {
Expand Down
49 changes: 45 additions & 4 deletions src/__tests__/hook-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,20 @@ vi.mock('../transcript-parser.js', () => ({
parseTranscriptForVotes: mockParseTranscriptForVotes,
}));

const voteMocks = vi.hoisted(() => ({
hasPendingVoteDeltas: vi.fn().mockResolvedValue(false),
}));
vi.mock('../votes.js', () => ({
incrementUpvoted: mockIncrementUpvoted,
syncVotesToTeam: mockSyncVotesToTeam,
hasPendingVoteDeltas: (...args: unknown[]) => voteMocks.hasPendingVoteDeltas(...args),
}));

const reportsBranchMocks = vi.hoisted(() => ({
ensureReportsWorktree: vi.fn().mockResolvedValue('/tmp/reports-wt'),
commitAndPushReports: vi.fn().mockResolvedValue(true),
updateReports: vi.fn().mockResolvedValue(true),
}));
vi.mock('../utils/reports-branch.js', () => ({
ensureReportsWorktree: (...args: unknown[]) => reportsBranchMocks.ensureReportsWorktree(...args),
commitAndPushReports: (...args: unknown[]) => reportsBranchMocks.commitAndPushReports(...args),
updateReports: (...args: unknown[]) => reportsBranchMocks.updateReports(...args),
}));

const mockSeedProjectAgentRoot = vi.fn().mockResolvedValue(undefined);
Expand Down Expand Up @@ -665,6 +667,45 @@ describe('hook-handlers registry', () => {
expect(mockIncrementUpvoted).not.toHaveBeenCalled();
});

it('votes-sync skips updateReports when there are no pending vote deltas', async () => {
const registry = buildHandlerRegistry();
const handler = registry.find(
(r) => r.event === 'stop' && r.handler.name === 'votes-sync',
)!.handler;

reportsBranchMocks.updateReports.mockClear();
await handler.execute(
{ session_id: 'sid-skip-reports', cwd: '/x', transcript_path: '/t/transcript.jsonl' },
'claude',
);
expect(reportsBranchMocks.updateReports).not.toHaveBeenCalled();
});

it('votes-sync writes votes through updateReports when deltas are pending', async () => {
const registry = buildHandlerRegistry();
const handler = registry.find(
(r) => r.event === 'stop' && r.handler.name === 'votes-sync',
)!.handler;

voteMocks.hasPendingVoteDeltas.mockResolvedValueOnce(true);
mockSyncVotesToTeam.mockResolvedValueOnce(true);
reportsBranchMocks.updateReports.mockClear();
reportsBranchMocks.updateReports.mockImplementationOnce(
async (_cfg: unknown, write: (wt: string) => Promise<unknown>) => {
await write('/wt');
return true;
},
);

await handler.execute(
{ session_id: 'sid-write-reports', cwd: '/x', transcript_path: '/t/transcript.jsonl' },
'claude',
);

expect(reportsBranchMocks.updateReports).toHaveBeenCalledOnce();
expect(mockSyncVotesToTeam).toHaveBeenCalledWith('/wt', 'test', expect.any(String));
});

it('votes-sync: incrementUpvoted receives all ids when referenced and recalled are identical', async () => {
const registry = buildHandlerRegistry();
const handler = registry.find(
Expand Down
18 changes: 17 additions & 1 deletion src/__tests__/reports-branch-readonly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ vi.mock('../update.js', () => ({
}));

import { acquireLock, releaseLock } from '../update.js';
import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree } from '../utils/reports-branch.js';
import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree, updateReports } from '../utils/reports-branch.js';

const config: LocalConfig = {
repo: {
Expand Down Expand Up @@ -198,3 +198,19 @@ describe('refreshReportsWorktree', () => {
expect(releaseLock).toHaveBeenCalledOnce();
});
});

describe('updateReports', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(acquireLock).mockResolvedValue(false);
});

it('does not run the write callback when another reports write holds the lock', async () => {
const write = vi.fn();

expect(await updateReports(config, write)).toBe(false);

expect(write).not.toHaveBeenCalled();
expect(releaseLock).not.toHaveBeenCalled();
});
});
Loading
Loading