From 597a84fe79c2766ca657f0d3ca83f82b2f0c40f9 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 24 Sep 2026 10:24:37 +0200 Subject: [PATCH 1/6] fix(votes): keep votes with the scope they were cast in (#787) Every scope recorded into one ~/.teamai/votes/.yaml, so a vote cast in one project (recall feedback, a recall search, a Stop whose push failed) was pushed to the team of whichever scope synced next: the leak usage.jsonl had before #758. Votes now live in the data home of the scope that resolves for the session: /votes/ for a project, ~/.teamai/user-votes/ for the user scope. The Stop hook uses the config the dispatcher resolved; the pull report, recall search, `recall feedback` and the vote view read only that scope's votes, and the CLI readers resolve it with resolveConfigForDir, so an unreadable project config falls back to no other scope: `recall feedback` exits 1 and the vote view names the broken file. The shared ~/.teamai/votes/ is never read. Its V2 `votes` map is the last merged remote snapshot of whichever team synced, not this scope's history, and seeding a scope from it would let `recall feedback --negative` push a decrement and merged timestamps derived from another team. The remote votes/.yaml format is unchanged. --- CHANGELOG.md | 1 + docs/designs/data-directory-layout.md | 5 +- src/__tests__/hook-dispatch-scope.test.ts | 2 +- src/__tests__/p3-functionize.test.ts | 2 +- src/__tests__/recall.test.ts | 38 ++-- src/__tests__/votes-scope.test.ts | 266 ++++++++++++++++++++++ src/__tests__/votes.test.ts | 15 +- src/hook-handlers.ts | 4 +- src/recall.ts | 32 +-- src/skill-content.ts | 2 +- src/team-push.ts | 14 +- src/types.ts | 19 +- src/viz.ts | 16 +- src/votes.ts | 20 +- 14 files changed, 373 insertions(+), 63 deletions(-) create mode 100644 src/__tests__/votes-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa0cbfc..8bf7f652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,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/.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 (`/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. In a project whose config cannot be read, `teamai recall feedback` records nothing and exits 1 instead of recording into the user scope, 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 they are never read or pushed; the team's `votes/.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 (`/usage.jsonl`: the project partition, or `/.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 `, since the scan needs it to tell which namespaces are the member's. diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 46df6a2f..3ac7e5e4 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -312,7 +312,10 @@ already route through `getDataHome()`. Skill usage moved there too (#748): 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 is likewise an A2 singleton +read. Local votes followed for the same reason (#787): `/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 is likewise an A2 singleton (events carry `cwd`/`sessionId`); "two projects' events don't mix" is satisfied by `getEventsPath()` reading `HOME` at call time, not by per-project dirs. diff --git a/src/__tests__/hook-dispatch-scope.test.ts b/src/__tests__/hook-dispatch-scope.test.ts index 96471372..ae293de2 100644 --- a/src/__tests__/hook-dispatch-scope.test.ts +++ b/src/__tests__/hook-dispatch-scope.test.ts @@ -150,7 +150,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(); }); diff --git a/src/__tests__/p3-functionize.test.ts b/src/__tests__/p3-functionize.test.ts index a4536692..6166a6b0 100644 --- a/src/__tests__/p3-functionize.test.ts +++ b/src/__tests__/p3-functionize.test.ts @@ -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. diff --git a/src/__tests__/recall.test.ts b/src/__tests__/recall.test.ts index 409949c0..21a9c4e0 100644 --- a/src/__tests__/recall.test.ts +++ b/src/__tests__/recall.test.ts @@ -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 ────────────────────────────────────────── @@ -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(() => { @@ -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'); @@ -98,24 +101,24 @@ 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); @@ -123,19 +126,28 @@ describe('autoUpvote', () => { 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; diff --git a/src/__tests__/votes-scope.test.ts b/src/__tests__/votes-scope.test.ts new file mode 100644 index 00000000..b6c67009 --- /dev/null +++ b/src/__tests__/votes-scope.test.ts @@ -0,0 +1,266 @@ +/** + * Votes stay with the scope they were cast in (#787): the real dispatcher, + * report push, `recall feedback` and vote view, observed through the votes each + * team's reports checkout receives in a sandbox HOME. Only the machine-level + * handlers that reach the network or spawn processes are stubbed. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import YAML from 'yaml'; +import type { LocalConfig, UserVotesV2 } from '../types.js'; + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawn: vi.fn(() => ({ on: vi.fn(), stdin: { on: vi.fn(), end: vi.fn((_: string, done: () => void) => done()) }, unref: vi.fn() })), +})); +vi.mock('../pull.js', () => ({ pull: vi.fn(async () => undefined) })); +vi.mock('../update.js', () => ({ doUpdate: vi.fn(async () => undefined) })); +vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: vi.fn(async () => null) })); +// Each team's reports checkout sits beside its clone, where getReportsDir puts it; +// a write lands there instead of being pushed. +vi.mock('../utils/reports-branch.js', async () => { + const nodePath = await import('node:path'); + const nodeFs = await import('node:fs'); + const checkout = (config: LocalConfig): string => { + const dir = nodePath.join(nodePath.dirname(config.repo.localPath), 'reports-wt'); + nodeFs.mkdirSync(dir, { recursive: true }); + return dir; + }; + return { + updateReports: vi.fn(async (config: LocalConfig, write: (wt: string) => Promise) => (await write(checkout(config))) !== null), + ensureReportsWorktree: vi.fn(async (config: LocalConfig) => checkout(config)), + }; +}); + +const { hookDispatchCli } = await import('../hook-dispatch-cli.js'); +const { resolveProjectDataHome, saveLocalConfigForScope } = await import('../config.js'); +const { reportUsageToTeam } = await import('../team-push.js'); +const { recallFeedback } = await import('../votes.js'); +const { resolveVizRoot } = await import('../viz.js'); + +let tmp: string; +let originalHome: string | undefined; +let originalCwd: string; + +beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-votes-scope-'))); + originalHome = process.env.HOME; + originalCwd = process.cwd(); + process.env.HOME = path.join(tmp, 'home'); + fs.mkdirSync(process.env.HOME); +}); + +afterEach(() => { + process.chdir(originalCwd); + process.exitCode = undefined; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +const teamaiHome = () => path.join(tmp, 'home', '.teamai'); + +function userScope(): LocalConfig { + const teamRepo = path.join(teamaiHome(), 'team-repo'); + fs.mkdirSync(teamRepo, { recursive: true }); + fs.writeFileSync(path.join(teamRepo, 'teamai.yaml'), 'team: user-team\nrepo: https://example.test/acme/user-team.git\n'); + const config: LocalConfig = { + repo: { localPath: teamRepo, remote: 'https://example.test/acme/user-team.git' }, + username: 'tester', scope: 'user', additionalRoles: [], + }; + fs.writeFileSync(path.join(teamaiHome(), 'config.yaml'), YAML.stringify(config)); + return config; +} + +/** Project A, set up for the same member as the user scope. */ +async function projectA(): Promise<{ root: string; dataHome: string; config: LocalConfig }> { + const root = path.join(tmp, 'project-a'); + fs.mkdirSync(root); + execFileSync('git', ['init', '-q'], { cwd: root }); + const dataHome = await resolveProjectDataHome(root); + const teamRepo = path.join(dataHome, 'team-repo'); + fs.mkdirSync(teamRepo, { recursive: true }); + fs.writeFileSync(path.join(teamRepo, 'teamai.yaml'), 'team: team-a\nrepo: https://example.test/acme/team-a.git\n'); + const config: LocalConfig = { + repo: { localPath: teamRepo, remote: 'https://example.test/acme/team-a.git' }, + username: 'tester', scope: 'project', projectRoot: root, additionalRoles: [], dataHome, + }; + await saveLocalConfigForScope(config); + return { root, dataHome, config }; +} + +/** A project whose config cannot be read. */ +async function brokenProject(): Promise<{ root: string; dataHome: string }> { + const root = path.join(tmp, 'project-broken'); + fs.mkdirSync(root); + execFileSync('git', ['init', '-q'], { cwd: root }); + const dataHome = await resolveProjectDataHome(root); + fs.mkdirSync(dataHome, { recursive: true }); + fs.writeFileSync(path.join(dataHome, 'config.yaml'), 'repo: [not: a, valid config\n'); + return { root, dataHome }; +} + +/** A directory no project claims: its sessions belong to the user scope. */ +function outsideAnyProject(): string { + const dir = path.join(tmp, 'scratch'); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +/** A Stop hook whose transcript declares `docId` recalled and used. */ +async function stop(cwd: string, docId: string): Promise { + const transcript = path.join(tmp, `transcript-${docId}.jsonl`); + fs.writeFileSync(transcript, JSON.stringify({ type: 'assistant', message: { content: [{ + type: 'text', + text: ` `, + }] } }) + '\n'); + for (const bgOnly of [false, true]) { + const stdinFile = path.join(tmp, `stdin-${Date.now()}-${Math.random()}.json`); + fs.writeFileSync(stdinFile, JSON.stringify({ session_id: `sid-${docId}`, cwd, hook_event_name: 'Stop', transcript_path: transcript })); + await hookDispatchCli('stop', 'claude', '*', { bgOnly, stdinFile }); + } +} + +async function feedbackIn(cwd: string, docId: string): Promise { + process.chdir(cwd); + try { + await recallFeedback({ positive: docId }); + } finally { + process.chdir(originalCwd); + } +} + +/** A team's `votes/` on its reports checkout. */ +const reportsVotesDir = (config: LocalConfig) => path.join(path.dirname(config.repo.localPath), 'reports-wt', 'votes'); + +/** Upvotes per doc in the member's file on a team's reports checkout. */ +function teamVotes(config: LocalConfig): Record { + const file = path.join(reportsVotesDir(config), 'tester.yaml'); + if (!fs.existsSync(file)) return {}; + const parsed = YAML.parse(fs.readFileSync(file, 'utf-8')) as UserVotesV2; + return Object.fromEntries(Object.entries(parsed.votes).map(([doc, entry]) => [doc, entry.upvoted_count])); +} + +async function report(config: LocalConfig): Promise { + await reportUsageToTeam(config.repo.localPath, config.username, { skipTruncate: true, selfConfig: config }); +} + +/** What an earlier release leaves in the shared directory: a delta no scope may push. */ +function sharedPendingVote(docId: string): void { + const dir = path.join(teamaiHome(), 'votes'); + fs.mkdirSync(dir, { recursive: true }); + const votes: UserVotesV2 = { + version: 2, + votes: { [docId]: { recalled_count: 1, upvoted_count: 1, last_recalled_at: '2026-01-01T00:00:00.000Z' } }, + deltas: { [docId]: { recalled_delta: 1, upvoted_delta: 1 } }, + }; + fs.writeFileSync(path.join(dir, 'tester.yaml'), YAML.stringify(votes)); +} + +describe('votes stay with the scope they were cast in (#787)', () => { + it('a vote cast in project A never reaches the user-scope team', async () => { + const user = userScope(); + const a = await projectA(); + + await feedbackIn(a.root, 'doc-a'); + await stop(outsideAnyProject(), 'doc-u'); + await report(user); + await report(a.config); + + expect(teamVotes(user)).toEqual({ 'doc-u': 1 }); + expect(teamVotes(a.config)).toEqual({ 'doc-a': 1 }); + }); + + it('a vote cast in the user scope never reaches project A\'s team', async () => { + const user = userScope(); + const a = await projectA(); + + await feedbackIn(outsideAnyProject(), 'doc-u'); + await stop(a.root, 'doc-a'); + await report(a.config); + await report(user); + + expect(teamVotes(a.config)).toEqual({ 'doc-a': 1 }); + expect(teamVotes(user)).toEqual({ 'doc-u': 1 }); + }); + + it('votes pending in the shared directory before the upgrade are pushed by no scope', async () => { + const user = userScope(); + const a = await projectA(); + sharedPendingVote('doc-before-upgrade'); + + await stop(a.root, 'doc-a'); + await stop(outsideAnyProject(), 'doc-u'); + await report(a.config); + await report(user); + + expect(teamVotes(a.config)).toEqual({ 'doc-a': 1 }); + expect(teamVotes(user)).toEqual({ 'doc-u': 1 }); + }); + + it('what an earlier release writes after a rollback is not pushed after re-upgrading', async () => { + const user = userScope(); + const a = await projectA(); + await feedbackIn(a.root, 'doc-a'); + // Rolled back: the earlier release records into the shared directory again. + sharedPendingVote('doc-during-rollback'); + + await report(user); + await report(a.config); + + expect(teamVotes(user)).toEqual({}); + expect(teamVotes(a.config)).toEqual({ 'doc-a': 1 }); + }); + + it('recall feedback records into the votes of the scope of its cwd', async () => { + userScope(); + const a = await projectA(); + + await feedbackIn(a.root, 'doc-a'); + await feedbackIn(outsideAnyProject(), 'doc-u'); + + const upvoted = (file: string) => Object.keys((YAML.parse(fs.readFileSync(file, 'utf-8')) as UserVotesV2).votes); + expect(upvoted(path.join(a.dataHome, 'votes', 'tester.yaml'))).toEqual(['doc-a']); + expect(upvoted(path.join(teamaiHome(), 'user-votes', 'tester.yaml'))).toEqual(['doc-u']); + expect(fs.existsSync(path.join(teamaiHome(), 'votes'))).toBe(false); + }); + + it('recall feedback in a project whose config cannot be read records nothing, not even in the user scope', async () => { + userScope(); + const { root, dataHome } = await brokenProject(); + + await feedbackIn(root, 'doc-x'); + + expect(process.exitCode).toBe(1); + expect(fs.existsSync(path.join(teamaiHome(), 'user-votes'))).toBe(false); + expect(fs.existsSync(path.join(dataHome, 'votes'))).toBe(false); + }); + + it('the vote view reads the votes of the scope of its cwd', async () => { + const user = userScope(); + const a = await projectA(); + + process.chdir(a.root); + expect((await resolveVizRoot({})).votesDir).toBe(reportsVotesDir(a.config)); + process.chdir(outsideAnyProject()); + expect((await resolveVizRoot({})).votesDir).toBe(reportsVotesDir(user)); + }); + + it('the vote view in a project whose config cannot be read shows no other scope\'s votes and names the file', async () => { + userScope(); + const { root, dataHome } = await brokenProject(); + + process.chdir(root); + + await expect(resolveVizRoot({})).rejects.toThrow(`${path.join(dataHome, 'config.yaml')}`); + }); + + it('with no scope set up, the local vote view reads the user scope\'s own directory, not the shared one', async () => { + process.chdir(outsideAnyProject()); + + expect((await resolveVizRoot({})).votesDir).toBe(path.join(teamaiHome(), 'user-votes')); + }); +}); diff --git a/src/__tests__/votes.test.ts b/src/__tests__/votes.test.ts index b826210b..ca591f9e 100644 --- a/src/__tests__/votes.test.ts +++ b/src/__tests__/votes.test.ts @@ -347,10 +347,9 @@ describe('syncVotesToTeam', () => { describe('recallFeedback', () => { beforeEach(() => { - vi.doMock('../config.js', () => ({ - autoDetectInit: () => Promise.resolve({ - localConfig: { username: 'testuser', repo: { localPath: tmpDir } }, - }), + vi.doMock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + resolveConfigForDir: () => Promise.resolve({ username: 'testuser', scope: 'user', repo: { localPath: tmpDir } }), })); }); @@ -359,7 +358,7 @@ describe('recallFeedback', () => { }); it('positive increments upvoted_count', async () => { - const votesDir = path.join(tmpDir, '.teamai', 'votes'); + const votesDir = path.join(tmpDir, '.teamai', 'user-votes'); fs.mkdirSync(votesDir, { recursive: true }); await incrementRecalled(path.join(votesDir, 'testuser.yaml'), ['doc-a']); @@ -371,7 +370,7 @@ describe('recallFeedback', () => { }); it('negative decrements upvoted_count (floor at 0)', async () => { - const votesDir = path.join(tmpDir, '.teamai', 'votes'); + const votesDir = path.join(tmpDir, '.teamai', 'user-votes'); fs.mkdirSync(votesDir, { recursive: true }); await incrementRecalled(path.join(votesDir, 'testuser.yaml'), ['doc-b']); @@ -383,7 +382,7 @@ describe('recallFeedback', () => { }); it('negative on missing doc warns without crashing', async () => { - const votesDir = path.join(tmpDir, '.teamai', 'votes'); + const votesDir = path.join(tmpDir, '.teamai', 'user-votes'); fs.mkdirSync(votesDir, { recursive: true }); // Should not throw @@ -391,7 +390,7 @@ describe('recallFeedback', () => { }); it('negative deletes last_upvoted_at when upvoted_count reaches 0', async () => { - const votesDir = path.join(tmpDir, '.teamai', 'votes'); + const votesDir = path.join(tmpDir, '.teamai', 'user-votes'); fs.mkdirSync(votesDir, { recursive: true }); const votePath = path.join(votesDir, 'testuser.yaml'); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 30fe1211..5b9e785f 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -359,8 +359,8 @@ const votesSyncHandler: HookHandler = { const { incrementUpvoted, syncVotesToTeam } = await import('./votes.js'); const voteData = await parseTranscriptForVotes(transcriptPath); - const { getUserVotesDir } = await import('./types.js'); - const votesDir = getUserVotesDir(); + const { getVotesDir } = await import('./types.js'); + const votesDir = getVotesDir(localConfig); const votePath = path.join(votesDir, `${localConfig.username}.yaml`); // Only count upvotes for docs actually recalled this session, to avoid crediting hallucinated/distractor doc-ids diff --git a/src/recall.ts b/src/recall.ts index 2be2849a..32327355 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -6,12 +6,11 @@ import type { SearchResult } from './utils/search-index.js'; import { readFileSafe, ensureDir, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import type { GlobalOptions, SearchIndex, LocalConfig } from './types.js'; -import { getDataHome, getTeamaiHome } from './types.js'; +import { getDataHome, getTeamaiHome, getVotesDir } from './types.js'; import { queryCodeKnowledge } from './code-knowledge-recall.js'; import type { CodeKnowledgeResult, SourceAnchor } from './code-knowledge-recall.js'; import { recordRecallQuality } from './recall-quality.js'; import { deriveSessionId } from './utils/session-id.js'; -import { getUserHome } from './utils/home.js'; /** Relevance threshold for codebase graph hits. * These are log-compressed to a bounded [0,10] range (see `queryCodeKnowledge` @@ -114,11 +113,6 @@ export function computeIdfBaseline(indexes: SearchIndex[]): number { return Math.log((maxEntries + 1) / 2) + 1; } -/** Resolve votes dir dynamically (respects HOME changes in tests). */ -function getVotesLocalDir(): string { - return path.join(getUserHome(), '.teamai', 'votes'); -} - /** Search result with scope label for merged output. */ interface ScopedSearchResult extends SearchResult { scope?: 'user' | 'project'; @@ -149,10 +143,9 @@ interface ScopedSearchResult extends SearchResult { // │ └─ ~/.teamai/sessions/-recall-cache.json // │ (read by contribute-check's knowledge-gap detection) // │ -// └─ autoUpvote(results, username, repoPath) -// ├─ write ~/.teamai/votes/.yaml (local) -// └─ copy to /votes/.yaml -// (pushed on next pull via auto-report) +// └─ autoUpvote(results, config) +// └─ write getVotesDir(config)/.yaml (local, per scope) +// (pushed by that scope's next report) // /** @@ -250,15 +243,14 @@ export function formatResults(results: ScopedSearchResult[]): string { */ export async function autoUpvote( results: SearchResult[], - username: string, - _repoPath: string, + config: LocalConfig, ): Promise { if (results.length === 0) return; try { const { incrementRecalled } = await import('./votes.js'); - const votesDir = getVotesLocalDir(); - const localVotePath = path.join(votesDir, `${username}.yaml`); + const votesDir = getVotesDir(config); + const localVotePath = path.join(votesDir, `${config.username}.yaml`); await ensureDir(votesDir); const docIds = results.map((r) => r.entry.filename.replace(/\.md$/i, '')); @@ -575,10 +567,10 @@ export async function recall( const output = formatResults(topResults); process.stdout.write(output + '\n'); - // Auto-upvote (best-effort, non-blocking for dry-run). Vote deltas currently - // share one HOME-level store, so layered project mode records only active - // project results. Inherited user hits remain read-only to avoid attributing - // their votes to the project team during the next report. + // Auto-upvote (best-effort, non-blocking for dry-run). Each scope keeps its + // own votes (#787); layered project mode records only active project results, + // since the session belongs to the project. Inherited user hits remain + // read-only. if (!options.dryRun) { const voteScopes = projectConfig ? scopeIndexes.filter((scopeInfo) => scopeInfo.scope === 'project') @@ -587,7 +579,7 @@ export async function recall( const scopeResults = topResults.filter(r => r.scope === scopeInfo.scope); if (scopeResults.length > 0) { try { - await autoUpvote(scopeResults, scopeInfo.config.username, scopeInfo.config.repo.localPath); + await autoUpvote(scopeResults, scopeInfo.config); } catch (e) { log.error(`autoUpvote skipped for ${scopeInfo.scope}: ${(e as Error).message}`); } diff --git a/src/skill-content.ts b/src/skill-content.ts index 31e4c242..f552c7f1 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -163,7 +163,7 @@ async function gateFor(team: TeamDetection): Promise { } /** The first line of an error, without the colon that introduces its code frame. */ -function firstLine(text: string): string { +export function firstLine(text: string): string { return text.trim().split('\n')[0].trim().replace(/:$/, ''); } diff --git a/src/team-push.ts b/src/team-push.ts index fbb91efe..28f49b5f 100644 --- a/src/team-push.ts +++ b/src/team-push.ts @@ -14,7 +14,7 @@ import { import { writeFile, readFileSafe, ensureDir, pathExists, readJson, writeJson } from './utils/fs.js'; import { log } from './utils/logger.js'; import type { UserStats, UserInterventionStats, SessionMetrics, TokenUsage, DashboardEvent, LocalConfig } from './types.js'; -import { getUserVotesDir, emptyTokenUsage, addTokenUsage, usesBranchWorktree } from './types.js'; +import { getVotesDir, emptyTokenUsage, addTokenUsage, usesBranchWorktree } from './types.js'; import { getUserHome } from './utils/home.js'; import { aggregateDailySessions, @@ -52,7 +52,7 @@ interface PromptTokenDelta { // [read scope usage file] ─has events?─▶ merge stats // │ │ // ▼ ▼ -// [stage pending votes from ~/.teamai/votes/] [write stats/.yaml] +// [stage pending votes from scope votes dir] [write stats/.yaml] // │ │ // ▼ ◄────────────────────────────────────────┘ // [anything to push?] ──no──▶ SKIP @@ -405,6 +405,8 @@ export async function reportUsageToTeam( try { // This scope's own skill usage (#748); a caller without a scope reports none. const events = reportsConfig ? await readUsageEvents(reportsConfig) : []; + // This scope's own votes (#787), likewise. + const votesDir = reportsConfig ? getVotesDir(reportsConfig) : undefined; const filesToPush: string[] = []; // Fold the local dashboard event log into per-session metrics once, then derive @@ -483,9 +485,9 @@ export async function reportUsageToTeam( // Always stage pending local votes (V2 delta-aware merge) try { - if (await pathExists(getUserVotesDir())) { + if (votesDir && await pathExists(votesDir)) { const { syncVotesToTeam } = await import('./votes.js'); - const synced = await syncVotesToTeam(writeRoot, username, getUserVotesDir()); + const synced = await syncVotesToTeam(writeRoot, username, votesDir); if (synced) { filesToPush.push(`votes/${username}.yaml`); } @@ -499,9 +501,9 @@ export async function reportUsageToTeam( // must not abandon the success bookkeeping below. if (useReportsBranch && reportsConfig) { let hasVotes = false; - if (!hasStats && await pathExists(getUserVotesDir())) { + if (!hasStats && votesDir && await pathExists(votesDir)) { const { hasPendingVoteDeltas } = await import('./votes.js'); - hasVotes = await hasPendingVoteDeltas(getUserVotesDir(), username); + hasVotes = await hasPendingVoteDeltas(votesDir, username); } if (!hasStats && !hasVotes) { log.debug('No usage events or votes to report'); diff --git a/src/types.ts b/src/types.ts index 1252417f..4600c01e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1665,9 +1665,24 @@ export function getUserLearningsDir(): string { export function getUserSearchIndexPath(): string { return path.join(getTeamaiHomeDir(), 'search-index.json'); } -/** User-scope votes dir, `~/.teamai/votes`. Evaluated at call time. */ +/** + * User-scope votes dir, `~/.teamai/user-votes`. Evaluated at call time. Not + * `~/.teamai/votes`: every scope used to record there, and an earlier release + * still does after a rollback, so what it holds names no project. It is never + * read, so no scope can push it to its team (#787). + */ export function getUserVotesDir(): string { - return path.join(getTeamaiHomeDir(), 'votes'); + return path.join(getTeamaiHomeDir(), 'user-votes'); +} +/** + * The local votes dir of one scope: `/votes`, so each scope pushes + * only the votes cast where it is set up (#787); the user scope's is + * getUserVotesDir(). + */ +export function getVotesDir(config: LocalConfig): string { + const dataHome = getDataHome(config); + if (path.resolve(dataHome) !== path.resolve(getTeamaiHomeDir())) return path.join(dataHome, 'votes'); + return getUserVotesDir(); } export const CultureCompanySchema = z.object({ diff --git a/src/viz.ts b/src/viz.ts index f61caf28..9845601c 100644 --- a/src/viz.ts +++ b/src/viz.ts @@ -11,7 +11,7 @@ import os from 'node:os'; import { loadIndex, buildIndex } from './utils/search-index.js'; import { loadUserVotes } from './votes.js'; -import { detectProjectConfig, loadLocalConfig } from './config.js'; +import { BROKEN_CONFIG_ADVICE, findUnreadableProjectConfig, resolveConfigForDir } from './config.js'; import { getTeamaiHomeDir, getUserVotesDir, @@ -133,7 +133,8 @@ interface VizPaths { /** * Resolve the data root and derivative directories for the viz pipeline. * - * Precedence: explicit `--repo` flag → project-scope config → user config → ~/.teamai fallback. + * Precedence: explicit `--repo` flag → the cwd's scope (resolveConfigForDir: project, else user) + * → ~/.teamai fallback. An unreadable project config throws rather than fall back. * `config.repo.kind` is 'git' | 'http' | 'self'. In self mode, votes/stats live in the reports * worktree (ensureReportsWorktree), while learnings remain in the local ~/.teamai tree. */ @@ -156,7 +157,16 @@ export async function resolveVizRoot(opts: VizOptions): Promise { }; } - const config = opts.config !== undefined ? opts.config : await detectProjectConfig() ?? await loadLocalConfig(); + const config = opts.config !== undefined ? opts.config : await resolveConfigForDir(); + if (config === null && opts.config === undefined) { + // A broken project config is no scope: the local fallback would show the + // user scope's votes and learnings as this project's (#787). + const unreadable = await findUnreadableProjectConfig(); + if (unreadable) { + const { firstLine } = await import('./skill-content.js'); + throw new Error(`${firstLine(unreadable)}. ${BROKEN_CONFIG_ADVICE}`); + } + } if (config?.repo?.localPath) { const { usesBranchWorktree } = await import('./types.js'); diff --git a/src/votes.ts b/src/votes.ts index 79a0945f..1b26b435 100644 --- a/src/votes.ts +++ b/src/votes.ts @@ -200,11 +200,21 @@ export async function syncVotesToTeam( * Record manual feedback for a recalled document. */ export async function recallFeedback(opts: { positive?: string; negative?: string }): Promise { - const { autoDetectInit } = await import('./config.js'); - const { localConfig } = await autoDetectInit(); - const { username } = localConfig; - const { getUserVotesDir } = await import('./types.js'); - const votePath = path.join(getUserVotesDir(), `${username}.yaml`); + const { resolveConfigForDir, findUnreadableProjectConfig, BROKEN_CONFIG_ADVICE } = await import('./config.js'); + // The votes of the cwd's scope (#787). An unreadable project config falls + // back to no other scope: the feedback would reach that scope's team. + const localConfig = await resolveConfigForDir(); + if (!localConfig) { + const unreadable = await findUnreadableProjectConfig(); + const { firstLine } = await import('./skill-content.js'); + log.error(unreadable + ? `No feedback recorded: ${firstLine(unreadable)}. ${BROKEN_CONFIG_ADVICE}` + : 'No feedback recorded: teamai is not set up here. Run `teamai init` first.'); + process.exitCode = 1; + return; + } + const { getVotesDir } = await import('./types.js'); + const votePath = path.join(getVotesDir(localConfig), `${localConfig.username}.yaml`); if (opts.positive) { await incrementUpvoted(votePath, [opts.positive]); From db744bca4db2ac22d9f71c72d28dbf3c30e22890 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 24 Sep 2026 11:44:14 +0200 Subject: [PATCH 2/6] fix(votes): address pre-review findings (#787) - recall search: in a project whose config cannot be read, detection falls back to another scope; record no recalled count there, so the vote cannot reach that scope's team. Which scope the search itself uses stays #796's. - recall feedback: with no project config and an empty or invalid user config, name the file and the fix (requireInit's error) instead of "not set up here". - CHANGELOG: note the recall search case; the shared directory is never read or pushed "by this release" (an earlier release still pushes it). - Design doc: getUserVotesDir() is the exception to "getters unchanged". --- CHANGELOG.md | 2 +- docs/designs/data-directory-layout.md | 7 +++--- src/__tests__/votes-scope.test.ts | 34 +++++++++++++++++++++++++++ src/recall.ts | 8 +++++-- src/votes.ts | 19 +++++++++++---- 5 files changed, 59 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf7f652..e219df85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,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/.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 (`/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. In a project whose config cannot be read, `teamai recall feedback` records nothing and exits 1 instead of recording into the user scope, 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 they are never read or pushed; the team's `votes/.yaml` keeps its format (for [#787](https://github.com/Tencent/teamai-cli/issues/787)). +- Votes stay with the team of the scope they were cast in. Every scope used to record into one `~/.teamai/votes/.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 (`/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. 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/.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 (`/usage.jsonl`: the project partition, or `/.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 `, since the scan needs it to tell which namespaces are the member's. diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 3ac7e5e4..6cc350cd 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -306,7 +306,8 @@ 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 @@ -315,8 +316,8 @@ an earlier release still writes after a rollback; the shared file is never read. Local votes followed for the same reason (#787): `/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 is likewise an A2 singleton -(events carry `cwd`/`sessionId`); "two projects' events don't mix" is satisfied by +is never read, and its pending deltas are not pushed. The dashboard is likewise +an A2 singleton (events carry `cwd`/`sessionId`); "two projects' events don't mix" is satisfied by `getEventsPath()` reading `HOME` at call time, not by per-project dirs. **`anchor` on save.** Previously only migration wrote a partition's `anchor` diff --git a/src/__tests__/votes-scope.test.ts b/src/__tests__/votes-scope.test.ts index b6c67009..c1ad9710 100644 --- a/src/__tests__/votes-scope.test.ts +++ b/src/__tests__/votes-scope.test.ts @@ -40,6 +40,7 @@ const { resolveProjectDataHome, saveLocalConfigForScope } = await import('../con const { reportUsageToTeam } = await import('../team-push.js'); const { recallFeedback } = await import('../votes.js'); const { resolveVizRoot } = await import('../viz.js'); +const { recall } = await import('../recall.js'); let tmp: string; let originalHome: string | undefined; @@ -239,6 +240,39 @@ describe('votes stay with the scope they were cast in (#787)', () => { expect(fs.existsSync(path.join(dataHome, 'votes'))).toBe(false); }); + it('recall feedback with an empty user config names the file, not "not set up"', async () => { + fs.mkdirSync(teamaiHome(), { recursive: true }); + fs.writeFileSync(path.join(teamaiHome(), 'config.yaml'), ''); + const printed: unknown[] = []; + const errors = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { printed.push(...args); }); + try { + await feedbackIn(outsideAnyProject(), 'doc-u'); + } finally { + errors.mockRestore(); + } + + expect(process.exitCode).toBe(1); + expect(printed.join('\n')).toContain(`${path.join(teamaiHome(), 'config.yaml')} could not be read: it is empty`); + }); + + it('a recall search in a project whose config cannot be read records no recalled count in the user scope', async () => { + const user = userScope(); + const learnings = path.join(user.repo.localPath, 'learnings'); + fs.mkdirSync(learnings, { recursive: true }); + fs.writeFileSync(path.join(learnings, 'api-timeout.md'), '---\ntitle: "API timeout fix"\nauthor: tester\ndate: 2026-05-01\n---\n\nRaise the API timeout.\n'); + const { root } = await brokenProject(); + + process.chdir(root); + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await recall('api timeout', {}); + } finally { + stdout.mockRestore(); + } + + expect(fs.existsSync(path.join(teamaiHome(), 'user-votes'))).toBe(false); + }); + it('the vote view reads the votes of the scope of its cwd', async () => { const user = userScope(); const a = await projectA(); diff --git a/src/recall.ts b/src/recall.ts index 32327355..76b4786c 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -414,8 +414,12 @@ export async function recall( const scopeIndexes: Array<{ index: SearchIndex; scope: 'user' | 'project'; config: LocalConfig; learningsBase: string }> = []; let projectConfig: LocalConfig | null = null; + // A project config that cannot be read makes detection fall back to another + // scope; searching there is #796's, but its votes must not reach that scope's + // team (#787). + let projectUnreadable = false; try { - projectConfig = await detectProjectConfig(); + projectConfig = await detectProjectConfig(undefined, () => { projectUnreadable = true; }); } catch { log.debug('recall: project scope detection failed'); } @@ -571,7 +575,7 @@ export async function recall( // own votes (#787); layered project mode records only active project results, // since the session belongs to the project. Inherited user hits remain // read-only. - if (!options.dryRun) { + if (!options.dryRun && !projectUnreadable) { const voteScopes = projectConfig ? scopeIndexes.filter((scopeInfo) => scopeInfo.scope === 'project') : scopeIndexes; diff --git a/src/votes.ts b/src/votes.ts index 1b26b435..98317dd7 100644 --- a/src/votes.ts +++ b/src/votes.ts @@ -200,16 +200,25 @@ export async function syncVotesToTeam( * Record manual feedback for a recalled document. */ export async function recallFeedback(opts: { positive?: string; negative?: string }): Promise { - const { resolveConfigForDir, findUnreadableProjectConfig, BROKEN_CONFIG_ADVICE } = await import('./config.js'); + const { resolveConfigForDir, findUnreadableProjectConfig, requireInit, BROKEN_CONFIG_ADVICE } = await import('./config.js'); // The votes of the cwd's scope (#787). An unreadable project config falls // back to no other scope: the feedback would reach that scope's team. const localConfig = await resolveConfigForDir(); if (!localConfig) { const unreadable = await findUnreadableProjectConfig(); - const { firstLine } = await import('./skill-content.js'); - log.error(unreadable - ? `No feedback recorded: ${firstLine(unreadable)}. ${BROKEN_CONFIG_ADVICE}` - : 'No feedback recorded: teamai is not set up here. Run `teamai init` first.'); + let reason = 'teamai is not set up here. Run `teamai init` first.'; + if (unreadable) { + const { firstLine } = await import('./skill-content.js'); + reason = `${firstLine(unreadable)}. ${BROKEN_CONFIG_ADVICE}`; + } else { + // No user config, or one that cannot be read: requireInit says which. + try { + await requireInit(); + } catch (e) { + reason = e instanceof Error ? e.message : String(e); + } + } + log.error(`No feedback recorded: ${reason}`); process.exitCode = 1; return; } From 0094f0767a69fed5f09b785a3a0a3b20c714f76c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 24 Sep 2026 11:59:32 +0200 Subject: [PATCH 3/6] fix(votes): address second pre-review round (#787) - recall feedback: name an unusable user config through throwMissingOrInvalid (now exported) instead of re-running requireInit, which loaded the config twice and printed its parse error twice. - recall search: a project detection that throws is treated like an unreadable config, so no recalled count lands in the fallback scope. - The recall-search test now asserts the search ran and that neither the shared directory nor the broken project's votes/ was written; it fails on origin/main too. - Design doc: re-wrap the edited paragraph. --- docs/designs/data-directory-layout.md | 9 +++++---- src/__tests__/votes-scope.test.ts | 8 ++++++-- src/config.ts | 2 +- src/recall.ts | 7 ++++--- src/votes.ts | 16 +++++++--------- 5 files changed, 23 insertions(+), 19 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 6cc350cd..008cfb1b 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -306,8 +306,8 @@ 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, except `getUserVotesDir()` -(below). 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 @@ -317,8 +317,9 @@ read. Local votes followed for the same reason (#787): `/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 is likewise -an A2 singleton (events carry `cwd`/`sessionId`); "two projects' events don't mix" is satisfied by -`getEventsPath()` reading `HOME` at call time, not by per-project dirs. +an A2 singleton (events carry `cwd`/`sessionId`); "two projects' events don't +mix" is satisfied by `getEventsPath()` reading `HOME` at call time, not by +per-project dirs. **`anchor` on save.** Previously only migration wrote a partition's `anchor` reverse-lookup file, so freshly-init'd partitions had none. `saveLocalConfigForScope` diff --git a/src/__tests__/votes-scope.test.ts b/src/__tests__/votes-scope.test.ts index c1ad9710..30bd613d 100644 --- a/src/__tests__/votes-scope.test.ts +++ b/src/__tests__/votes-scope.test.ts @@ -260,17 +260,21 @@ describe('votes stay with the scope they were cast in (#787)', () => { const learnings = path.join(user.repo.localPath, 'learnings'); fs.mkdirSync(learnings, { recursive: true }); fs.writeFileSync(path.join(learnings, 'api-timeout.md'), '---\ntitle: "API timeout fix"\nauthor: tester\ndate: 2026-05-01\n---\n\nRaise the API timeout.\n'); - const { root } = await brokenProject(); + const { root, dataHome } = await brokenProject(); process.chdir(root); - const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + let out = ''; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { out += String(chunk); return true; }); try { await recall('api timeout', {}); } finally { stdout.mockRestore(); } + expect(out).toContain('API timeout fix'); expect(fs.existsSync(path.join(teamaiHome(), 'user-votes'))).toBe(false); + expect(fs.existsSync(path.join(teamaiHome(), 'votes'))).toBe(false); + expect(fs.existsSync(path.join(dataHome, 'votes'))).toBe(false); }); it('the vote view reads the votes of the scope of its cwd', async () => { diff --git a/src/config.ts b/src/config.ts index 40845014..db2eec16 100644 --- a/src/config.ts +++ b/src/config.ts @@ -165,7 +165,7 @@ export async function requireInit(): Promise { * parse or validation error, but not a file that is empty or cannot be opened, * so those two are named here. */ -async function throwMissingOrInvalid(configPath: string): Promise { +export async function throwMissingOrInvalid(configPath: string): Promise { if (!(await pathExists(configPath))) { throw new NotInitializedError('teamai is not initialized. Run `teamai init` first.'); } diff --git a/src/recall.ts b/src/recall.ts index 76b4786c..80446a97 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -414,13 +414,14 @@ export async function recall( const scopeIndexes: Array<{ index: SearchIndex; scope: 'user' | 'project'; config: LocalConfig; learningsBase: string }> = []; let projectConfig: LocalConfig | null = null; - // A project config that cannot be read makes detection fall back to another - // scope; searching there is #796's, but its votes must not reach that scope's - // team (#787). + // A project config that cannot be read (or checked) makes detection fall back + // to another scope; searching there is #796's, but its votes must not reach + // that scope's team (#787). let projectUnreadable = false; try { projectConfig = await detectProjectConfig(undefined, () => { projectUnreadable = true; }); } catch { + projectUnreadable = true; log.debug('recall: project scope detection failed'); } diff --git a/src/votes.ts b/src/votes.ts index 98317dd7..e9324784 100644 --- a/src/votes.ts +++ b/src/votes.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import YAML from 'yaml'; import type { UserVotes, UserVotesV2, VoteEntryV2 } from './types.js'; -import { readFileSafe, writeFile, ensureDir } from './utils/fs.js'; +import { readFileSafe, writeFile, ensureDir, expandHome } from './utils/fs.js'; import { log } from './utils/logger.js'; /** @@ -200,23 +200,21 @@ export async function syncVotesToTeam( * Record manual feedback for a recalled document. */ export async function recallFeedback(opts: { positive?: string; negative?: string }): Promise { - const { resolveConfigForDir, findUnreadableProjectConfig, requireInit, BROKEN_CONFIG_ADVICE } = await import('./config.js'); + const { resolveConfigForDir, findUnreadableProjectConfig, throwMissingOrInvalid, BROKEN_CONFIG_ADVICE } = await import('./config.js'); // The votes of the cwd's scope (#787). An unreadable project config falls // back to no other scope: the feedback would reach that scope's team. const localConfig = await resolveConfigForDir(); if (!localConfig) { const unreadable = await findUnreadableProjectConfig(); - let reason = 'teamai is not set up here. Run `teamai init` first.'; + let reason: string; if (unreadable) { const { firstLine } = await import('./skill-content.js'); reason = `${firstLine(unreadable)}. ${BROKEN_CONFIG_ADVICE}`; } else { - // No user config, or one that cannot be read: requireInit says which. - try { - await requireInit(); - } catch (e) { - reason = e instanceof Error ? e.message : String(e); - } + // No user config, or one that cannot be read: say which. + const { getUserConfigPath } = await import('./types.js'); + reason = await throwMissingOrInvalid(expandHome(getUserConfigPath())) + .catch((e: unknown) => e instanceof Error ? e.message : String(e)); } log.error(`No feedback recorded: ${reason}`); process.exitCode = 1; From 70e45991328f6ba18dbfcf93ad700167cec031fa Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 24 Sep 2026 12:15:47 +0200 Subject: [PATCH 4/6] fix(votes): record recall votes from a deleted cwd in the user scope (#787) The previous commit treated any throw from recall's project detection as an unreadable project, including a cwd that no longer exists. Such a cwd holds no project and resolves to the user scope everywhere else (resolveConfigForDir, detectTeam), so its recalled counts belong there. Tests pin that case and the single parse-error line for an invalid user config in recall feedback. --- src/__tests__/votes-scope.test.ts | 34 +++++++++++++++++++++++++++++++ src/recall.ts | 7 +++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/__tests__/votes-scope.test.ts b/src/__tests__/votes-scope.test.ts index 30bd613d..0c3c860c 100644 --- a/src/__tests__/votes-scope.test.ts +++ b/src/__tests__/votes-scope.test.ts @@ -255,6 +255,40 @@ describe('votes stay with the scope they were cast in (#787)', () => { expect(printed.join('\n')).toContain(`${path.join(teamaiHome(), 'config.yaml')} could not be read: it is empty`); }); + it('recall feedback with an invalid user config prints its parse error once and names the file', async () => { + fs.mkdirSync(teamaiHome(), { recursive: true }); + fs.writeFileSync(path.join(teamaiHome(), 'config.yaml'), 'repo: [not: a, valid config\n'); + const printed: string[] = []; + const errors = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { printed.push(args.join(' ')); }); + try { + await feedbackIn(outsideAnyProject(), 'doc-u'); + } finally { + errors.mockRestore(); + } + + expect(process.exitCode).toBe(1); + expect(printed.filter((line) => line.includes('Invalid local config'))).toHaveLength(1); + expect(printed.join('\n')).toContain(`${path.join(teamaiHome(), 'config.yaml')} could not be read: it is not a valid teamai config`); + }); + + it('a recall search from a directory that no longer exists records into the user scope', async () => { + const user = userScope(); + const learnings = path.join(user.repo.localPath, 'learnings'); + fs.mkdirSync(learnings, { recursive: true }); + fs.writeFileSync(path.join(learnings, 'api-timeout.md'), '---\ntitle: "API timeout fix"\nauthor: tester\ndate: 2026-05-01\n---\n\nRaise the API timeout.\n'); + const gone = outsideAnyProject(); + process.chdir(gone); + fs.rmSync(gone, { recursive: true }); + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await recall('api timeout', {}); + } finally { + stdout.mockRestore(); + } + + expect(fs.existsSync(path.join(teamaiHome(), 'user-votes', 'tester.yaml'))).toBe(true); + }); + it('a recall search in a project whose config cannot be read records no recalled count in the user scope', async () => { const user = userScope(); const learnings = path.join(user.repo.localPath, 'learnings'); diff --git a/src/recall.ts b/src/recall.ts index 80446a97..ea4fe48c 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -420,8 +420,11 @@ export async function recall( let projectUnreadable = false; try { projectConfig = await detectProjectConfig(undefined, () => { projectUnreadable = true; }); - } catch { - projectUnreadable = true; + } catch (e) { + // A cwd that no longer exists holds no project: user scope, as in + // resolveConfigForDir. + const gone = typeof e === 'object' && e !== null && 'code' in e && e.code === 'ENOENT'; + if (!gone) projectUnreadable = true; log.debug('recall: project scope detection failed'); } From 7a1ca7a76d05e12828584e0daa912bc59a76cfd0 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 24 Sep 2026 12:25:53 +0200 Subject: [PATCH 5/6] fix(votes): address CI review findings (#787) - getVotesDir: a historical project-scoped ~/.teamai/config.yaml with no projectRoot (schema-valid, not backfilled) made getDataHome throw, so recall, feedback and the Stop hook recorded no vote. It lives in ~/.teamai, as recall and viz already treat it, so its votes go to the user scope's user-votes/. - git-native-memory design doc: the local votes path is user-votes/. --- docs/designs/git-native-memory.md | 2 +- src/__tests__/votes-scope.test.ts | 11 +++++++++++ src/types.ts | 5 ++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/designs/git-native-memory.md b/docs/designs/git-native-memory.md index d9b9d1fe..907115d8 100644 --- a/docs/designs/git-native-memory.md +++ b/docs/designs/git-native-memory.md @@ -39,7 +39,7 @@ │ ~/.teamai/ │ │ │ learnings/ (local copy)│◀────────┘ │ search-index.json │ - │ votes/.yaml │ + │ user-votes/.yaml │ └──────────┬───────────────┘ │ teamai recall "api timeout" diff --git a/src/__tests__/votes-scope.test.ts b/src/__tests__/votes-scope.test.ts index 0c3c860c..e24e3659 100644 --- a/src/__tests__/votes-scope.test.ts +++ b/src/__tests__/votes-scope.test.ts @@ -311,6 +311,17 @@ describe('votes stay with the scope they were cast in (#787)', () => { expect(fs.existsSync(path.join(dataHome, 'votes'))).toBe(false); }); + it('a historical project-scoped ~/.teamai/config.yaml without projectRoot records into the user scope', async () => { + const user = userScope(); + fs.writeFileSync(path.join(teamaiHome(), 'config.yaml'), YAML.stringify({ ...user, scope: 'project' })); + + await feedbackIn(outsideAnyProject(), 'doc-legacy-project'); + await stop(outsideAnyProject(), 'doc-u'); + + const votes = YAML.parse(fs.readFileSync(path.join(teamaiHome(), 'user-votes', 'tester.yaml'), 'utf-8')) as UserVotesV2; + expect(Object.keys(votes.votes).sort()).toEqual(['doc-legacy-project', 'doc-u']); + }); + it('the vote view reads the votes of the scope of its cwd', async () => { const user = userScope(); const a = await projectA(); diff --git a/src/types.ts b/src/types.ts index 4600c01e..6e859ec4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1677,9 +1677,12 @@ export function getUserVotesDir(): string { /** * The local votes dir of one scope: `/votes`, so each scope pushes * only the votes cast where it is set up (#787); the user scope's is - * getUserVotesDir(). + * getUserVotesDir(). A historical project-scoped `~/.teamai/config.yaml` with + * no projectRoot lives in ~/.teamai, as recall and viz treat it, so its votes + * are the user scope's. */ export function getVotesDir(config: LocalConfig): string { + if (!config.dataHome && config.scope === 'project' && !config.projectRoot) return getUserVotesDir(); const dataHome = getDataHome(config); if (path.resolve(dataHome) !== path.resolve(getTeamaiHomeDir())) return path.join(dataHome, 'votes'); return getUserVotesDir(); From 6e0c92c9397b917da480a30089f02f527ac07d54 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 24 Sep 2026 13:30:02 +0200 Subject: [PATCH 6/6] fix(votes): address CI review (#787) - recall feedback --negative counts the upvotes the scope's own team already holds (its reports checkout's votes/.yaml plus the deltas not yet pushed). A scope's file starts empty on upgrade, so a doc upvoted before it was rejected as not found, or as having no upvotes once a later recall counted it. The shared ~/.teamai/votes and other scopes' teams are never read. - The adoption judge (#723, merged meanwhile) recorded into and synced from the user scope's votes in every scope, which pushed the user scope's pending votes to the project's team. It uses the scope's votes like the Stop handler. - votes-scope tests: Stop transcripts prove adoption with a Read of the recalled file (#723), and the update.js mock keeps the real lock. --- CHANGELOG.md | 2 +- src/__tests__/votes-scope.test.ts | 101 +++++++++++++++++++++++++++--- src/hook-handlers.ts | 4 +- src/votes.ts | 17 ++++- 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d12d4ca0..82133316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +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/.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 (`/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. 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/.yaml` keeps its format (for [#787](https://github.com/Tencent/teamai-cli/issues/787)). +- Votes stay with the team of the scope they were cast in. Every scope used to record into one `~/.teamai/votes/.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 (`/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/.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 (`/usage.jsonl`: the project partition, or `/.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 `, since the scan needs it to tell which namespaces are the member's. diff --git a/src/__tests__/votes-scope.test.ts b/src/__tests__/votes-scope.test.ts index e24e3659..ce7805b5 100644 --- a/src/__tests__/votes-scope.test.ts +++ b/src/__tests__/votes-scope.test.ts @@ -17,7 +17,12 @@ vi.mock('node:child_process', async (importOriginal) => ({ spawn: vi.fn(() => ({ on: vi.fn(), stdin: { on: vi.fn(), end: vi.fn((_: string, done: () => void) => done()) }, unref: vi.fn() })), })); vi.mock('../pull.js', () => ({ pull: vi.fn(async () => undefined) })); -vi.mock('../update.js', () => ({ doUpdate: vi.fn(async () => undefined) })); +vi.mock('../update.js', async (importOriginal) => ({ + ...(await importOriginal()), + doUpdate: vi.fn(async () => undefined), +})); +// The opt-in adoption judge (#723) asks a local CLI; here it adopts every candidate. +vi.mock('../votes-judge.js', () => ({ judgeAdoption: vi.fn(async (_reply: string, ids: string[]) => ids) })); vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: vi.fn(async () => null) })); // Each team's reports checkout sits beside its clone, where getReportsDir puts it; // a write lands there instead of being pushed. @@ -111,13 +116,20 @@ function outsideAnyProject(): string { return dir; } -/** A Stop hook whose transcript declares `docId` recalled and used. */ -async function stop(cwd: string, docId: string): Promise { +/** A Stop hook whose transcript recalls `docId` and, unless `opened` is false, opens its file. */ +async function stop(cwd: string, docId: string, opened = true): Promise { + const doc = path.join(tmp, `${docId}.md`); + fs.writeFileSync(doc, `# ${docId}\n`); const transcript = path.join(tmp, `transcript-${docId}.jsonl`); - fs.writeFileSync(transcript, JSON.stringify({ type: 'assistant', message: { content: [{ - type: 'text', - text: ` `, - }] } }) + '\n'); + fs.writeFileSync(transcript, [ + JSON.stringify({ type: 'assistant', message: { content: [{ + type: 'text', + text: `--- [teamai:recall:start] ---\nFile: ${doc}\n--- [teamai:recall:end] ---`, + }] } }), + ...(opened ? [JSON.stringify({ type: 'assistant', message: { content: [{ + type: 'tool_use', name: 'Read', input: { file_path: doc }, + }] } })] : []), + ].join('\n') + '\n'); for (const bgOnly of [false, true]) { const stdinFile = path.join(tmp, `stdin-${Date.now()}-${Math.random()}.json`); fs.writeFileSync(stdinFile, JSON.stringify({ session_id: `sid-${docId}`, cwd, hook_event_name: 'Stop', transcript_path: transcript })); @@ -145,6 +157,28 @@ function teamVotes(config: LocalConfig): Record { return Object.fromEntries(Object.entries(parsed.votes).map(([doc, entry]) => [doc, entry.upvoted_count])); } +async function negativeIn(cwd: string, docId: string): Promise { + process.chdir(cwd); + try { + await recallFeedback({ negative: docId }); + } finally { + process.chdir(originalCwd); + } +} + +/** A team's file on its reports checkout, as the last sync left it. */ +function teamFile(config: LocalConfig, upvotes: Record): void { + fs.mkdirSync(reportsVotesDir(config), { recursive: true }); + const votes: UserVotesV2 = { + version: 2, + votes: Object.fromEntries(Object.entries(upvotes).map(([doc, n]) => [doc, { + recalled_count: n, upvoted_count: n, last_recalled_at: '2026-01-01T00:00:00.000Z', last_upvoted_at: '2026-01-01T00:00:00.000Z', + }])), + deltas: {}, + }; + fs.writeFileSync(path.join(reportsVotesDir(config), 'tester.yaml'), YAML.stringify(votes)); +} + async function report(config: LocalConfig): Promise { await reportUsageToTeam(config.repo.localPath, config.username, { skipTruncate: true, selfConfig: config }); } @@ -188,6 +222,23 @@ describe('votes stay with the scope they were cast in (#787)', () => { expect(teamVotes(user)).toEqual({ 'doc-u': 1 }); }); + it('an upvote the adoption judge records in project A reaches only project A\'s team', async () => { + const user = userScope(); + const a = await projectA(); + await feedbackIn(outsideAnyProject(), 'doc-u'); + vi.stubEnv('TEAMAI_UPVOTE_JUDGE', '1'); + try { + await stop(a.root, 'doc-judged', false); + } finally { + vi.unstubAllEnvs(); + } + await report(user); + await report(a.config); + + expect(teamVotes(user)).toEqual({ 'doc-u': 1 }); + expect(teamVotes(a.config)).toEqual({ 'doc-judged': 1 }); + }); + it('votes pending in the shared directory before the upgrade are pushed by no scope', async () => { const user = userScope(); const a = await projectA(); @@ -229,6 +280,42 @@ describe('votes stay with the scope they were cast in (#787)', () => { expect(fs.existsSync(path.join(teamaiHome(), 'votes'))).toBe(false); }); + it('recall feedback --negative lowers the upvotes the scope\'s team already holds from before the upgrade', async () => { + userScope(); + const a = await projectA(); + // The team's file from before the upgrade; the scope's own file does not hold these docs yet, + // or holds only a recall counted after the upgrade. + teamFile(a.config, { 'doc-old': 2, 'doc-recalled': 2 }); + const local: UserVotesV2 = { + version: 2, + votes: { 'doc-recalled': { recalled_count: 1, upvoted_count: 0, last_recalled_at: '2026-09-01T00:00:00.000Z' } }, + deltas: { 'doc-recalled': { recalled_delta: 1, upvoted_delta: 0 } }, + }; + fs.mkdirSync(path.join(a.dataHome, 'votes'), { recursive: true }); + fs.writeFileSync(path.join(a.dataHome, 'votes', 'tester.yaml'), YAML.stringify(local)); + + await negativeIn(a.root, 'doc-old'); + await negativeIn(a.root, 'doc-recalled'); + await report(a.config); + + expect(teamVotes(a.config)).toEqual({ 'doc-old': 1, 'doc-recalled': 1 }); + }); + + it('recall feedback --negative ignores upvotes that only another scope or the shared directory holds', async () => { + const user = userScope(); + const a = await projectA(); + teamFile(user, { 'doc-u': 2 }); + sharedPendingVote('doc-shared'); + + await negativeIn(a.root, 'doc-u'); + await negativeIn(a.root, 'doc-shared'); + await report(a.config); + await report(user); + + expect(teamVotes(a.config)).toEqual({}); + expect(teamVotes(user)).toEqual({ 'doc-u': 2 }); + }); + it('recall feedback in a project whose config cannot be read records nothing, not even in the user scope', async () => { userScope(); const { root, dataHome } = await brokenProject(); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index af1106d3..26c5c937 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -555,8 +555,8 @@ const votesJudgeHandler: HookHandler = { // increment dedups to nothing, could re-trigger a local-CLI judge call on // every subsequent Stop (issue #723 review). Filtering here keeps the cost // at ~one CLI call per session in the steady state. - const { getUserVotesDir, getUserLearningsDir, usesBranchWorktree } = await import('./types.js'); - const votesDir = getUserVotesDir(); + const { getVotesDir, getUserLearningsDir, usesBranchWorktree } = await import('./types.js'); + const votesDir = getVotesDir(localConfig); const votePath = path.join(votesDir, `${localConfig.username}.yaml`); const { creditedDocIdsForSession } = await import('./votes.js'); const ledgerCredited = await creditedDocIdsForSession(votePath, sessionId); diff --git a/src/votes.ts b/src/votes.ts index 88c6b2cb..e866105e 100644 --- a/src/votes.ts +++ b/src/votes.ts @@ -453,7 +453,7 @@ export async function recallFeedback(opts: { positive?: string; negative?: strin process.exitCode = 1; return; } - const { getVotesDir } = await import('./types.js'); + const { getVotesDir, getReportsDir } = await import('./types.js'); const votePath = path.join(getVotesDir(localConfig), `${localConfig.username}.yaml`); if (opts.positive) { @@ -473,8 +473,19 @@ export async function recallFeedback(opts: { positive?: string; negative?: strin // downvote cannot race the detached judge or a sync clearing deltas. const { acquired, value } = await withVotesLock(votePath, async () => { const data = await loadUserVotes(votePath); - if (!data.votes[opts.negative!]) return 'missing' as const; - const entry = data.votes[opts.negative!]; + // The scope's own file starts empty on upgrade (#787), so the upvotes its + // team already holds count too: that file plus the deltas not yet pushed. + const team = await loadUserVotes(path.join(getReportsDir(localConfig), 'votes', `${localConfig.username}.yaml`)); + const teamEntry = team.votes[opts.negative!]; + const known = data.votes[opts.negative!] ?? teamEntry; + if (!known) return 'missing' as const; + const entry: VoteEntryV2 = { + ...known, + upvoted_count: Math.max( + data.votes[opts.negative!]?.upvoted_count ?? 0, + (teamEntry?.upvoted_count ?? 0) + (data.deltas[opts.negative!]?.upvoted_delta ?? 0), + ), + }; if (entry.upvoted_count <= 0) return 'none' as const; const existingDelta = data.deltas[opts.negative!] ?? { recalled_delta: 0, upvoted_delta: 0 };