Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ All notable changes to this project will be documented in this file. See [standa
- 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)).
- Every team hook handler now works in the scope `hook-dispatch` resolved for the hook's `cwd`. The team correction keywords, votes and webhooks read their config again from the directory the hook process ran in, so when that `cwd` no longer existed (a deleted worktree) and the host started the hook inside another project, that project's keywords were applied, the session's votes were recorded under its member and pushed to its team, and its webhooks fired. The background handlers (session-start pull, webhooks, update check) also run again when that `cwd` no longer exists: on macOS and Linux their detached process was started in it, so the start failed silently and none of them ran. It now starts in the temp directory, as on Windows (for [#752](https://github.com/Tencent/teamai-cli/issues/752)).
- Votes stay with the team of the scope they were cast in. Every scope used to record into one `~/.teamai/votes/<user>.yaml`, so a vote cast in one project (by `teamai recall feedback`, a recall search, or a Stop hook whose push failed) was pushed to the team of whichever scope synced next. Votes now go to the data directory of the scope that resolves for the session's directory (`<dataHome>/votes/` for a project, `~/.teamai/user-votes/` for the user scope), and the Stop hook, the pull report, `teamai recall feedback` and the knowledge-base vote view each read only that scope's votes. Since a scope's file starts empty, `teamai recall feedback --negative` also counts the upvotes that scope's team already holds, so a doc upvoted before the upgrade can still be lowered. In a project whose config cannot be read, `teamai recall feedback` records nothing and exits 1 instead of recording into the user scope, a recall search records no recalled count, and the knowledge-base report names the broken file instead of showing the user scope's votes. Votes in `~/.teamai/votes/` name no project, whether an earlier release left them there or writes them again after a rollback, so this release never reads or pushes them; the team's `votes/<user>.yaml` keeps its format (for [#787](https://github.com/Tencent/teamai-cli/issues/787)).
- Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`<dataHome>/usage.jsonl`: the project partition, or `<repo>/.teamai` for an install not yet migrated to one, and `~/.teamai/user-usage.jsonl` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. Events in `~/.teamai/usage.jsonl` name no project, whether an earlier release left them there or writes them again after a rollback, so they are never read or reported. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)).
- `teamai init` no longer hangs without a terminal. When the provider had no session it spawned `gh auth login --web` (or `gf auth login`, `cnb login`) with inherited stdio and waited for a browser device flow that nobody could complete, about five minutes for GitHub, then exited with the provider's error and no hint of the missing credential. Each login now refuses up front when the run is not interactive and names the credential to prepare (`GITHUB_TOKEN` / `GH_TOKEN`, `CNB_TOKEN`, or for TGit a prior `gf auth login`, since a `TGIT_TOKEN` PAT is REST-API-only and cannot clone). A run is non-interactive when stdin is not a TTY or when `CI` or `TEAMAI_NONINTERACTIVE` is set, so an agent sandbox with a pseudo-terminal can declare itself unattended, and every prompt in the CLI follows the same rule. `git` also runs with its prompts closed in that case — `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=echo` and `GCM_INTERACTIVE=never`, each only where the caller set nothing — so a missing clone credential fails at once instead of waiting on a terminal prompt or an askpass or credential-manager dialog. `ssh` keeps its own settings: its batch flag is only reachable through `GIT_SSH_COMMAND`, which would override each repository's `core.sshCommand` (for [#711](https://github.com/Tencent/teamai-cli/issues/711)).
- A `manifest/roles.yaml` that exists but does not parse now fails the pull for that scope instead of warning and syncing with no role filter at all, for a member with no role as much as for one with a role. The same applies to `init` and `push`, which each fell back to a guess at the namespaces when any error came out of the loader. The legacy role migration skips with a warning instead of failing, so every command, `pull` included, still loads the config and can fetch the fixed manifest; until it can run, the member holds no role rather than every role, so hooks, MCP servers and env variables scoped by `roles:` reach them no more than skills do. For a member with no active project that fallback meant an unfiltered sync, so a broken manifest delivered every namespace it was written to gate. Only an absent manifest still means "this team does not use roles"; an unreadable or empty file is an error, as it now is for `manifest/projects.yaml` too. `push` stops at its scan for such a manifest (exit 2) even with `--role <ns>`, since the scan needs it to tell which namespaces are the member's.
Expand Down
11 changes: 8 additions & 3 deletions docs/designs/data-directory-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,14 +314,19 @@ just works. Seven consts that already had runtime getters and no live consumers
were removed.

**Functionization ≠ project-scoping.** All of these are class-A2 (machine-level):
the getters still return `~/.teamai/...`, unchanged. The project-scoped equivalents
the getters still return `~/.teamai/...`, unchanged, except
`getUserVotesDir()` (below). The project-scoped equivalents
already route through `getDataHome()`. Skill usage moved there too (#748):
`usage.jsonl` lives in each scope's `getDataHome()`, because one shared file let a
project's report carry every project's skills. The user scope records in
`~/.teamai/user-usage.jsonl`, not that old shared `~/.teamai/usage.jsonl`, which
an earlier release still writes after a rollback; the shared file is never
read. The dashboard stays an A2 singleton: `teamai dashboard`, `stats --by-repo`,
`session save` and the contribute check read across scopes. Each event instead
read. Local votes followed for the same reason (#787): `<dataHome>/votes/`, and
`~/.teamai/user-votes/` (`getUserVotesDir()`) for the user scope, so a scope
pushes only the votes cast where it is set up. The old shared `~/.teamai/votes/`
is never read, and its pending deltas are not pushed. The dashboard stays an A2
singleton: `teamai dashboard`, `stats --by-repo`, `session save` and the
contribute check read across scopes. Each event instead
carries `dataHome`, the `getDataHome()` of the scope the hook resolved (#785), and a
scope's report keeps only its own. An event written before that field existed is
attributed by its `cwd`, realpath'd, to the project whose root holds it, never to
Expand Down
2 changes: 1 addition & 1 deletion docs/designs/git-native-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
│ ~/.teamai/ │ │
│ learnings/ (local copy)│◀────────┘
│ search-index.json │
│ votes/<user>.yaml │
│ user-votes/<user>.yaml │
└──────────┬───────────────┘
│
teamai recall "api timeout"
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/hook-dispatch-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ describe('hook runs and the scope they belong to (#748)', () => {
const events = fs.readFileSync(path.join(teamaiHome(), 'dashboard', 'events.jsonl'), 'utf-8')
.split('\n').filter(Boolean).map((line) => JSON.parse(line) as { type: string; correction?: boolean });
expect(events.find((e) => e.type === 'prompt_submit')?.correction).toBe(false);
expect(fs.readdirSync(path.join(teamaiHome(), 'votes'))).toEqual(['tester.yaml']);
expect(fs.readdirSync(path.join(teamaiHome(), 'user-votes'))).toEqual(['tester.yaml']);
expect(fetchSpy).not.toHaveBeenCalled();
});

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/p3-functionize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('P3 path getters honor a runtime HOME change (no vi.resetModules)', ()
const types = await import('../types.js');
// HOME was stubbed in beforeEach; the getters must reflect it at CALL time.
expect(types.getTeamaiHomeDir()).toBe(path.join(home, '.teamai'));
expect(types.getUserVotesDir()).toBe(path.join(home, '.teamai', 'votes'));
expect(types.getUserVotesDir()).toBe(path.join(home, '.teamai', 'user-votes'));
expect(types.getSessionLogsDir()).toBe(path.join(home, '.teamai', 'session-logs'));

// Swap HOME again mid-test — a module-load const could never do this.
Expand Down
38 changes: 25 additions & 13 deletions src/__tests__/recall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import YAML from 'yaml';
import { autoUpvote } from '../recall.js';
import { buildIndex, loadIndex, search } from '../utils/search-index.js';
import type { SearchResult } from '../utils/search-index.js';
import type { SearchIndex, UserVotesV2 } from '../types.js';
import type { LocalConfig, SearchIndex, UserVotesV2 } from '../types.js';

// ─── Test helpers ──────────────────────────────────────────

Expand Down Expand Up @@ -45,11 +45,14 @@ describe('autoUpvote', () => {
let repoPath: string;
const originalHome = process.env.HOME;

let config: LocalConfig;

beforeEach(() => {
tmpDir = makeTmpDir();
repoPath = path.join(tmpDir, 'repo');
fs.mkdirSync(path.join(repoPath, 'votes'), { recursive: true });
process.env.HOME = tmpDir;
config = { repo: { localPath: repoPath, remote: '' }, username: 'jeff', scope: 'user', additionalRoles: [] };
});

afterEach(() => {
Expand All @@ -75,10 +78,10 @@ describe('autoUpvote', () => {

it('T12: creates new vote entry on first upvote (V2 format)', async () => {
const results = [makeResult('api-timeout-2026-03-20-abc.md')];
await autoUpvote(results, 'jeff', repoPath);
await autoUpvote(results, config);

// Check local votes file
const localPath = path.join(tmpDir, '.teamai', 'votes', 'jeff.yaml');
const localPath = path.join(tmpDir, '.teamai', 'user-votes', 'jeff.yaml');
expect(fs.existsSync(localPath)).toBe(true);

const content = fs.readFileSync(localPath, 'utf-8');
Expand All @@ -98,44 +101,53 @@ describe('autoUpvote', () => {
const results = [makeResult('api-timeout-2026-03-20-abc.md')];

// First vote
await autoUpvote(results, 'jeff', repoPath);
const localPath = path.join(tmpDir, '.teamai', 'votes', 'jeff.yaml');
await autoUpvote(results, config);
const localPath = path.join(tmpDir, '.teamai', 'user-votes', 'jeff.yaml');
const firstContent = fs.readFileSync(localPath, 'utf-8');
const firstParsed = YAML.parse(firstContent) as UserVotesV2;
expect(firstParsed.votes['api-timeout-2026-03-20-abc'].recalled_count).toBe(1);

// Second vote (same doc) — should increment
await autoUpvote(results, 'jeff', repoPath);
await autoUpvote(results, config);
const secondContent = fs.readFileSync(localPath, 'utf-8');
const secondParsed = YAML.parse(secondContent) as UserVotesV2;
expect(secondParsed.votes['api-timeout-2026-03-20-abc'].recalled_count).toBe(2);
});

it('accumulates votes for different docs', async () => {
await autoUpvote([makeResult('doc-a.md')], 'jeff', repoPath);
await autoUpvote([makeResult('doc-b.md')], 'jeff', repoPath);
await autoUpvote([makeResult('doc-a.md')], config);
await autoUpvote([makeResult('doc-b.md')], config);

const localPath = path.join(tmpDir, '.teamai', 'votes', 'jeff.yaml');
const localPath = path.join(tmpDir, '.teamai', 'user-votes', 'jeff.yaml');
const content = fs.readFileSync(localPath, 'utf-8');
const parsed = YAML.parse(content) as UserVotesV2;
expect(Object.keys(parsed.votes)).toHaveLength(2);
expect(parsed.votes['doc-a']).toBeDefined();
expect(parsed.votes['doc-b']).toBeDefined();
});

it('records into the votes of the scope it searched, a project\'s under its data home (#787)', async () => {
const dataHome = path.join(tmpDir, '.teamai', 'projects', 'project-a');
await autoUpvote([makeResult('doc-a.md')], { ...config, scope: 'project', projectRoot: path.join(tmpDir, 'project-a'), dataHome });

const parsed = YAML.parse(fs.readFileSync(path.join(dataHome, 'votes', 'jeff.yaml'), 'utf-8')) as UserVotesV2;
expect(Object.keys(parsed.votes)).toEqual(['doc-a']);
expect(fs.existsSync(path.join(tmpDir, '.teamai', 'user-votes'))).toBe(false);
});

it('handles empty results gracefully', async () => {
await autoUpvote([], 'jeff', repoPath);
const localPath = path.join(tmpDir, '.teamai', 'votes', 'jeff.yaml');
await autoUpvote([], config);
const localPath = path.join(tmpDir, '.teamai', 'user-votes', 'jeff.yaml');
expect(fs.existsSync(localPath)).toBe(false);
});

it('recovers from corrupt local votes file', async () => {
const localDir = path.join(tmpDir, '.teamai', 'votes');
const localDir = path.join(tmpDir, '.teamai', 'user-votes');
fs.mkdirSync(localDir, { recursive: true });
fs.writeFileSync(path.join(localDir, 'jeff.yaml'), '{ corrupt yaml !!!', 'utf-8');

const results = [makeResult('new-doc.md')];
await autoUpvote(results, 'jeff', repoPath);
await autoUpvote(results, config);

const content = fs.readFileSync(path.join(localDir, 'jeff.yaml'), 'utf-8');
const parsed = YAML.parse(content) as UserVotesV2;
Expand Down
Loading
Loading