From cb1d0ff8ce1d670078da454bc104db059a05fe94 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 19:03:41 +0200 Subject: [PATCH 1/2] fix(hooks): run every handler in the scope the dispatcher resolved (#752) hook-dispatch resolves the scope from the payload cwd, then chdirs there. The correction keywords, votes-sync and webhook-dispatch read their config again through autoDetectInit() on the process cwd, so when that chdir failed (a deleted worktree) and the host started the hook inside another project, they used that project: its keywords, its member for the session's votes, and its webhooks. HookHandler.execute now receives the config the dispatcher resolved, and the three handlers read their scope from it. --- CHANGELOG.md | 1 + src/__tests__/codex-stop-hint.test.ts | 24 +-- src/__tests__/git-kind-reports.test.ts | 2 +- src/__tests__/hook-dispatch-scope.test.ts | 50 ++++++ src/__tests__/hook-dispatch.test.ts | 41 ++++- src/__tests__/hook-handlers.test.ts | 161 +++++++++++--------- src/__tests__/pkg-team-distribution.test.ts | 2 +- src/hook-dispatch-cli.ts | 2 +- src/hook-dispatch.ts | 17 ++- src/hook-handlers.ts | 37 +++-- src/webhook.ts | 13 +- 11 files changed, 228 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a02098e..2c5db5f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes - 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 hooks and skill usage an unreadable project config never falls back to the user scope; `teamai doctor` reports it. 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 (for [#752](https://github.com/Tencent/teamai-cli/issues/752)). - 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, `/.teamai` in single-repo mode, `~/.teamai` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. A machine's first user scope starts with an empty file, since the events it held by then cannot be attributed; on a machine with only project scopes, events recorded before the upgrade stay unreported. 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__/codex-stop-hint.test.ts b/src/__tests__/codex-stop-hint.test.ts index 55f9e8f63..3f6f90ac3 100644 --- a/src/__tests__/codex-stop-hint.test.ts +++ b/src/__tests__/codex-stop-hint.test.ts @@ -44,18 +44,18 @@ describe('Codex Stop hint handoff with persisted session state', () => { it('keeps Stop silent, persists the hint, and delivers it only once on the next prompt', async () => { await seed(CONTRIBUTE_SMART_THRESHOLD + 1); - expect(await stop.execute(stdin, 'codex')).toBeNull(); + expect(await stop.execute(stdin, 'codex', null)).toBeNull(); const state = await readContributeState(stdin.session_id); expect(state.hinted).toBe(true); expect(state.pendingHint).toContain('teamai skill get share'); - expect(await stop.execute(stdin, 'codex')).toBeNull(); + expect(await stop.execute(stdin, 'codex', null)).toBeNull(); expect((await readContributeState(stdin.session_id)).pendingHint).toBe(state.pendingHint); - expect(JSON.parse((await prompt.execute(stdin, 'codex'))!)).toEqual({ + expect(JSON.parse((await prompt.execute(stdin, 'codex', null))!)).toEqual({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: state.pendingHint }, }); expect((await readContributeState(stdin.session_id)).pendingHint).toBeUndefined(); - expect(await prompt.execute(stdin, 'codex')).toBeNull(); - expect(await stop.execute(stdin, 'codex')).toBeNull(); + expect(await prompt.execute(stdin, 'codex', null)).toBeNull(); + expect(await stop.execute(stdin, 'codex', null)).toBeNull(); }); it.each(['codex', 'codex-internal', 'tcodex'])( @@ -65,7 +65,7 @@ describe('Codex Stop hint handoff with persisted session state', () => { // listed, so they took the Claude branch, Codex rejected it, and the // stash that would have recovered the hint never ran. await seed(CONTRIBUTE_SMART_THRESHOLD + 1); - expect(await stop.execute(stdin, tool)).toBeNull(); + expect(await stop.execute(stdin, tool, null)).toBeNull(); expect((await readContributeState(stdin.session_id)).pendingHint).toBeTruthy(); }, ); @@ -74,7 +74,7 @@ describe('Codex Stop hint handoff with persisted session state', () => { await seed(CONTRIBUTE_SMART_THRESHOLD + 1); // Hidden path: the host shows nothing, so the model has to pass it on. - await stop.execute(stdin, 'codex'); + await stop.execute(stdin, 'codex', null); const stashed = (await readContributeState(stdin.session_id)).pendingHint!; expect(stashed).toContain('verbatim'); expect(stashed).toContain('[teamai]'); @@ -89,25 +89,25 @@ describe('Codex Stop hint handoff with persisted session state', () => { lastEvaluated: Date.now(), friction: { interrupt: 0, toolReject: 0, correction: 1, toolError: 0 }, }); - const payload = JSON.parse((await stop.execute(fresh, 'claude'))!); + const payload = JSON.parse((await stop.execute(fresh, 'claude', null))!); expect(payload.hookSpecificOutput.additionalContext).toContain('[teamai]'); expect(payload.hookSpecificOutput.additionalContext).not.toContain('verbatim'); }); it('does not queue or deliver a hint below threshold', async () => { await seed(CONTRIBUTE_SMART_THRESHOLD - 1); - expect(await stop.execute(stdin, 'codex')).toBeNull(); + expect(await stop.execute(stdin, 'codex', null)).toBeNull(); expect((await readContributeState(stdin.session_id)).hinted).toBeFalsy(); - expect(await prompt.execute(stdin, 'codex')).toBeNull(); + expect(await prompt.execute(stdin, 'codex', null)).toBeNull(); }); it('drops a queued hint if the user contributed before the next prompt', async () => { await seed(CONTRIBUTE_SMART_THRESHOLD + 1); - await stop.execute(stdin, 'codex'); + await stop.execute(stdin, 'codex', null); await writeContributeState(stdin.session_id, { ...await readContributeState(stdin.session_id), contributed: true, }); - expect(await prompt.execute(stdin, 'codex')).toBeNull(); + expect(await prompt.execute(stdin, 'codex', null)).toBeNull(); expect((await readContributeState(stdin.session_id)).pendingHint).toBeUndefined(); }); }); diff --git a/src/__tests__/git-kind-reports.test.ts b/src/__tests__/git-kind-reports.test.ts index f720950b7..4d7ff3cfe 100644 --- a/src/__tests__/git-kind-reports.test.ts +++ b/src/__tests__/git-kind-reports.test.ts @@ -657,7 +657,7 @@ describe('skill usage stays in the scope that recorded it (#748)', () => { async function useSkill(cwd: string, skill: string): Promise { const track = buildHandlerRegistry().find((r) => r.handler.name === 'track'); if (!track) throw new Error('track handler is not registered'); - await track.handler.execute({ session_id: `s-${skill}`, cwd, tool_name: 'Skill', tool_input: { skill } }, 'claude'); + await track.handler.execute({ session_id: `s-${skill}`, cwd, tool_name: 'Skill', tool_input: { skill } }, 'claude', null); } async function reportedSkills(origin: string): Promise { diff --git a/src/__tests__/hook-dispatch-scope.test.ts b/src/__tests__/hook-dispatch-scope.test.ts index 02b3eb9f1..26e52d007 100644 --- a/src/__tests__/hook-dispatch-scope.test.ts +++ b/src/__tests__/hook-dispatch-scope.test.ts @@ -17,6 +17,7 @@ vi.mock('node:child_process', async (importOriginal) => ({ 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) })); +vi.mock('../utils/reports-branch.js', () => ({ updateReports: vi.fn(async () => undefined) })); const { hookDispatchCli } = await import('../hook-dispatch-cli.js'); const { resolveProjectDataHome, saveLocalConfigForScope } = await import('../config.js'); @@ -104,6 +105,55 @@ describe('hook runs and the scope they belong to (#748)', () => { expect(fs.readFileSync(path.join(teamaiHome(), 'usage.jsonl'), 'utf-8')).toContain('skill-g'); }); + it('handlers follow the scope the dispatcher resolved, not the directory the hook process runs in (#752)', async () => { + userScope(); + fs.writeFileSync(path.join(teamaiHome(), 'team-repo', 'teamai.yaml'), 'team: user-team\nrepo: https://example.test/acme/user-team.git\n'); + const root = gitRepo('project-a'); + const dataHome = await resolveProjectDataHome(root); + const teamRepoA = path.join(dataHome, 'team-repo'); + fs.mkdirSync(teamRepoA, { recursive: true }); + fs.writeFileSync(path.join(teamRepoA, 'teamai.yaml'), [ + 'team: team-a', + 'repo: https://example.test/acme/team-a.git', + 'sharing:', + ' intervention:', + ' correctionKeywords: [rehazlo]', + ' webhooks:', + ' enabled: true', + ' endpoints:', + ' - { url: "https://hooks.team-a.test/in", type: json, retries: 0 }', + '', + ].join('\n')); + await saveLocalConfigForScope({ + repo: { localPath: teamRepoA, remote: 'https://example.test/acme/team-a.git' }, + username: 'alice', scope: 'project', projectRoot: root, additionalRoles: [], dataHome, + }); + const fetchSpy = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + const transcript = path.join(tmp, 'transcript.jsonl'); + fs.writeFileSync(transcript, JSON.stringify({ type: 'assistant', message: { content: [{ + type: 'text', + text: ' ', + }] } }) + '\n'); + // The session's worktree is gone, so chdir fails and the host's launch + // directory, project A, stays the process cwd. + process.chdir(root); + const base = { session_id: 'sid-g', cwd: path.join(tmp, 'deleted-worktree') }; + + try { + await hook('prompt-submit', '*', { ...base, hook_event_name: 'UserPromptSubmit', prompt: 'rehazlo' }); + await hook('stop', '*', { ...base, hook_event_name: 'Stop', transcript_path: transcript }); + } finally { + vi.unstubAllGlobals(); + } + + 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(fetchSpy).not.toHaveBeenCalled(); + }); + it('a project whose config cannot be read records nothing, not even in the user scope', async () => { userScope(); const root = gitRepo('project-a'); diff --git a/src/__tests__/hook-dispatch.test.ts b/src/__tests__/hook-dispatch.test.ts index adbb9c625..cc1b2f52c 100644 --- a/src/__tests__/hook-dispatch.test.ts +++ b/src/__tests__/hook-dispatch.test.ts @@ -27,11 +27,30 @@ import { describe('hook-dispatch', () => { describe('routing', () => { + it('hands every handler the scope it was created with', async () => { + const localConfig = { repo: { localPath: '/team', remote: '' }, username: 'u', scope: 'user' as const, additionalRoles: [] }; + const a = createHandler('a'); + const b = createHandler('b'); + const dispatcher = createDispatcher({ + localConfig, + handlers: [ + { event: 'stop', matcher: '*', handler: a }, + { event: 'stop', matcher: '*', handler: b, background: true }, + ], + }); + + await dispatcher.dispatch('stop', '*', {}, 'claude'); + + expect(a.execute).toHaveBeenCalledWith({}, 'claude', localConfig); + expect(b.execute).toHaveBeenCalledWith({}, 'claude', localConfig); + }); + it('dispatches to all handlers registered for the given event+matcher', async () => { const pullHandler = createHandler('pull'); const dashboardHandler = createHandler('dashboard-report'); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'session-start', matcher: '*', handler: pullHandler }, { event: 'session-start', matcher: '*', handler: dashboardHandler }, @@ -50,6 +69,7 @@ describe('hook-dispatch', () => { const stopHandler = createHandler('update'); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'session-start', matcher: '*', handler: createHandler('pull') }, { event: 'stop', matcher: '*', handler: stopHandler }, @@ -65,6 +85,7 @@ describe('hook-dispatch', () => { const skillHandler = createHandler('track'); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'post-tool-use', matcher: '*', handler: createHandler('dashboard') }, { event: 'post-tool-use', matcher: 'Skill', handler: skillHandler }, @@ -81,6 +102,7 @@ describe('hook-dispatch', () => { const bashHandler = createHandler('auto-recall'); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'post-tool-use', matcher: '*', handler: wildcardHandler }, { event: 'post-tool-use', matcher: 'Bash', handler: bashHandler }, @@ -101,6 +123,7 @@ describe('hook-dispatch', () => { const successHandler = createHandler('success'); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'session-start', matcher: '*', handler: failingHandler }, { event: 'session-start', matcher: '*', handler: successHandler }, @@ -117,6 +140,7 @@ describe('hook-dispatch', () => { failingHandler.execute.mockRejectedValue(new Error('boom')); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'session-start', matcher: '*', handler: failingHandler }, { event: 'session-start', matcher: '*', handler: createHandler('ok') }, @@ -137,6 +161,7 @@ describe('hook-dispatch', () => { const silentHandler = createHandler('dashboard'); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'post-tool-use', matcher: 'Bash', handler: outputHandler }, { event: 'post-tool-use', matcher: '*', handler: silentHandler }, @@ -150,6 +175,7 @@ describe('hook-dispatch', () => { it('returns null output when no handler produces output', async () => { const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'session-start', matcher: '*', handler: createHandler('pull') }, { event: 'session-start', matcher: '*', handler: createHandler('dashboard') }, @@ -168,7 +194,7 @@ describe('hook-dispatch', () => { const second = createHandler('contribute', JSON.stringify({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: 'CONTRIBUTE' }, })); - const dispatcher = createDispatcher({ handlers: [ + const dispatcher = createDispatcher({ localConfig: null, handlers: [ { event: 'stop', matcher: '*', handler: first }, { event: 'stop', matcher: '*', handler: second }, ] }); @@ -181,7 +207,7 @@ describe('hook-dispatch', () => { it('merges Cursor followup messages from concurrent handlers', async () => { const first = createHandler('votes', JSON.stringify({ followup_message: 'VOTES' })); const second = createHandler('contribute', JSON.stringify({ followup_message: 'CONTRIBUTE' })); - const dispatcher = createDispatcher({ handlers: [ + const dispatcher = createDispatcher({ localConfig: null, handlers: [ { event: 'stop', matcher: '*', handler: first }, { event: 'stop', matcher: '*', handler: second }, ] }); @@ -204,6 +230,7 @@ describe('hook-dispatch', () => { }, })); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'session-start', matcher: '*', handler: mrHint }, { event: 'session-start', matcher: '*', handler: packageHint }, @@ -222,6 +249,7 @@ describe('hook-dispatch', () => { const handler2 = createHandler('h2'); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'stop', matcher: '*', handler: handler1 }, { event: 'stop', matcher: '*', handler: handler2 }, @@ -231,8 +259,8 @@ describe('hook-dispatch', () => { const stdin = { session_id: 'abc', cwd: '/project' }; await dispatcher.dispatch('stop', '*', stdin, 'claude'); - expect(handler1.execute).toHaveBeenCalledWith(stdin, 'claude'); - expect(handler2.execute).toHaveBeenCalledWith(stdin, 'claude'); + expect(handler1.execute).toHaveBeenCalledWith(stdin, 'claude', null); + expect(handler2.execute).toHaveBeenCalledWith(stdin, 'claude', null); }); }); @@ -247,6 +275,7 @@ describe('hook-dispatch', () => { const fastHandler = createHandler('fast', 'quick'); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'session-start', matcher: '*', handler: slowHandler, timeoutMs: 50 }, { event: 'session-start', matcher: '*', handler: fastHandler }, @@ -268,6 +297,7 @@ describe('hook-dispatch', () => { const bg = createHandler('update'); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'stop', matcher: '*', handler: fg }, { event: 'stop', matcher: '*', handler: bg, background: true }, @@ -286,6 +316,7 @@ describe('hook-dispatch', () => { const bg = createHandler('update'); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'stop', matcher: '*', handler: fg }, { event: 'stop', matcher: '*', handler: bg, background: true }, @@ -305,6 +336,7 @@ describe('hook-dispatch', () => { const bg = createHandler('update'); const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'stop', matcher: '*', handler: fg }, { event: 'stop', matcher: '*', handler: bg, background: true }, @@ -319,6 +351,7 @@ describe('hook-dispatch', () => { it('hasBackground reflects whether the event+matcher has a background handler', () => { const dispatcher = createDispatcher({ + localConfig: null, handlers: [ { event: 'stop', matcher: '*', handler: createHandler('contribute-check') }, { event: 'stop', matcher: '*', handler: createHandler('update'), background: true }, diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index e2acb0d4b..3e074fd3c 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -142,6 +142,10 @@ vi.mock('../project-agent-root.js', () => ({ import { buildHandlerRegistry, buildVotesNudge, filterHandlersForConfig, type HandlerRegistration } from '../hook-handlers.js'; import { createDispatcher } from '../hook-dispatch.js'; +import type { LocalConfig } from '../types.js'; + +/** The scope hook-dispatch resolved for the hook's cwd, handed to every handler. */ +const scope: LocalConfig = { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user', additionalRoles: [] }; // ── Tests ──────────────────────────────────────────────── @@ -197,7 +201,7 @@ describe('hook-handlers registry', () => { (r) => r.event === 'session-start' && r.handler.name === 'pull', )!.handler; - await handler.execute({ session_id: 's-pull', cwd: '/tmp/some-project' }, 'claude'); + await handler.execute({ session_id: 's-pull', cwd: '/tmp/some-project' }, 'claude', null); expect(mockSeedProjectAgentRoot).toHaveBeenCalledWith('claude', '/tmp/some-project'); expect(mockPull).toHaveBeenCalledWith({ silent: true }); @@ -217,7 +221,7 @@ describe('hook-handlers registry', () => { (r) => r.event === 'session-start' && r.handler.name === 'pull', )!.handler; - await handler.execute({ workspace_roots: ['/tmp/cursor-project'] }, 'cursor'); + await handler.execute({ workspace_roots: ['/tmp/cursor-project'] }, 'cursor', null); expect(mockSeedProjectAgentRoot).toHaveBeenCalledWith('cursor', '/tmp/cursor-project'); }); @@ -230,7 +234,7 @@ describe('hook-handlers registry', () => { await handler.execute( { cwd: '/from-cwd', workspace_roots: ['/from-roots'] }, - 'claude', + 'claude', null, ); expect(mockSeedProjectAgentRoot).toHaveBeenCalledWith('claude', '/from-cwd'); @@ -260,7 +264,7 @@ describe('hook-handlers registry', () => { const originalEnv = process.env.CLAUDE_SESSION_ID; delete process.env.CLAUDE_SESSION_ID; try { - const result = await handler.execute({ cwd: '/tmp/some-project' }, 'claude'); + const result = await handler.execute({ cwd: '/tmp/some-project' }, 'claude', null); expect(result).toBeNull(); expect(mockContributeCheckForSession).toHaveBeenCalledOnce(); const [sessionId, cwd] = mockContributeCheckForSession.mock.calls[0]; @@ -282,7 +286,7 @@ describe('hook-handlers registry', () => { mockContributeCheckForSession.mockResolvedValueOnce({ hint: null }); - await handler.execute({ session_id: 'sid-abc', cwd: '/x' }, 'claude'); + await handler.execute({ session_id: 'sid-abc', cwd: '/x' }, 'claude', null); // transcriptPath is the third arg; absent from this stdin so it is undefined. expect(mockContributeCheckForSession).toHaveBeenCalledWith('sid-abc', '/x', undefined, false); }); @@ -297,7 +301,7 @@ describe('hook-handlers registry', () => { await handler.execute( { session_id: 'sid-abc', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', null, ); expect(mockContributeCheckForSession).toHaveBeenCalledWith('sid-abc', '/x', '/t/transcript.jsonl', false); }); @@ -310,7 +314,7 @@ describe('hook-handlers registry', () => { mockContributeCheckForSession.mockResolvedValueOnce({ hint: '[teamai] hello' }); - const result = await handler.execute({ session_id: 's', cwd: '/x' }, 'cursor'); + const result = await handler.execute({ session_id: 's', cwd: '/x' }, 'cursor', null); expect(result).not.toBeNull(); const parsed = JSON.parse(result!); expect(parsed.followup_message).toContain('[teamai] hello'); @@ -326,7 +330,7 @@ describe('hook-handlers registry', () => { mockContributeCheckForSession.mockResolvedValueOnce({ hint: '[teamai] hello' }); - const result = await handler.execute({ session_id: 's', cwd: '/x' }, 'claude'); + const result = await handler.execute({ session_id: 's', cwd: '/x' }, 'claude', null); const parsed = JSON.parse(result!); expect(parsed.hookSpecificOutput.additionalContext).toBe('[teamai] hello'); }); @@ -341,7 +345,7 @@ describe('hook-handlers registry', () => { // returns hint:null (it persisted the hint as pendingHint itself). mockContributeCheckForSession.mockResolvedValueOnce({ hint: null }); - const result = await handler.execute({ session_id: 's1', cwd: '/x' }, tool); + const result = await handler.execute({ session_id: 's1', cwd: '/x' }, tool, null); expect(result).toBeNull(); expect(mockContributeCheckForSession).toHaveBeenCalledWith('s1', '/x', undefined, true); }); @@ -354,7 +358,7 @@ describe('hook-handlers registry', () => { mockContributeCheckForSession.mockResolvedValueOnce({ hint: '[teamai] do share' }); - const result = await handler.execute({ session_id: 's2', cwd: '/x' }, 'claude'); + const result = await handler.execute({ session_id: 's2', cwd: '/x' }, 'claude', null); expect(result).not.toBeNull(); expect(result).toContain('do share'); // claude is not a stash tool: called with stash=false. @@ -372,7 +376,7 @@ describe('hook-handlers registry', () => { }); mockContributeCheckForSession.mockClear(); - const result = await handler.execute({ session_id: 's3', cwd: '/x' }, 'claude'); + const result = await handler.execute({ session_id: 's3', cwd: '/x' }, 'claude', null); expect(result).toBeNull(); expect(mockContributeCheckForSession).not.toHaveBeenCalled(); }); @@ -388,7 +392,7 @@ describe('hook-handlers registry', () => { }); mockContributeCheckForSession.mockResolvedValueOnce({ hint: '[teamai] do share' }); - const result = await handler.execute({ session_id: 's4', cwd: '/x' }, 'claude'); + const result = await handler.execute({ session_id: 's4', cwd: '/x' }, 'claude', null); expect(result).toContain('do share'); }); @@ -403,7 +407,7 @@ describe('hook-handlers registry', () => { }); mockContributeCheckForSession.mockClear(); - const result = await handler.execute({ session_id: 's3b', cwd: '/x' }, 'claude'); + const result = await handler.execute({ session_id: 's3b', cwd: '/x' }, 'claude', null); expect(result).toBeNull(); expect(mockContributeCheckForSession).not.toHaveBeenCalled(); }); @@ -419,7 +423,7 @@ describe('hook-handlers registry', () => { }); mockContributeCheckForSession.mockClear(); - const result = await handler.execute({ session_id: 's3c', cwd: '/x' }, 'claude'); + const result = await handler.execute({ session_id: 's3c', cwd: '/x' }, 'claude', null); expect(result).toBeNull(); expect(mockContributeCheckForSession).not.toHaveBeenCalled(); }); @@ -435,7 +439,7 @@ describe('hook-handlers registry', () => { mockAutoDetectInit.mockRejectedValueOnce(new NotInitializedError('teamai is not initialized. Run `teamai init` first.')); mockContributeCheckForSession.mockClear(); - const result = await handler.execute({ session_id: 's5', cwd: '/x' }, 'claude'); + const result = await handler.execute({ session_id: 's5', cwd: '/x' }, 'claude', null); expect(result).toBeNull(); expect(mockContributeCheckForSession).not.toHaveBeenCalled(); }); @@ -451,7 +455,7 @@ describe('hook-handlers registry', () => { mockContributeCheckForSession.mockClear(); // An existing directory: a deleted one holds no project config to be unreadable. - const result = await handler.execute({ session_id: 's5c', cwd: process.cwd() }, 'claude'); + const result = await handler.execute({ session_id: 's5c', cwd: process.cwd() }, 'claude', null); expect(result).toBeNull(); expect(mockContributeCheckForSession).not.toHaveBeenCalled(); }); @@ -467,7 +471,7 @@ describe('hook-handlers registry', () => { cwd === undefined ? '/launcher/.teamai/config.yaml: bad indentation' : null); mockContributeCheckForSession.mockResolvedValueOnce({ hint: '[teamai] do share' }); try { - const result = await handler.execute({ session_id: 's5e', cwd: process.cwd() }, 'claude'); + const result = await handler.execute({ session_id: 's5e', cwd: process.cwd() }, 'claude', null); expect(result).toContain('do share'); } finally { mockFindUnreadableProjectConfig.mockReset(); @@ -487,7 +491,7 @@ describe('hook-handlers registry', () => { )!.handler; mockContributeCheckForSession.mockClear(); try { - const result = await handler.execute({ session_id: 's5f', cwd: path.join(file, 'sub') }, 'claude'); + const result = await handler.execute({ session_id: 's5f', cwd: path.join(file, 'sub') }, 'claude', null); expect(result).toBeNull(); expect(mockContributeCheckForSession).not.toHaveBeenCalled(); } finally { @@ -506,7 +510,7 @@ describe('hook-handlers registry', () => { }); mockContributeCheckForSession.mockClear(); - const result = await handler.execute({ session_id: 's5d', cwd: '/x' }, 'claude'); + const result = await handler.execute({ session_id: 's5d', cwd: '/x' }, 'claude', null); expect(result).toBeNull(); expect(mockContributeCheckForSession).not.toHaveBeenCalled(); }); @@ -520,7 +524,7 @@ describe('hook-handlers registry', () => { mockAutoDetectInit.mockRejectedValueOnce(new Error('Team config (teamai.yaml) not found. Check your repo path.')); mockContributeCheckForSession.mockClear(); - const result = await handler.execute({ session_id: 's5b', cwd: '/x' }, 'claude'); + const result = await handler.execute({ session_id: 's5b', cwd: '/x' }, 'claude', null); expect(result).toBeNull(); expect(mockContributeCheckForSession).not.toHaveBeenCalled(); }); @@ -534,7 +538,7 @@ describe('hook-handlers registry', () => { process.env.TEAMAI_CONTRIBUTE_HINT_DISABLED = '1'; mockContributeCheckForSession.mockClear(); try { - const result = await handler.execute({ session_id: 's6', cwd: '/x' }, 'claude'); + const result = await handler.execute({ session_id: 's6', cwd: '/x' }, 'claude', null); expect(result).toBeNull(); expect(mockContributeCheckForSession).not.toHaveBeenCalled(); } finally { @@ -555,7 +559,7 @@ describe('hook-handlers registry', () => { mockTakePendingHint.mockResolvedValueOnce('[teamai] stashed'); mockTakePendingVotesHint.mockResolvedValueOnce('[teamai] votes nudge'); - const result = await handler.execute({ session_id: 's7', cwd: '/x' }, 'codebuddy'); + const result = await handler.execute({ session_id: 's7', cwd: '/x' }, 'codebuddy', null); // The stash is consumed (so it is not delivered later) but not shown. expect(mockTakePendingHint).toHaveBeenCalledWith(expect.any(String)); expect(result).not.toBeNull(); @@ -571,7 +575,7 @@ describe('hook-handlers registry', () => { mockTakePendingHint.mockResolvedValueOnce('[teamai] stashed'); - const result = await handler.execute({ session_id: 's3', cwd: '/x' }, tool); + const result = await handler.execute({ session_id: 's3', cwd: '/x' }, tool, null); expect(result).not.toBeNull(); const parsed = JSON.parse(result!); expect(parsed.hookSpecificOutput.hookEventName).toBe('UserPromptSubmit'); @@ -584,7 +588,7 @@ describe('hook-handlers registry', () => { (r) => r.event === 'prompt-submit' && r.handler.name === 'package-pending-hint', )!.handler; - const result = await handler.execute({ session_id: 's4', cwd: '/x' }, 'claude'); + const result = await handler.execute({ session_id: 's4', cwd: '/x' }, 'claude', null); expect(result).toBeNull(); expect(mockTakePendingHint).not.toHaveBeenCalled(); expect(mockTakePendingPackageHint).toHaveBeenCalledWith('s4'); @@ -598,7 +602,7 @@ describe('hook-handlers registry', () => { mockTakePendingHint.mockResolvedValueOnce(null); - const result = await handler.execute({ session_id: 's5', cwd: '/x' }, 'codebuddy'); + const result = await handler.execute({ session_id: 's5', cwd: '/x' }, 'codebuddy', null); expect(result).toBeNull(); }); @@ -608,7 +612,7 @@ describe('hook-handlers registry', () => { (r) => r.event === 'prompt-submit' && r.handler.name === 'package-pending-hint', )!.handler; - const output = await handler.execute({ session_id: 's6', cwd: '/x' }, 'claude'); + const output = await handler.execute({ session_id: 's6', cwd: '/x' }, 'claude', null); expect(JSON.parse(output!).hookSpecificOutput.additionalContext) .toBe('Run `teamai packages`'); @@ -786,7 +790,7 @@ describe('hook-handlers registry', () => { it('TodoWrite gets no recall nudge where teamai is not set up (#748)', async () => { const registry = buildHandlerRegistry(); expect(registry.some((r) => r.matcher === 'TodoWrite' && r.handler.name === 'todowrite-hint')).toBe(true); - const dispatcher = createDispatcher({ handlers: filterHandlersForConfig(registry, null) }); + const dispatcher = createDispatcher({ handlers: filterHandlersForConfig(registry, null), localConfig: null }); const result = await dispatcher.dispatch( 'post-tool-use', 'TodoWrite', { session_id: 'td-748', tool_name: 'TodoWrite' }, 'claude', 'foreground', @@ -810,14 +814,14 @@ describe('hook-handlers registry', () => { const result1 = await handler.execute( { session_id: 'sid-votes-1', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', scope, ); expect(result1).not.toBeNull(); // Same session, same conditions — should nudge again (no marker blocks repeat) const result2 = await handler.execute( { session_id: 'sid-votes-1', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', scope, ); expect(result2).not.toBeNull(); }); @@ -835,7 +839,7 @@ describe('hook-handlers registry', () => { const result = await handler.execute( { session_id: 'sid-votes-2', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', scope, ); expect(result).toBeNull(); }); @@ -854,7 +858,7 @@ describe('hook-handlers registry', () => { const result = await handler.execute( { session_id: 'sid-votes-3', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', scope, ); expect(result).toBeNull(); }); @@ -874,7 +878,7 @@ describe('hook-handlers registry', () => { await handler.execute( { session_id: 'sid-filter-1', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', scope, ); expect(mockIncrementUpvoted).toHaveBeenCalledOnce(); @@ -894,7 +898,7 @@ describe('hook-handlers registry', () => { await handler.execute( { session_id: 'sid-filter-2', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', scope, ); expect(mockIncrementUpvoted).not.toHaveBeenCalled(); @@ -909,7 +913,7 @@ describe('hook-handlers registry', () => { reportsBranchMocks.updateReports.mockClear(); await handler.execute( { session_id: 'sid-skip-reports', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', scope, ); expect(reportsBranchMocks.updateReports).not.toHaveBeenCalled(); }); @@ -932,7 +936,7 @@ describe('hook-handlers registry', () => { await handler.execute( { session_id: 'sid-write-reports', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', scope, ); expect(reportsBranchMocks.updateReports).toHaveBeenCalledOnce(); @@ -952,7 +956,7 @@ describe('hook-handlers registry', () => { await handler.execute( { session_id: 'sid-filter-3', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', scope, ); expect(mockIncrementUpvoted).toHaveBeenCalledOnce(); @@ -972,7 +976,7 @@ describe('hook-handlers registry', () => { await handler.execute( { session_id: 'sid-filter-empty-recalled', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', scope, ); expect(mockIncrementUpvoted).not.toHaveBeenCalled(); @@ -993,7 +997,7 @@ describe('hook-handlers registry', () => { const result = await handler.execute( { session_id: 'sid-votes-stash', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - tool, + tool, scope, ); // Stash path returns null (hint goes to the votes-hint sidecar) expect(result).toBeNull(); @@ -1016,7 +1020,7 @@ describe('hook-handlers registry', () => { const result = await handler.execute( { session_id: 'sid-votes-stdout', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, - 'claude', + 'claude', scope, ); expect(result).not.toBeNull(); expect(mockStashVotesHint).not.toHaveBeenCalled(); @@ -1036,8 +1040,8 @@ describe('hook-handlers registry', () => { .mockResolvedValueOnce(false); const stdin = { session_id: 'sid-cursor', cwd: '/x', transcript_path: '/t/transcript.jsonl' }; - expect(await handler.execute(stdin, 'cursor')).not.toBeNull(); - expect(await handler.execute(stdin, 'cursor')).toBeNull(); + expect(await handler.execute(stdin, 'cursor', scope)).not.toBeNull(); + expect(await handler.execute(stdin, 'cursor', scope)).toBeNull(); expect(mockClaimVotesNudge).toHaveBeenCalledTimes(2); }); @@ -1047,7 +1051,7 @@ describe('hook-handlers registry', () => { recalledDocIds: ['doc-a'], }); mockContributeCheckForSession.mockResolvedValueOnce({ hint: 'CONTRIBUTE-HINT' }); - const dispatcher = createDispatcher({ handlers: buildHandlerRegistry() }); + const dispatcher = createDispatcher({ handlers: buildHandlerRegistry(), localConfig: scope }); const result = await dispatcher.dispatch( 'stop', @@ -1073,7 +1077,7 @@ describe('hook-handlers registry', () => { mockTakePendingHint.mockResolvedValueOnce('[teamai] contribute hint'); mockTakePendingVotesHint.mockResolvedValueOnce('[teamai] votes hint'); - const result = await handler.execute({ session_id: 'sid-merge', cwd: '/x' }, tool); + const result = await handler.execute({ session_id: 'sid-merge', cwd: '/x' }, tool, scope); expect(result).not.toBeNull(); const parsed = JSON.parse(result!); const ctx = parsed.hookSpecificOutput.additionalContext; @@ -1090,7 +1094,7 @@ describe('hook-handlers registry', () => { mockTakePendingHint.mockResolvedValueOnce(null); mockTakePendingVotesHint.mockResolvedValueOnce('[teamai] votes only'); - const result = await handler.execute({ session_id: 'sid-votes-only', cwd: '/x' }, 'codebuddy'); + const result = await handler.execute({ session_id: 'sid-votes-only', cwd: '/x' }, 'codebuddy', scope); expect(result).not.toBeNull(); const parsed = JSON.parse(result!); expect(parsed.hookSpecificOutput.additionalContext).toBe('[teamai] votes only'); @@ -1105,7 +1109,7 @@ describe('hook-handlers registry', () => { mockTakePendingHint.mockResolvedValueOnce(null); mockTakePendingVotesHint.mockResolvedValueOnce(null); - const result = await handler.execute({ session_id: 'sid-both-absent', cwd: '/x' }, 'codebuddy'); + const result = await handler.execute({ session_id: 'sid-both-absent', cwd: '/x' }, 'codebuddy', scope); expect(result).toBeNull(); }); }); @@ -1124,24 +1128,24 @@ describe('post-tool-use dispatch — local-agent runs detached, never blocks hos const stdin = { tool_name: 'Bash', tool_input: { command: 'ls' }, cwd: '/tmp/proj' }; it('hasBackground is true for the post-tool-use wildcard', () => { - const dispatcher = createDispatcher({ handlers: buildHandlerRegistry() }); + const dispatcher = createDispatcher({ handlers: buildHandlerRegistry(), localConfig: scope }); expect(dispatcher.hasBackground('post-tool-use', '*')).toBe(true); }); it('foreground pass does NOT invoke local-agent-sync (host is not blocked on HTTP)', async () => { - const dispatcher = createDispatcher({ handlers: buildHandlerRegistry() }); + const dispatcher = createDispatcher({ handlers: buildHandlerRegistry(), localConfig: scope }); await dispatcher.dispatch('post-tool-use', '*', stdin, 'claude', 'foreground'); expect(mockReportAndSyncFromHook).not.toHaveBeenCalled(); }); it('background pass DOES invoke local-agent-sync (report/sync still happen)', async () => { - const dispatcher = createDispatcher({ handlers: buildHandlerRegistry() }); + const dispatcher = createDispatcher({ handlers: buildHandlerRegistry(), localConfig: scope }); await dispatcher.dispatch('post-tool-use', '*', stdin, 'claude', 'background'); expect(mockReportAndSyncFromHook).toHaveBeenCalledOnce(); }); it('foreground pass still runs the fast local dashboard-report handler', async () => { - const dispatcher = createDispatcher({ handlers: buildHandlerRegistry() }); + const dispatcher = createDispatcher({ handlers: buildHandlerRegistry(), localConfig: scope }); await dispatcher.dispatch('post-tool-use', '*', stdin, 'claude', 'foreground'); // dashboard-report parses the event and appends locally — it must stay inline. expect(mockParseHookEvent).toHaveBeenCalled(); @@ -1152,30 +1156,35 @@ describe('dashboard-report team correction keywords', () => { const handler = () => buildHandlerRegistry().find( (r) => r.event === 'prompt-submit' && r.handler.name === 'dashboard-report', )!.handler; + let teamRepo: string; beforeEach(() => { mockParseHookEvent.mockClear(); - mockAutoDetectInit.mockClear(); + teamRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-keywords-')); }); - it('passes sharing.intervention.correctionKeywords to parseHookEvent on prompt hooks', async () => { - mockAutoDetectInit.mockResolvedValueOnce({ - localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, - teamConfig: { team: 'test', repo: '', toolPaths: {}, sharing: { intervention: { correctionKeywords: ['rehazlo'] } } }, - }); - await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: 'rehazlo' }, 'claude'); + afterEach(() => { + fs.rmSync(teamRepo, { recursive: true, force: true }); + }); + + const scopeWithTeam = (): LocalConfig => ({ ...scope, repo: { localPath: teamRepo, remote: '' } }); + + it('passes the scope\'s sharing.intervention.correctionKeywords to parseHookEvent on prompt hooks', async () => { + fs.writeFileSync(path.join(teamRepo, 'teamai.yaml'), + 'team: test\nrepo: r\nsharing:\n intervention:\n correctionKeywords: [rehazlo]\n'); + await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: 'rehazlo' }, 'claude', scopeWithTeam()); expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: ['rehazlo'] }); }); - it('falls back to built-in keywords only when team config cannot be read', async () => { - mockAutoDetectInit.mockRejectedValueOnce(new Error('not initialized')); - await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: 'wrong' }, 'claude'); + it('falls back to built-in keywords only when the scope\'s team config cannot be read', async () => { + await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: 'wrong' }, 'claude', scopeWithTeam()); expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: [] }); }); - it('does not read team config for hooks without a prompt', async () => { - await handler().execute({ hook_event_name: 'Stop', session_id: 's' }, 'claude'); - expect(mockAutoDetectInit).not.toHaveBeenCalled(); + it('uses no team keywords for hooks without a prompt', async () => { + fs.writeFileSync(path.join(teamRepo, 'teamai.yaml'), + 'team: test\nrepo: r\nsharing:\n intervention:\n correctionKeywords: [rehazlo]\n'); + await handler().execute({ hook_event_name: 'Stop', session_id: 's' }, 'claude', scopeWithTeam()); expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: [] }); }); }); @@ -1205,7 +1214,7 @@ describe('webhook-dispatch handler (#701, #702)', () => { tool_response: 'SYNTHETIC_PRIVATE_OUTPUT', session_id: 'sid', }, - 'claude', + 'claude', scope, ); expect(mockSendWebhook).toHaveBeenCalledOnce(); @@ -1219,26 +1228,26 @@ describe('webhook-dispatch handler (#701, #702)', () => { }); it('maps SessionStart to session-start (#702)', async () => { - await handler().execute({ hook_event_name: 'SessionStart', session_id: 'sid' }, 'claude'); + await handler().execute({ hook_event_name: 'SessionStart', session_id: 'sid' }, 'claude', scope); expect(mockSendWebhook).toHaveBeenCalledOnce(); expect(mockSendWebhook.mock.calls[0][0]).toBe('session-start'); }); it('maps Stop to session-stop (#702)', async () => { - await handler().execute({ hook_event_name: 'Stop', session_id: 'sid' }, 'claude'); + await handler().execute({ hook_event_name: 'Stop', session_id: 'sid' }, 'claude', scope); expect(mockSendWebhook).toHaveBeenCalledOnce(); expect(mockSendWebhook.mock.calls[0][0]).toBe('session-stop'); }); it('never emits an "unknown" event for an unmapped hook (#702)', async () => { - await handler().execute({ hook_event_name: 'PreToolUse', session_id: 'sid' }, 'claude'); + await handler().execute({ hook_event_name: 'PreToolUse', session_id: 'sid' }, 'claude', scope); expect(mockSendWebhook).not.toHaveBeenCalled(); }); // Codex review finding 1: hosts that send camelCase hook names (Cursor/ // CodeBuddy) were silently dropped by the PascalCase-only lookup. it('maps camelCase sessionStart to session-start (#702, camelCase host)', async () => { - await handler().execute({ hook_event_name: 'sessionStart', session_id: 'sid' }, 'cursor'); + await handler().execute({ hook_event_name: 'sessionStart', session_id: 'sid' }, 'cursor', scope); expect(mockSendWebhook).toHaveBeenCalledOnce(); expect(mockSendWebhook.mock.calls[0][0]).toBe('session-start'); }); @@ -1256,7 +1265,7 @@ describe('webhook-dispatch handler (#701, #702)', () => { tool_response: 'SYNTHETIC_PRIVATE_OUTPUT', session_id: 'sid', }, - 'cursor', + 'cursor', scope, ); expect(mockSendWebhook).toHaveBeenCalledOnce(); const [event, payload] = mockSendWebhook.mock.calls[0]; @@ -1276,7 +1285,7 @@ describe('webhook-dispatch handler (#701, #702)', () => { tool_response: 'const API_KEY = "SYNTHETIC_SECRET_NOT_REAL";', session_id: 'sid', }, - 'cursor', + 'cursor', scope, ); expect(mockSendWebhook).toHaveBeenCalledOnce(); const [event, payload] = mockSendWebhook.mock.calls[0]; @@ -1297,7 +1306,7 @@ describe('webhook-dispatch handler (#701, #702)', () => { }); it('maps SessionEnd to session-stop (#702, Copilot)', async () => { - await handler().execute({ hook_event_name: 'SessionEnd', session_id: 'sid' }, 'copilot'); + await handler().execute({ hook_event_name: 'SessionEnd', session_id: 'sid' }, 'copilot', scope); expect(mockSendWebhook).toHaveBeenCalledOnce(); expect(mockSendWebhook.mock.calls[0][0]).toBe('session-stop'); }); @@ -1313,7 +1322,7 @@ describe('webhook-dispatch handler (#701, #702)', () => { tool_input: { command: '/etc/passwd; rm -rf /' }, session_id: 'sid', }, - 'claude', + 'claude', scope, ); expect(mockSendWebhook).toHaveBeenCalledOnce(); @@ -1349,7 +1358,7 @@ describe('post-tool-use Skill-matcher dispatch routes Cursor SKILL.md Read to th }); it('a Cursor Read of SKILL.md dispatched via the Skill matcher produces a skill-use webhook with {skillName}', async () => { - const dispatcher = createDispatcher({ handlers: buildHandlerRegistry() }); + const dispatcher = createDispatcher({ handlers: buildHandlerRegistry(), localConfig: scope }); await dispatcher.dispatch( 'post-tool-use', 'Skill', @@ -1372,7 +1381,7 @@ describe('post-tool-use Skill-matcher dispatch routes Cursor SKILL.md Read to th }); it('a normal Read (non-SKILL.md) dispatched via the Skill matcher produces empty skill-use data', async () => { - const dispatcher = createDispatcher({ handlers: buildHandlerRegistry() }); + const dispatcher = createDispatcher({ handlers: buildHandlerRegistry(), localConfig: scope }); await dispatcher.dispatch( 'post-tool-use', 'Skill', @@ -1408,7 +1417,7 @@ describe('track-slash handler: dotted and colon skill names', () => { await handler.execute( { prompt: '/org.setup some args', hook_event_name: 'UserPromptSubmit' }, - 'claude', + 'claude', null, ); expect(appendUsageEvent).toHaveBeenCalledOnce(); @@ -1428,7 +1437,7 @@ describe('track-slash handler: dotted and colon skill names', () => { await handler.execute( { prompt: '/ns:deploy some args', hook_event_name: 'UserPromptSubmit' }, - 'claude', + 'claude', null, ); expect(appendUsageEvent).toHaveBeenCalledOnce(); diff --git a/src/__tests__/pkg-team-distribution.test.ts b/src/__tests__/pkg-team-distribution.test.ts index f8f24ca5b..af15d69b2 100644 --- a/src/__tests__/pkg-team-distribution.test.ts +++ b/src/__tests__/pkg-team-distribution.test.ts @@ -233,7 +233,7 @@ describe('team package distribution flow', () => { ); const hint = await sessionStartHint!.handler.execute( { cwd: teammateProject }, - 'claude', + 'claude', null, ); expect(hint).toContain('teamai packages'); diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index d9e3f744c..50e018fb8 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -426,7 +426,7 @@ export async function hookDispatchCli( } const localConfig = await resolveConfigForDir(cwd); const handlers = filterHandlersForConfig(buildHandlerRegistry(), localConfig); - const dispatcher = createDispatcher({ handlers }); + const dispatcher = createDispatcher({ handlers, localConfig }); // Detached child: run the fire-and-forget handlers, then exit. No output is // wired back to the host (the parent already returned). diff --git a/src/hook-dispatch.ts b/src/hook-dispatch.ts index 15c05c60f..d28b2bac9 100644 --- a/src/hook-dispatch.ts +++ b/src/hook-dispatch.ts @@ -6,16 +6,25 @@ * The dispatcher reads STDIN once and fans out to all registered handlers. * * Design: - * - Handlers are pure functions: (stdin, tool) → output | null + * - Handlers are pure functions: (stdin, tool, config) → output | null, where + * config is the scope the dispatcher resolved for the hook's cwd * - Promise.allSettled ensures one handler crash doesn't take down others * - Compatible structured outputs are merged so concurrent hints are preserved */ +import type { LocalConfig } from './types.js'; + // ─── Public types ─────────────────────────────────────── export interface HookHandler { name: string; - execute(stdin: Record, tool: string): Promise; + /** + * `config` is the scope the dispatcher resolved for the hook's cwd, or null + * when none applies (teamai not set up there, or its config unreadable). + * Handlers read their scope from it, never from the process cwd: the + * dispatcher's chdir into the hook's cwd can fail (#752). + */ + execute(stdin: Record, tool: string, config: LocalConfig | null): Promise; } export interface HandlerRegistration { @@ -51,6 +60,8 @@ export interface DispatchResult { export interface DispatcherConfig { handlers: HandlerRegistration[]; + /** The scope every handler runs in; see HookHandler.execute. */ + localConfig: LocalConfig | null; } export interface Dispatcher { @@ -167,7 +178,7 @@ export function createDispatcher(config: DispatcherConfig): Dispatcher { const settled = await Promise.allSettled( matched.map((reg) => { const timeoutMs = reg.timeoutMs ?? DEFAULT_TIMEOUT_MS; - return withTimeout(reg.handler.execute(stdin, tool), timeoutMs, reg.handler.name); + return withTimeout(reg.handler.execute(stdin, tool, config.localConfig), timeoutMs, reg.handler.name); }), ); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index cf1266b39..30fe12116 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -134,18 +134,20 @@ const updateHandler: HookHandler = { }; /** - * Team course-correction keywords for the current project. The dispatcher has - * already chdir'd to the hook payload's cwd (hook-dispatch-cli), so - * autoDetectInit() resolves the right project. Only prompt hooks pay for the config read; an - * unreadable config means "built-in keywords only". + * Team course-correction keywords of the hook's scope. Only prompt hooks pay for + * the team config read; no scope or an unreadable team config means "built-in + * keywords only". */ -async function teamCorrectionKeywords(stdin: Record): Promise { - if (typeof stdin.prompt !== 'string') return []; +async function teamCorrectionKeywords( + stdin: Record, + config: LocalConfig | null, +): Promise { + if (typeof stdin.prompt !== 'string' || !config) return []; try { - const { autoDetectInit } = await import('./config.js'); + const { loadTeamConfig } = await import('./config.js'); const { getInterventionSharing } = await import('./types.js'); - const { teamConfig } = await autoDetectInit(); - return getInterventionSharing(teamConfig).correctionKeywords; + const teamConfig = await loadTeamConfig(config.repo.localPath); + return teamConfig ? getInterventionSharing(teamConfig).correctionKeywords : []; } catch { return []; } @@ -170,11 +172,11 @@ async function userModelAliases(stdin: Record): Promise { - const { teamConfig } = await autoDetectInit(); +export async function loadWebhookConfig(localConfig?: LocalConfig): Promise { + if (!localConfig) return getWebhookSharing((await autoDetectInit()).teamConfig); + const teamConfig = await loadTeamConfig(localConfig.repo.localPath); + if (!teamConfig) throw new Error(`No usable team config (teamai.yaml) in ${localConfig.repo.localPath}.`); return getWebhookSharing(teamConfig); } From e322551128221e7ef1fc9961646221b27ad26678 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 19:18:40 +0200 Subject: [PATCH 2/2] fix(hooks): start the background pass when the hook's cwd is gone (#752) The detached child was spawned in the payload cwd. On macOS and Linux a cwd that no longer exists (a deleted worktree) fails the spawn, the error is swallowed, and no background handler runs: no session-start pull, webhook or update check. Start it in the temp dir instead, as the Windows WMI launch already does. The child resolves its scope from the payload, and its handlers now use that config, so the directory it starts in names no project. --- CHANGELOG.md | 2 +- src/__tests__/hook-dispatch-cli.test.ts | 27 +++++++++++++++++++++++++ src/hook-dispatch-cli.ts | 5 ++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5db5f56..cb4c9d3ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes - 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 hooks and skill usage an unreadable project config never falls back to the user scope; `teamai doctor` reports it. 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 (for [#752](https://github.com/Tencent/teamai-cli/issues/752)). +- 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)). - 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, `/.teamai` in single-repo mode, `~/.teamai` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. A machine's first user scope starts with an empty file, since the events it held by then cannot be attributed; on a machine with only project scopes, events recorded before the upgrade stay unreported. 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__/hook-dispatch-cli.test.ts b/src/__tests__/hook-dispatch-cli.test.ts index aed0d2b4c..1927f3be4 100644 --- a/src/__tests__/hook-dispatch-cli.test.ts +++ b/src/__tests__/hook-dispatch-cli.test.ts @@ -43,6 +43,33 @@ describe('deriveDispatchSessionId', () => { }); describe('hookDispatchCli', () => { + it('starts the background pass from the temp dir when the payload cwd no longer exists', async () => { + // spawn() fails on a missing cwd, and that error is swallowed: no background + // handler (session-start pull, webhook, update check) would run at all. + const stdinFile = path.join(os.tmpdir(), `gone-cwd-hook-${process.pid}-${Date.now()}.json`); + const gone = path.join(os.tmpdir(), `teamai-deleted-worktree-${process.pid}-${Date.now()}`); + fs.writeFileSync(stdinFile, JSON.stringify({ hook_event_name: 'Stop', session_id: 's', cwd: gone })); + const originalCwd = process.cwd(); + mockSpawn.mockClear(); + mockSpawn.mockReturnValue({ + on: vi.fn(), + stdin: { on: vi.fn(), end: vi.fn((_: string, done: () => void) => done()) }, + unref: vi.fn(), + }); + + try { + await hookDispatchCli('stop', 'claude', '*', { stdinFile }); + expect(mockSpawn).toHaveBeenCalledOnce(); + // The same fallback the Windows WMI launch uses: a directory that exists + // and belongs to no project, so a handler still reading the process cwd + // (the session-start pull) cannot land in the launcher's project. + expect(mockSpawn.mock.calls[0][2]).toMatchObject({ cwd: os.tmpdir() }); + } finally { + process.chdir(originalCwd); + fs.rmSync(stdinFile, { force: true }); + } + }); + it('passes a path-free fallback session ID to a Copilot detached handler', async () => { const stdinFile = path.join(os.tmpdir(), `copilot-hook-${process.pid}-${Date.now()}.json`); const cwd = process.cwd(); diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index 50e018fb8..b9bb6cc29 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -104,7 +104,10 @@ async function spawnPlainDetached( detached: true, windowsHide: true, stdio: ['pipe', 'ignore', 'ignore'], - ...(cwd ? { cwd } : {}), + // A cwd that no longer exists (a deleted worktree) fails the spawn, so the + // temp dir stands in, as for the WMI launch: the child resolves its scope + // from the payload anyway, and the temp dir belongs to no project. + ...(cwd ? { cwd: fs.existsSync(cwd) ? cwd : os.tmpdir() } : {}), }); child.on('error', () => {}); await new Promise((resolve) => {