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: 2 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ 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.

Project machine-data (config, state, the team-repo clone, search index, MCP
manifests, resource cache) lives in a per-project partition under
`~/.teamai/projects/<slug>/`, **not** in the business repo, so your workspace has no
Expand Down
2 changes: 2 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ 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` 刷新上报检出。

项目的机器数据(config、state、team-repo 克隆、搜索索引、MCP manifest、资源缓存)
存放在 `~/.teamai/projects/<slug>/` 下的按项目分区里,**不再**放进业务仓库,因此工作区
无 teamai 残留,且同一仓库的 `git worktree` 共享同一分区。各 Agent 的项目根目录
Expand Down
287 changes: 284 additions & 3 deletions 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 } from '../utils/reports-branch.js';
import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree } from '../utils/reports-branch.js';
import { pushRepoDirectly } from '../utils/git.js';
import { reportUsageToTeam } from '../team-push.js';

Expand Down Expand Up @@ -68,10 +68,10 @@ exit 0
return { origin, clone };
}

function gitConfig(clone: string, origin: string): LocalConfig {
function gitConfig(clone: string, origin: string, username = 'alice'): LocalConfig {
return {
repo: { localPath: clone, remote: origin, kind: 'git' },
username: 'alice',
username,
scope: 'user',
additionalRoles: [],
};
Expand Down Expand Up @@ -237,3 +237,284 @@ describe('git-kind reports branch', () => {
expect(reportsTree).toContain('members/bob.yaml');
});
});

const READ_ONLY = { pushIfCreated: false } as const;

/** Another independent checkout of the same team repo (a second member or machine). */
async function cloneCheckout(origin: string, name: string, username = 'alice'): Promise<LocalConfig> {
const clone = path.join(tmp, name, 'team-repo');
fs.mkdirSync(path.dirname(clone), { recursive: true });
await simpleGit().clone(origin, clone);
await configureGit(clone);
return gitConfig(clone, origin, username);
}

/** Write one report file into the reports worktree and publish it. */
async function publish(cfg: LocalConfig, relPath: string, content: string): Promise<boolean> {
const wt = await ensureReportsWorktree(cfg);
fs.mkdirSync(path.dirname(path.join(wt, relPath)), { recursive: true });
fs.writeFileSync(path.join(wt, relPath), content);
return commitAndPushReports(cfg, `[teamai] Update ${relPath}`, [relPath]);
}

async function originReportsFile(origin: string, relPath: string): Promise<string> {
return simpleGit(origin).show([`teamai-reports:${relPath}`]);
}

async function originHasReportsBranch(origin: string): Promise<boolean> {
const heads = await simpleGit(origin).raw(['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
return heads.split('\n').includes('teamai-reports');
}

describe('git-kind reports: read-only cold start (#558)', () => {
it('materializes a local reports view without publishing the branch', async () => {
const { origin, clone } = await seedBareOrigin();
const cfg = gitConfig(clone, origin);

await refreshReportsWorktree(cfg, READ_ONLY);
const wt = await ensureReportsWorktree(cfg, READ_ONLY);

expect(fs.existsSync(path.join(wt, '.gitignore'))).toBe(true);
expect(await originHasReportsBranch(origin)).toBe(false);
});

it('reuses the unpublished local branch after its worktree is removed, and a writer publishes it', async () => {
const { origin, clone } = await seedBareOrigin();
const cfg = gitConfig(clone, origin);

const wt = await ensureReportsWorktree(cfg, READ_ONLY);
fs.rmSync(wt, { recursive: true, force: true });

await expect(ensureReportsWorktree(cfg, READ_ONLY)).resolves.toBe(wt);
expect(await originHasReportsBranch(origin)).toBe(false);

expect(await publish(cfg, 'members/alice.yaml', 'username: alice\n')).toBe(true);
expect(await originReportsFile(origin, 'members/alice.yaml')).toBe('username: alice\n');
});
});

describe('git-kind reports: refresh before reading (#557)', () => {
it('picks up report data another member pushed', async () => {
const { origin, clone } = await seedBareOrigin();
const alice = gitConfig(clone, origin);
expect(await publish(alice, 'members/alice.yaml', 'username: alice\n')).toBe(true);

const bob = await cloneCheckout(origin, 'bob', 'bob');
expect(await publish(bob, 'votes/bob.yaml', 'version: 2\n')).toBe(true);

const wt = await ensureReportsWorktree(alice, READ_ONLY);
expect(fs.existsSync(path.join(wt, 'votes', 'bob.yaml'))).toBe(false);

await refreshReportsWorktree(alice, READ_ONLY);
expect(fs.readFileSync(path.join(wt, 'votes', 'bob.yaml'), 'utf-8')).toBe('version: 2\n');
});

it('keeps an unpushed report commit that rebases cleanly, so the next push delivers it', async () => {
const { origin, clone } = await seedBareOrigin();
const alice = gitConfig(clone, origin);
const wt = await ensureReportsWorktree(alice);

// A report committed while the push failed (e.g. offline).
fs.mkdirSync(path.join(wt, 'stats'), { recursive: true });
fs.writeFileSync(path.join(wt, 'stats', 'alice.yaml'), 'n: 1\n');
const wtGit = simpleGit(wt);
await wtGit.add(['stats/alice.yaml']);
await wtGit.commit('offline stats');

const bob = await cloneCheckout(origin, 'bob', 'bob');
expect(await publish(bob, 'votes/bob.yaml', 'version: 2\n')).toBe(true);

await refreshReportsWorktree(alice, READ_ONLY);
expect(fs.existsSync(path.join(wt, 'votes', 'bob.yaml'))).toBe(true);
expect(fs.readFileSync(path.join(wt, 'stats', 'alice.yaml'), 'utf-8')).toBe('n: 1\n');

expect(await publish(alice, 'members/alice.yaml', 'username: alice\n')).toBe(true);
expect(await originReportsFile(origin, 'stats/alice.yaml')).toBe('n: 1\n');
expect(await originReportsFile(origin, 'votes/bob.yaml')).toBe('version: 2\n');
});

it('keeps uncommitted report files while updating to origin', async () => {
const { origin, clone } = await seedBareOrigin();
const alice = gitConfig(clone, origin);
const wt = await ensureReportsWorktree(alice);

// A writer has written its file but not committed it yet.
fs.mkdirSync(path.join(wt, 'stats'), { recursive: true });
fs.writeFileSync(path.join(wt, 'stats', 'alice.yaml'), 'n: 1\n');

const bob = await cloneCheckout(origin, 'bob', 'bob');
expect(await publish(bob, 'votes/bob.yaml', 'version: 2\n')).toBe(true);

await refreshReportsWorktree(alice, READ_ONLY);
expect(fs.existsSync(path.join(wt, 'votes', 'bob.yaml'))).toBe(true);
expect(fs.readFileSync(path.join(wt, 'stats', 'alice.yaml'), 'utf-8')).toBe('n: 1\n');

expect(await commitAndPushReports(alice, '[teamai] Update usage stats for alice', ['stats/alice.yaml'])).toBe(true);
expect(await originReportsFile(origin, 'stats/alice.yaml')).toBe('n: 1\n');
});

it('does not leave autostash conflict markers when an unpushed commit, a dirty report, and origin all touch the same file', 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);
const wtA = await ensureReportsWorktree(machineA);

// Unpushed local commit (e.g. a session report written while offline).
fs.mkdirSync(path.join(wtA, 'sessions'), { recursive: true });
fs.writeFileSync(path.join(wtA, 'sessions', 'alice.yaml'), 'session: a1\n');
const wtGit = simpleGit(wtA);
await wtGit.add(['sessions/alice.yaml']);
await wtGit.commit('offline session');

// Dirty tracked stats, plus a newer copy of the same file on origin.
fs.writeFileSync(path.join(wtA, 'stats', 'alice.yaml'), 'n: 3\n');
expect(await publish(machineB, 'stats/alice.yaml', 'n: 2\n')).toBe(true);

await refreshReportsWorktree(machineA, READ_ONLY);

const stats = fs.readFileSync(path.join(wtA, 'stats', 'alice.yaml'), 'utf-8');
expect(stats).not.toMatch(/^(<<<<<<<|=======|>>>>>>>)/m);
expect(stats).toBe('n: 3\n');
expect(fs.readFileSync(path.join(wtA, 'sessions', 'alice.yaml'), 'utf-8')).toBe('session: a1\n');

const status = await wtGit.status();
expect(status.conflicted).toEqual([]);
expect((await wtGit.raw(['stash', 'list'])).trim()).toBe('');
});

it('drops an unpushed commit that conflicts with newer origin data so the checkout never stays diverged', 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);
const wtB = await ensureReportsWorktree(machineB);

// Machine B commits a merge from its stale copy but never pushes it...
fs.writeFileSync(path.join(wtB, 'stats', 'alice.yaml'), 'n: 100\n');
const wtGit = simpleGit(wtB);
await wtGit.add(['stats/alice.yaml']);
await wtGit.commit('stale stats');
// ...while machine A publishes a newer copy of the same file.
expect(await publish(machineA, 'stats/alice.yaml', 'n: 2\n')).toBe(true);

await refreshReportsWorktree(machineB, READ_ONLY);
expect(fs.readFileSync(path.join(wtB, 'stats', 'alice.yaml'), 'utf-8')).toBe('n: 2\n');
const ahead = await wtGit.raw(['rev-list', '--count', 'origin/teamai-reports..HEAD']);
expect(ahead.trim()).toBe('0');

expect(await publish(machineB, 'members/alice.yaml', 'username: alice\n')).toBe(true);
expect(await originReportsFile(origin, 'members/alice.yaml')).toBe('username: alice\n');
});
});

describe('self-mode reports: shared stash', () => {
it('does not drop a pre-existing business-worktree stash whose message contains autostash', async () => {
const { origin, clone } = await seedBareOrigin();
const teamaiDir = path.join(clone, '.teamai');
fs.mkdirSync(teamaiDir, { recursive: true });
const alice: LocalConfig = {
repo: { localPath: teamaiDir, remote: origin, kind: 'self', businessRepoRoot: clone },
username: 'alice',
scope: 'user',
additionalRoles: [],
};
const machineB = await cloneCheckout(origin, 'machine-b');

expect(await publish(alice, 'stats/alice.yaml', 'n: 1\n')).toBe(true);
const wtA = await ensureReportsWorktree(alice);

fs.mkdirSync(path.join(wtA, 'sessions'), { recursive: true });
fs.writeFileSync(path.join(wtA, 'sessions', 'alice.yaml'), 'session: a1\n');
const wtGit = simpleGit(wtA);
await wtGit.add(['sessions/alice.yaml']);
await wtGit.commit('offline session');

const businessGit = simpleGit(clone);
fs.writeFileSync(path.join(clone, 'app.txt'), 'committed\n');
await businessGit.add(['app.txt']);
await businessGit.commit('app');
fs.writeFileSync(path.join(clone, 'app.txt'), 'wip\n');
await businessGit.stash(['push', '-m', 'autostash']);
const stashBefore = (await businessGit.raw(['stash', 'list'])).trim();
expect(stashBefore).toMatch(/autostash/);

fs.writeFileSync(path.join(wtA, 'stats', 'alice.yaml'), 'n: 3\n');
expect(await publish(machineB, 'stats/alice.yaml', 'n: 2\n')).toBe(true);

await refreshReportsWorktree(alice, READ_ONLY);

const stats = fs.readFileSync(path.join(wtA, 'stats', 'alice.yaml'), 'utf-8');
expect(stats).not.toMatch(/^(<<<<<<<|=======|>>>>>>>)/m);
expect(stats).toBe('n: 3\n');
expect((await wtGit.status()).conflicted).toEqual([]);

const stashAfter = (await businessGit.raw(['stash', 'list'])).trim();
expect(stashAfter).toBe(stashBefore);
expect(fs.readFileSync(path.join(clone, 'app.txt'), 'utf-8')).toBe('committed\n');
});

it('does not drop a business-worktree stash created while reports rebase is running', async () => {
const { origin, clone } = await seedBareOrigin();
const teamaiDir = path.join(clone, '.teamai');
fs.mkdirSync(teamaiDir, { recursive: true });
const alice: LocalConfig = {
repo: { localPath: teamaiDir, remote: origin, kind: 'self', businessRepoRoot: clone },
username: 'alice',
scope: 'user',
additionalRoles: [],
};
const machineB = await cloneCheckout(origin, 'machine-b');

expect(await publish(alice, 'stats/alice.yaml', 'n: 1\n')).toBe(true);
const wtA = await ensureReportsWorktree(alice);

fs.mkdirSync(path.join(wtA, 'sessions'), { recursive: true });
fs.writeFileSync(path.join(wtA, 'sessions', 'alice.yaml'), 'session: a1\n');
const wtGit = simpleGit(wtA);
await wtGit.add(['sessions/alice.yaml']);
await wtGit.commit('offline session');

const businessGit = simpleGit(clone);
fs.writeFileSync(path.join(clone, 'app.txt'), 'committed\n');
await businessGit.add(['app.txt']);
await businessGit.commit('app');

const marker = path.join(tmp, 'rebase-started');
const release = path.join(tmp, 'rebase-continue');
const hookDir = path.join(clone, '.git', 'hooks');
fs.mkdirSync(hookDir, { recursive: true });
fs.writeFileSync(
path.join(hookDir, 'pre-rebase'),
`#!/bin/sh\nprintf 'ready\\n' > ${JSON.stringify(marker)}\nwhile [ ! -f ${JSON.stringify(release)} ]; do sleep 0.05; done\n`,
);
fs.chmodSync(path.join(hookDir, 'pre-rebase'), 0o755);

fs.writeFileSync(path.join(wtA, 'stats', 'alice.yaml'), 'n: 3\n');
expect(await publish(machineB, 'stats/alice.yaml', 'n: 2\n')).toBe(true);

const refreshing = refreshReportsWorktree(alice, READ_ONLY);
const deadline = Date.now() + 8000;
while (!fs.existsSync(marker) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
expect(fs.existsSync(marker)).toBe(true);

fs.writeFileSync(path.join(clone, 'app.txt'), 'wip-during-rebase\n');
await businessGit.stash(['push', '-m', 'business-wip']);
const stashDuring = (await businessGit.raw(['stash', 'list'])).trim();
expect(stashDuring).toMatch(/business-wip/);

fs.writeFileSync(release, 'go\n');
await refreshing;

const stats = fs.readFileSync(path.join(wtA, 'stats', 'alice.yaml'), 'utf-8');
expect(stats).not.toMatch(/^(<<<<<<<|=======|>>>>>>>)/m);
expect(stats).toBe('n: 3\n');
expect((await wtGit.status()).conflicted).toEqual([]);
expect((await businessGit.raw(['stash', 'list'])).trim()).toBe(stashDuring);
expect(fs.readFileSync(path.join(clone, 'app.txt'), 'utf-8')).toBe('committed\n');
});
});
4 changes: 3 additions & 1 deletion src/__tests__/members.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ describe('listMembers', () => {

expect(log.info).toHaveBeenCalledWith('No team members registered');
expect(consoleSpy).not.toHaveBeenCalled();
expect(reportsMocks.ensureReportsWorktree).toHaveBeenCalled();
// Listing is read-only: a cold start must not publish the reports branch.
expect(reportsMocks.refreshReportsWorktree).toHaveBeenCalledWith(expect.anything(), { pushIfCreated: false });
expect(reportsMocks.ensureReportsWorktree).toHaveBeenCalledWith(expect.anything(), { pushIfCreated: false });
});

it('should display members without role tags', async () => {
Expand Down
11 changes: 9 additions & 2 deletions src/__tests__/pull-scope-isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ import { reconcileMcpForConfig } from '../mcp-reconcile.js';
import { reportUsageToTeam } from '../team-push.js';
import { readUsageEvents, truncateUsageAfterReport } from '../usage-tracker.js';
import { releaseLock } from '../update.js';
import type { TeamaiConfig, LocalConfig } from '../types.js';
import { SYNC_LOCK_FILENAME, type TeamaiConfig, type LocalConfig } from '../types.js';

const SKIP_MSG = 'project scope detected, skipped user scope';

Expand Down Expand Up @@ -221,7 +221,14 @@ describe('pull scope isolation (issue #73)', () => {
await vi.advanceTimersByTimeAsync(5000);
await pulling;
expect(truncateUsageAfterReport).not.toHaveBeenCalled();
expect(releaseLock).not.toHaveBeenCalled();
// Partition sync-locks stay held until the late report finishes. The
// reports worktree lock (.reports-lock) is acquired and released around
// the earlier read-only refresh, which is independent of that wait.
expect(
vi.mocked(releaseLock).mock.calls.filter(
([lock]) => typeof lock === 'string' && path.basename(lock) === SYNC_LOCK_FILENAME,
),
).toEqual([]);
expect(pullSources).toHaveBeenCalled();
await pull({ silent: true });
expect(reportUsageToTeam).toHaveBeenCalledTimes(1);
Expand Down
Loading
Loading