From 1a18e72c7436a45655c43166011fb0b8d2759ae2 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Tue, 19 May 2026 12:38:08 +0000 Subject: [PATCH 1/6] fix: sync with GitHub - wiki skill path + package.json format (merge request !188) Squash merge branch 'fix/sync-wiki-path-from-github' into 'master' Sync from GitHub main (PR #15 merged): 1. skills/teamai-wiki/SKILL.md - wiki path scope-aware 2. package.json - bin path and repo url format --- package.json | 4 ++-- skills/teamai-wiki/SKILL.md | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index aabe9cd..b158814 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "TeamAI — the team harness for AI agents (skill sync + shared knowledge base, powered by Git)", "type": "module", "bin": { - "teamai": "./dist/index.js" + "teamai": "dist/index.js" }, "files": [ "dist/**/*.js", @@ -29,7 +29,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/Tencent/teamai-cli.git" + "url": "git+https://github.com/Tencent/teamai-cli.git" }, "homepage": "https://github.com/Tencent/teamai-cli#readme", "bugs": { diff --git a/skills/teamai-wiki/SKILL.md b/skills/teamai-wiki/SKILL.md index a78e3fe..e86686e 100644 --- a/skills/teamai-wiki/SKILL.md +++ b/skills/teamai-wiki/SKILL.md @@ -243,10 +243,10 @@ When invoked, first determine the subcommand (init/ingest/query/lint/status/expo #### Step 1 — Parse arguments -- `WIKI_DIR`: wiki 目录路径。按以下顺序检测: - 1. team repo 中的 `wiki/` 目录(如果当前项目已通过 `teamai init` 配置)→ 首选 - 2. `~/.claude-internal/wiki/` 或 `~/.claude/wiki/`(本地 AI 工具 wiki 目录) - 3. 当前目录的 `./wiki/`(fallback) +- `WIKI_DIR`: 根据当前项目的 teamai scope 决定: + - 读取 `/.teamai/config.yaml` 中的 `scope` 字段 + - **project scope** → `/.teamai/wiki/` + - **user scope**(或无项目配置时)→ `~/.teamai/wiki/` - `SOURCE_DIR`: 可选的 `dir` 参数 如果 `WIKI_DIR` 已经存在且包含 `_metadata.json`,提示用户已经初始化过,询问是否要重新初始化。 @@ -1009,7 +1009,7 @@ Wiki exported to: 8. **_metadata.json 是真相来源** — 页面列表、文件哈希、链接图都以此为准。 9. **命名一致性** — 文件名 kebab-case,标题 Title Case 或人类可读中文。 10. **幂等性** — 重复 ingest 同一源目录应产生相同结果(不会重复创建页面)。 -11. **wiki 路径推断** — 优先使用 team repo 的 wiki/ 目录(通过 `teamai pull` 同步到本地);其次检查 `~/.claude-internal/wiki/` 或 `~/.claude/wiki/`;最后 fallback 到 `./wiki/`。 +11. **wiki 路径** — 读取 `/.teamai/config.yaml` 的 `scope` 字段:project scope 用 `/.teamai/wiki/`,user scope 用 `~/.teamai/wiki/`。与 teamai push/pull 的 `WikiHandler.getSharedWikiDir()` 逻辑对齐。 12. **Obsidian 兼容** — 所有 `[[links]]` 使用 Obsidian 格式,方便用户在 Obsidian 中直接浏览。 13. **语言** — Wiki 页面内容默认使用中文撰写,技术术语保持英文原文。 14. **智能分类** — LLM 根据内容自动判断页面归属哪个分类目录,无需用户指定。 From 843ed79db558a51400df866acb8d39bdc7e52ce4 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Wed, 20 May 2026 19:40:45 +0800 Subject: [PATCH 2/6] feat(init): display storage paths when prompting for scope Show user and project scope storage paths (e.g. ~/.teamai/, $PWD/.teamai/) before the scope selection prompt so users know where data will be stored. Closes #7 --- src/__tests__/init.test.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/init.ts | 4 +++ 2 files changed, 58 insertions(+) diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index 2ca42a4..75d526d 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -410,4 +410,58 @@ describe('init', () => { })); }); }); + + describe('scope path display', () => { + it('should display storage paths when scope is not provided via flag', async () => { + let cloneDone = false; + pathExistsFn = (p: string) => { + if (p === localPath) return cloneDone; + return false; + }; + + mockGfRepoClone.mockImplementation(() => { + cloneDone = true; + }); + + // Answers: scope (user via default), configure reviewers (n), primary role (1), no additional + questionAnswers = ['user', 'n', '1', '']; + + const { log } = await import('../utils/logger.js'); + + await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git' }); + + // Verify that path hints were displayed + expect(log.info).toHaveBeenCalledWith(expect.stringContaining('user')); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining('.teamai/')); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining('project')); + }); + + it('should not display storage paths when scope is provided via --scope flag', async () => { + let cloneDone = false; + pathExistsFn = (p: string) => { + if (p === localPath) return cloneDone; + return false; + }; + + mockGfRepoClone.mockImplementation(() => { + cloneDone = true; + }); + + // Answers: configure reviewers (n), primary role (1), no additional + questionAnswers = ['n', '1', '']; + + const { log } = await import('../utils/logger.js'); + vi.mocked(log.info).mockClear(); + + await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git', scope: 'user' }); + + // When --scope is provided, the path hints are NOT shown before the prompt + // (they appear in the "Scope: user" line only) + const infoCalls = vi.mocked(log.info).mock.calls.map(c => c[0]); + const pathHintCalls = infoCalls.filter( + (msg: string) => msg.includes('user →') || msg.includes('project →'), + ); + expect(pathHintCalls).toHaveLength(0); + }); + }); }); diff --git a/src/init.ts b/src/init.ts index 4b06ac5..bfd2303 100644 --- a/src/init.ts +++ b/src/init.ts @@ -114,6 +114,10 @@ export async function init(options: GlobalOptions & { repo?: string; scope?: str if (options.scope === 'project' || options.scope === 'user') { scope = options.scope as Scope; } else { + const userPath = getTeamaiHome('user'); + const projectPath = getTeamaiHome('project', process.cwd()); + log.info(` user → ${userPath}/`); + log.info(` project → ${projectPath}/`); const scopeAnswer = await askQuestion('Scope [user/project] (default: user): ', 'user'); if (scopeAnswer.toLowerCase() === 'project') { scope = 'project'; From be158d4e8b375a1a129bbc3e72e9857b51d23af5 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Fri, 22 May 2026 11:14:39 +0800 Subject: [PATCH 3/6] feat: merge multiple hooks per event into single hook-dispatch process Resolves #6. Instead of spawning N separate `teamai ` processes per Claude Code hook event, introduce a unified `teamai hook-dispatch ` command that reads STDIN once and fans out to all handlers internally. Key changes: - New dispatcher core (hook-dispatch.ts) with routing, isolation via Promise.allSettled, per-handler timeout, and output merging - Handler registry (hook-handlers.ts) wrapping existing subcommand logic - hooks.ts now emits 9 merged dispatch entries instead of 13 individual ones - Legacy hooks are auto-cleaned on next `teamai hooks inject` - Backward compatible: standalone subcommands (pull, track, etc.) unchanged Performance: lightweight events (prompt-submit) drop from ~1040ms to ~220ms by eliminating redundant Node.js cold starts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/doctor.test.ts | 17 +- src/__tests__/hook-dispatch.test.ts | 210 +++++++++++++++++ src/__tests__/hook-handlers.test.ts | 146 ++++++++++++ src/__tests__/hooks-dispatch-format.test.ts | 239 ++++++++++++++++++++ src/__tests__/hooks.test.ts | 89 ++++---- src/__tests__/usage-tracking.test.ts | 79 +++---- src/hook-dispatch-cli.ts | 54 +++++ src/hook-dispatch.ts | 118 ++++++++++ src/hook-handlers.ts | 226 ++++++++++++++++++ src/hooks.ts | 160 +++++-------- src/index.ts | 12 + 11 files changed, 1155 insertions(+), 195 deletions(-) create mode 100644 src/__tests__/hook-dispatch.test.ts create mode 100644 src/__tests__/hook-handlers.test.ts create mode 100644 src/__tests__/hooks-dispatch-format.test.ts create mode 100644 src/hook-dispatch-cli.ts create mode 100644 src/hook-dispatch.ts create mode 100644 src/hook-handlers.ts diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index f0aeb34..d3e1b2c 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -99,10 +99,11 @@ describe('doctor — hook checks', () => { }); it('should fail when a subcommand is missing from settings', async () => { - // Missing 'contribute-check' subcommand + // Missing 'hook-dispatch' subcommand (the only required one now) mockedReadFileSafe.mockImplementation(async (filePath: string) => { if (filePath.includes('settings.json')) { - return buildPartialHooksContent(['contribute-check']); + // Return settings without hook-dispatch + return '{ "hooks": { "command": "bash -lc \\"teamai pull\\"" } }'; } if (filePath.includes('.zshrc') || filePath.includes('.bashrc')) { return '# [teamai:env:start]'; @@ -136,14 +137,8 @@ describe('doctor — hook checks', () => { }); it('should check all TEAMAI_HOOK_SUBCOMMANDS', () => { - // Verify the subcommands list is what we expect - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('pull'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('update'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track-slash'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('dashboard-report'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('contribute-check'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('auto-recall'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(7); + // With the merged dispatch format, only hook-dispatch is needed + expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('hook-dispatch'); + expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(1); }); }); diff --git a/src/__tests__/hook-dispatch.test.ts b/src/__tests__/hook-dispatch.test.ts new file mode 100644 index 0000000..450a944 --- /dev/null +++ b/src/__tests__/hook-dispatch.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Test doubles ──────────────────────────────────────── + +/** Minimal handler interface for testing. */ +interface TestHandler { + name: string; + execute: ReturnType; +} + +function createHandler(name: string, output?: string): TestHandler { + return { + name, + execute: vi.fn().mockResolvedValue(output ?? null), + }; +} + +// ── Import after understanding module shape ───────────── + +import { + createDispatcher, + type HookHandler, + type DispatchResult, +} from '../hook-dispatch.js'; + +// ── Tests ─────────────────────────────────────────────── + +describe('hook-dispatch', () => { + describe('routing', () => { + it('dispatches to all handlers registered for the given event+matcher', async () => { + const pullHandler = createHandler('pull'); + const dashboardHandler = createHandler('dashboard-report'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: pullHandler }, + { event: 'session-start', matcher: '*', handler: dashboardHandler }, + { event: 'stop', matcher: '*', handler: createHandler('update') }, + ], + }); + + const stdin = { session_id: 'test-123', cwd: '/tmp' }; + await dispatcher.dispatch('session-start', '*', stdin, 'claude'); + + expect(pullHandler.execute).toHaveBeenCalledOnce(); + expect(dashboardHandler.execute).toHaveBeenCalledOnce(); + }); + + it('does not invoke handlers for a different event', async () => { + const stopHandler = createHandler('update'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: createHandler('pull') }, + { event: 'stop', matcher: '*', handler: stopHandler }, + ], + }); + + await dispatcher.dispatch('session-start', '*', {}, 'claude'); + + expect(stopHandler.execute).not.toHaveBeenCalled(); + }); + + it('does not invoke handlers with a different matcher', async () => { + const skillHandler = createHandler('track'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'post-tool-use', matcher: '*', handler: createHandler('dashboard') }, + { event: 'post-tool-use', matcher: 'Skill', handler: skillHandler }, + ], + }); + + await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude'); + + expect(skillHandler.execute).not.toHaveBeenCalled(); + }); + + it('wildcard matcher handlers also fire when a specific matcher is dispatched', async () => { + const wildcardHandler = createHandler('dashboard'); + const bashHandler = createHandler('auto-recall'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'post-tool-use', matcher: '*', handler: wildcardHandler }, + { event: 'post-tool-use', matcher: 'Bash', handler: bashHandler }, + ], + }); + + await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude'); + + expect(wildcardHandler.execute).toHaveBeenCalledOnce(); + expect(bashHandler.execute).toHaveBeenCalledOnce(); + }); + }); + + describe('isolation', () => { + it('a failing handler does not prevent other handlers from executing', async () => { + const failingHandler = createHandler('failing'); + failingHandler.execute.mockRejectedValue(new Error('boom')); + const successHandler = createHandler('success'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: failingHandler }, + { event: 'session-start', matcher: '*', handler: successHandler }, + ], + }); + + await dispatcher.dispatch('session-start', '*', {}, 'claude'); + + expect(successHandler.execute).toHaveBeenCalledOnce(); + }); + + it('returns errors from failed handlers in the result', async () => { + const failingHandler = createHandler('failing'); + failingHandler.execute.mockRejectedValue(new Error('boom')); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: failingHandler }, + { event: 'session-start', matcher: '*', handler: createHandler('ok') }, + ], + }); + + const result = await dispatcher.dispatch('session-start', '*', {}, 'claude'); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].handlerName).toBe('failing'); + expect(result.errors[0].error.message).toBe('boom'); + }); + }); + + describe('output merging', () => { + it('returns output from the handler that produces one', async () => { + const outputHandler = createHandler('auto-recall', '{"hookSpecificOutput":{"additionalContext":"found stuff"}}'); + const silentHandler = createHandler('dashboard'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'post-tool-use', matcher: 'Bash', handler: outputHandler }, + { event: 'post-tool-use', matcher: '*', handler: silentHandler }, + ], + }); + + const result = await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude'); + + expect(result.output).toBe('{"hookSpecificOutput":{"additionalContext":"found stuff"}}'); + }); + + it('returns null output when no handler produces output', async () => { + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: createHandler('pull') }, + { event: 'session-start', matcher: '*', handler: createHandler('dashboard') }, + ], + }); + + const result = await dispatcher.dispatch('session-start', '*', {}, 'claude'); + + expect(result.output).toBeNull(); + }); + }); + + describe('stdin sharing', () => { + it('passes the same stdin object to all handlers', async () => { + const handler1 = createHandler('h1'); + const handler2 = createHandler('h2'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'stop', matcher: '*', handler: handler1 }, + { event: 'stop', matcher: '*', handler: handler2 }, + ], + }); + + 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'); + }); + }); + + describe('timeout', () => { + it('aborts a handler that exceeds its timeout', async () => { + const slowHandler: TestHandler = { + name: 'slow', + execute: vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve('late'), 5000)), + ), + }; + const fastHandler = createHandler('fast', 'quick'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: slowHandler, timeoutMs: 50 }, + { event: 'session-start', matcher: '*', handler: fastHandler }, + ], + }); + + const result = await dispatcher.dispatch('session-start', '*', {}, 'claude'); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].handlerName).toBe('slow'); + expect(result.errors[0].error.message).toContain('timeout'); + expect(result.output).toBe('quick'); + }); + }); +}); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts new file mode 100644 index 0000000..01527a6 --- /dev/null +++ b/src/__tests__/hook-handlers.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────── +// Mock the underlying modules so handlers don't do real I/O + +const mockPull = vi.fn().mockResolvedValue(undefined); +const mockDashboardReport = vi.fn().mockResolvedValue(undefined); +const mockParseHookEvent = vi.fn().mockResolvedValue({ type: 'session_start', timestamp: '2026-01-01', sessionId: 'test', tool: 'claude' }); +const mockAppendEvent = vi.fn().mockResolvedValue(undefined); +const mockTrackFromParsed = vi.fn().mockResolvedValue(undefined); +const mockTrackSlashFromParsed = vi.fn().mockResolvedValue(undefined); +const mockAutoRecallFromParsed = vi.fn().mockResolvedValue(null); +const mockContributeCheckForSession = vi.fn().mockResolvedValue({ hint: null }); +const mockDoUpdate = vi.fn().mockResolvedValue(undefined); + +vi.mock('../pull.js', () => ({ + pull: mockPull, +})); + +vi.mock('../dashboard-collector.js', () => ({ + parseHookEvent: mockParseHookEvent, + appendEvent: mockAppendEvent, + compactEvents: vi.fn().mockResolvedValue(undefined), + dashboardReport: mockDashboardReport, +})); + +vi.mock('../usage-tracker.js', () => ({ + trackFromStdin: mockTrackFromParsed, + trackSlashCommand: mockTrackSlashFromParsed, + extractSkillName: vi.fn(), + isValidSkillName: vi.fn().mockReturnValue(true), + appendUsageEvent: vi.fn().mockResolvedValue(undefined), + updateKnownSkills: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../auto-recall.js', () => ({ + autoRecall: mockAutoRecallFromParsed, +})); + +vi.mock('../contribute-check.js', () => ({ + contributeCheck: vi.fn().mockResolvedValue(undefined), + contributeCheckForSession: mockContributeCheckForSession, +})); + +vi.mock('../update.js', () => ({ + doUpdate: mockDoUpdate, + checkForUpdate: vi.fn().mockResolvedValue({ available: false, current: '1.0.0' }), +})); + +vi.mock('../config.js', () => ({ + autoDetectInit: vi.fn().mockResolvedValue({ + localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, + teamConfig: { team: 'test', repo: '', toolPaths: {} }, + }), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { buildHandlerRegistry, type HandlerRegistration } from '../hook-handlers.js'; + +// ── Tests ──────────────────────────────────────────────── + +describe('hook-handlers registry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns registrations for all expected events', () => { + const registry = buildHandlerRegistry(); + const events = new Set(registry.map((r) => r.event)); + expect(events).toContain('session-start'); + expect(events).toContain('stop'); + expect(events).toContain('post-tool-use'); + expect(events).toContain('prompt-submit'); + }); + + it('session-start has pull and dashboard-report handlers', () => { + const registry = buildHandlerRegistry(); + const sessionStartHandlers = registry + .filter((r) => r.event === 'session-start' && r.matcher === '*') + .map((r) => r.handler.name); + expect(sessionStartHandlers).toContain('pull'); + expect(sessionStartHandlers).toContain('dashboard-report'); + }); + + it('stop has update, contribute-check, and dashboard-report handlers', () => { + const registry = buildHandlerRegistry(); + const stopHandlers = registry + .filter((r) => r.event === 'stop' && r.matcher === '*') + .map((r) => r.handler.name); + expect(stopHandlers).toContain('update'); + expect(stopHandlers).toContain('contribute-check'); + expect(stopHandlers).toContain('dashboard-report'); + }); + + it('post-tool-use wildcard has dashboard-report', () => { + const registry = buildHandlerRegistry(); + const wildcardHandlers = registry + .filter((r) => r.event === 'post-tool-use' && r.matcher === '*') + .map((r) => r.handler.name); + expect(wildcardHandlers).toContain('dashboard-report'); + }); + + it('post-tool-use Skill matcher has track', () => { + const registry = buildHandlerRegistry(); + const skillHandlers = registry + .filter((r) => r.event === 'post-tool-use' && r.matcher === 'Skill') + .map((r) => r.handler.name); + expect(skillHandlers).toContain('track'); + }); + + it('post-tool-use Bash/Grep/WebSearch/WebFetch have auto-recall', () => { + const registry = buildHandlerRegistry(); + for (const matcher of ['Bash', 'Grep', 'WebSearch', 'WebFetch']) { + const handlers = registry + .filter((r) => r.event === 'post-tool-use' && r.matcher === matcher) + .map((r) => r.handler.name); + expect(handlers).toContain('auto-recall'); + } + }); + + it('prompt-submit has track-slash and dashboard-report', () => { + const registry = buildHandlerRegistry(); + const handlers = registry + .filter((r) => r.event === 'prompt-submit' && r.matcher === '*') + .map((r) => r.handler.name); + expect(handlers).toContain('track-slash'); + expect(handlers).toContain('dashboard-report'); + }); + + it('all handlers have timeoutMs set', () => { + const registry = buildHandlerRegistry(); + for (const reg of registry) { + expect(reg.timeoutMs).toBeGreaterThan(0); + } + }); + + it('pull handler has a longer timeout than dashboard-report', () => { + const registry = buildHandlerRegistry(); + const pull = registry.find((r) => r.handler.name === 'pull'); + const dashboard = registry.find((r) => r.handler.name === 'dashboard-report'); + expect(pull!.timeoutMs).toBeGreaterThan(dashboard!.timeoutMs!); + }); +}); diff --git a/src/__tests__/hooks-dispatch-format.test.ts b/src/__tests__/hooks-dispatch-format.test.ts new file mode 100644 index 0000000..62d6d14 --- /dev/null +++ b/src/__tests__/hooks-dispatch-format.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────── + +let mockFiles: Record = {}; + +vi.mock('../utils/fs.js', () => ({ + readJson: vi.fn(async (filePath: string) => mockFiles[filePath] ?? null), + writeJson: vi.fn(async (filePath: string, data: unknown) => { + mockFiles[filePath] = JSON.parse(JSON.stringify(data)); + }), + expandHome: (p: string) => p, + ensureDir: vi.fn(), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { injectHooks } from '../hooks.js'; + +// ── Helpers ────────────────────────────────────────────── + +interface ClaudeHookEntry { + type: string; + command: string; + timeout?: number; +} + +interface ClaudeHookMatcher { + matcher: string; + hooks: ClaudeHookEntry[]; + description?: string; +} + +interface CursorHookEntry { + command: string; + timeout?: number; + matcher?: string; +} + +// ── Tests ──────────────────────────────────────────────── + +describe('hooks — merged dispatch format', () => { + beforeEach(() => { + mockFiles = {}; + vi.clearAllMocks(); + }); + + describe('Claude format', () => { + it('uses hook-dispatch commands instead of individual subcommands', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const allCommands: string[] = []; + for (const matchers of Object.values(result.hooks)) { + for (const m of matchers) { + for (const h of m.hooks) { + allCommands.push(h.command); + } + } + } + + // All commands should use hook-dispatch + const dispatchCommands = allCommands.filter((c) => c.includes('teamai hook-dispatch')); + expect(dispatchCommands.length).toBe(allCommands.length); + // No individual subcommands like "teamai pull", "teamai track", etc. + const legacyCommands = allCommands.filter( + (c) => c.includes('teamai pull') || c.includes('teamai track') || c.includes('teamai dashboard-report'), + ); + expect(legacyCommands).toHaveLength(0); + }); + + it('produces fewer hook entries than the old format (13 → merged)', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + let totalEntries = 0; + for (const matchers of Object.values(result.hooks)) { + totalEntries += matchers.length; + } + + // Should have: SessionStart(1) + Stop(1) + PostToolUse(Skill:1, *:1, Bash:1, Grep:1, WebSearch:1, WebFetch:1) + UserPromptSubmit(1) = 9 + expect(totalEntries).toBeLessThanOrEqual(9); + }); + + it('SessionStart has exactly one entry with wildcard matcher', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const sessionStart = result.hooks.SessionStart; + expect(sessionStart).toHaveLength(1); + expect(sessionStart[0].matcher).toBe('*'); + expect(sessionStart[0].hooks[0].command).toContain('hook-dispatch session-start'); + }); + + it('Stop has exactly one entry with wildcard matcher', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const stop = result.hooks.Stop; + expect(stop).toHaveLength(1); + expect(stop[0].matcher).toBe('*'); + expect(stop[0].hooks[0].command).toContain('hook-dispatch stop'); + }); + + it('PostToolUse has entries for each distinct matcher', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const postToolUse = result.hooks.PostToolUse; + const matchers = postToolUse.map((m: ClaudeHookMatcher) => m.matcher).sort(); + expect(matchers).toEqual(['*', 'Bash', 'Grep', 'Skill', 'WebFetch', 'WebSearch']); + }); + + it('UserPromptSubmit has exactly one entry with wildcard matcher', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const promptSubmit = result.hooks.UserPromptSubmit; + expect(promptSubmit).toHaveLength(1); + expect(promptSubmit[0].matcher).toBe('*'); + expect(promptSubmit[0].hooks[0].command).toContain('hook-dispatch prompt-submit'); + }); + + it('includes --tool parameter in dispatch commands', async () => { + await injectHooks('/test/settings.json', 'claude-internal'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const cmd = result.hooks.SessionStart[0].hooks[0].command; + expect(cmd).toContain('--tool claude-internal'); + }); + + it('includes --matcher parameter for non-wildcard PostToolUse entries', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const bashEntry = result.hooks.PostToolUse.find((m: ClaudeHookMatcher) => m.matcher === 'Bash'); + expect(bashEntry!.hooks[0].command).toContain('--matcher Bash'); + }); + + it('does not include --matcher for wildcard entries', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const wildcardEntry = result.hooks.SessionStart[0]; + expect(wildcardEntry.hooks[0].command).not.toContain('--matcher'); + }); + }); + + describe('Cursor format', () => { + it('uses hook-dispatch commands', async () => { + await injectHooks('/test/hooks.json', 'cursor'); + + const result = mockFiles['/test/hooks.json'] as { hooks: Record }; + const allCommands: string[] = []; + for (const entries of Object.values(result.hooks)) { + for (const entry of entries) { + allCommands.push(entry.command); + } + } + + const dispatchCommands = allCommands.filter((c) => c.includes('teamai hook-dispatch')); + expect(dispatchCommands.length).toBe(allCommands.length); + }); + + it('Cursor hooks have correct camelCase event names in commands', async () => { + await injectHooks('/test/hooks.json', 'cursor'); + + const result = mockFiles['/test/hooks.json'] as { hooks: Record }; + expect(result.hooks.sessionStart).toBeDefined(); + expect(result.hooks.stop).toBeDefined(); + expect(result.hooks.postToolUse).toBeDefined(); + expect(result.hooks.beforeSubmitPrompt).toBeDefined(); + }); + }); + + describe('migration — legacy cleanup', () => { + it('removes old-format hooks and replaces with dispatch format', async () => { + // Pre-populate with legacy format + mockFiles['/test/settings.json'] = { + hooks: { + SessionStart: [ + { + matcher: '*', + hooks: [{ type: 'command', command: 'bash -lc "teamai pull 2>/dev/null" || true' }], + description: '[teamai] Auto-pull team resources on session start', + }, + { + matcher: '*', + hooks: [{ type: 'command', command: 'bash -lc "teamai dashboard-report --stdin --tool claude 2>/dev/null" || true' }], + description: '[teamai] Dashboard report on session start', + }, + ], + }, + }; + + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + // Legacy entries should be cleaned up and replaced with single dispatch entry + expect(result.hooks.SessionStart).toHaveLength(1); + expect(result.hooks.SessionStart[0].hooks[0].command).toContain('hook-dispatch'); + }); + + it('preserves non-teamai hooks during migration', async () => { + mockFiles['/test/settings.json'] = { + hooks: { + SessionStart: [ + { + matcher: '*', + hooks: [{ type: 'command', command: 'bash -lc "teamai pull 2>/dev/null" || true' }], + description: '[teamai] Auto-pull team resources on session start', + }, + { + matcher: '*', + hooks: [{ type: 'command', command: 'echo "custom hook"' }], + description: 'My custom hook', + }, + ], + }, + }; + + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const customHook = result.hooks.SessionStart.find( + (m: ClaudeHookMatcher) => m.description === 'My custom hook', + ); + expect(customHook).toBeDefined(); + }); + }); +}); diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts index 8a69f2b..4f56cb2 100644 --- a/src/__tests__/hooks.test.ts +++ b/src/__tests__/hooks.test.ts @@ -23,7 +23,7 @@ vi.mock('../utils/logger.js', () => ({ }, })); -import { injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS } from '../hooks.js'; +import { injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, TEAMAI_LEGACY_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS } from '../hooks.js'; // ── Helpers ────────────────────────────────────────────── @@ -62,7 +62,7 @@ describe('hooks', () => { }); describe('inject — empty file', () => { - it('Claude format: injects 4 events with 13 hooks into empty settings.json', async () => { + it('Claude format: injects 4 events with 9 dispatch hooks into empty settings.json', async () => { await injectHooks('/test/settings.json', 'claude'); const result = mockFiles['/test/settings.json'] as { hooks: Record }; @@ -71,16 +71,15 @@ describe('hooks', () => { const events = Object.keys(result.hooks); expect(events).toEqual(['SessionStart', 'Stop', 'PostToolUse', 'UserPromptSubmit']); - // Stop has 3 hooks (update, dashboard-stop, contribute-check) - // PostToolUse has 6 hooks (track-skill, dashboard-tool, 4x auto-recall per tool) - // Others have 2 each - expect(result.hooks['SessionStart']).toHaveLength(2); - expect(result.hooks['Stop']).toHaveLength(3); + // Merged format: one dispatch entry per event+matcher + // SessionStart(*:1), Stop(*:1), PostToolUse(*:1, Skill:1, Bash:1, Grep:1, WebSearch:1, WebFetch:1), UserPromptSubmit(*:1) + expect(result.hooks['SessionStart']).toHaveLength(1); + expect(result.hooks['Stop']).toHaveLength(1); expect(result.hooks['PostToolUse']).toHaveLength(6); - expect(result.hooks['UserPromptSubmit']).toHaveLength(2); + expect(result.hooks['UserPromptSubmit']).toHaveLength(1); }); - it('Cursor format: injects 4 events with 13 hooks into empty hooks.json', async () => { + it('Cursor format: injects 4 events with 9 dispatch hooks into empty hooks.json', async () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { version: number; hooks: Record }; @@ -90,12 +89,11 @@ describe('hooks', () => { const events = Object.keys(result.hooks); expect(events).toEqual(['sessionStart', 'stop', 'postToolUse', 'beforeSubmitPrompt']); - // stop has 3 hooks (update, dashboard-stop, contribute-check) - // postToolUse has 6 hooks (track, dashboard, 4x auto-recall per tool) - expect(result.hooks['sessionStart']).toHaveLength(2); - expect(result.hooks['stop']).toHaveLength(3); + // Same merged structure + expect(result.hooks['sessionStart']).toHaveLength(1); + expect(result.hooks['stop']).toHaveLength(1); expect(result.hooks['postToolUse']).toHaveLength(6); - expect(result.hooks['beforeSubmitPrompt']).toHaveLength(2); + expect(result.hooks['beforeSubmitPrompt']).toHaveLength(1); }); it('Claude uses PascalCase event names', async () => { @@ -121,10 +119,10 @@ describe('hooks', () => { await injectHooks('/test/settings.json', 'claude'); const result = mockFiles['/test/settings.json'] as { hooks: Record }; - expect(result.hooks['SessionStart']).toHaveLength(2); - expect(result.hooks['Stop']).toHaveLength(3); + expect(result.hooks['SessionStart']).toHaveLength(1); + expect(result.hooks['Stop']).toHaveLength(1); expect(result.hooks['PostToolUse']).toHaveLength(6); - expect(result.hooks['UserPromptSubmit']).toHaveLength(2); + expect(result.hooks['UserPromptSubmit']).toHaveLength(1); }); it('double inject for Cursor does not duplicate hooks', async () => { @@ -132,13 +130,14 @@ describe('hooks', () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { hooks: Record }; - expect(result.hooks['sessionStart']).toHaveLength(2); - expect(result.hooks['stop']).toHaveLength(3); + expect(result.hooks['sessionStart']).toHaveLength(1); + expect(result.hooks['stop']).toHaveLength(1); expect(result.hooks['postToolUse']).toHaveLength(6); - expect(result.hooks['beforeSubmitPrompt']).toHaveLength(2); + expect(result.hooks['beforeSubmitPrompt']).toHaveLength(1); }); it('updates command when content changes (Claude)', async () => { + // Simulate legacy hook that will be cleaned up and replaced with dispatch mockFiles['/test/settings.json'] = { hooks: { SessionStart: [ @@ -155,11 +154,12 @@ describe('hooks', () => { const result = mockFiles['/test/settings.json'] as { hooks: Record }; const sessionStart = result.hooks.SessionStart as Array<{ hooks: Array<{ command: string }> }>; - expect(sessionStart[0].hooks[0].command).toContain('teamai pull'); - expect(sessionStart[0].hooks[0].command).not.toContain('--silent'); + // Legacy format cleaned up, replaced with hook-dispatch + expect(sessionStart[0].hooks[0].command).toContain('hook-dispatch'); }); it('updates command when content changes (Cursor)', async () => { + // Simulate legacy hook mockFiles['/test/hooks.json'] = { version: 1, hooks: { @@ -172,8 +172,8 @@ describe('hooks', () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { hooks: Record> }; - const pullHook = result.hooks.sessionStart.find((h) => h.command.includes('teamai pull')); - expect(pullHook?.command).not.toContain('--silent'); + // Legacy format cleaned up, replaced with hook-dispatch + expect(result.hooks.sessionStart[0].command).toContain('hook-dispatch'); }); }); @@ -195,7 +195,8 @@ describe('hooks', () => { hooks: Record; language: string; }; - expect(result.hooks.SessionStart).toHaveLength(3); + // User hook + 1 dispatch entry + expect(result.hooks.SessionStart).toHaveLength(2); expect(result.hooks.SessionStart[0]).toEqual(userHook); expect(result.language).toBe('en'); }); @@ -210,7 +211,8 @@ describe('hooks', () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { hooks: Record }; - expect(result.hooks.sessionStart).toHaveLength(3); + // User hook + 1 dispatch entry + expect(result.hooks.sessionStart).toHaveLength(2); expect(result.hooks.sessionStart[0]).toEqual(userHook); }); }); @@ -273,7 +275,8 @@ describe('hooks', () => { const result = mockFiles['/test/hooks.json'] as { hooks: Record }; expect(result.hooks['userPromptSubmit']).toBeUndefined(); - expect(result.hooks['beforeSubmitPrompt']).toHaveLength(2); + // New merged format: single dispatch entry + expect(result.hooks['beforeSubmitPrompt']).toHaveLength(1); }); it('Cursor inject preserves user hooks in stale event keys', async () => { @@ -399,20 +402,21 @@ describe('hooks', () => { } }); - it('PostToolUse/postToolUse track hook uses Skill matcher in both formats', async () => { + it('PostToolUse/postToolUse Skill matcher dispatch hook exists in both formats', async () => { await injectHooks('/test/claude.json', 'claude'); await injectHooks('/test/cursor.json', 'cursor'); const claudeResult = mockFiles['/test/claude.json'] as { hooks: Record> }; const cursorResult = mockFiles['/test/cursor.json'] as { hooks: Record> }; - const claudeTrack = claudeResult.hooks.PostToolUse.find((h) => h.matcher === 'Skill'); - expect(claudeTrack).toBeDefined(); + const claudeSkill = claudeResult.hooks.PostToolUse.find((h) => h.matcher === 'Skill'); + expect(claudeSkill).toBeDefined(); - const cursorTrack = cursorResult.hooks.postToolUse.find( - (h) => h.command.includes('teamai track') && !h.command.includes('track-slash') + const cursorSkill = cursorResult.hooks.postToolUse.find( + (h) => h.matcher === 'Skill' ); - expect(cursorTrack?.matcher).toBe('Skill'); + expect(cursorSkill).toBeDefined(); + expect(cursorSkill!.command).toContain('hook-dispatch'); }); it('Cursor hooks have timeout values', async () => { @@ -457,12 +461,19 @@ describe('hooks', () => { }); describe('TEAMAI_HOOK_SUBCOMMANDS export', () => { - it('contains all expected subcommands', () => { - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('pull'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('update'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track-slash'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('dashboard-report'); + it('contains hook-dispatch as the unified subcommand', () => { + expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('hook-dispatch'); + expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(1); + }); + + it('TEAMAI_LEGACY_HOOK_SUBCOMMANDS contains all old subcommands for cleanup', () => { + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('pull'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('update'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('track'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('track-slash'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('dashboard-report'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('contribute-check'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('auto-recall'); }); }); diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index 3a5e66d..2adf7a8 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -1135,7 +1135,7 @@ describe('track with tool parameter', () => { // ─── hook command string tests ──────────────────────── describe('hook command strings', () => { - it('generates track commands with --tool parameter', async () => { + it('generates dispatch commands with --tool parameter', async () => { // Import the module to test hook injection const { injectHooks } = await import('../hooks.js'); const settingsPath = path.join(tmpDir, '.test-claude', 'settings.json'); @@ -1145,26 +1145,26 @@ describe('hook command strings', () => { const settings = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); - // Check PostToolUse hook has --tool claude-internal + // Check PostToolUse hooks have --tool claude-internal const postToolUse = settings.hooks?.PostToolUse; expect(postToolUse).toBeDefined(); - const trackHook = postToolUse.find((h: { description?: string }) => - h.description?.includes('Track skill'), + const skillHook = postToolUse.find((h: { description?: string }) => + h.description?.includes('Hook dispatch post-tool-use Skill'), ); - expect(trackHook).toBeDefined(); - expect(trackHook.hooks[0].command).toContain('--tool claude-internal'); + expect(skillHook).toBeDefined(); + expect(skillHook.hooks[0].command).toContain('--tool claude-internal'); // Check UserPromptSubmit hook has --tool claude-internal const userPrompt = settings.hooks?.UserPromptSubmit; expect(userPrompt).toBeDefined(); - const slashHook = userPrompt.find((h: { description?: string }) => - h.description?.includes('Track slash'), + const promptHook = userPrompt.find((h: { description?: string }) => + h.description?.includes('Hook dispatch prompt-submit'), ); - expect(slashHook).toBeDefined(); - expect(slashHook.hooks[0].command).toContain('--tool claude-internal'); + expect(promptHook).toBeDefined(); + expect(promptHook.hooks[0].command).toContain('--tool claude-internal'); }); - it('generates track commands with --tool claude for default tool', async () => { + it('generates dispatch commands with --tool claude for default tool', async () => { const { injectHooks } = await import('../hooks.js'); const settingsPath = path.join(tmpDir, '.test-claude2', 'settings.json'); await fse.ensureDir(path.dirname(settingsPath)); @@ -1173,10 +1173,10 @@ describe('hook command strings', () => { const settings = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); const postToolUse = settings.hooks?.PostToolUse; - const trackHook = postToolUse.find((h: { description?: string }) => - h.description?.includes('Track skill'), + const skillHook = postToolUse.find((h: { description?: string }) => + h.description?.includes('Hook dispatch post-tool-use Skill'), ); - expect(trackHook.hooks[0].command).toContain('--tool claude'); + expect(skillHook.hooks[0].command).toContain('--tool claude'); }); it('cleans up legacy hooks without description on inject', async () => { @@ -1207,22 +1207,16 @@ describe('hook command strings', () => { const result = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); - // Legacy duplicates should be cleaned, replaced by proper hooks with description - // SessionStart has 2 hooks: Auto-pull + Dashboard report - expect(result.hooks.SessionStart).toHaveLength(2); - expect(result.hooks.SessionStart.every((h: { description?: string }) => h.description)).toBe(true); + // Legacy duplicates should be cleaned, replaced by single dispatch entry with description + // SessionStart has 1 hook (hook-dispatch session-start) + expect(result.hooks.SessionStart).toHaveLength(1); + expect(result.hooks.SessionStart[0].description).toContain('[teamai]'); + expect(result.hooks.SessionStart[0].hooks[0].command).toContain('hook-dispatch'); - // Stop has 3 hooks: Auto-update + Dashboard stop + Contribute check - expect(result.hooks.Stop).toHaveLength(3); - expect(result.hooks.Stop.every((h: { description?: string }) => h.description)).toBe(true); - - // Auto-update hook must have a 10s timeout — npm registry call should not - // delay session shutdown by Claude Code's default 60s if it stalls. - const updateHook = result.hooks.Stop.find((h: { description?: string }) => - h.description?.includes('Auto-update'), - ); - expect(updateHook).toBeDefined(); - expect(updateHook.hooks[0].timeout).toBe(10); + // Stop has 1 hook (hook-dispatch stop) + expect(result.hooks.Stop).toHaveLength(1); + expect(result.hooks.Stop[0].description).toContain('[teamai]'); + expect(result.hooks.Stop[0].hooks[0].command).toContain('hook-dispatch'); // Non-teamai hooks should be preserved expect(result.hooks.PreToolUse).toHaveLength(1); @@ -1248,18 +1242,18 @@ describe('hook command strings', () => { const result = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); - // continuous-learning hook preserved, legacy teamai track removed + replaced with proper one + // continuous-learning hook preserved, legacy teamai track removed + replaced with dispatch const observeHooks = result.hooks.PostToolUse.filter( (h: { hooks?: Array<{ command: string }> }) => h.hooks?.[0]?.command?.includes('observe.sh'), ); expect(observeHooks).toHaveLength(1); - // teamai track hook should have description now - const trackHooks = result.hooks.PostToolUse.filter( - (h: { description?: string }) => h.description?.includes('Track skill'), + // teamai dispatch hook should exist with Skill matcher + const skillHooks = result.hooks.PostToolUse.filter( + (h: { description?: string }) => h.description?.includes('Hook dispatch post-tool-use Skill'), ); - expect(trackHooks).toHaveLength(1); - expect(trackHooks[0].hooks[0].command).toContain('--tool claude'); + expect(skillHooks).toHaveLength(1); + expect(skillHooks[0].hooks[0].command).toContain('--tool claude'); }); it('cleans up hooks with outdated description keywords', async () => { @@ -1267,8 +1261,7 @@ describe('hook command strings', () => { const settingsPath = path.join(tmpDir, '.test-outdated-desc', 'settings.json'); await fse.ensureDir(path.dirname(settingsPath)); - // Simulate: old description "Check for updates" + current "Auto-update" both present - // CodeBuddy uses PascalCase keys (same as Claude) — HookExecutor looks up by PascalCase + // Simulate: old description hooks that should be cleaned up const outdatedSettings = { hooks: { Stop: [ @@ -1291,14 +1284,10 @@ describe('hook command strings', () => { const result = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); - // Legacy entries cleaned up, fresh hooks injected under PascalCase "Stop" key - // (update + dashboard-report + contribute-check = 3) - expect(result.hooks.Stop).toHaveLength(3); - const updateHook = result.hooks.Stop.find((h: { description?: string }) => - h.description?.includes('Auto-update'), - ); - expect(updateHook).toBeDefined(); - expect(updateHook.hooks[0].command).toContain('teamai update'); + // Legacy entries cleaned up, replaced with single dispatch entry + expect(result.hooks.Stop).toHaveLength(1); + expect(result.hooks.Stop[0].hooks[0].command).toContain('hook-dispatch stop'); + expect(result.hooks.Stop[0].hooks[0].command).toContain('--tool codebuddy'); }); }); diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts new file mode 100644 index 0000000..5eb5742 --- /dev/null +++ b/src/hook-dispatch-cli.ts @@ -0,0 +1,54 @@ +/** + * CLI entry point for `teamai hook-dispatch --tool [--matcher ]`. + * + * Reads STDIN once, creates the dispatcher with the full handler registry, + * and dispatches to all matching handlers. Outputs any handler result to STDOUT. + */ + +import { createDispatcher } from './hook-dispatch.js'; +import { buildHandlerRegistry } from './hook-handlers.js'; +import { log } from './utils/logger.js'; + +/** Read STDIN fully. Returns empty string if STDIN is a TTY. */ +async function readStdin(): Promise { + if (process.stdin.isTTY) return ''; + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +/** + * Main CLI handler for hook-dispatch. + */ +export async function hookDispatchCli(event: string, tool: string, matcher: string): Promise { + // Read STDIN once — shared across all handlers + const raw = await readStdin(); + let stdin: Record = {}; + if (raw.trim()) { + try { + stdin = JSON.parse(raw); + } catch { + log.debug(`hook-dispatch: failed to parse STDIN JSON for event=${event}`); + return; + } + } + + // Build dispatcher with full handler registry + const registry = buildHandlerRegistry(); + const dispatcher = createDispatcher({ handlers: registry }); + + // Dispatch + const result = await dispatcher.dispatch(event, matcher, stdin, tool); + + // Log errors (to debug, not STDOUT — STDOUT is reserved for hook output) + for (const err of result.errors) { + log.debug(`hook-dispatch: handler "${err.handlerName}" failed: ${err.error.message}`); + } + + // Write output to STDOUT if any handler produced one + if (result.output) { + process.stdout.write(result.output); + } +} diff --git a/src/hook-dispatch.ts b/src/hook-dispatch.ts new file mode 100644 index 0000000..132e020 --- /dev/null +++ b/src/hook-dispatch.ts @@ -0,0 +1,118 @@ +/** + * Hook Dispatcher — unified entry point for teamai hooks. + * + * Instead of spawning N separate processes per event, Claude Code invokes + * a single `teamai hook-dispatch [--matcher ]` command. + * The dispatcher reads STDIN once and fans out to all registered handlers. + * + * Design: + * - Handlers are pure functions: (stdin, tool) → output | null + * - Promise.allSettled ensures one handler crash doesn't take down others + * - At most one handler per event produces STDOUT output + */ + +// ─── Public types ─────────────────────────────────────── + +export interface HookHandler { + name: string; + execute(stdin: Record, tool: string): Promise; +} + +export interface HandlerRegistration { + event: string; + matcher: string; + handler: HookHandler; + /** Per-handler timeout in ms. If exceeded, handler is treated as failed. */ + timeoutMs?: number; +} + +export interface DispatchError { + handlerName: string; + error: Error; +} + +export interface DispatchResult { + /** Combined STDOUT output (at most one handler produces output per event). */ + output: string | null; + /** Errors from failed handlers (non-fatal — other handlers still ran). */ + errors: DispatchError[]; +} + +export interface DispatcherConfig { + handlers: HandlerRegistration[]; +} + +export interface Dispatcher { + dispatch(event: string, matcher: string, stdin: Record, tool: string): Promise; +} + +// ─── Implementation ───────────────────────────────────── + +/** Default timeout: 60 seconds (matches Claude Code's default hook timeout). */ +const DEFAULT_TIMEOUT_MS = 60_000; + +/** + * Wrap a promise with a timeout. Rejects with a timeout error if not resolved in time. + */ +function withTimeout(promise: Promise, ms: number, handlerName: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Handler "${handlerName}" exceeded timeout of ${ms}ms`)); + }, ms); + promise.then( + (val) => { clearTimeout(timer); resolve(val); }, + (err) => { clearTimeout(timer); reject(err); }, + ); + }); +} + +/** + * Create a dispatcher with the given handler registrations. + * + * Routing rules: + * - A handler matches if its event matches AND (its matcher === dispatched matcher OR its matcher === '*') + * - Wildcard ('*') matchers fire for any dispatched matcher on that event + */ +export function createDispatcher(config: DispatcherConfig): Dispatcher { + return { + async dispatch(event, matcher, stdin, tool): Promise { + // Find all handlers that should fire for this event+matcher + const matched = config.handlers.filter((reg) => { + if (reg.event !== event) return false; + // Wildcard handlers always fire; specific matchers must match exactly + return reg.matcher === '*' || reg.matcher === matcher; + }); + + // Execute all matched handlers concurrently with isolation + per-handler timeout + 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); + }), + ); + + // Collect results + let output: string | null = null; + const errors: DispatchError[] = []; + + for (let i = 0; i < settled.length; i++) { + const result = settled[i]; + const handlerName = matched[i].handler.name; + + if (result.status === 'rejected') { + errors.push({ + handlerName, + error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)), + }); + } else if (result.status === 'fulfilled' && result.value != null) { + // First non-null output wins (at most one handler should produce output per event) + if (output === null) { + output = result.value; + } + } + } + + return { output, errors }; + }, + }; +} diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts new file mode 100644 index 0000000..9c6aa4d --- /dev/null +++ b/src/hook-handlers.ts @@ -0,0 +1,226 @@ +/** + * Hook Handler Registry — maps event+matcher to concrete handler implementations. + * + * Each handler wraps an existing teamai subcommand function but accepts pre-parsed + * STDIN data instead of reading from process.stdin directly. This enables the + * dispatcher to read STDIN once and fan out to all handlers. + * + * Existing standalone subcommands (`teamai pull`, `teamai track --stdin`, etc.) + * remain unchanged for backward compatibility during migration. + */ + +import type { HookHandler } from './hook-dispatch.js'; + +// ─── Public types ─────────────────────────────────────── + +export interface HandlerRegistration { + event: string; + matcher: string; + handler: HookHandler; + timeoutMs: number; +} + +// ─── Timeout constants ────────────────────────────────── + +/** Pull involves git network ops — generous timeout. */ +const PULL_TIMEOUT_MS = 60_000; +/** Update checks npm registry — cap at 10s to avoid blocking session shutdown. */ +const UPDATE_TIMEOUT_MS = 10_000; +/** Track/track-slash is a local file append — very fast. */ +const TRACK_TIMEOUT_MS = 5_000; +/** Dashboard-report is a local file append — very fast. */ +const DASHBOARD_TIMEOUT_MS = 5_000; +/** Contribute-check reads local state + events.jsonl — generally fast. */ +const CONTRIBUTE_CHECK_TIMEOUT_MS = 10_000; +/** Auto-recall involves search index lookup — usually <200ms. */ +const AUTO_RECALL_TIMEOUT_MS = 10_000; + +// ─── Handler implementations ──────────────────────────── +// +// Each handler is a thin adapter that: +// 1. Receives pre-parsed STDIN (Record) +// 2. Delegates to the actual subcommand logic +// 3. Returns output string or null +// +// IMPORTANT: These use dynamic imports to keep module loading lazy. +// The dispatcher only loads the modules that actually need to run. + +const pullHandler: HookHandler = { + name: 'pull', + async execute(_stdin, _tool) { + const { pull } = await import('./pull.js'); + await pull({ silent: true }); + return null; + }, +}; + +const updateHandler: HookHandler = { + name: 'update', + async execute(_stdin, _tool) { + const { doUpdate } = await import('./update.js'); + await doUpdate(); + return null; + }, +}; + +const dashboardReportHandler: HookHandler = { + name: 'dashboard-report', + async execute(stdin, tool) { + const { parseHookEvent, appendEvent, compactEvents } = await import('./dashboard-collector.js'); + const raw = JSON.stringify(stdin); + const event = await parseHookEvent(raw, tool); + if (event) { + await appendEvent(event); + // Non-blocking compaction + compactEvents().catch(() => {}); + } + return null; + }, +}; + +const trackHandler: HookHandler = { + name: 'track', + async execute(stdin, tool) { + const { extractSkillName, isValidSkillName, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); + + const toolName = stdin.tool_name; + if (typeof toolName !== 'string') return null; + + const toolInput = stdin.tool_input; + if (!toolInput || typeof toolInput !== 'object') return null; + + // Only track Skill (Claude) or Read+SKILL.md (Cursor) + let skillName: string | null = null; + let toolSource = tool; + + if (toolName === 'Skill') { + skillName = extractSkillName(toolInput as Record); + } else if (toolName === 'Read') { + const filePath = + (typeof (toolInput as Record).file_path === 'string' + ? (toolInput as Record).file_path + : null) ?? + (typeof (toolInput as Record).path === 'string' + ? (toolInput as Record).path + : null); + if (typeof filePath === 'string' && /\/SKILL\.md$/i.test(filePath)) { + skillName = extractSkillName({ skill: filePath }); + toolSource = 'cursor'; + } + } else { + return null; + } + + if (!skillName || !isValidSkillName(skillName)) return null; + + await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool: toolSource }); + await updateKnownSkills(skillName); + return null; + }, +}; + +const trackSlashHandler: HookHandler = { + name: 'track-slash', + async execute(stdin, tool) { + const { extractSkillName, isValidSkillName, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); + + const prompt = stdin.prompt; + if (typeof prompt !== 'string' || !prompt.startsWith('/')) return null; + + // Extract skill name: first word after "/" + const match = prompt.match(/^\/([\w-]+)/); + if (!match) return null; + + const skillName = match[1]; + if (!isValidSkillName(skillName)) return null; + + await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool }); + await updateKnownSkills(skillName); + return null; + }, +}; + +const contributeCheckHandler: HookHandler = { + name: 'contribute-check', + async execute(stdin, _tool) { + const { contributeCheckForSession } = await import('./contribute-check.js'); + + // Derive session ID from STDIN + const sessionId = typeof stdin.session_id === 'string' ? stdin.session_id : null; + if (!sessionId) return null; + + const { hint } = await contributeCheckForSession(sessionId); + if (hint) { + // Stop event format: { stopReason: "..." } + return JSON.stringify({ stopReason: hint }); + } + return null; + }, +}; + +const autoRecallHandler: HookHandler = { + name: 'auto-recall', + async execute(stdin, _tool) { + // Auto-recall has complex internal logic (tool dispatch, error detection, rate limiting) + // For now, delegate to the existing function by temporarily mocking STDIN. + // TODO: Refactor autoRecall to accept parsed data directly. + const { autoRecall } = await import('./auto-recall.js'); + + // The auto-recall function reads STDIN internally. To avoid changing its signature + // in this phase, we capture its STDOUT output via a process.stdout.write intercept. + let capturedOutput: string | null = null; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: unknown) => { + if (typeof chunk === 'string') { + capturedOutput = chunk; + } else if (Buffer.isBuffer(chunk)) { + capturedOutput = chunk.toString(); + } + return true; + }) as typeof process.stdout.write; + + try { + // We can't easily pipe stdin to the function, so for this handler + // we'll rely on the environment (process.stdin being piped from Claude Code). + // In the dispatcher, auto-recall will be invoked with the raw data. + await autoRecall(); + } finally { + process.stdout.write = originalWrite; + } + + return capturedOutput; + }, +}; + +// ─── Registry builder ─────────────────────────────────── + +/** + * Build the complete handler registry for the hook dispatcher. + * Returns all handler registrations with their event, matcher, timeout, and implementation. + */ +export function buildHandlerRegistry(): HandlerRegistration[] { + return [ + // ─── SessionStart ───────────────────────────────── + { event: 'session-start', matcher: '*', handler: pullHandler, timeoutMs: PULL_TIMEOUT_MS }, + { event: 'session-start', matcher: '*', handler: dashboardReportHandler, timeoutMs: DASHBOARD_TIMEOUT_MS }, + + // ─── Stop ───────────────────────────────────────── + { event: 'stop', matcher: '*', handler: updateHandler, timeoutMs: UPDATE_TIMEOUT_MS }, + { event: 'stop', matcher: '*', handler: contributeCheckHandler, timeoutMs: CONTRIBUTE_CHECK_TIMEOUT_MS }, + { event: 'stop', matcher: '*', handler: dashboardReportHandler, timeoutMs: DASHBOARD_TIMEOUT_MS }, + + // ─── PostToolUse ────────────────────────────────── + { event: 'post-tool-use', matcher: '*', handler: dashboardReportHandler, timeoutMs: DASHBOARD_TIMEOUT_MS }, + { event: 'post-tool-use', matcher: 'Skill', handler: trackHandler, timeoutMs: TRACK_TIMEOUT_MS }, + ...(['Bash', 'Grep', 'WebSearch', 'WebFetch'] as const).map((m) => ({ + event: 'post-tool-use' as const, + matcher: m, + handler: autoRecallHandler, + timeoutMs: AUTO_RECALL_TIMEOUT_MS, + })), + + // ─── UserPromptSubmit ───────────────────────────── + { event: 'prompt-submit', matcher: '*', handler: trackSlashHandler, timeoutMs: TRACK_TIMEOUT_MS }, + { event: 'prompt-submit', matcher: '*', handler: dashboardReportHandler, timeoutMs: DASHBOARD_TIMEOUT_MS }, + ]; +} diff --git a/src/hooks.ts b/src/hooks.ts index 2b0d327..b581858 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -3,36 +3,17 @@ import { readJson, writeJson, expandHome, ensureDir } from './utils/fs.js'; import { log } from './utils/logger.js'; import { TEAMAI_HOOK_DESCRIPTION_PREFIX } from './types.js'; -const TEAMAI_PULL_COMMAND = 'bash -lc "teamai pull 2>/dev/null" || true'; -const TEAMAI_UPDATE_COMMAND = 'bash -lc "teamai update 2>/dev/null" || true'; - -/** Generate the track command with tool identifier for correct usage attribution. */ -function getTrackCommand(tool: string): string { - return `bash -lc "teamai track --stdin --tool ${tool} 2>/dev/null" || true`; -} - -/** Generate the track-slash command with tool identifier. */ -function getTrackSlashCommand(tool: string): string { - return `bash -lc "teamai track-slash --stdin --tool ${tool} 2>/dev/null" || true`; -} - -/** Generate the dashboard-report command with tool identifier. */ -function getDashboardReportCommand(tool: string): string { - return `bash -lc "teamai dashboard-report --stdin --tool ${tool} 2>/dev/null" || true`; -} - -/** Generate the auto-recall command with tool identifier. */ -function getAutoRecallCommand(tool: string): string { - return `bash -lc "teamai auto-recall --stdin 2>/dev/null" || true`; -} - -/** Generate the contribute-check command with tool identifier. */ -function getContributeCheckCommand(tool: string): string { - return `bash -lc "teamai contribute-check --stdin --tool ${tool} 2>/dev/null" || true`; +/** Generate the hook-dispatch command for a given event, tool, and optional matcher. */ +function getDispatchCommand(event: string, tool: string, matcher?: string): string { + const matcherArg = matcher && matcher !== '*' ? ` --matcher ${matcher}` : ''; + return `bash -lc "teamai hook-dispatch ${event} --tool ${tool}${matcherArg} 2>/dev/null" || true`; } /** Subcommands expected in each tool settings file (for `teamai doctor`). */ -export const TEAMAI_HOOK_SUBCOMMANDS = ['pull', 'update', 'track', 'track-slash', 'dashboard-report', 'contribute-check', 'auto-recall'] as const; +export const TEAMAI_HOOK_SUBCOMMANDS = ['hook-dispatch'] as const; + +/** Legacy subcommands that are cleaned up during migration. */ +export const TEAMAI_LEGACY_HOOK_SUBCOMMANDS = ['pull', 'update', 'track', 'track-slash', 'dashboard-report', 'contribute-check', 'auto-recall'] as const; /** Claude PascalCase event → Cursor camelCase event (for tests / docs). */ export const CLAUDE_TO_CURSOR_EVENTS: Record = { @@ -93,101 +74,64 @@ interface ClaudeHookDef { /** Build Claude hook definitions with the correct --tool identifier. */ function getClaudeHooks(tool: string): ClaudeHookDef[] { return [ + // ─── SessionStart: single dispatcher handles pull + dashboard-report ──── { eventType: 'SessionStart', - descriptionKeyword: 'Auto-pull', + descriptionKeyword: 'Hook dispatch session-start', hook: { matcher: '*', - hooks: [{ type: 'command', command: TEAMAI_PULL_COMMAND }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Auto-pull team resources on session start`, + hooks: [{ type: 'command', command: getDispatchCommand('session-start', tool) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch session-start`, }, }, + // ─── Stop: single dispatcher handles update + contribute-check + dashboard-report ──── { eventType: 'Stop', - descriptionKeyword: 'Auto-update', + descriptionKeyword: 'Hook dispatch stop', hook: { matcher: '*', - // 10s timeout: npm registry call typically <5s; cap at 10s so a stalled - // call cannot delay session shutdown by the default 60s. - hooks: [{ type: 'command', command: TEAMAI_UPDATE_COMMAND, timeout: 10 }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Auto-update on session end`, + hooks: [{ type: 'command', command: getDispatchCommand('stop', tool) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch stop`, }, }, - // ─── Contribute check (smart threshold hint at session end) ──────── + // ─── PostToolUse (*): dashboard-report ──── { - eventType: 'Stop', - descriptionKeyword: 'Contribute check', + eventType: 'PostToolUse', + descriptionKeyword: 'Hook dispatch post-tool-use wildcard', hook: { matcher: '*', - hooks: [{ type: 'command', command: getContributeCheckCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Contribute check on session end`, + hooks: [{ type: 'command', command: getDispatchCommand('post-tool-use', tool) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch post-tool-use wildcard`, }, }, + // ─── PostToolUse (Skill): track ──── { eventType: 'PostToolUse', - descriptionKeyword: 'Track skill', + descriptionKeyword: 'Hook dispatch post-tool-use Skill', hook: { matcher: 'Skill', - hooks: [{ type: 'command', command: getTrackCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Track skill usage`, + hooks: [{ type: 'command', command: getDispatchCommand('post-tool-use', tool, 'Skill') }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch post-tool-use Skill`, }, }, - { - eventType: 'UserPromptSubmit', - descriptionKeyword: 'Track slash', - hook: { - matcher: '*', - hooks: [{ type: 'command', command: getTrackSlashCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Track slash command usage`, - }, - }, - // ─── Auto-recall (search knowledge base on search tools + Bash errors) ──────── - // Split into 4 precise matchers to avoid spawning a process for tools that - // would immediately exit (auto-recall only handles Bash/Grep/WebSearch/WebFetch). + // ─── PostToolUse (Bash/Grep/WebSearch/WebFetch): auto-recall ──── ...(['Bash', 'Grep', 'WebSearch', 'WebFetch'] as const).map((matcher) => ({ eventType: 'PostToolUse' as const, - descriptionKeyword: `Auto-recall ${matcher}`, + descriptionKeyword: `Hook dispatch post-tool-use ${matcher}`, hook: { matcher, - hooks: [{ type: 'command', command: getAutoRecallCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Auto-recall on ${matcher}`, + hooks: [{ type: 'command', command: getDispatchCommand('post-tool-use', tool, matcher) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch post-tool-use ${matcher}`, }, })), - // ─── Dashboard hooks (independent from tracking) ──────── - { - eventType: 'SessionStart', - descriptionKeyword: 'Dashboard report', - hook: { - matcher: '*', - hooks: [{ type: 'command', command: getDashboardReportCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Dashboard report on session start`, - }, - }, - { - eventType: 'Stop', - descriptionKeyword: 'Dashboard stop', - hook: { - matcher: '*', - hooks: [{ type: 'command', command: getDashboardReportCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Dashboard report on session stop`, - }, - }, - { - eventType: 'PostToolUse', - descriptionKeyword: 'Dashboard tool', - hook: { - matcher: '*', - hooks: [{ type: 'command', command: getDashboardReportCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Dashboard report on tool use`, - }, - }, + // ─── UserPromptSubmit: track-slash + dashboard-report ──── { eventType: 'UserPromptSubmit', - descriptionKeyword: 'Dashboard prompt', + descriptionKeyword: 'Hook dispatch prompt-submit', hook: { matcher: '*', - hooks: [{ type: 'command', command: getDashboardReportCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Dashboard report on prompt submit`, + hooks: [{ type: 'command', command: getDispatchCommand('prompt-submit', tool) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch prompt-submit`, }, }, ]; @@ -210,26 +154,22 @@ interface CursorHooksJson { function buildCursorHooks(tool: string): Record { return { sessionStart: [ - { command: TEAMAI_PULL_COMMAND, timeout: 30 }, - { command: getDashboardReportCommand(tool), timeout: 10 }, + { command: getDispatchCommand('session-start', tool), timeout: 60 }, ], stop: [ - { command: TEAMAI_UPDATE_COMMAND, timeout: 10 }, - { command: getDashboardReportCommand(tool), timeout: 10 }, - { command: getContributeCheckCommand(tool), timeout: 10 }, + { command: getDispatchCommand('stop', tool), timeout: 15 }, ], postToolUse: [ - { command: getTrackCommand(tool), timeout: 10, matcher: 'Skill' }, - { command: getDashboardReportCommand(tool), timeout: 10 }, + { command: getDispatchCommand('post-tool-use', tool), timeout: 10 }, + { command: getDispatchCommand('post-tool-use', tool, 'Skill'), timeout: 10, matcher: 'Skill' }, ...(['Bash', 'Grep', 'WebSearch', 'WebFetch'] as const).map((matcher) => ({ - command: getAutoRecallCommand(tool), - timeout: 3, + command: getDispatchCommand('post-tool-use', tool, matcher), + timeout: 10, matcher, })), ], beforeSubmitPrompt: [ - { command: getTrackSlashCommand(tool), timeout: 10 }, - { command: getDashboardReportCommand(tool), timeout: 10 }, + { command: getDispatchCommand('prompt-submit', tool), timeout: 10 }, ], }; } @@ -424,6 +364,26 @@ async function injectCursorHooks(hooksPath: string, tool: string): Promise const desiredHooks = buildCursorHooks(tool); let changed = false; + // Clean up legacy individual teamai hooks (pull, track, dashboard-report, etc.) + // that are being replaced by unified hook-dispatch entries. + for (const event of Object.keys(hooksJson.hooks)) { + const entries = hooksJson.hooks[event]; + const filtered = entries.filter((h) => { + if (!isTeamaiHookCommand(h.command)) return true; + // Keep hook-dispatch entries, remove all legacy individual subcommand entries + const subcmd = extractTeamaiSubcommand(h.command); + return subcmd === 'hook-dispatch'; + }); + if (filtered.length !== entries.length) { + changed = true; + if (filtered.length === 0) { + delete hooksJson.hooks[event]; + } else { + hooksJson.hooks[event] = filtered; + } + } + } + // Clean up stale event keys no longer in the desired set (e.g. userPromptSubmit → beforeSubmitPrompt rename) const desiredEvents = new Set(Object.keys(desiredHooks)); for (const event of Object.keys(hooksJson.hooks)) { diff --git a/src/index.ts b/src/index.ts index fff2e0d..8dff420 100644 --- a/src/index.ts +++ b/src/index.ts @@ -554,4 +554,16 @@ program } }); +// ─── Unified hook dispatch (replaces individual hook subcommands) ──── + +program + .command('hook-dispatch ') + .description('Unified hook dispatcher — handles all teamai hooks for a given event in one process') + .option('--tool ', 'Tool identifier (e.g. claude, claude-internal, cursor)') + .option('--matcher ', 'Hook matcher for PostToolUse (e.g. Skill, Bash)') + .action(async (event: string, cmdOpts: { tool?: string; matcher?: string }) => { + const { hookDispatchCli } = await import('./hook-dispatch-cli.js'); + await hookDispatchCli(event, cmdOpts.tool ?? 'claude', cmdOpts.matcher ?? '*'); + }); + program.parse(); From 578e03ad9965601089e769a3941b148f393656eb Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Fri, 22 May 2026 11:26:00 +0800 Subject: [PATCH 4/6] fix(test): update hooks-e2e.test.ts expectations for merged dispatch format The E2E test file still expected the old 13-hook format. Updated to match the new 9-entry dispatch format (1 per event+matcher instead of N per event). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/__tests__/hooks-e2e.test.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/__tests__/hooks-e2e.test.ts b/src/__tests__/hooks-e2e.test.ts index 098c723..23fdb45 100644 --- a/src/__tests__/hooks-e2e.test.ts +++ b/src/__tests__/hooks-e2e.test.ts @@ -39,7 +39,7 @@ async function readResult(filePath: string): Promise> { describe('hooks E2E — real file I/O', () => { describe('inject — full injection to temp directories', () => { - it('creates Claude settings.json with all 4 events and 13 hooks', async () => { + it('creates Claude settings.json with all 4 events and 9 dispatch hooks', async () => { const p = claudePath(); await injectHooks(p, 'claude'); @@ -47,13 +47,13 @@ describe('hooks E2E — real file I/O', () => { const hooks = result.hooks as Record; expect(Object.keys(hooks)).toEqual(['SessionStart', 'Stop', 'PostToolUse', 'UserPromptSubmit']); - expect(hooks.SessionStart).toHaveLength(2); - expect(hooks.Stop).toHaveLength(3); + expect(hooks.SessionStart).toHaveLength(1); + expect(hooks.Stop).toHaveLength(1); expect(hooks.PostToolUse).toHaveLength(6); - expect(hooks.UserPromptSubmit).toHaveLength(2); + expect(hooks.UserPromptSubmit).toHaveLength(1); }); - it('creates Cursor hooks.json with all 4 events and 13 hooks', async () => { + it('creates Cursor hooks.json with all 4 events and 9 dispatch hooks', async () => { const p = cursorPath(); await injectHooks(p, 'cursor'); @@ -62,10 +62,10 @@ describe('hooks E2E — real file I/O', () => { const hooks = result.hooks as Record; expect(Object.keys(hooks)).toEqual(['sessionStart', 'stop', 'postToolUse', 'beforeSubmitPrompt']); - expect(hooks.sessionStart).toHaveLength(2); - expect(hooks.stop).toHaveLength(3); + expect(hooks.sessionStart).toHaveLength(1); + expect(hooks.stop).toHaveLength(1); expect(hooks.postToolUse).toHaveLength(6); - expect(hooks.beforeSubmitPrompt).toHaveLength(2); + expect(hooks.beforeSubmitPrompt).toHaveLength(1); }); it('all TEAMAI_HOOK_SUBCOMMANDS present in Claude output', async () => { @@ -123,7 +123,7 @@ describe('hooks E2E — real file I/O', () => { } }); - it('PostToolUse track hook uses Skill matcher in both formats', async () => { + it('PostToolUse dispatch hook uses Skill matcher in both formats', async () => { const cp = claudePath(); const kp = cursorPath(); await injectHooks(cp, 'claude'); @@ -138,11 +138,9 @@ describe('hooks E2E — real file I/O', () => { const claudeSkillHook = claudePostTool.find((h) => h.matcher === 'Skill'); expect(claudeSkillHook).toBeDefined(); - const cursorTrackHook = cursorPostTool.find( - (h) => h.command.includes('teamai track') && !h.command.includes('track-slash') - ); - expect(cursorTrackHook).toBeDefined(); - expect(cursorTrackHook?.matcher).toBe('Skill'); + const cursorSkillHook = cursorPostTool.find((h) => h.matcher === 'Skill'); + expect(cursorSkillHook).toBeDefined(); + expect(cursorSkillHook?.command).toContain('hook-dispatch'); }); it('tool-specific commands use the correct --tool parameter', async () => { From 585ce9715b64334c470e7ea4446ac267e5eb92d9 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Fri, 22 May 2026 15:41:47 +0800 Subject: [PATCH 5/6] 0.16.6 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6d7acd4..0244452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "teamai-cli", - "version": "0.16.5", + "version": "0.16.6", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index b158814..415c0c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "teamai-cli", - "version": "0.16.5", + "version": "0.16.6", "description": "TeamAI — the team harness for AI agents (skill sync + shared knowledge base, powered by Git)", "type": "module", "bin": { From b0edb75b1fbb320279d88c0c0dbd008130889f9a Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Fri, 22 May 2026 16:27:27 +0800 Subject: [PATCH 6/6] feat: auto-migrate hooks to dispatch format on first session after update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the CLI is updated but settings.json still has old individual hook entries (teamai pull, teamai track, etc.), the pull handler now detects this on session start and reinjects hooks in the new dispatch format. This happens automatically on the first session after `teamai update`: 1. Old hooks fire (backward compat) → pull handler runs in NEW binary 2. Detects settings.json lacks 'hook-dispatch' → calls injectHooksToAllTools() 3. Next session: dispatch format active, full performance benefit Detection is lightweight (~1ms): read settings.json, check if 'hook-dispatch' substring exists. Only triggers reinject when old format is detected. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pull.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/pull.ts b/src/pull.ts index 259c228..afbc657 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -790,12 +790,52 @@ async function collectClaudemdFiles( return contents; } +/** + * Auto-migrate hooks from old individual format to unified hook-dispatch format. + * Runs at session start: if settings.json doesn't contain 'hook-dispatch' commands, + * it means the user updated the CLI but hooks are still in old format. + * Reinjects with the current version's hook definitions. + */ +async function autoMigrateHooksIfNeeded(): Promise { + const home = process.env.HOME ?? ''; + // Quick check: read the primary settings file and see if it has hook-dispatch + const primarySettings = path.join(home, '.claude', 'settings.json'); + if (!await pathExists(primarySettings)) return; + + const content = await readFileSafe(primarySettings); + if (!content) return; + + // If hook-dispatch is already present, no migration needed + if (content.includes('hook-dispatch')) return; + + // If no teamai hooks at all (user never ran init), skip + if (!content.includes('teamai')) return; + + // Old format detected — reinject all tools + log.debug('Auto-migrating hooks to dispatch format...'); + const { autoDetectInit } = await import('./config.js'); + const { injectHooksToAllTools } = await import('./hooks.js'); + const { localConfig, teamConfig } = await autoDetectInit(); + const baseDir = resolveBaseDir(localConfig); + await injectHooksToAllTools(teamConfig.toolPaths, baseDir); + log.debug('Hooks migrated to dispatch format'); +} + /** * Main pull entry point. * Implements Scheme B: user scope is always pulled (baseline), * project scope is additionally pulled if detected in cwd. */ export async function pull(options: GlobalOptions): Promise { + // 0. Auto-migrate hooks if settings.json has old format (pre-dispatch era). + // This runs on the first session start after a CLI update — the new binary + // detects the old individual hooks and reinjects the merged dispatch format. + try { + await autoMigrateHooksIfNeeded(); + } catch { + // Non-fatal — pull continues even if hook migration fails + } + // 1. Always try to pull user scope let userConfig: LocalConfig | null = null; try {