diff --git a/CHANGELOG.md b/CHANGELOG.md index 467bda7b..e52a1d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to this project will be documented in this file. See [standa - The lock behind `pull`, `push`, the reports and learnings worktrees, learnings publishing, migration, self-mode bootstrap and the update check no longer hands one lock to two live processes. Reclaiming a stale lock renamed over whatever file was there once it had judged the lock stale, and it judged live locks stale: one that had just been released (and could be re-created by a third process before the rename), one whose owner had created it but not yet written it, one owned by a process running as another user (for example a `sudo teamai` run), and one it could not read. Under 16 processes contending on one lock, about 1% of acquisitions overlapped another holder, enough for two pulls to report the same usage twice. Now only a lock whose owner is provably gone is reclaimed; a lock that vanished gets one more exclusive create, and a new lock is published with its content already in place (written to a temp file, then hard-linked to the lock name; a filesystem without hard links falls back to the previous create). A lock that names no owner (empty, partly written, unreadable) is never reclaimed: if a crash left one, `pull` and `push` report busy until it is removed, and a warning names the file. Migration skips the lock's temporary files, which a contending pull creates and removes while the copy runs. With the change, the same stress run shows no overlap (for [#760](https://github.com/Tencent/teamai-cli/issues/760)). - Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for team hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition (the session-start pull still resolves its project on its own). A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - Every team hook handler now works in the scope `hook-dispatch` resolved for the hook's `cwd`. The team correction keywords, votes and webhooks read their config again from the directory the hook process ran in, so when that `cwd` no longer existed (a deleted worktree) and the host started the hook inside another project, that project's keywords were applied, the session's votes were recorded under its member and pushed to its team, and its webhooks fired. The background handlers (session-start pull, webhooks, update check) also run again when that `cwd` no longer exists: on macOS and Linux their detached process was started in it, so the start failed silently and none of them ran. It now starts in the temp directory, as on Windows (for [#752](https://github.com/Tencent/teamai-cli/issues/752)). +- Votes stay with the team of the scope they were cast in. Every scope used to record into one `~/.teamai/votes/.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/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index a70531a3..2f6e0c81 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -314,14 +314,19 @@ just works. Seven consts that already had runtime getters and no live consumers were removed. **Functionization ≠ project-scoping.** All of these are class-A2 (machine-level): -the getters still return `~/.teamai/...`, unchanged. The project-scoped equivalents +the getters still return `~/.teamai/...`, unchanged, except +`getUserVotesDir()` (below). The project-scoped equivalents already route through `getDataHome()`. Skill usage moved there too (#748): `usage.jsonl` lives in each scope's `getDataHome()`, because one shared file let a project's report carry every project's skills. The user scope records in `~/.teamai/user-usage.jsonl`, not that old shared `~/.teamai/usage.jsonl`, which an earlier release still writes after a rollback; the shared file is never -read. The dashboard stays an A2 singleton: `teamai dashboard`, `stats --by-repo`, -`session save` and the contribute check read across scopes. Each event instead +read. Local votes followed for the same reason (#787): `/votes/`, and +`~/.teamai/user-votes/` (`getUserVotesDir()`) for the user scope, so a scope +pushes only the votes cast where it is set up. The old shared `~/.teamai/votes/` +is never read, and its pending deltas are not pushed. The dashboard stays an A2 +singleton: `teamai dashboard`, `stats --by-repo`, `session save` and the +contribute check read across scopes. Each event instead carries `dataHome`, the `getDataHome()` of the scope the hook resolved (#785), and a scope's report keeps only its own. An event written before that field existed is attributed by its `cwd`, realpath'd, to the project whose root holds it, never to diff --git a/docs/designs/git-native-memory.md b/docs/designs/git-native-memory.md index c0d9b63b..c5ca36b7 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__/hook-dispatch-scope.test.ts b/src/__tests__/hook-dispatch-scope.test.ts index 4ec8bbd8..7fbfd0a8 100644 --- a/src/__tests__/hook-dispatch-scope.test.ts +++ b/src/__tests__/hook-dispatch-scope.test.ts @@ -167,7 +167,7 @@ describe('hook runs and the scope they belong to (#748)', () => { const events = fs.readFileSync(path.join(teamaiHome(), 'dashboard', 'events.jsonl'), 'utf-8') .split('\n').filter(Boolean).map((line) => JSON.parse(line) as { type: string; correction?: boolean }); expect(events.find((e) => e.type === 'prompt_submit')?.correction).toBe(false); - expect(fs.readdirSync(path.join(teamaiHome(), 'votes'))).toEqual(['tester.yaml']); + expect(fs.readdirSync(path.join(teamaiHome(), 'user-votes'))).toEqual(['tester.yaml']); expect(fetchSpy).not.toHaveBeenCalled(); }); 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..ce7805b5 --- /dev/null +++ b/src/__tests__/votes-scope.test.ts @@ -0,0 +1,436 @@ +/** + * 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', 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. +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'); +const { recall } = await import('../recall.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 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: `--- [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 })); + 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 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 }); +} + +/** 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('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(); + 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 --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(); + + 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('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('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'); + 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, dataHome } = await brokenProject(); + + process.chdir(root); + 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('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(); + + 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 7f1e20db..72231188 100644 --- a/src/__tests__/votes.test.ts +++ b/src/__tests__/votes.test.ts @@ -534,10 +534,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 } }), })); }); @@ -546,7 +545,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']); @@ -558,7 +557,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']); @@ -570,7 +569,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 @@ -578,7 +577,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/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/hook-handlers.ts b/src/hook-handlers.ts index f1c8b043..26c5c937 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -388,8 +388,8 @@ const votesSyncHandler: HookHandler = { const { incrementUpvoted, syncVotesToTeam, pruneUpvoteLedger } = 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`); // Count an upvote when a recalled doc was actually adopted this session. @@ -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/recall.ts b/src/recall.ts index 14897f91..68058c2a 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, '')); @@ -429,9 +421,17 @@ 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 (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(); - } catch { + projectConfig = await detectProjectConfig(undefined, () => { 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'); } @@ -582,11 +582,11 @@ 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. - if (!options.dryRun) { + // 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 && !projectUnreadable) { const voteScopes = projectConfig ? scopeIndexes.filter((scopeInfo) => scopeInfo.scope === 'project') : scopeIndexes; @@ -594,7 +594,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 682ddb79..9b003d1e 100644 --- a/src/team-push.ts +++ b/src/team-push.ts @@ -15,7 +15,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, getDataHome, getTeamaiHomeDir, emptyTokenUsage, addTokenUsage, usesBranchWorktree } from './types.js'; +import { getVotesDir, getDataHome, getTeamaiHomeDir, emptyTokenUsage, addTokenUsage, usesBranchWorktree } from './types.js'; import { getUserHome } from './utils/home.js'; import { aggregateDailySessions, @@ -53,7 +53,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 @@ -446,6 +446,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 @@ -523,9 +525,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`); } @@ -539,9 +541,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 4c741de9..13ba3f15 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1673,9 +1673,27 @@ 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(). 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(); } 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 4144f88a..e866105e 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, writeFileAtomic, ensureDir } from './utils/fs.js'; +import { readFileSafe, writeFileAtomic, ensureDir, expandHome } from './utils/fs.js'; import { log } from './utils/logger.js'; /** @@ -433,11 +433,28 @@ 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, 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: 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: 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; + return; + } + const { getVotesDir, getReportsDir } = await import('./types.js'); + const votePath = path.join(getVotesDir(localConfig), `${localConfig.username}.yaml`); if (opts.positive) { // No sessionId → credit unconditionally. Report honestly: only claim @@ -456,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 };