From 61d2f1093897d7c32e7b7c8fcae1ad6109224b1f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 20 Oct 2026 15:10:48 +0200 Subject: [PATCH 1/2] feat(tools): add a per-tool descriptor with home and skill-load evidence (#1221) TOOLS records each built-in tool's home directory and how its agent loads a skill (dedicated skill tool, prompt mention, or 'unknown'), with an evidence class per value. ToolId is derived from DEFAULT_TOOL_PATHS, so a new built-in tool fails tsc until it has a descriptor. KNOWN_AGENTS user skills paths for pi, omp and opencode now match their user-scope defaults; home detection and the init picker read home.dir so Pi detection does not move. Tests pin the tool lists to ToolId, every user-scope path under its home, and skillLoad to the tools that receive the wildcard post-tool-use hook. --- src/__tests__/tools.test.ts | 106 ++++++++++++++++++++++++++++++++++ src/init.ts | 4 +- src/known-agents.ts | 3 +- src/resources/agent-format.ts | 1 + src/tools.ts | 56 ++++++++++++++++++ src/types.ts | 7 ++- 6 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/tools.test.ts create mode 100644 src/tools.ts diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts new file mode 100644 index 000000000..4038193c8 --- /dev/null +++ b/src/__tests__/tools.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { installedBuiltinHookDefs } from '../builtin-hooks.js'; +import { KNOWN_AGENTS, SELF_MODE_AGENT_CHOICES } from '../known-agents.js'; +import { ModelAgentSchema, type ModelAgent } from '../models/profile.js'; +import { ALL_SUPPORTED_TOOLS, EXTRAS_BASE_TOOL, type ToolName } from '../resources/agent-format.js'; +import { isToolId, TOOLS, type ToolDescriptor } from '../tools.js'; +import { DEFAULT_TOOL_PATHS, scopedToolPaths, TeamaiConfigSchema, type ToolId } from '../types.js'; +import { CODEX_TOOL_IDS, normalizeAgentType } from '../utils/tool-names.js'; +import type { LocalWorkflowTool } from '../workflow/local-tool-adapter.js'; + +const TOOL_IDS = Object.keys(DEFAULT_TOOL_PATHS).filter(isToolId); +const USER_PATHS = scopedToolPaths(TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.com/t.git' }), { scope: 'user' }); + +/** User-scope paths keyed by field, `$HOME`-relative. Copilot's are relative to its own home. */ +function userPaths(tool: ToolId): Array<[field: string, relPath: string]> { + const { mcpProject: _project, userScope: _userScope, ...fields } = USER_PATHS[tool]; + return Object.entries(fields) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string') + .map(([field, relPath]) => [field, tool === 'copilot' ? path.posix.join(TOOLS.copilot.home.dir, relPath) : relPath]); +} + +type IsSubset = [TSub] extends [TSuper] ? true : false; + +describe('ToolId', () => { + it('covers every typed tool list', () => { + const lists: true[] = [ + true satisfies IsSubset, + true satisfies IsSubset, + true satisfies IsSubset, + ]; + expect(lists).toHaveLength(3); + }); + + it('covers every runtime tool list and alias table', () => { + const ids = [ + ...ALL_SUPPORTED_TOOLS, + ...ModelAgentSchema.options, + ...SELF_MODE_AGENT_CHOICES, + ...CODEX_TOOL_IDS, + ...Object.entries(EXTRAS_BASE_TOOL).flat(), + ...TOOL_IDS.map(normalizeAgentType), + ]; + expect(ids.filter((id) => !isToolId(id))).toEqual([]); + }); + + // KNOWN_AGENTS.skillsPath is also the no-config project root (project-agent-root.ts), + // so these keep their project path; home detection reads TOOLS[tool].home instead. + const PROJECT_SKILLS_PATH: ToolId[] = ['omp', 'opencode', 'pi']; + + it('gives each built-in tool the user skills path KNOWN_AGENTS reports, apart from the project-path ones', () => { + const disagreements = TOOL_IDS.flatMap((tool) => { + const fromDefaults = Object.fromEntries(userPaths(tool)).skills; + const known = KNOWN_AGENTS.find((agent) => agent.id === tool)?.skillsPath; + return fromDefaults === known ? [] : [tool]; + }); + expect(disagreements.sort()).toEqual(PROJECT_SKILLS_PATH); + for (const tool of PROJECT_SKILLS_PATH) { + expect(KNOWN_AGENTS.find((agent) => agent.id === tool)?.skillsPath).toBe(DEFAULT_TOOL_PATHS[tool].skills); + } + }); +}); + +describe('TOOLS home', () => { + // User-scope paths that sit outside the tool's home, each for a reason. + const OUTSIDE_HOME: Record = { + 'claude.mcp': 'Claude Code keeps its user MCP servers in ~/.claude.json, beside ~/.claude', + 'zcode.mcp': 'ZCode reads user MCP servers from the shared ~/.agents/mcp.json', + 'hermes.claudemd': 'retired ~/AGENTS.md, kept only so cleanup can find it; Hermes reads SOUL.md', + 'workbuddy.claudemd': 'retired ~/AGENTS.md, kept only so cleanup can find it', + }; + + it('holds every user-scope path of the tool, apart from the listed exceptions', () => { + const outside = TOOL_IDS.flatMap((tool) => userPaths(tool) + .filter(([, relPath]) => !relPath.startsWith(`${TOOLS[tool].home.dir}/`)) + .map(([field, relPath]) => ({ key: `${tool}.${field}`, relPath }))); + expect(outside.map(({ key }) => key).sort()).toEqual(Object.keys(OUTSIDE_HOME).sort()); + }); +}); + +describe('TOOLS skillLoad', () => { + const WILDCARD_POST_TOOL_USE = 'Hook dispatch post-tool-use wildcard'; + + /** Whether teamai installs the wildcard post-tool-use hook for this tool, the pass that sees its skill loads. */ + function hasWildcardPostToolUse(tool: ToolId): boolean { + const paths = DEFAULT_TOOL_PATHS[tool]; + // dsh-hooks.ts renders builtinHookDefs('dsh') into dsh's Claude-format bridge file. + const settingsDriven = tool === 'dsh' || 'settings' in paths || 'hooks' in paths; + return installedBuiltinHookDefs(tool, settingsDriven) + .some((def) => def.key === WILDCARD_POST_TOOL_USE && def.matcher === '*'); + } + + it('is known exactly for the tools that receive the wildcard post-tool-use hook', () => { + const known = TOOL_IDS.filter((tool) => TOOLS[tool].skillLoad !== 'unknown'); + expect(known).toEqual(TOOL_IDS.filter(hasWildcardPostToolUse)); + }); + + it('is required on every descriptor', () => { + const tools = { + ...TOOLS, + // @ts-expect-error a tool whose skill loading nobody has observed says 'unknown' + pi: { home: { dir: '.pi' } }, + } satisfies Record; + expect(tools.pi.home.dir).toBe('.pi'); + }); +}); diff --git a/src/init.ts b/src/init.ts index ee198563f..70df373e0 100644 --- a/src/init.ts +++ b/src/init.ts @@ -107,6 +107,7 @@ import { SELF_MODE_AGENT_CHOICES, KNOWN_AGENTS, } from './known-agents.js'; +import { TOOLS } from './tools.js'; /** * Record each relocated tool root (every entry of `RELOCATABLE_TOOLS`, by the @@ -1082,8 +1083,7 @@ export async function promptForSelfModeAgents(options: { const detected = await detectHomeInstalledAgents(); const tools = SELF_MODE_AGENT_CHOICES.map((id) => { const meta = KNOWN_AGENTS.find((a) => a.id === id); - const root = meta ? path.posix.dirname(meta.skillsPath) : `.${id}`; - return { id, label: meta?.displayName ?? id, root }; + return { id, label: meta?.displayName ?? id, root: TOOLS[id].home.dir }; }); const detectedLabels = detected diff --git a/src/known-agents.ts b/src/known-agents.ts index 05f8eb587..a0513d092 100644 --- a/src/known-agents.ts +++ b/src/known-agents.ts @@ -13,6 +13,7 @@ import { detectToolRoot, } from './types.js'; import { isToolInstalledForConfig } from './resources/base.js'; +import { isToolId, TOOLS } from './tools.js'; import type { LocalConfig, TeamaiConfig, Scope } from './types.js'; import { getUserHome } from './utils/home.js'; @@ -313,7 +314,7 @@ export async function detectHomeInstalledAgents( if (!skillsPath) continue; // The tool's own dir, e.g. ".claude" or ".config/muse" — never a shared // parent like ".config" that exists on most machines. - const rootSegment = path.posix.dirname(skillsPath); + const rootSegment = isToolId(id) ? TOOLS[id].home.dir : path.posix.dirname(skillsPath); // A Claude Code relocated with CLAUDE_CONFIG_DIR (or a Codex with // CODEX_HOME) may have no default root at all; the developer still uses // it. This runs before any config exists, so the variable is the only signal. diff --git a/src/resources/agent-format.ts b/src/resources/agent-format.ts index 652f8edea..8d53d07ec 100644 --- a/src/resources/agent-format.ts +++ b/src/resources/agent-format.ts @@ -6,6 +6,7 @@ import { getDispatchCommand } from '../builtin-hooks.js'; // ─── Tool name type ────────────────────────────────────────────────────────── +/** The built-in tools that get subagents. `ToolId` (types.ts) is the full built-in set. */ export type ToolName = 'claude' | 'claude-internal' | 'tclaude' | 'codebuddy' | 'codex' | 'codex-internal' | 'tcodex' | 'cursor' | 'copilot' | 'joycode' | 'qoder' | 'qoder-cn' | 'kiro' | 'zcode' | 'omp' | 'opencode' | 'workbuddy' | 'muse'; export const ALL_SUPPORTED_TOOLS: ToolName[] = [ diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 000000000..74113f278 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,56 @@ +import type { ToolId } from './types.js'; + +export type Evidence = 'verified' | 'source' | 'docs' | 'assumed'; + +/** The host's dedicated skill tool, by its raw name. */ +export type SkillCall = { readonly name: string; readonly evidence: Evidence }; + +export type SkillLoad = + | { readonly calls: readonly SkillCall[]; readonly mention?: '$' | '/skill:' } + | 'unknown'; + +export type ToolDescriptor = { readonly home: { readonly dir: string }; readonly skillLoad: SkillLoad }; + +/** + * What teamai knows about each built-in tool. `home.dir` is relative to `$HOME`. + * + * `calls` lists only a dedicated skill tool; a read of a SKILL.md path is not + * one. `mention` is a prompt prefix that loads a skill with no tool call. + * `'unknown'` records nothing. Evidence per call: + * - verified: a recorded run of the tool, or its own loader run on teamai's output + * - source: the tool's source or shipped bundle + * - docs: the tool's documentation + * - assumed: teamai's own code or the issue matrix only + */ +export const TOOLS = { + claude: { home: { dir: '.claude' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'verified' }] } }, + codex: { home: { dir: '.codex' }, skillLoad: { calls: [], mention: '$' } }, + 'codex-internal': { home: { dir: '.codex-internal' }, skillLoad: { calls: [], mention: '$' } }, + 'claude-internal': { home: { dir: '.claude-internal' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'assumed' }] } }, + tclaude: { home: { dir: '.tclaude' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'assumed' }] } }, + tcodex: { home: { dir: '.tcodex' }, skillLoad: { calls: [], mention: '$' } }, + cursor: { home: { dir: '.cursor' }, skillLoad: { calls: [] } }, + copilot: { home: { dir: '.copilot' }, skillLoad: { calls: [{ name: 'skill', evidence: 'verified' }] } }, + joycode: { home: { dir: '.joycode' }, skillLoad: 'unknown' }, + qoder: { home: { dir: '.qoder' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'source' }] } }, + 'qoder-cn': { home: { dir: '.qoder-cn' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'source' }] } }, + kiro: { home: { dir: '.kiro' }, skillLoad: 'unknown' }, + trae: { home: { dir: '.trae' }, skillLoad: 'unknown' }, + 'trae-cn': { home: { dir: '.trae-cn' }, skillLoad: 'unknown' }, + zcode: { home: { dir: '.zcode' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'source' }] } }, + omp: { home: { dir: '.omp' }, skillLoad: { calls: [] } }, + pi: { home: { dir: '.pi' }, skillLoad: { calls: [], mention: '/skill:' } }, + // The CodeBuddy IDE sends `skill` (src/utils/tool-names.ts). + codebuddy: { home: { dir: '.codebuddy' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'verified' }, { name: 'skill', evidence: 'assumed' }] } }, + // Hermes (`skill_view`) and OpenClaw (`skills_read`) have skill tools, but teamai installs no post-tool-use hook for them yet. + openclaw: { home: { dir: '.openclaw' }, skillLoad: 'unknown' }, + hermes: { home: { dir: '.hermes' }, skillLoad: 'unknown' }, + dsh: { home: { dir: '.dsh' }, skillLoad: { calls: [{ name: 'skill', evidence: 'verified' }] } }, + workbuddy: { home: { dir: '.workbuddy' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'source' }, { name: 'skill', evidence: 'assumed' }] } }, + opencode: { home: { dir: '.config/opencode' }, skillLoad: { calls: [{ name: 'skill', evidence: 'verified' }] } }, + muse: { home: { dir: '.config/muse' }, skillLoad: { calls: [{ name: 'read_skill', evidence: 'verified' }] } }, +} as const satisfies Record; + +export function isToolId(value: string): value is ToolId { + return Object.hasOwn(TOOLS, value); +} diff --git a/src/types.ts b/src/types.ts index 0884798ec..57f3709b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -397,7 +397,7 @@ const TeamScriptSchema = z.object({ * Tools left without `mcp` are skipped by MCP sync rather than guessed at, so a * wrong guess can never create a junk config file on a user's machine. */ -export const DEFAULT_TOOL_PATHS: Record> = { +export const DEFAULT_TOOL_PATHS = { claude: { skills: '.claude/skills', rules: '.claude/rules', settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md', agents: '.claude/agents', mcp: '.claude.json', mcpProject: '.mcp.json' }, // Codex reads no rules directory: `.codex/rules` holds its exec-policy // `*.rules` files. In user scope teamai's blocks go to ~/.codex/AGENTS.md, @@ -656,7 +656,10 @@ export const DEFAULT_TOOL_PATHS: Record> agents: '.agents/agents', userScope: { skills: '.config/muse/skills', settings: '.config/muse/settings.json', agents: '.config/muse/agents' }, }, -}; +} satisfies Record>; + +/** A built-in tool. Custom tools in a team's `toolPaths` stay plain strings. */ +export type ToolId = keyof typeof DEFAULT_TOOL_PATHS; export const TeamaiConfigBaseSchema = z.object({ team: z.string(), From 90c1b356ce8c16f46b660348fce93a84e2b982e5 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 20 Oct 2026 21:39:10 +0200 Subject: [PATCH 2/2] feat(tools): track skill use from tool calls, path reads, and mentions (#1221) One background skill-use handler on the wildcard post-tool-use pass credits a skill when the call is the tool's skill tool (TOOLS[tool].skillLoad) or a read of an installed skill's SKILL.md (classifyToolCall, shell reads with ~/ and $HOME/ included). Prompt-submit credits /name at the start, Codex $name, and the block Pi expands /skill:name into. Each skill counts once per session turn and only when installed under a root skill delivery uses. Removes the per-tool Skill hook, SKILL_TOOL_MATCHER, and the OpenCode matcher passes; OpenCode sends raw tool names, with a one-release Skill alias. A team that disabled the retired built-in key keeps tool-call tracking off. --- docs/guide/reporting.md | 2 +- docs/guide/zh-CN/reporting.md | 2 +- skill-data/core/references/troubleshooting.md | 2 +- src/__tests__/builtin-hooks.test.ts | 19 +- src/__tests__/copilot-adapter.test.ts | 14 +- .../e2e/deleted-worktree-scope-810.test.ts | 47 +- .../hooks-skill-retire/claude-team-only.json | 16 + .../hooks-skill-retire/claude.after.json | 74 +++ .../hooks-skill-retire/claude.before.json | 84 ++++ .../hooks-skill-retire/codex.after.json | 89 ++++ .../hooks-skill-retire/codex.before.json | 100 ++++ .../hooks-skill-retire/copilot.after.json | 58 +++ .../hooks-skill-retire/copilot.before.json | 66 +++ .../hooks-skill-retire/cursor.after.json | 34 ++ .../hooks-skill-retire/cursor.before.json | 39 ++ .../hooks-skill-retire/zcode.after.json | 81 +++ .../hooks-skill-retire/zcode.before.json | 95 ++++ .../fixtures/hooks/claude-internal.json | 10 - src/__tests__/fixtures/hooks/claude.json | 10 - src/__tests__/fixtures/hooks/codebuddy.json | 11 - src/__tests__/fixtures/hooks/cursor.json | 5 - src/__tests__/fixtures/hooks/workbuddy.json | 11 - .../fixtures/skill-use/claude-skill.json | 12 + .../fixtures/skill-use/codebuddy-skill.json | 15 + .../fixtures/skill-use/codex-real-a-cat.json | 12 + .../skill-use/codex-real-a-prompt.json | 9 + .../skill-use/codex-real-b-mention.json | 9 + .../fixtures/skill-use/codex-real-c-cat.json | 12 + .../skill-use/codex-real-c-mention.json | 9 + .../fixtures/skill-use/copilot-skill.json | 10 + .../fixtures/skill-use/cursor-real-read.json | 14 + .../fixtures/skill-use/dsh-skill.json | 9 + .../fixtures/skill-use/hermes-skill-view.json | 10 + .../fixtures/skill-use/muse-read-skill.json | 13 + .../fixtures/skill-use/omp-read-uri.json | 10 + .../fixtures/skill-use/opencode-skill.json | 10 + .../fixtures/skill-use/pi-mention.json | 5 + src/__tests__/fixtures/skill-use/pi-read.json | 10 + src/__tests__/git-kind-reports.test.ts | 9 +- src/__tests__/hook-dispatch-scope.test.ts | 44 +- src/__tests__/hook-handlers.test.ts | 141 +++--- src/__tests__/hooks-cmd.test.ts | 4 +- src/__tests__/hooks-dispatch-format.test.ts | 6 +- src/__tests__/hooks-e2e.test.ts | 26 +- src/__tests__/hooks-reconcile-scope.test.ts | 4 +- src/__tests__/hooks-skill-retire.test.ts | 77 +++ src/__tests__/hooks-team.test.ts | 24 +- src/__tests__/hooks.test.ts | 30 +- src/__tests__/muse.test.ts | 9 +- src/__tests__/opencode-hooks.test.ts | 24 +- src/__tests__/skill-use.test.ts | 473 ++++++++++++++++++ src/__tests__/tool-call.test.ts | 25 +- src/__tests__/tools.test.ts | 5 + src/__tests__/usage-tracking.test.ts | 181 ++++--- src/builtin-hooks.ts | 26 +- src/contribute-check.ts | 2 +- src/hook-handlers.ts | 143 ++---- src/opencode-hooks.ts | 48 +- src/resources/hooks.ts | 24 + src/skill-use.ts | 261 ++++++++++ src/tools.ts | 16 +- src/types.ts | 9 +- src/update.ts | 23 + src/usage-tracker.ts | 430 +++++----------- src/utils/shell-command.ts | 28 +- src/utils/tool-call.ts | 3 +- 66 files changed, 2332 insertions(+), 801 deletions(-) create mode 100644 src/__tests__/fixtures/hooks-skill-retire/claude-team-only.json create mode 100644 src/__tests__/fixtures/hooks-skill-retire/claude.after.json create mode 100644 src/__tests__/fixtures/hooks-skill-retire/claude.before.json create mode 100644 src/__tests__/fixtures/hooks-skill-retire/codex.after.json create mode 100644 src/__tests__/fixtures/hooks-skill-retire/codex.before.json create mode 100644 src/__tests__/fixtures/hooks-skill-retire/copilot.after.json create mode 100644 src/__tests__/fixtures/hooks-skill-retire/copilot.before.json create mode 100644 src/__tests__/fixtures/hooks-skill-retire/cursor.after.json create mode 100644 src/__tests__/fixtures/hooks-skill-retire/cursor.before.json create mode 100644 src/__tests__/fixtures/hooks-skill-retire/zcode.after.json create mode 100644 src/__tests__/fixtures/hooks-skill-retire/zcode.before.json create mode 100644 src/__tests__/fixtures/skill-use/claude-skill.json create mode 100644 src/__tests__/fixtures/skill-use/codebuddy-skill.json create mode 100644 src/__tests__/fixtures/skill-use/codex-real-a-cat.json create mode 100644 src/__tests__/fixtures/skill-use/codex-real-a-prompt.json create mode 100644 src/__tests__/fixtures/skill-use/codex-real-b-mention.json create mode 100644 src/__tests__/fixtures/skill-use/codex-real-c-cat.json create mode 100644 src/__tests__/fixtures/skill-use/codex-real-c-mention.json create mode 100644 src/__tests__/fixtures/skill-use/copilot-skill.json create mode 100644 src/__tests__/fixtures/skill-use/cursor-real-read.json create mode 100644 src/__tests__/fixtures/skill-use/dsh-skill.json create mode 100644 src/__tests__/fixtures/skill-use/hermes-skill-view.json create mode 100644 src/__tests__/fixtures/skill-use/muse-read-skill.json create mode 100644 src/__tests__/fixtures/skill-use/omp-read-uri.json create mode 100644 src/__tests__/fixtures/skill-use/opencode-skill.json create mode 100644 src/__tests__/fixtures/skill-use/pi-mention.json create mode 100644 src/__tests__/fixtures/skill-use/pi-read.json create mode 100644 src/__tests__/hooks-skill-retire.test.ts create mode 100644 src/__tests__/skill-use.test.ts create mode 100644 src/skill-use.ts diff --git a/docs/guide/reporting.md b/docs/guide/reporting.md index 336b87187..34e52d710 100644 --- a/docs/guide/reporting.md +++ b/docs/guide/reporting.md @@ -68,4 +68,4 @@ Generated by teamai digest With no reported data yet, the command prints `No team usage data available yet.` -Slash-command tracking counts complete skill names, such as `/code-review`, followed by whitespace or the end of the prompt. Paths such as `/Volumes/External/project` and URL components are not skill invocations. This prevents new false counts; it does not remove previously recorded usage. +A skill use is counted when the agent calls its skill tool, reads a skill's `SKILL.md`, or the prompt names the skill: `/code-review` at the start of the prompt, `$code-review` in Codex, `/skill:code-review` in Pi. Only skills installed for the tool count, and each skill counts once per prompt. Paths such as `/Volumes/External/project` and URL components are not skill invocations. Previously recorded usage stays. diff --git a/docs/guide/zh-CN/reporting.md b/docs/guide/zh-CN/reporting.md index 6361c9796..f57c59577 100644 --- a/docs/guide/zh-CN/reporting.md +++ b/docs/guide/zh-CN/reporting.md @@ -68,4 +68,4 @@ Generated by teamai digest 还没有上报数据时,输出 `No team usage data available yet.` -斜杠命令统计只识别完整技能名,例如 `/code-review`,名称后面必须是空白或提示词结尾。`/Volumes/External/project` 这样的路径和 URL 中的路径段不算技能调用。这会阻止新增误计数,但不会删除已经记录的使用数据。 +以下情况记一次技能使用:agent 调用技能工具,读取某个技能的 `SKILL.md`,或者提示词点名技能:提示词开头的 `/code-review`,Codex 中的 `$code-review`,Pi 中的 `/skill:code-review`。只统计该工具已安装的技能,每个技能在一次提示中只记一次。`/Volumes/External/project` 这样的路径和 URL 中的路径段不算技能调用。已经记录的使用数据保持不变。 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 989702f1c..78210ca14 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -111,7 +111,7 @@ This is the #1 onboarding issue. In order: ## Path names appear in skill usage statistics -Slash-command tracking accepts complete `/skill-name` tokens followed by whitespace or the end of the prompt. Absolute paths such as `/Volumes/External/project` and URL components are not skill invocations. Updating prevents new false counts; already recorded usage remains. +Slash-command tracking accepts a complete `/skill-name` token at the start of the prompt, followed by whitespace or the end of the prompt, and only for a skill installed for the tool. Absolute paths such as `/Volumes/External/project` and URL components are not skill invocations. Updating prevents new false counts; already recorded usage remains. ## Submodule checkouts and linked skill directories diff --git a/src/__tests__/builtin-hooks.test.ts b/src/__tests__/builtin-hooks.test.ts index 3d3e938f7..2b56f0634 100644 --- a/src/__tests__/builtin-hooks.test.ts +++ b/src/__tests__/builtin-hooks.test.ts @@ -2,17 +2,25 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { builtinHookDefs, applyBuiltinOverride } from '../builtin-hooks.js'; describe('builtinHookDefs — unified built-in hook model', () => { - it('returns 7 built-in defs for Claude in canonical order, all tagged source=builtin', () => { + it('returns 6 built-in defs for Claude in canonical order, all tagged source=builtin', () => { const defs = builtinHookDefs('claude'); - expect(defs).toHaveLength(7); + expect(defs).toHaveLength(6); expect(defs.every((d) => d.source === 'builtin')).toBe(true); expect(defs.map((d) => d.event)).toEqual([ 'SessionStart', 'Stop', - 'PostToolUse', 'PostToolUse', 'PostToolUse', + 'PostToolUse', 'PostToolUse', 'UserPromptSubmit', 'SubagentStop', ]); }); + it('installs no matcher-scoped Skill pass: the wildcard post-tool-use pass tracks skill use (#1221)', () => { + for (const tool of ['claude', 'cursor', 'copilot', 'codex', 'muse', 'zcode']) { + const defs = builtinHookDefs(tool); + expect(defs.some((d) => d.key === 'Hook dispatch post-tool-use Skill' || d.command.includes('--matcher Skill'))).toBe(false); + expect(defs.filter((d) => d.event === 'PostToolUse').map((d) => d.matcher)).toEqual(['*', 'TodoWrite']); + } + }); + it('adds SubagentStop only for the agents that fire it (#884)', () => { const subagentStop = (tool: string) => builtinHookDefs(tool).filter((d) => d.event === 'SubagentStop'); for (const tool of ['claude', 'claude-internal', 'tclaude', 'codex', 'codex-internal', 'tcodex', 'codebuddy', 'qoder', 'qoder-cn']) { @@ -49,7 +57,7 @@ describe('builtinHookDefs — unified built-in hook model', () => { it('adds a lifecycle-complete SessionEnd hook only for Copilot', () => { const defs = builtinHookDefs('copilot'); - expect(defs).toHaveLength(7); + expect(defs).toHaveLength(6); expect(defs.at(-1)).toEqual(expect.objectContaining({ event: 'SessionEnd', matcher: '*', @@ -64,9 +72,6 @@ describe('builtinHookDefs — unified built-in hook model', () => { const cursor = builtinHookDefs('cursor'); expect(cursor.find((d) => d.key === 'Hook dispatch session-start')?.timeout).toBe(15); expect(cursor.find((d) => d.key === 'Hook dispatch post-tool-use TodoWrite')?.timeout).toBe(3); - const cursorSkill = cursor.find((d) => d.key === 'Hook dispatch post-tool-use Skill'); - expect(cursorSkill?.matcher).toBe('Read'); - expect(cursorSkill?.command).toContain('--matcher Skill'); for (const tool of ['codex', 'codex-internal', 'tcodex']) { const codex = builtinHookDefs(tool); expect(codex.find((d) => d.key === 'Hook dispatch session-start')?.timeout).toBe(15); diff --git a/src/__tests__/copilot-adapter.test.ts b/src/__tests__/copilot-adapter.test.ts index 6e22f75ab..76367d478 100644 --- a/src/__tests__/copilot-adapter.test.ts +++ b/src/__tests__/copilot-adapter.test.ts @@ -234,14 +234,8 @@ describe('GitHub Copilot adapter', () => { command: expect.stringContaining('teamai hook-dispatch session-start --tool copilot'), timeoutSec: expect.any(Number), })); - expect(parsed.hooks.PostToolUse).toEqual(expect.arrayContaining([ - expect.objectContaining({ - matcher: 'skill', - command: expect.stringContaining('--matcher Skill'), - }), - expect.objectContaining({ matcher: 'TodoWrite' }), - ])); - expect(parsed.hooks.PostToolUse.some((entry) => entry.matcher === undefined)).toBe(true); + expect(parsed.hooks.PostToolUse.map((entry) => entry.matcher)).toEqual([undefined, 'TodoWrite']); + expect(JSON.stringify(parsed.hooks.PostToolUse)).not.toContain('--matcher Skill'); expect(await fse.readFile(settingsPath, 'utf8')).toBe(settings); }); @@ -307,11 +301,11 @@ describe('GitHub Copilot adapter', () => { expect(await getHookStatus(hookPath, 'copilot')).toBe('installed'); expect(await hasTeamaiHooks(hookPath, 'copilot')).toBe(true); - const skillHook = reconciled.hooks.PostToolUse.find((entry) => entry.command.includes('--matcher Skill')) as { + const todoHook = reconciled.hooks.PostToolUse.find((entry) => entry.command.includes('--matcher TodoWrite')) as { command: string; matcher?: string; }; - delete skillHook.matcher; + delete todoHook.matcher; await fse.writeJson(hookPath, reconciled); expect(await getHookStatus(hookPath, 'copilot')).toBe('missing'); diff --git a/src/__tests__/e2e/deleted-worktree-scope-810.test.ts b/src/__tests__/e2e/deleted-worktree-scope-810.test.ts index 63eff0872..dbcd469ea 100644 --- a/src/__tests__/e2e/deleted-worktree-scope-810.test.ts +++ b/src/__tests__/e2e/deleted-worktree-scope-810.test.ts @@ -42,6 +42,8 @@ interface RecordedEvent { } const SESSION = 's-b'; +/** The turns that use `team-skill`: p1's call, p2's `/team-skill` (its call and `track` repeat it), p3's `track-slash`. */ +const SKILL_USES = 3; function cliEnv(home: string): NodeJS.ProcessEnv { // The host's session id would override the payloads' own. @@ -150,7 +152,7 @@ function scenario({ userScope, breakProjectConfig = false, bare = false, project 'enabledAgents: [claude]', // A relocated Claude Code that only the project records. 'toolRoots:', - ' claude: .claude-alt', + ' claude: ~/.claude-alt', ...(projectHintOff ? ['contributeHintEnabled: false'] : []), '', ].join('\n')); @@ -169,14 +171,20 @@ function scenario({ userScope, breakProjectConfig = false, bare = false, project '', ].join('\n')); } - // A skill on disk under the project's Claude root, so `track-slash` counts - // `/team-skill` only when it looks there. + // A skill on disk under the project's Claude root, so a use of `team-skill` + // counts only when the hook looks there. fs.mkdirSync(path.join(home, '.claude-alt', 'skills', 'team-skill'), { recursive: true }); fs.writeFileSync(path.join(home, '.claude-alt', 'skills', 'team-skill', 'SKILL.md'), '---\nname: team-skill\ndescription: fixture\n---\n'); // While the worktree exists: a prompt and a skill use. - await hook('prompt-submit', { hook_event_name: 'UserPromptSubmit', prompt: 'work in b' }); - await hook('post-tool-use', skillUse, 'Skill'); + // Each turn carries Claude Code's `prompt_id`; a skill counts once per turn (#1221). + await hook('prompt-submit', { hook_event_name: 'UserPromptSubmit', prompt: 'work in b', prompt_id: 'p1' }); + await hook('post-tool-use', { ...skillUse, prompt_id: 'p1' }); + // Its credit is written by the detached pass; the turn ends before the worktree goes. + const credited = Date.now() + 20_000; + while (!fs.existsSync(path.join(partition, 'usage.jsonl')) && Date.now() < credited) { + await new Promise((r) => setTimeout(r, 100)); + } if (movedOn) { // Another repo, in the user scope: it records a scope and an anchor of its own. const elsewhere = path.join(sandbox, 'elsewhere'); @@ -193,15 +201,15 @@ function scenario({ userScope, breakProjectConfig = false, bare = false, project friction: { interrupt: 1, toolReject: 0, correction: 0, toolError: 0 }, promptSummary: 'work in b' }); // After it is gone, through the dispatcher... - await hook('prompt-submit', { hook_event_name: 'UserPromptSubmit', prompt: '/team-skill again' }); - await hook('post-tool-use', { hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: {}, tool_response: {} }); - await hook('post-tool-use', skillUse, 'Skill'); + await hook('prompt-submit', { hook_event_name: 'UserPromptSubmit', prompt: '/team-skill again', prompt_id: 'p2' }); + await hook('post-tool-use', { hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: {}, tool_response: {}, prompt_id: 'p2' }); + await hook('post-tool-use', { ...skillUse, prompt_id: 'p2' }); await hook('session-end', { hook_event_name: 'SessionEnd' }); state.stopOutput = await hook('stop', { hook_event_name: 'Stop' }); // ...and through the entry points hooks from an earlier install still call. await legacy(['dashboard-report'], { hook_event_name: 'UserPromptSubmit', prompt: 'legacy prompt' }); - await legacy(['track'], skillUse); - await legacy(['track-slash'], { hook_event_name: 'UserPromptSubmit', prompt: '/team-skill go' }); + await legacy(['track'], { ...skillUse, prompt_id: 'p2' }); + await legacy(['track-slash'], { hook_event_name: 'UserPromptSubmit', prompt: '/team-skill go', prompt_id: 'p3' }); // SessionEnd and Stop are recorded by a detached child: wait for them. Where // nothing may be recorded, wait instead for a later session's detached Stop, @@ -209,7 +217,8 @@ function scenario({ userScope, breakProjectConfig = false, bare = false, project if (breakProjectConfig) await hook('stop', { hook_event_name: 'Stop', session_id: 'control', cwd: home }); const settled = breakProjectConfig ? () => readJsonl(eventsFile).some((e) => e.sessionId === 'control' && e.type === 'stop') - : () => ['session_end', 'stop'].every((type) => afterRemoval().some((e) => e.type === type)); + : () => ['session_end', 'stop'].every((type) => afterRemoval().some((e) => e.type === type)) + && readJsonl(path.join(partition, 'usage.jsonl')).length >= SKILL_USES; const deadline = Date.now() + 20_000; while (!settled() && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 200)); @@ -245,7 +254,7 @@ describe('hook events after their worktree is deleted (#810)', () => { it('records them under the project, with the session\'s repo', async () => { const events = s.afterRemoval(); - expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use']); + expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use', 'tool_use']); const key = await dataHomeKey(s.state.partition); expect(events.map((e) => [e.type, e.dataHomeKey, e.projectAnchor])).toEqual( events.map((e) => [e.type, key, s.state.anchor])); @@ -260,7 +269,7 @@ describe('hook events after their worktree is deleted (#810)', () => { }); it('records the skill uses in the project\'s usage, not the user scope\'s', () => { - expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(5); + expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(SKILL_USES); expect(readJsonl(path.join(s.state.home, '.teamai', 'user-usage.jsonl'))).toHaveLength(0); }); }); @@ -275,13 +284,13 @@ describe('hook events after their worktree is deleted (#810)', () => { it('records them under the project instead of dropping them', async () => { const events = s.afterRemoval(); - expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use']); + expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use', 'tool_use']); const key = await dataHomeKey(s.state.partition); expect(events.every((e) => e.dataHomeKey === key && e.projectAnchor === s.state.anchor)).toBe(true); }); it('records the skill uses in the project\'s usage', () => { - expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(5); + expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(SKILL_USES); }); }); @@ -310,14 +319,14 @@ describe('hook events after their worktree is deleted (#810)', () => { it('records them under the project, with the session\'s repo', async () => { const events = s.afterRemoval(); - expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use']); + expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use', 'tool_use']); const key = await dataHomeKey(s.state.partition); expect(events.map((e) => [e.type, e.dataHomeKey, e.projectAnchor])).toEqual( events.map((e) => [e.type, key, s.state.anchor])); }); it('records the skill uses in the project\'s usage, not the user scope\'s', () => { - expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(5); + expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(SKILL_USES); expect(readJsonl(path.join(s.state.home, '.teamai', 'user-usage.jsonl'))).toHaveLength(0); }); }); @@ -332,11 +341,11 @@ describe('hook events after their worktree is deleted (#810)', () => { it('records them under the worktree\'s project, not where the session went next', async () => { const events = s.afterRemoval(); - expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use']); + expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use', 'tool_use']); const key = await dataHomeKey(s.state.partition); expect(events.map((e) => [e.type, e.dataHomeKey, e.projectAnchor])).toEqual( events.map((e) => [e.type, key, s.state.anchor])); - expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(5); + expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(SKILL_USES); }); }); diff --git a/src/__tests__/fixtures/hooks-skill-retire/claude-team-only.json b/src/__tests__/fixtures/hooks-skill-retire/claude-team-only.json new file mode 100644 index 000000000..22fcbc119 --- /dev/null +++ b/src/__tests__/fixtures/hooks-skill-retire/claude-team-only.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit", + "hooks": [ + { + "type": "command", + "command": "if [ -n \"$CURSOR_VERSION\" ] && { home=\"${HOME:-$USERPROFILE}\"; [ -n \"$home\" ] && { grep -q -- '--tool cursor' \"$home/.cursor/hooks.json\" 2>/dev/null || { [ -n \"$CURSOR_PROJECT_DIR\" ] && grep -q -- '--tool cursor' \"$CURSOR_PROJECT_DIR/.cursor/hooks.json\" 2>/dev/null; }; }; }; then exit 0; fi; if [ -n \"$COPILOT_PROJECT_DIR\" ] && node -e 'const fs = require(\"node:fs\"), path = require(\"node:path\"), crypto = require(\"node:crypto\"); const runs = function copilotRunsEntries(data) {\n const file = data;\n if (file?.version !== 1 || file.disableAllHooks || !file.hooks || typeof file.hooks !== \"object\") return [];\n return Object.entries(file.hooks).flatMap(([event, entries]) => !Array.isArray(entries) ? [] : entries.filter((entry) => entry?.type === \"command\").flatMap((entry) => [entry.bash, entry.powershell, entry.command].filter((command) => typeof command === \"string\").map((command) => [event, entry.matcher, command])));\n}; const key = (event, matcher, command) => crypto.createHash(\"sha256\") .update(JSON.stringify([event, matcher && matcher !== \"*\" ? matcher : \"\", command])).digest(\"hex\"); try { process.exit(fs.readdirSync(process.argv[1]).some(name => { if (!name.endsWith(\".json\")) return false; try { return runs(JSON.parse(fs.readFileSync(path.join(process.argv[1], name), \"utf8\"))) .some(([event, matcher, command]) => key(event, matcher, command) === process.argv[2]); } catch { return false; } }) ? 0 : 1); } catch { process.exit(1); }' \"$COPILOT_PROJECT_DIR/.github/hooks\" 377246543ea22ca6721b8216ed45827013ec0df33d9b8cf1c61b70ec33f6da3c 2>/dev/null; then exit 0; fi; bash .teamai/team-scripts/lint.sh" + } + ], + "description": "[teamai:team:lint] Lint after edits" + } + ] + } +} diff --git a/src/__tests__/fixtures/hooks-skill-retire/claude.after.json b/src/__tests__/fixtures/hooks-skill-retire/claude.after.json new file mode 100644 index 000000000..057a0187e --- /dev/null +++ b/src/__tests__/fixtures/hooks-skill-retire/claude.after.json @@ -0,0 +1,74 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch session-start --tool claude 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch session-start" + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch stop --tool claude 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch stop" + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool claude 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch post-tool-use wildcard" + }, + { + "matcher": "TodoWrite", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool claude --matcher TodoWrite 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch post-tool-use TodoWrite" + } + ], + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch prompt-submit --tool claude 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch prompt-submit" + } + ], + "SubagentStop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch subagent-stop --tool claude 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch subagent-stop" + } + ] + } +} diff --git a/src/__tests__/fixtures/hooks-skill-retire/claude.before.json b/src/__tests__/fixtures/hooks-skill-retire/claude.before.json new file mode 100644 index 000000000..94fab9095 --- /dev/null +++ b/src/__tests__/fixtures/hooks-skill-retire/claude.before.json @@ -0,0 +1,84 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch session-start --tool claude 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch session-start" + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch stop --tool claude 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch stop" + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool claude 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch post-tool-use wildcard" + }, + { + "matcher": "Skill", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool claude --matcher Skill 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch post-tool-use Skill" + }, + { + "matcher": "TodoWrite", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool claude --matcher TodoWrite 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch post-tool-use TodoWrite" + } + ], + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch prompt-submit --tool claude 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch prompt-submit" + } + ], + "SubagentStop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch subagent-stop --tool claude 2>/dev/null\" || true" + } + ], + "description": "[teamai] Hook dispatch subagent-stop" + } + ] + } +} diff --git a/src/__tests__/fixtures/hooks-skill-retire/codex.after.json b/src/__tests__/fixtures/hooks-skill-retire/codex.after.json new file mode 100644 index 000000000..db486387f --- /dev/null +++ b/src/__tests__/fixtures/hooks-skill-retire/codex.after.json @@ -0,0 +1,89 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch session-start --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch session-start --tool codex 2>$null; exit 0", + "timeout": 15, + "additionalContextLimit": 0 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch stop --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch stop --tool codex 2>$null; exit 0", + "timeout": 15 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch post-tool-use --tool codex 2>$null; exit 0", + "timeout": 10 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool codex --matcher TodoWrite 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch post-tool-use --tool codex --matcher TodoWrite 2>$null; exit 0", + "timeout": 3 + } + ], + "matcher": "TodoWrite" + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch prompt-submit --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch prompt-submit --tool codex 2>$null; exit 0", + "timeout": 10 + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch subagent-stop --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch subagent-stop --tool codex 2>$null; exit 0", + "timeout": 15 + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch subagent-start --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch subagent-start --tool codex 2>$null; exit 0", + "timeout": 15, + "additionalContextLimit": 0 + } + ] + } + ] + } +} diff --git a/src/__tests__/fixtures/hooks-skill-retire/codex.before.json b/src/__tests__/fixtures/hooks-skill-retire/codex.before.json new file mode 100644 index 000000000..5805626a2 --- /dev/null +++ b/src/__tests__/fixtures/hooks-skill-retire/codex.before.json @@ -0,0 +1,100 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch session-start --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch session-start --tool codex 2>$null; exit 0", + "timeout": 15, + "additionalContextLimit": 0 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch stop --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch stop --tool codex 2>$null; exit 0", + "timeout": 15 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch post-tool-use --tool codex 2>$null; exit 0", + "timeout": 10 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool codex --matcher Skill 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch post-tool-use --tool codex --matcher Skill 2>$null; exit 0", + "timeout": 10 + } + ], + "matcher": "Skill" + }, + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool codex --matcher TodoWrite 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch post-tool-use --tool codex --matcher TodoWrite 2>$null; exit 0", + "timeout": 3 + } + ], + "matcher": "TodoWrite" + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch prompt-submit --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch prompt-submit --tool codex 2>$null; exit 0", + "timeout": 10 + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch subagent-stop --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch subagent-stop --tool codex 2>$null; exit 0", + "timeout": 15 + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -lc \"teamai hook-dispatch subagent-start --tool codex 2>/dev/null\" || true", + "commandWindows": "teamai hook-dispatch subagent-start --tool codex 2>$null; exit 0", + "timeout": 15, + "additionalContextLimit": 0 + } + ] + } + ] + } +} diff --git a/src/__tests__/fixtures/hooks-skill-retire/copilot.after.json b/src/__tests__/fixtures/hooks-skill-retire/copilot.after.json new file mode 100644 index 000000000..ba52d4b0c --- /dev/null +++ b/src/__tests__/fixtures/hooks-skill-retire/copilot.after.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "hooks": { + "SessionStart": [ + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch session-start --tool copilot 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch session-start --tool copilot 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch session-start --tool copilot 2>/dev/null\" || true", + "timeoutSec": 15 + } + ], + "Stop": [ + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch stop --tool copilot 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch stop --tool copilot 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch stop --tool copilot 2>/dev/null\" || true", + "timeoutSec": 15 + } + ], + "PostToolUse": [ + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch post-tool-use --tool copilot 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch post-tool-use --tool copilot 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool copilot 2>/dev/null\" || true", + "timeoutSec": 10 + }, + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch post-tool-use --tool copilot --matcher TodoWrite 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch post-tool-use --tool copilot --matcher TodoWrite 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool copilot --matcher TodoWrite 2>/dev/null\" || true", + "matcher": "TodoWrite", + "timeoutSec": 3 + } + ], + "UserPromptSubmit": [ + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch prompt-submit --tool copilot 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch prompt-submit --tool copilot 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch prompt-submit --tool copilot 2>/dev/null\" || true", + "timeoutSec": 10 + } + ], + "SessionEnd": [ + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch session-end --tool copilot 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch session-end --tool copilot 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch session-end --tool copilot 2>/dev/null\" || true", + "timeoutSec": 15 + } + ] + } +} diff --git a/src/__tests__/fixtures/hooks-skill-retire/copilot.before.json b/src/__tests__/fixtures/hooks-skill-retire/copilot.before.json new file mode 100644 index 000000000..4be5fbdad --- /dev/null +++ b/src/__tests__/fixtures/hooks-skill-retire/copilot.before.json @@ -0,0 +1,66 @@ +{ + "version": 1, + "hooks": { + "SessionStart": [ + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch session-start --tool copilot 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch session-start --tool copilot 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch session-start --tool copilot 2>/dev/null\" || true", + "timeoutSec": 15 + } + ], + "Stop": [ + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch stop --tool copilot 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch stop --tool copilot 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch stop --tool copilot 2>/dev/null\" || true", + "timeoutSec": 15 + } + ], + "PostToolUse": [ + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch post-tool-use --tool copilot 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch post-tool-use --tool copilot 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool copilot 2>/dev/null\" || true", + "timeoutSec": 10 + }, + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch post-tool-use --tool copilot --matcher Skill 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch post-tool-use --tool copilot --matcher Skill 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool copilot --matcher Skill 2>/dev/null\" || true", + "matcher": "skill", + "timeoutSec": 10 + }, + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch post-tool-use --tool copilot --matcher TodoWrite 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch post-tool-use --tool copilot --matcher TodoWrite 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool copilot --matcher TodoWrite 2>/dev/null\" || true", + "matcher": "TodoWrite", + "timeoutSec": 3 + } + ], + "UserPromptSubmit": [ + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch prompt-submit --tool copilot 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch prompt-submit --tool copilot 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch prompt-submit --tool copilot 2>/dev/null\" || true", + "timeoutSec": 10 + } + ], + "SessionEnd": [ + { + "type": "command", + "bash": "bash -lc \"teamai hook-dispatch session-end --tool copilot 2>/dev/null\" || true", + "powershell": "teamai hook-dispatch session-end --tool copilot 2>$null; exit 0", + "command": "bash -lc \"teamai hook-dispatch session-end --tool copilot 2>/dev/null\" || true", + "timeoutSec": 15 + } + ] + } +} diff --git a/src/__tests__/fixtures/hooks-skill-retire/cursor.after.json b/src/__tests__/fixtures/hooks-skill-retire/cursor.after.json new file mode 100644 index 000000000..6cac1fb32 --- /dev/null +++ b/src/__tests__/fixtures/hooks-skill-retire/cursor.after.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "bash -lc \"teamai hook-dispatch session-start --tool cursor 2>/dev/null\" || true", + "timeout": 15 + } + ], + "stop": [ + { + "command": "bash -lc \"teamai hook-dispatch stop --tool cursor 2>/dev/null\" || true", + "timeout": 15 + } + ], + "postToolUse": [ + { + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool cursor 2>/dev/null\" || true", + "timeout": 10 + }, + { + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool cursor --matcher TodoWrite 2>/dev/null\" || true", + "timeout": 3, + "matcher": "TodoWrite" + } + ], + "beforeSubmitPrompt": [ + { + "command": "bash -lc \"teamai hook-dispatch prompt-submit --tool cursor 2>/dev/null\" || true", + "timeout": 10 + } + ] + } +} diff --git a/src/__tests__/fixtures/hooks-skill-retire/cursor.before.json b/src/__tests__/fixtures/hooks-skill-retire/cursor.before.json new file mode 100644 index 000000000..8e4c5f8ec --- /dev/null +++ b/src/__tests__/fixtures/hooks-skill-retire/cursor.before.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "bash -lc \"teamai hook-dispatch session-start --tool cursor 2>/dev/null\" || true", + "timeout": 15 + } + ], + "stop": [ + { + "command": "bash -lc \"teamai hook-dispatch stop --tool cursor 2>/dev/null\" || true", + "timeout": 15 + } + ], + "postToolUse": [ + { + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool cursor 2>/dev/null\" || true", + "timeout": 10 + }, + { + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool cursor --matcher Skill 2>/dev/null\" || true", + "timeout": 10, + "matcher": "Read" + }, + { + "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool cursor --matcher TodoWrite 2>/dev/null\" || true", + "timeout": 3, + "matcher": "TodoWrite" + } + ], + "beforeSubmitPrompt": [ + { + "command": "bash -lc \"teamai hook-dispatch prompt-submit --tool cursor 2>/dev/null\" || true", + "timeout": 10 + } + ] + } +} diff --git a/src/__tests__/fixtures/hooks-skill-retire/zcode.after.json b/src/__tests__/fixtures/hooks-skill-retire/zcode.after.json new file mode 100644 index 000000000..e93da1cd6 --- /dev/null +++ b/src/__tests__/fixtures/hooks-skill-retire/zcode.after.json @@ -0,0 +1,81 @@ +{ + "hooks": { + "enabled": true, + "events": { + "SessionStart": [ + { + "hooks": [ + { + "type": "process", + "command": "bash", + "args": [ + "-lc", + "teamai hook-dispatch session-start --tool zcode" + ], + "timeoutMs": 180000 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "process", + "command": "bash", + "args": [ + "-lc", + "teamai hook-dispatch stop --tool zcode" + ], + "timeoutMs": 60000 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "process", + "command": "bash", + "args": [ + "-lc", + "teamai hook-dispatch post-tool-use --tool zcode" + ], + "timeoutMs": 30000 + } + ] + }, + { + "hooks": [ + { + "type": "process", + "command": "bash", + "args": [ + "-lc", + "teamai hook-dispatch post-tool-use --tool zcode --matcher TodoWrite" + ], + "timeoutMs": 30000 + } + ], + "matcher": "TodoWrite" + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "process", + "command": "bash", + "args": [ + "-lc", + "teamai hook-dispatch prompt-submit --tool zcode" + ], + "timeoutMs": 60000 + } + ] + } + ] + } + } +} diff --git a/src/__tests__/fixtures/hooks-skill-retire/zcode.before.json b/src/__tests__/fixtures/hooks-skill-retire/zcode.before.json new file mode 100644 index 000000000..bf96c74ba --- /dev/null +++ b/src/__tests__/fixtures/hooks-skill-retire/zcode.before.json @@ -0,0 +1,95 @@ +{ + "hooks": { + "enabled": true, + "events": { + "SessionStart": [ + { + "hooks": [ + { + "type": "process", + "command": "bash", + "args": [ + "-lc", + "teamai hook-dispatch session-start --tool zcode" + ], + "timeoutMs": 180000 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "process", + "command": "bash", + "args": [ + "-lc", + "teamai hook-dispatch stop --tool zcode" + ], + "timeoutMs": 60000 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "process", + "command": "bash", + "args": [ + "-lc", + "teamai hook-dispatch post-tool-use --tool zcode" + ], + "timeoutMs": 30000 + } + ] + }, + { + "hooks": [ + { + "type": "process", + "command": "bash", + "args": [ + "-lc", + "teamai hook-dispatch post-tool-use --tool zcode --matcher Skill" + ], + "timeoutMs": 30000 + } + ], + "matcher": "Skill" + }, + { + "hooks": [ + { + "type": "process", + "command": "bash", + "args": [ + "-lc", + "teamai hook-dispatch post-tool-use --tool zcode --matcher TodoWrite" + ], + "timeoutMs": 30000 + } + ], + "matcher": "TodoWrite" + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "process", + "command": "bash", + "args": [ + "-lc", + "teamai hook-dispatch prompt-submit --tool zcode" + ], + "timeoutMs": 60000 + } + ] + } + ] + } + } +} diff --git a/src/__tests__/fixtures/hooks/claude-internal.json b/src/__tests__/fixtures/hooks/claude-internal.json index 87dbe533a..09c930941 100644 --- a/src/__tests__/fixtures/hooks/claude-internal.json +++ b/src/__tests__/fixtures/hooks/claude-internal.json @@ -35,16 +35,6 @@ ], "description": "[teamai] Hook dispatch post-tool-use wildcard" }, - { - "matcher": "Skill", - "hooks": [ - { - "type": "command", - "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool claude-internal --matcher Skill 2>/dev/null\" || true" - } - ], - "description": "[teamai] Hook dispatch post-tool-use Skill" - }, { "matcher": "TodoWrite", "hooks": [ diff --git a/src/__tests__/fixtures/hooks/claude.json b/src/__tests__/fixtures/hooks/claude.json index 94fab9095..057a0187e 100644 --- a/src/__tests__/fixtures/hooks/claude.json +++ b/src/__tests__/fixtures/hooks/claude.json @@ -35,16 +35,6 @@ ], "description": "[teamai] Hook dispatch post-tool-use wildcard" }, - { - "matcher": "Skill", - "hooks": [ - { - "type": "command", - "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool claude --matcher Skill 2>/dev/null\" || true" - } - ], - "description": "[teamai] Hook dispatch post-tool-use Skill" - }, { "matcher": "TodoWrite", "hooks": [ diff --git a/src/__tests__/fixtures/hooks/codebuddy.json b/src/__tests__/fixtures/hooks/codebuddy.json index 7684c3a83..993707858 100644 --- a/src/__tests__/fixtures/hooks/codebuddy.json +++ b/src/__tests__/fixtures/hooks/codebuddy.json @@ -38,17 +38,6 @@ ], "description": "[teamai] Hook dispatch post-tool-use wildcard" }, - { - "matcher": "Skill", - "hooks": [ - { - "type": "command", - "command": "PATH=\"$HOME/.teamai/bin:$PATH\" teamai hook-dispatch post-tool-use --tool codebuddy --matcher Skill 2>/dev/null || true", - "timeout": 10 - } - ], - "description": "[teamai] Hook dispatch post-tool-use Skill" - }, { "matcher": "TodoWrite", "hooks": [ diff --git a/src/__tests__/fixtures/hooks/cursor.json b/src/__tests__/fixtures/hooks/cursor.json index 8e4c5f8ec..6cac1fb32 100644 --- a/src/__tests__/fixtures/hooks/cursor.json +++ b/src/__tests__/fixtures/hooks/cursor.json @@ -18,11 +18,6 @@ "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool cursor 2>/dev/null\" || true", "timeout": 10 }, - { - "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool cursor --matcher Skill 2>/dev/null\" || true", - "timeout": 10, - "matcher": "Read" - }, { "command": "bash -lc \"teamai hook-dispatch post-tool-use --tool cursor --matcher TodoWrite 2>/dev/null\" || true", "timeout": 3, diff --git a/src/__tests__/fixtures/hooks/workbuddy.json b/src/__tests__/fixtures/hooks/workbuddy.json index f9f034ef2..0d61f6368 100644 --- a/src/__tests__/fixtures/hooks/workbuddy.json +++ b/src/__tests__/fixtures/hooks/workbuddy.json @@ -38,17 +38,6 @@ ], "description": "[teamai] Hook dispatch post-tool-use wildcard" }, - { - "matcher": "Skill", - "hooks": [ - { - "type": "command", - "command": "PATH=\"$HOME/.teamai/bin:$PATH\" teamai hook-dispatch post-tool-use --tool workbuddy --matcher Skill 2>/dev/null || true", - "timeout": 10 - } - ], - "description": "[teamai] Hook dispatch post-tool-use Skill" - }, { "matcher": "TodoWrite", "hooks": [ diff --git a/src/__tests__/fixtures/skill-use/claude-skill.json b/src/__tests__/fixtures/skill-use/claude-skill.json new file mode 100644 index 000000000..ab73429ed --- /dev/null +++ b/src/__tests__/fixtures/skill-use/claude-skill.json @@ -0,0 +1,12 @@ +{ + "session_id": "claude-run", + "cwd": "${CWD}", + "prompt_id": "claude-run-turn", + "permission_mode": "bypassPermissions", + "hook_event_name": "PostToolUse", + "tool_name": "Skill", + "tool_input": { + "skill": "hello-skill" + }, + "duration_ms": 3 +} diff --git a/src/__tests__/fixtures/skill-use/codebuddy-skill.json b/src/__tests__/fixtures/skill-use/codebuddy-skill.json new file mode 100644 index 000000000..17e00d31b --- /dev/null +++ b/src/__tests__/fixtures/skill-use/codebuddy-skill.json @@ -0,0 +1,15 @@ +{ + "session_id": "codebuddy-run", + "cwd": "${CWD}", + "hook_event_name": "PostToolUse", + "tool_name": "Skill", + "tool_input": { + "skill": "hello-skill" + }, + "agent_type": "cli", + "permission_mode": "bypassPermissions", + "client": "CLI", + "version": "2.169.0", + "generation_id": "codebuddy-run-turn", + "model": "custom-local:mock-model" +} diff --git a/src/__tests__/fixtures/skill-use/codex-real-a-cat.json b/src/__tests__/fixtures/skill-use/codex-real-a-cat.json new file mode 100644 index 000000000..24e1cd19a --- /dev/null +++ b/src/__tests__/fixtures/skill-use/codex-real-a-cat.json @@ -0,0 +1,12 @@ +{ + "session_id": "codex-run-a", + "turn_id": "codex-run-a-turn", + "cwd": "${CWD}", + "hook_event_name": "PostToolUse", + "model": "gpt-6.1-sol", + "permission_mode": "bypassPermissions", + "tool_name": "Bash", + "tool_input": { + "command": "cat ${HOME}/.codex/skills/hello-skill/SKILL.md" + } +} diff --git a/src/__tests__/fixtures/skill-use/codex-real-a-prompt.json b/src/__tests__/fixtures/skill-use/codex-real-a-prompt.json new file mode 100644 index 000000000..58f25f35e --- /dev/null +++ b/src/__tests__/fixtures/skill-use/codex-real-a-prompt.json @@ -0,0 +1,9 @@ +{ + "session_id": "codex-run-a", + "turn_id": "codex-run-a-turn", + "cwd": "${CWD}", + "hook_event_name": "UserPromptSubmit", + "model": "gpt-6.1-sol", + "permission_mode": "bypassPermissions", + "prompt": "Say hello using the hello-skill skill." +} diff --git a/src/__tests__/fixtures/skill-use/codex-real-b-mention.json b/src/__tests__/fixtures/skill-use/codex-real-b-mention.json new file mode 100644 index 000000000..511bbb955 --- /dev/null +++ b/src/__tests__/fixtures/skill-use/codex-real-b-mention.json @@ -0,0 +1,9 @@ +{ + "session_id": "codex-run-b", + "turn_id": "codex-run-b-turn", + "cwd": "${CWD}", + "hook_event_name": "UserPromptSubmit", + "model": "gpt-6.1-sol", + "permission_mode": "bypassPermissions", + "prompt": "$hello-skill say hi" +} diff --git a/src/__tests__/fixtures/skill-use/codex-real-c-cat.json b/src/__tests__/fixtures/skill-use/codex-real-c-cat.json new file mode 100644 index 000000000..4124466e2 --- /dev/null +++ b/src/__tests__/fixtures/skill-use/codex-real-c-cat.json @@ -0,0 +1,12 @@ +{ + "session_id": "codex-run-c", + "turn_id": "codex-run-c-turn", + "cwd": "${CWD}", + "hook_event_name": "PostToolUse", + "model": "gpt-6.1-sol", + "permission_mode": "bypassPermissions", + "tool_name": "Bash", + "tool_input": { + "command": "cat ${HOME}/.codex/skills/hello-skill/SKILL.md" + } +} diff --git a/src/__tests__/fixtures/skill-use/codex-real-c-mention.json b/src/__tests__/fixtures/skill-use/codex-real-c-mention.json new file mode 100644 index 000000000..b99f0834e --- /dev/null +++ b/src/__tests__/fixtures/skill-use/codex-real-c-mention.json @@ -0,0 +1,9 @@ +{ + "session_id": "codex-run-c", + "turn_id": "codex-run-c-turn", + "cwd": "${CWD}", + "hook_event_name": "UserPromptSubmit", + "model": "gpt-6.1-sol", + "permission_mode": "bypassPermissions", + "prompt": "$hello-skill say hi, and read the skill file first to double check it." +} diff --git a/src/__tests__/fixtures/skill-use/copilot-skill.json b/src/__tests__/fixtures/skill-use/copilot-skill.json new file mode 100644 index 000000000..2ad82ca7c --- /dev/null +++ b/src/__tests__/fixtures/skill-use/copilot-skill.json @@ -0,0 +1,10 @@ +{ + "hook_event_name": "PostToolUse", + "session_id": "copilot-run", + "timestamp": "2026-10-20T11:48:48.824Z", + "cwd": "${CWD}", + "tool_name": "skill", + "tool_input": { + "skill": "hello-skill" + } +} diff --git a/src/__tests__/fixtures/skill-use/cursor-real-read.json b/src/__tests__/fixtures/skill-use/cursor-real-read.json new file mode 100644 index 000000000..23bd99833 --- /dev/null +++ b/src/__tests__/fixtures/skill-use/cursor-real-read.json @@ -0,0 +1,14 @@ +{ + "generation_id": "cursor-run-turn", + "model": "default", + "tool_name": "Read", + "tool_input": { + "file_path": "${CWD}/.cursor/skills/hello-skill/SKILL.md" + }, + "duration": 692.337, + "session_id": "cursor-run", + "hook_event_name": "postToolUse", + "workspace_roots": [ + "${CWD}" + ] +} diff --git a/src/__tests__/fixtures/skill-use/dsh-skill.json b/src/__tests__/fixtures/skill-use/dsh-skill.json new file mode 100644 index 000000000..c6da77343 --- /dev/null +++ b/src/__tests__/fixtures/skill-use/dsh-skill.json @@ -0,0 +1,9 @@ +{ + "cwd": "${CWD}", + "session_id": "dsh-run", + "hook_event_name": "PostToolUse", + "tool_name": "skill", + "tool_input": { + "name": "hello-skill" + } +} diff --git a/src/__tests__/fixtures/skill-use/hermes-skill-view.json b/src/__tests__/fixtures/skill-use/hermes-skill-view.json new file mode 100644 index 000000000..4e0a6ad0c --- /dev/null +++ b/src/__tests__/fixtures/skill-use/hermes-skill-view.json @@ -0,0 +1,10 @@ +{ + "hook_event_name": "post_tool_call", + "tool_name": "skill_view", + "tool_input": { + "name": "hello-skill" + }, + "session_id": "hermes-run", + "cwd": "${CWD}", + "profile": "default" +} diff --git a/src/__tests__/fixtures/skill-use/muse-read-skill.json b/src/__tests__/fixtures/skill-use/muse-read-skill.json new file mode 100644 index 000000000..5838f2125 --- /dev/null +++ b/src/__tests__/fixtures/skill-use/muse-read-skill.json @@ -0,0 +1,13 @@ +{ + "hook_event_name": "PostToolUse", + "tool_name": "read_skill", + "tool_input": { + "name": "hello-skill" + }, + "session_id": "muse-run", + "turn_id": "muse-run-turn", + "cwd": "${CWD}", + "model": "mock-model", + "permission_mode": "default", + "model_provider": "meta" +} diff --git a/src/__tests__/fixtures/skill-use/omp-read-uri.json b/src/__tests__/fixtures/skill-use/omp-read-uri.json new file mode 100644 index 000000000..326adbfa9 --- /dev/null +++ b/src/__tests__/fixtures/skill-use/omp-read-uri.json @@ -0,0 +1,10 @@ +{ + "cwd": "${CWD}", + "session_id": "omp-run", + "hook_event_name": "PostToolUse", + "tool_name": "read", + "tool_input": { + "path": "skill://hello-skill" + }, + "tool_status": "success" +} diff --git a/src/__tests__/fixtures/skill-use/opencode-skill.json b/src/__tests__/fixtures/skill-use/opencode-skill.json new file mode 100644 index 000000000..0d70fef6b --- /dev/null +++ b/src/__tests__/fixtures/skill-use/opencode-skill.json @@ -0,0 +1,10 @@ +{ + "cwd": "${CWD}", + "session_id": "opencode-run", + "hook_event_name": "PostToolUse", + "tool_name": "skill", + "tool_input": { + "id": "hello-skill" + }, + "tool_status": "success" +} diff --git a/src/__tests__/fixtures/skill-use/pi-mention.json b/src/__tests__/fixtures/skill-use/pi-mention.json new file mode 100644 index 000000000..a76904db3 --- /dev/null +++ b/src/__tests__/fixtures/skill-use/pi-mention.json @@ -0,0 +1,5 @@ +{ + "cwd": "${CWD}", + "session_id": "pi-run", + "prompt": "\nReferences are relative to ${HOME}/.pi/agent/skills/hello-skill.\n\n# hello-skill\n\n\nsay hi" +} diff --git a/src/__tests__/fixtures/skill-use/pi-read.json b/src/__tests__/fixtures/skill-use/pi-read.json new file mode 100644 index 000000000..13a4a0fff --- /dev/null +++ b/src/__tests__/fixtures/skill-use/pi-read.json @@ -0,0 +1,10 @@ +{ + "cwd": "${CWD}", + "session_id": "pi-run", + "hook_event_name": "PostToolUse", + "tool_name": "read", + "tool_input": { + "path": "${HOME}/.pi/agent/skills/hello-skill/SKILL.md" + }, + "tool_status": "success" +} diff --git a/src/__tests__/git-kind-reports.test.ts b/src/__tests__/git-kind-reports.test.ts index a3419e1d3..9be5a0f3e 100644 --- a/src/__tests__/git-kind-reports.test.ts +++ b/src/__tests__/git-kind-reports.test.ts @@ -18,6 +18,7 @@ import { listMembers } from '../members.js'; import { resolveProjectDataHome, saveLocalConfigForScope } from '../config.js'; import { buildHandlerRegistry } from '../hook-handlers.js'; import YAML from 'yaml'; +import { getUserHome } from '../utils/home.js'; let tmp: string; let originalHome: string; @@ -671,8 +672,12 @@ 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'); + const track = buildHandlerRegistry().find((r) => r.handler.name === 'skill-use'); + if (!track) throw new Error('skill-use handler is not registered'); + // Only an installed skill counts (#1221). + const skillDir = path.join(getUserHome(), '.claude', 'skills', skill); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `# ${skill}\n`); // The handler records under the scope the dispatcher resolved for the hook (#810). const payload = { session_id: `s-${skill}`, cwd, tool_name: 'Skill', tool_input: { skill } }; await track.handler.execute(payload, 'claude', await resolveHookConfig(payload, 'claude')); diff --git a/src/__tests__/hook-dispatch-scope.test.ts b/src/__tests__/hook-dispatch-scope.test.ts index 0973b3f91..099ff5d79 100644 --- a/src/__tests__/hook-dispatch-scope.test.ts +++ b/src/__tests__/hook-dispatch-scope.test.ts @@ -37,6 +37,7 @@ vi.mock('../update.js', () => ({ doUpdate: vi.fn(async () => undefined), acquireLock: vi.fn(async () => true), releaseLock: vi.fn(async () => undefined), + withFileLock: vi.fn(async (_lockPath: string, _wait: unknown, body: () => Promise) => ({ held: true, value: await body() })), })); vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: vi.fn(async () => null) })); vi.mock('../utils/reports-branch.js', () => ({ updateReports: vi.fn(async () => undefined) })); @@ -55,6 +56,11 @@ beforeEach(() => { originalCwd = process.cwd(); process.env.HOME = path.join(tmp, 'home'); fs.mkdirSync(process.env.HOME); + // Only an installed skill counts as used (#1221). + for (const skill of ['skill-a', 'skill-b', 'skill-g']) { + fs.mkdirSync(path.join(process.env.HOME, '.claude', 'skills', skill), { recursive: true }); + fs.writeFileSync(path.join(process.env.HOME, '.claude', 'skills', skill, 'SKILL.md'), `# ${skill}\n`); + } }); afterEach(() => { @@ -69,11 +75,11 @@ afterEach(() => { const teamaiHome = () => path.join(tmp, 'home', '.teamai'); /** Run one hook event the way a host does: foreground pass, then the background pass. */ -async function hook(event: string, matcher: string, payload: Record): Promise { +async function hook(event: string, matcher: string, payload: Record, tool = 'claude'): Promise { for (const bgOnly of [false, true]) { const stdinFile = path.join(tmp, `stdin-${Date.now()}-${Math.random()}.json`); fs.writeFileSync(stdinFile, JSON.stringify(payload)); - await hookDispatchCli(event, 'claude', matcher, { bgOnly, stdinFile }); + await hookDispatchCli(event, tool, matcher, { bgOnly, stdinFile }); } } @@ -97,7 +103,7 @@ describe('hook runs and the scope they belong to (#748)', () => { const base = { session_id: 'sid-b', cwd }; await hook('session-start', '*', { ...base, hook_event_name: 'SessionStart' }); await hook('prompt-submit', '*', { ...base, hook_event_name: 'UserPromptSubmit', prompt: 'hello' }); - await hook('post-tool-use', 'Skill', { ...base, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-b' } }); + await hook('post-tool-use', '*', { ...base, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-b' } }); await hook('post-tool-use', '*', { ...base, hook_event_name: 'PostToolUse', tool_name: 'Bash', tool_input: {} }); await hook('stop', '*', { ...base, hook_event_name: 'Stop' }); @@ -116,7 +122,7 @@ describe('hook runs and the scope they belong to (#748)', () => { // OpenClaw's plugin spawns hook-dispatch in the workspace without a cwd field. process.chdir(root); - await hook('post-tool-use', 'Skill', { session_id: 'sid-a', hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-a' } }); + await hook('post-tool-use', '*', { session_id: 'sid-a', hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-a' } }); expect(fs.readFileSync(path.join(dataHome, 'usage.jsonl'), 'utf-8')).toContain('skill-a'); }); @@ -125,7 +131,7 @@ describe('hook runs and the scope they belong to (#748)', () => { userScope(); const gone = path.join(tmp, 'deleted-worktree'); - await hook('post-tool-use', 'Skill', { session_id: 'sid-g', cwd: gone, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-g' } }); + await hook('post-tool-use', '*', { session_id: 'sid-g', cwd: gone, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-g' } }); expect(fs.readFileSync(path.join(teamaiHome(), 'user-usage.jsonl'), 'utf-8')).toContain('skill-g'); }); @@ -193,7 +199,7 @@ describe('hook runs and the scope they belong to (#748)', () => { fs.mkdirSync(dataHome, { recursive: true }); fs.writeFileSync(path.join(dataHome, 'config.yaml'), 'repo: [not: a, valid config\n'); - await hook('post-tool-use', 'Skill', { session_id: 'sid-a', cwd: root, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-a' } }); + await hook('post-tool-use', '*', { session_id: 'sid-a', cwd: root, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-a' } }); expect(fs.existsSync(path.join(teamaiHome(), 'user-usage.jsonl'))).toBe(false); }); @@ -209,7 +215,7 @@ describe('hook runs and the scope they belong to (#748)', () => { fs.writeFileSync(path.join(legacy, 'config.yaml'), `repo:\n localPath: ${path.join(legacy, 'team-repo')}\n remote: https://example.test/acme/other-team.git\nusername: tester\nscope: project\n`); - await hook('post-tool-use', 'Skill', { session_id: 'sid-a', cwd: root, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-a' } }); + await hook('post-tool-use', '*', { session_id: 'sid-a', cwd: root, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-a' } }); expect(fs.existsSync(path.join(legacy, 'usage.jsonl'))).toBe(false); }); @@ -240,3 +246,27 @@ describe('hook runs and the scope they belong to (#748)', () => { expect(fs.existsSync(path.join(teamaiHome(), 'package-hints'))).toBe(false); }); }); + +describe('skill use through the CLI (#1221)', () => { + it('counts a Codex `$name` mention and the `cat` of its SKILL.md in one turn once', async () => { + userScope(); + const home = path.join(tmp, 'home'); + const project = gitRepo('codex-proj'); + process.chdir(project); + const skillMd = path.join(home, '.codex', 'skills', 'hello-skill', 'SKILL.md'); + fs.mkdirSync(path.dirname(skillMd), { recursive: true }); + fs.writeFileSync(skillMd, '# hello-skill\n'); + const captured = (name: string): Record => { + const text = fs.readFileSync(path.join(__dirname, 'fixtures', 'skill-use', `${name}.json`), 'utf-8') + .replaceAll('${HOME}', home).replaceAll('${CWD}', project); + const payload: unknown = JSON.parse(text); + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) throw new Error(`${name} is not an object`); + return { ...payload }; + }; + await hook('prompt-submit', '*', captured('codex-real-c-mention'), 'codex'); + await hook('post-tool-use', '*', captured('codex-real-c-cat'), 'codex'); + const events: unknown[] = fs.readFileSync(path.join(teamaiHome(), 'user-usage.jsonl'), 'utf-8') + .split('\n').filter(Boolean).map((line) => JSON.parse(line)); + expect(events).toMatchObject([{ skill: 'hello-skill', tool: 'codex' }]); + }); +}); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 9d11f894a..16433f09e 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -47,17 +47,19 @@ vi.mock('../dashboard-collector.js', () => ({ (await vi.importActual('../utils/hook-cwd.js')).resolveHookCwd(stdin), })); -// Use the REAL resolveSkillUse (pure Skill/Read+SKILL.md logic, no I/O) so the -// webhook + track tests exercise the actual skill-name resolution shared by both -// callers, rather than a stub that could drift from production behavior. +// The REAL skill-name extraction and validation, so the webhook and skill-use +// tests exercise the resolution production runs; every named skill counts as +// installed, since these payloads name paths that do not exist here. vi.mock('../usage-tracker.js', async () => { const actual = await vi.importActual('../usage-tracker.js'); return { trackFromStdin: mockTrackFromParsed, trackSlashCommand: mockTrackSlashFromParsed, - resolveSkillUse: actual.resolveSkillUse, extractSkillName: actual.extractSkillName, isValidSkillName: actual.isValidSkillName, + skillRoots: vi.fn().mockResolvedValue([]), + skillExistsOnDisk: vi.fn().mockResolvedValue(true), + isInstalledSkillFile: vi.fn().mockResolvedValue(true), appendUsageEvent: vi.fn().mockResolvedValue(undefined), updateKnownSkills: vi.fn().mockResolvedValue(undefined), }; @@ -73,10 +75,11 @@ vi.mock('../webhook.js', () => ({ loadWebhookConfig: mockLoadWebhookConfig, })); -vi.mock('../contribute-check.js', () => ({ +vi.mock('../contribute-check.js', async () => ({ contributeCheck: vi.fn().mockResolvedValue(undefined), contributeCheckForSession: mockContributeCheckForSession, takePendingHint: mockTakePendingHint, + sanitizeSessionId: (await vi.importActual('../contribute-check.js')).sanitizeSessionId, })); vi.mock('../votes-judge.js', () => ({ @@ -86,6 +89,9 @@ vi.mock('../votes-judge.js', () => ({ vi.mock('../update.js', () => ({ doUpdate: mockDoUpdate, checkForUpdate: vi.fn().mockResolvedValue({ available: false, current: '1.0.0' }), + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), + withFileLock: vi.fn(async (_lockPath: string, _wait: unknown, body: () => Promise) => ({ held: true, value: await body() })), })); const mockAutoDetectInit = vi.fn().mockResolvedValue({ @@ -860,12 +866,11 @@ describe('hook-handlers registry', () => { expect(wildcardHandlers).toContain('dashboard-report'); }); - it('post-tool-use Skill matcher has track', () => { + it('post-tool-use tracks skill use on the wildcard pass, detached, with no Skill matcher (#1221)', () => { 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'); + expect(registry.filter((r) => r.event === 'post-tool-use' && r.matcher === 'Skill')).toEqual([]); + const skillUse = registry.find((r) => r.event === 'post-tool-use' && r.handler.name === 'skill-use'); + expect(skillUse).toMatchObject({ matcher: '*', background: true, requiresConfig: true }); }); // Perf regression: post-tool-use fires on every tool call, so its local-agent @@ -941,12 +946,12 @@ describe('hook-handlers registry', () => { } }); - it('prompt-submit has track-slash and dashboard-report', () => { + it('prompt-submit has skill-mention 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('skill-mention'); expect(handlers).toContain('dashboard-report'); }); @@ -1685,6 +1690,9 @@ describe('webhook-dispatch handler (#701, #702)', () => { const handler = () => buildHandlerRegistry().find( (r) => r.handler.name === 'webhook-dispatch', )!.handler; + const skillUse = () => buildHandlerRegistry().find( + (r) => r.handler.name === 'skill-use', + )!.handler; beforeEach(() => { vi.clearAllMocks(); @@ -1694,14 +1702,13 @@ describe('webhook-dispatch handler (#701, #702)', () => { }); }); - it('maps PostToolUse/Skill to skill-use and forwards only the skill name (#701, #702)', async () => { - await handler().execute( + it('sends skill-use for a Skill call and forwards only the skill name (#701, #702)', async () => { + await skillUse().execute( { hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'demo', args: 'api_key=SYNTHETIC_SECRET_NOT_REAL' }, tool_response: 'SYNTHETIC_PRIVATE_OUTPUT', - session_id: 'sid', }, 'claude', scope, ); @@ -1743,16 +1750,15 @@ describe('webhook-dispatch handler (#701, #702)', () => { // Codex review finding 9: Cursor represents skill use as a `Read` of a // SKILL.md file (tool_name: 'Read'), NOT a `Skill` tool — and it dispatches - // via the camelCase `postToolUse` event. The webhook must reach the same - // parity trackHandler has (shared resolveSkillUse), forwarding {skillName}. - it('maps a Cursor camelCase postToolUse Read of SKILL.md to skill-use (#702, #9)', async () => { - await handler().execute( + // via the camelCase `postToolUse` event. The webhook comes from the handler + // that records the use, so the two cannot disagree. + it('sends skill-use for a Cursor camelCase postToolUse Read of SKILL.md (#702, #9)', async () => { + await skillUse().execute( { hook_event_name: 'postToolUse', tool_name: 'Read', tool_input: { path: '/root/.cursor/skills/tdd/SKILL.md' }, tool_response: 'SYNTHETIC_PRIVATE_OUTPUT', - session_id: 'sid', }, 'cursor', scope, ); @@ -1763,25 +1769,16 @@ describe('webhook-dispatch handler (#701, #702)', () => { expect(JSON.stringify(payload)).not.toContain('SYNTHETIC_PRIVATE_OUTPUT'); }); - // Finding 9 guard: a NORMAL (non-SKILL.md) Read must NOT produce a skill-use - // webhook — data must be empty so a plain file read never leaks or fires. - it('does NOT emit skill-use data for a normal (non-SKILL.md) Read (#9 guard)', async () => { - await handler().execute( - { - hook_event_name: 'PostToolUse', - tool_name: 'Read', - tool_input: { path: '/root/project/src/secrets.ts' }, - tool_response: 'const API_KEY = "SYNTHETIC_SECRET_NOT_REAL";', - session_id: 'sid', - }, - 'cursor', scope, - ); - expect(mockSendWebhook).toHaveBeenCalledOnce(); - const [event, payload] = mockSendWebhook.mock.calls[0]; - expect(event).toBe('skill-use'); - expect(payload.data).toEqual({}); - expect(JSON.stringify(payload)).not.toContain('SYNTHETIC_SECRET_NOT_REAL'); - expect(JSON.stringify(payload)).not.toContain('secrets.ts'); + it('sends no webhook for a normal (non-SKILL.md) Read (#9 guard)', async () => { + const payload = { + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_input: { path: '/root/project/src/secrets.ts' }, + tool_response: 'const API_KEY = "SYNTHETIC_SECRET_NOT_REAL";', + }; + await skillUse().execute(payload, 'cursor', scope); + await handler().execute(payload, 'cursor', scope); + expect(mockSendWebhook).not.toHaveBeenCalled(); }); // Codex review finding 2: Copilot fires SessionEnd (not Stop); the handler @@ -1801,35 +1798,29 @@ describe('webhook-dispatch handler (#701, #702)', () => { }); // Codex review finding 3: an extracted skillName that fails isValidSkillName - // (e.g. a path-like `command` arg) must be dropped, not forwarded. Uses the - // real resolveSkillUse, which validates with isValidSkillName. - it('drops an invalid skillName instead of forwarding it (#701)', async () => { - await handler().execute( + // (e.g. a path-like `command` arg) is no skill use, so nothing is sent. + it('sends nothing for an invalid skillName (#701)', async () => { + await skillUse().execute( { hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { command: '/etc/passwd; rm -rf /' }, - session_id: 'sid', }, 'claude', scope, ); - expect(mockSendWebhook).toHaveBeenCalledOnce(); - const [, payload] = mockSendWebhook.mock.calls[0]; - expect(payload.data).toEqual({}); - expect(JSON.stringify(payload)).not.toContain('passwd'); + expect(mockSendWebhook).not.toHaveBeenCalled(); }); }); // Codex review finding 12: the tests above call handler.execute() directly, -// bypassing dispatcher routing. These drive the REAL dispatcher for the -// post-tool-use `Skill` matcher — the matcher Cursor's SKILL.md Read is wired to -// (git f0ab4eb switched Cursor tracking from Read to the Skill matcher; -// BUILTIN_HOOK_SPECS has no Read matcher) — to prove a Read payload routes -// through it to webhookHandler. webhookHandler is background: true, so it runs in -// the 'background' dispatch pass. (Cursor's own runtime is external and not -// testable in this repo; this covers the CLI-side routing that is.) -describe('post-tool-use Skill-matcher dispatch routes Cursor SKILL.md Read to the webhook (#702, #9, #12)', () => { +// bypassing dispatcher routing. These drive the REAL dispatcher on the +// wildcard post-tool-use pass, the one every tool installs (#1221), to prove a +// Read payload routes through it to the skill-use webhook. skill-use is +// background: true, so it runs in the 'background' dispatch pass. (Cursor's own +// runtime is external and not testable in this repo; this covers the CLI-side +// routing that is.) +describe('post-tool-use wildcard dispatch routes Cursor SKILL.md Read to the webhook (#702, #9, #12)', () => { beforeEach(() => { vi.clearAllMocks(); mockLoadWebhookConfig.mockResolvedValue({ @@ -1838,25 +1829,22 @@ describe('post-tool-use Skill-matcher dispatch routes Cursor SKILL.md Read to th }); }); - it('registers webhook-dispatch under the post-tool-use Skill matcher', () => { - const reg = buildHandlerRegistry().find( - (r) => r.event === 'post-tool-use' && r.matcher === 'Skill' && r.handler.name === 'webhook-dispatch', - ); - expect(reg).toBeDefined(); - expect(reg!.background).toBe(true); + it('registers no webhook-dispatch on post-tool-use: skill-use sends it', () => { + expect(buildHandlerRegistry().filter( + (r) => r.event === 'post-tool-use' && r.handler.name === 'webhook-dispatch', + )).toEqual([]); }); - it('a Cursor Read of SKILL.md dispatched via the Skill matcher produces a skill-use webhook with {skillName}', async () => { + it('a Cursor Read of SKILL.md on the wildcard pass produces a skill-use webhook with {skillName}', async () => { const dispatcher = createDispatcher({ handlers: buildHandlerRegistry(), localConfig: scope }); await dispatcher.dispatch( 'post-tool-use', - 'Skill', + '*', { hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: { path: '/root/.cursor/skills/tdd/SKILL.md' }, tool_response: 'SYNTHETIC_PRIVATE_OUTPUT', - session_id: 'sid', }, 'cursor', 'background', @@ -1869,32 +1857,27 @@ describe('post-tool-use Skill-matcher dispatch routes Cursor SKILL.md Read to th expect(JSON.stringify(payload)).not.toContain('SYNTHETIC_PRIVATE_OUTPUT'); }); - it('a normal Read (non-SKILL.md) dispatched via the Skill matcher produces empty skill-use data', async () => { + it('a normal Read (non-SKILL.md) on the wildcard pass sends no webhook', async () => { const dispatcher = createDispatcher({ handlers: buildHandlerRegistry(), localConfig: scope }); await dispatcher.dispatch( 'post-tool-use', - 'Skill', + '*', { hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: { path: '/root/project/src/secrets.ts' }, tool_response: 'const API_KEY = "SYNTHETIC_SECRET_NOT_REAL";', - session_id: 'sid', }, 'cursor', 'background', ); - expect(mockSendWebhook).toHaveBeenCalledOnce(); - const [, payload] = mockSendWebhook.mock.calls[0]; - expect(payload.data).toEqual({}); - expect(JSON.stringify(payload)).not.toContain('SYNTHETIC_SECRET_NOT_REAL'); - expect(JSON.stringify(payload)).not.toContain('secrets.ts'); + expect(mockSendWebhook).not.toHaveBeenCalled(); }); }); // PostToolUse payloads as Muse Code 1.4.6 sends them, tool_response trimmed. -describe('track handler: Muse Code', () => { +describe('skill-use handler: Muse Code', () => { const musePayload = (toolName: string, toolInput: Record) => ({ hook_event_name: 'PostToolUse', tool_name: toolName, @@ -1910,7 +1893,7 @@ describe('track handler: Muse Code', () => { model_provider: 'meta', }); const track = () => buildHandlerRegistry() - .find((r) => r.event === 'post-tool-use' && r.matcher === 'Skill' && r.handler.name === 'track')!.handler; + .find((r) => r.event === 'post-tool-use' && r.matcher === '*' && r.handler.name === 'skill-use')!.handler; it('records a read_skill call as a use of that skill', async () => { const { appendUsageEvent, updateKnownSkills } = await import('../usage-tracker.js'); @@ -1933,12 +1916,12 @@ describe('track handler: Muse Code', () => { }); }); -describe('track-slash handler: dotted and colon skill names', () => { +describe('skill-mention handler: dotted and colon skill names', () => { it('tracks a skill name that contains a dot', async () => { const { appendUsageEvent, updateKnownSkills } = await import('../usage-tracker.js'); const registry = buildHandlerRegistry(); const handler = registry.find( - (r) => r.event === 'prompt-submit' && r.handler.name === 'track-slash', + (r) => r.event === 'prompt-submit' && r.handler.name === 'skill-mention', )!.handler; vi.mocked(appendUsageEvent).mockClear(); @@ -1959,7 +1942,7 @@ describe('track-slash handler: dotted and colon skill names', () => { const { appendUsageEvent, updateKnownSkills } = await import('../usage-tracker.js'); const registry = buildHandlerRegistry(); const handler = registry.find( - (r) => r.event === 'prompt-submit' && r.handler.name === 'track-slash', + (r) => r.event === 'prompt-submit' && r.handler.name === 'skill-mention', )!.handler; vi.mocked(appendUsageEvent).mockClear(); @@ -1987,7 +1970,7 @@ describe('track-slash handler: dotted and colon skill names', () => { ])('does not record a path or a partial skill name: %s', async (prompt) => { const { appendUsageEvent, updateKnownSkills } = await import('../usage-tracker.js'); const handler = buildHandlerRegistry().find( - (r) => r.event === 'prompt-submit' && r.handler.name === 'track-slash', + (r) => r.event === 'prompt-submit' && r.handler.name === 'skill-mention', )!.handler; vi.mocked(appendUsageEvent).mockClear(); vi.mocked(updateKnownSkills).mockClear(); diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index 05bc2e716..96ea27277 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -671,7 +671,7 @@ describe('hooksList', () => { const text = out.join('\n'); const builtin = text.slice(text.indexOf('Built-in hooks (A)'), text.indexOf('Team hooks (B)')); const claude = builtinBlock(builtin, 'claude') ?? []; - expect(claude).toHaveLength(6); + expect(claude).toHaveLength(5); expect(claude.some((line) => line.startsWith('Stop →'))).toBe(false); }); @@ -749,7 +749,7 @@ describe('hooksList', () => { const builtin = text.slice(text.indexOf('Built-in hooks (A)'), text.indexOf('Team hooks (B)')); // Claude is reconciled through its settings file: the whole set. - expect(builtinBlock(builtin, 'claude')).toHaveLength(7); + expect(builtinBlock(builtin, 'claude')).toHaveLength(6); // Hermes installs a single on_session_start script running the raw // dispatch command (hermes-hooks.ts). expect(builtinBlock(builtin, 'hermes')).toEqual([ diff --git a/src/__tests__/hooks-dispatch-format.test.ts b/src/__tests__/hooks-dispatch-format.test.ts index a2467c473..2c919403f 100644 --- a/src/__tests__/hooks-dispatch-format.test.ts +++ b/src/__tests__/hooks-dispatch-format.test.ts @@ -118,7 +118,7 @@ describe('hooks — merged dispatch format', () => { 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(['*', 'Skill', 'TodoWrite']); + expect(matchers).toEqual(['*', 'TodoWrite']); }); it('UserPromptSubmit has exactly one entry with wildcard matcher', async () => { @@ -143,8 +143,8 @@ describe('hooks — merged dispatch format', () => { await injectHooks('/test/settings.json', 'claude'); const result = mockFiles['/test/settings.json'] as { hooks: Record }; - const skillEntry = result.hooks.PostToolUse.find((m: ClaudeHookMatcher) => m.matcher === 'Skill'); - expect(skillEntry!.hooks[0].command).toContain('--matcher Skill'); + const todoEntry = result.hooks.PostToolUse.find((m: ClaudeHookMatcher) => m.matcher === 'TodoWrite'); + expect(todoEntry!.hooks[0].command).toContain('--matcher TodoWrite'); }); it('does not include --matcher for wildcard entries', async () => { diff --git a/src/__tests__/hooks-e2e.test.ts b/src/__tests__/hooks-e2e.test.ts index 81d7d5b70..960dc053a 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 5 events and 7 dispatch hooks', async () => { + it('creates Claude settings.json with all 5 events and 6 dispatch hooks', async () => { const p = claudePath(); await injectHooks(p, 'claude'); @@ -49,12 +49,12 @@ describe('hooks E2E — real file I/O', () => { expect(Object.keys(hooks)).toEqual(['SessionStart', 'Stop', 'PostToolUse', 'UserPromptSubmit', 'SubagentStop']); expect(hooks.SessionStart).toHaveLength(1); expect(hooks.Stop).toHaveLength(1); - expect(hooks.PostToolUse).toHaveLength(3); + expect(hooks.PostToolUse).toHaveLength(2); expect(hooks.UserPromptSubmit).toHaveLength(1); expect(hooks.SubagentStop).toHaveLength(1); }); - it('creates Cursor hooks.json with all 4 events and 6 dispatch hooks', async () => { + it('creates Cursor hooks.json with all 4 events and 5 dispatch hooks', async () => { const p = cursorPath(); await injectHooks(p, 'cursor'); @@ -65,7 +65,7 @@ describe('hooks E2E — real file I/O', () => { expect(Object.keys(hooks)).toEqual(['sessionStart', 'stop', 'postToolUse', 'beforeSubmitPrompt']); expect(hooks.sessionStart).toHaveLength(1); expect(hooks.stop).toHaveLength(1); - expect(hooks.postToolUse).toHaveLength(3); + expect(hooks.postToolUse).toHaveLength(2); expect(hooks.beforeSubmitPrompt).toHaveLength(1); }); @@ -124,7 +124,7 @@ describe('hooks E2E — real file I/O', () => { } }); - it('PostToolUse dispatch hook exists in both formats (Cursor host matcher Read)', async () => { + it('both formats get the wildcard PostToolUse dispatch, with no Skill pass (#1221)', async () => { const cp = claudePath(); const kp = cursorPath(); await injectHooks(cp, 'claude'); @@ -136,16 +136,12 @@ describe('hooks E2E — real file I/O', () => { const claudePostTool = (claudeResult.hooks as Record>).PostToolUse; const cursorPostTool = (cursorResult.hooks as Record>).postToolUse; - const claudeSkillHook = claudePostTool.find((h) => h.matcher === 'Skill'); - expect(claudeSkillHook).toBeDefined(); - - // Cursor loads skills via Read of SKILL.md; host matcher is remapped to Read - // while the dispatch command still uses --matcher Skill (#1156). - const cursorSkillHook = cursorPostTool.find( - (h) => h.matcher === 'Read' && h.command.includes('--matcher Skill'), - ); - expect(cursorSkillHook).toBeDefined(); - expect(cursorSkillHook?.command).toContain('hook-dispatch'); + expect(claudePostTool.map((h) => h.matcher)).toEqual(['*', 'TodoWrite']); + // Cursor's wildcard entry has no host matcher; its Read of a SKILL.md is + // credited by the wildcard pass. + const cursorWildcard = cursorPostTool.find((h) => h.matcher === undefined); + expect(cursorWildcard?.command).toContain('hook-dispatch post-tool-use --tool cursor'); + expect(JSON.stringify([claudePostTool, cursorPostTool])).not.toContain('--matcher Skill'); }); it('tool-specific commands use the correct --tool parameter', async () => { diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index 8a4e899ba..9a1d50ae6 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -561,8 +561,8 @@ builtin: await reconcileTeamHooksForConfig(teamConfig, localConfig()); const cursor = await cursorSettings(); - // TodoWrite dropped → 2 built-in postToolUse entries instead of 3. - expect(cursor.hooks.postToolUse).toHaveLength(2); + // TodoWrite dropped → 1 built-in postToolUse entry instead of 2. + expect(cursor.hooks.postToolUse).toHaveLength(1); expect(cursor.hooks.postToolUse.some((h) => h.command.includes('TodoWrite'))).toBe(false); // Stop timeout overridden. expect((cursor.hooks.stop[0] as { timeout?: number }).timeout).toBe(99); diff --git a/src/__tests__/hooks-skill-retire.test.ts b/src/__tests__/hooks-skill-retire.test.ts new file mode 100644 index 000000000..1f5777731 --- /dev/null +++ b/src/__tests__/hooks-skill-retire.test.ts @@ -0,0 +1,77 @@ +/** + * Retiring the `post-tool-use --matcher Skill` hook (#1221): each `*.before.json` + * is the file the previous release wrote for one format family; reconcile it + * and only the Skill entry goes, every other byte stays. A project's team-only + * file never held the built-in, so it is written as before. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { injectHooks, reconcileHooks } from '../hooks.js'; +import type { HookDef } from '../types.js'; + +const FIXTURES = path.resolve(__dirname, 'fixtures', 'hooks-skill-retire'); + +/** The team hook the team-only fixture was written with. */ +const TEAM_LINT: HookDef = { + source: 'team', key: 'lint', event: 'PostToolUse', matcher: 'Edit', + command: 'bash .teamai/team-scripts/lint.sh', description: '[teamai:team:lint] Lint after edits', +}; + +let tmp: string; +let originalHome: string | undefined; + +beforeEach(() => { + tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-skill-retire-'))); + originalHome = process.env.HOME; + process.env.HOME = tmp; +}); + +afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +/** `value` without the array entries that hold a `--matcher Skill` dispatch. */ +function withoutSkillHook(value: unknown): unknown { + if (Array.isArray(value)) { + return value.filter((entry) => !JSON.stringify(entry).includes('--matcher Skill')).map(withoutSkillHook); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, withoutSkillHook(inner)])); + } + return value; +} + +const FAMILIES = [ + { name: 'claude', file: 'settings.json' }, + { name: 'codex', file: 'hooks.json' }, + { name: 'copilot', file: 'teamai.json' }, + { name: 'cursor', file: 'hooks.json' }, + { name: 'zcode', file: 'config.json' }, +]; + +describe('reconcile retires the Skill dispatch hook (#1221)', () => { + it.each(FAMILIES)('$name: removes only the Skill entry', async ({ name, file }) => { + const before = fs.readFileSync(path.join(FIXTURES, `${name}.before.json`), 'utf-8'); + expect(before).toContain('--matcher Skill'); + const settings = path.join(tmp, name, file); + fs.mkdirSync(path.dirname(settings), { recursive: true }); + fs.writeFileSync(settings, before); + + await injectHooks(settings, name); + + const after = fs.readFileSync(settings, 'utf-8'); + expect(JSON.parse(after)).toEqual(withoutSkillHook(JSON.parse(before))); + await expect(after).toMatchFileSnapshot(path.join(FIXTURES, `${name}.after.json`)); + }); + + it('writes a project team-only file byte for byte as the previous release did', async () => { + const before = fs.readFileSync(path.join(FIXTURES, 'claude-team-only.json'), 'utf-8'); + const settings = path.join(tmp, 'project', '.claude', 'settings.json'); + await reconcileHooks(settings, 'claude', [TEAM_LINT], { manifestPath: path.join(tmp, 'managed.json'), teamOnly: true }); + expect(fs.readFileSync(settings, 'utf-8')).toBe(before); + }); +}); diff --git a/src/__tests__/hooks-team.test.ts b/src/__tests__/hooks-team.test.ts index 03e824a53..c7b41fa8e 100644 --- a/src/__tests__/hooks-team.test.ts +++ b/src/__tests__/hooks-team.test.ts @@ -58,7 +58,7 @@ describe('reconcileHooks — team (B) hooks', () => { await reconcileHooks(SETTINGS, 'claude', [teamDef()], { manifestPath: MANIFEST }); const postToolUse = claudeHooks().PostToolUse; - expect(postToolUse).toHaveLength(4); // 3 built-in + 1 team + expect(postToolUse).toHaveLength(3); // 2 built-in + 1 team const team = postToolUse[postToolUse.length - 1]; expect(team.description).toBe('[teamai:hook:block-secret] scan secrets before bash'); expect(team.matcher).toBe('Bash'); @@ -76,7 +76,7 @@ describe('reconcileHooks — team (B) hooks', () => { await reconcileHooks(SETTINGS, 'claude', [teamDef()], { manifestPath: MANIFEST }); await reconcileHooks(SETTINGS, 'claude', [], { manifestPath: MANIFEST }); - expect(claudeHooks().PostToolUse).toHaveLength(3); // built-in only + expect(claudeHooks().PostToolUse).toHaveLength(2); // built-in only const descs = claudeHooks().PostToolUse.map((h) => h.description); expect(descs.every((d) => d?.startsWith('[teamai] '))).toBe(true); expect(manifest().claude).toBeUndefined(); @@ -88,7 +88,7 @@ describe('reconcileHooks — team (B) hooks', () => { const team = claudeHooks().PostToolUse.find((h) => h.description?.startsWith('[teamai:hook:block-secret]')); expect(team?.hooks[0].command).toBe(`${claudeHookOtherHostSkip('PostToolUse', 'Bash', 'echo changed')}echo changed`); - expect(claudeHooks().PostToolUse).toHaveLength(4); + expect(claudeHooks().PostToolUse).toHaveLength(3); }); it('does not touch user-authored hooks', async () => { @@ -102,7 +102,7 @@ describe('reconcileHooks — team (B) hooks', () => { expect(claudeHooks().PostToolUse[0]).toEqual({ matcher: '*', hooks: [{ type: 'command', command: 'echo user' }], description: 'mine', }); - expect(claudeHooks().PostToolUse).toHaveLength(5); // user + 3 built-in + 1 team + expect(claudeHooks().PostToolUse).toHaveLength(4); // user + 2 built-in + 1 team }); it('is idempotent with team hooks present', async () => { @@ -117,7 +117,7 @@ describe('reconcileHooks — team (B) hooks', () => { it('injects a team hook and records it in the manifest', async () => { await reconcileHooks(CURSOR, 'cursor', [teamDef()], { manifestPath: MANIFEST }); - expect(cursorHooks().postToolUse).toHaveLength(4); + expect(cursorHooks().postToolUse).toHaveLength(3); const team = cursorHooks().postToolUse.find((h) => h.command.includes('scan.sh')); expect(team?.command).toBe(teamDef().command); expect(team).toMatchObject({ matcher: 'Bash', timeout: 15 }); @@ -128,7 +128,7 @@ describe('reconcileHooks — team (B) hooks', () => { await reconcileHooks(CURSOR, 'cursor', [teamDef()], { manifestPath: MANIFEST }); await reconcileHooks(CURSOR, 'cursor', [], { manifestPath: MANIFEST }); - expect(cursorHooks().postToolUse).toHaveLength(3); // built-in only + expect(cursorHooks().postToolUse).toHaveLength(2); // built-in only expect(cursorHooks().postToolUse.some((h) => h.command.includes('scan.sh'))).toBe(false); expect(manifest().cursor).toBeUndefined(); }); @@ -138,11 +138,11 @@ describe('reconcileHooks — team (B) hooks', () => { it('skips a team hook whose tools list excludes the tool', async () => { const claudeOnly = teamDef({ tools: ['claude'] }); await reconcileHooks(CURSOR, 'cursor', [claudeOnly], { manifestPath: MANIFEST }); - expect(cursorHooks().postToolUse).toHaveLength(3); + expect(cursorHooks().postToolUse).toHaveLength(2); expect(manifest().cursor).toBeUndefined(); await reconcileHooks(SETTINGS, 'claude', [claudeOnly], { manifestPath: MANIFEST }); - expect(claudeHooks().PostToolUse).toHaveLength(4); + expect(claudeHooks().PostToolUse).toHaveLength(3); }); it('skips a team hook for an event Cursor does not support, but injects it for Claude', async () => { @@ -160,23 +160,23 @@ describe('reconcileHooks — team (B) hooks', () => { it('a built-in-only refresh (no manifest) preserves existing team hooks', async () => { // Team pass injects A + B. await reconcileHooks(SETTINGS, 'claude', [teamDef()], { manifestPath: MANIFEST }); - expect(claudeHooks().PostToolUse).toHaveLength(4); + expect(claudeHooks().PostToolUse).toHaveLength(3); // An old/builtin-only injector runs (no team context): must NOT drop the team hook. await reconcileHooks(SETTINGS, 'claude', []); const team = claudeHooks().PostToolUse.find((h) => h.description?.startsWith('[teamai:hook:block-secret]')); expect(team).toBeDefined(); - expect(claudeHooks().PostToolUse).toHaveLength(4); + expect(claudeHooks().PostToolUse).toHaveLength(3); }); it('Cursor: a built-in-only refresh preserves team hooks (manifest-tracked)', async () => { await reconcileHooks(CURSOR, 'cursor', [teamDef()], { manifestPath: MANIFEST }); - expect(cursorHooks().postToolUse).toHaveLength(4); + expect(cursorHooks().postToolUse).toHaveLength(3); await reconcileHooks(CURSOR, 'cursor', []); expect(cursorHooks().postToolUse.some((h) => h.command.includes('scan.sh'))).toBe(true); - expect(cursorHooks().postToolUse).toHaveLength(4); + expect(cursorHooks().postToolUse).toHaveLength(3); }); }); diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts index cff03876e..59099f0d9 100644 --- a/src/__tests__/hooks.test.ts +++ b/src/__tests__/hooks.test.ts @@ -66,7 +66,7 @@ describe('hooks', () => { }); describe('inject — empty file', () => { - it('Claude format: injects 5 events with 7 dispatch hooks into empty settings.json', async () => { + it('Claude format: injects 5 events with 6 dispatch hooks into empty settings.json', async () => { await injectHooks('/test/settings.json', 'claude'); const result = mockFiles['/test/settings.json'] as { hooks: Record }; @@ -77,15 +77,15 @@ describe('hooks', () => { // Merged dispatch format: one dispatch entry per event+matcher. // SessionStart(*:1), Stop(*:1), - // PostToolUse(*:1, Skill:1, TodoWrite:1), UserPromptSubmit(*:1), SubagentStop(*:1) + // PostToolUse(*:1, TodoWrite:1), UserPromptSubmit(*:1), SubagentStop(*:1) expect(result.hooks['SessionStart']).toHaveLength(1); expect(result.hooks['Stop']).toHaveLength(1); - expect(result.hooks['PostToolUse']).toHaveLength(3); + expect(result.hooks['PostToolUse']).toHaveLength(2); expect(result.hooks['UserPromptSubmit']).toHaveLength(1); expect(result.hooks['SubagentStop']).toHaveLength(1); }); - it('Cursor format: injects 4 events with 6 dispatch hooks into empty hooks.json', async () => { + it('Cursor format: injects 4 events with 5 dispatch hooks into empty hooks.json', async () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { version: number; hooks: Record }; @@ -98,7 +98,7 @@ describe('hooks', () => { // Same merged structure (with TodoWrite dispatch entry) expect(result.hooks['sessionStart']).toHaveLength(1); expect(result.hooks['stop']).toHaveLength(1); - expect(result.hooks['postToolUse']).toHaveLength(3); + expect(result.hooks['postToolUse']).toHaveLength(2); expect(result.hooks['beforeSubmitPrompt']).toHaveLength(1); }); @@ -108,7 +108,7 @@ describe('hooks', () => { const result = mockFiles['/test/codex-hooks.json'] as { hooks: Record }>> }; expect(result.hooks).toBeDefined(); expect(Object.keys(result.hooks)).toEqual(['SessionStart', 'Stop', 'PostToolUse', 'UserPromptSubmit', 'SubagentStop', 'SubagentStart']); - expect(result.hooks.PostToolUse).toHaveLength(3); + expect(result.hooks.PostToolUse).toHaveLength(2); expect(result.hooks.SessionStart[0].hooks[0].command).toContain('--tool codex'); expect(result.hooks.SessionStart[0].description).toBeUndefined(); }); @@ -159,7 +159,7 @@ describe('hooks', () => { const result = mockFiles['/test/settings.json'] as { hooks: Record }; expect(result.hooks['SessionStart']).toHaveLength(1); expect(result.hooks['Stop']).toHaveLength(1); - expect(result.hooks['PostToolUse']).toHaveLength(3); + expect(result.hooks['PostToolUse']).toHaveLength(2); expect(result.hooks['UserPromptSubmit']).toHaveLength(1); }); @@ -170,7 +170,7 @@ describe('hooks', () => { const result = mockFiles['/test/hooks.json'] as { hooks: Record }; expect(result.hooks['sessionStart']).toHaveLength(1); expect(result.hooks['stop']).toHaveLength(1); - expect(result.hooks['postToolUse']).toHaveLength(3); + expect(result.hooks['postToolUse']).toHaveLength(2); expect(result.hooks['beforeSubmitPrompt']).toHaveLength(1); }); @@ -546,23 +546,15 @@ describe('hooks', () => { } }); - it('PostToolUse/postToolUse Skill dispatch hook exists in both formats (Cursor host matcher Read)', async () => { + it('PostToolUse/postToolUse carry no Skill dispatch hook in either format: the wildcard pass tracks skill use (#1221)', 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 claudeSkill = claudeResult.hooks.PostToolUse.find((h) => h.matcher === 'Skill'); - expect(claudeSkill).toBeDefined(); - - // Cursor loads skills via Read of SKILL.md; host matcher is remapped to Read - // while the dispatch command still uses --matcher Skill (#1156). - const cursorSkill = cursorResult.hooks.postToolUse.find( - (h) => h.matcher === 'Read' && h.command.includes('--matcher Skill'), - ); - expect(cursorSkill).toBeDefined(); - expect(cursorSkill!.command).toContain('hook-dispatch'); + expect(claudeResult.hooks.PostToolUse.map((h) => h.matcher)).toEqual(['*', 'TodoWrite']); + expect(cursorResult.hooks.postToolUse.some((h) => h.matcher === 'Read' || h.command.includes('--matcher Skill'))).toBe(false); }); it('Cursor hooks have timeout values', async () => { diff --git a/src/__tests__/muse.test.ts b/src/__tests__/muse.test.ts index 223567ab3..cc3b2b327 100644 --- a/src/__tests__/muse.test.ts +++ b/src/__tests__/muse.test.ts @@ -57,14 +57,13 @@ describe('Muse Code hooks', () => { } }); - // Muse names its skill tool `read_skill`; a `Skill` matcher never fires there. - it("runs the skill-usage hook on Muse's read_skill tool", async () => { + it("runs the skill-usage hook on Muse's read_skill tool through the wildcard pass", async () => { await reconcileHooks(settingsPath, 'muse'); const settings = await fse.readJson(settingsPath) as MuseSettings; - const skillGroups = (settings.hooks?.PostToolUse ?? []) - .filter((group) => group.hooks.some((handler) => String(handler.command).includes('--matcher Skill'))); - expect(skillGroups.map((group) => group.matcher)).toEqual(['read_skill']); + const groups = settings.hooks?.PostToolUse ?? []; + expect(groups.some((group) => group.hooks.some((handler) => String(handler.command).includes('--matcher Skill')))).toBe(false); + expect(groups.map((group) => group.matcher)).toEqual([undefined, 'TodoWrite']); }); it('keeps the rest of an existing settings.json and its schema_version', async () => { diff --git a/src/__tests__/opencode-hooks.test.ts b/src/__tests__/opencode-hooks.test.ts index 2d35ea97d..669a3a715 100644 --- a/src/__tests__/opencode-hooks.test.ts +++ b/src/__tests__/opencode-hooks.test.ts @@ -72,14 +72,11 @@ describe('buildPluginSource', () => { expect(src).toContain('child.stdin.write(stdin)'); expect(src).not.toContain('new Response(stdin)'); }); - it('maps lowercase OpenCode tool ids back to PascalCase matchers', () => { - // OpenCode passes `skill` / `todowrite`; the handler registry keys matchers - // on `Skill` / `TodoWrite`. - expect(src).toContain("skill: 'Skill'"); - expect(src).toContain("todowrite: 'TodoWrite'"); - expect(src).toContain('TOOL_MATCHER[tool]'); - // The dead PascalCase id comparison must be gone. - expect(src).not.toContain("tool === 'Skill'"); + it('sends OpenCode\'s own tool ids on one wildcard pass, with no matcher-scoped pass (#1221)', () => { + expect(src).toContain('tool_name: tool,'); + expect(src).not.toContain('TOOL_MATCHER'); + expect(src).not.toContain("'--matcher'"); + expect(src).not.toContain("skill: 'Skill'"); }); it('forwards tool_name / tool_input on post-tool-use and prompt on chat.message', () => { expect(src).toContain('tool_name'); @@ -413,7 +410,7 @@ describe('OpenCode V2 built-in plugin', () => { expect(Object.keys(host.callbacks)).toEqual([]); }); - it('forwards prompt and tool results through shared dispatch, with aliases and exactly one matcher pass', async () => { + it('forwards prompt and tool results through shared dispatch, with aliases and no matcher pass', async () => { const host = await loadV2Plugin(); await host.callbacks['session.prompt']({ sessionID: 'ses_v2', prompt: { text: '/retry now' } }); await host.callbacks['tool.execute.after']({ tool: 'skill', sessionID: 'ses_v2', input: { name: 'review' }, status: 'completed', result: { content: [{ type: 'text', text: 'loaded' }, { type: 'file', filename: 'skip' }, { type: 'text', text: 'next' }] } }); @@ -423,16 +420,15 @@ describe('OpenCode V2 built-in plugin', () => { expect(host.dispatches.map((d) => d.args)).toEqual([ ['hook-dispatch', 'prompt-submit', '--tool', 'opencode'], ['hook-dispatch', 'post-tool-use', '--tool', 'opencode'], - ['hook-dispatch', 'post-tool-use', '--tool', 'opencode', '--matcher', 'Skill'], ['hook-dispatch', 'post-tool-use', '--tool', 'opencode'], ['hook-dispatch', 'post-tool-use', '--tool', 'opencode'], ['hook-dispatch', 'post-tool-use', '--tool', 'opencode'], ]); expect(host.dispatches[0].payload).toEqual({ cwd: '/work/proj', session_id: 'ses_v2', prompt: '/retry now' }); - expect(host.dispatches[1].payload).toMatchObject({ tool_name: 'Skill', tool_input: { name: 'review' }, tool_response: 'loaded\nnext', tool_status: 'success' }); - expect(host.dispatches[3].payload).toMatchObject({ tool_name: 'bash', tool_response: 'failed', tool_status: 'failure' }); - expect(host.dispatches[4].payload).toMatchObject({ tool_response: 'denied', tool_status: 'failure' }); - expect(host.dispatches[5].payload).toMatchObject({ tool_name: 'task', session_link: { child: 'ses_child', parent: 'ses_v2' } }); + expect(host.dispatches[1].payload).toMatchObject({ tool_name: 'skill', tool_input: { name: 'review' }, tool_response: 'loaded\nnext', tool_status: 'success' }); + expect(host.dispatches[2].payload).toMatchObject({ tool_name: 'bash', tool_response: 'failed', tool_status: 'failure' }); + expect(host.dispatches[3].payload).toMatchObject({ tool_response: 'denied', tool_status: 'failure' }); + expect(host.dispatches[4].payload).toMatchObject({ tool_name: 'task', session_link: { child: 'ses_child', parent: 'ses_v2' } }); await host.cleanup?.(); expect(host.disposed.sort()).toEqual(['session.compaction', 'session.context', 'session.prompt', 'tool.execute.after']); }); diff --git a/src/__tests__/skill-use.test.ts b/src/__tests__/skill-use.test.ts new file mode 100644 index 000000000..5df7447a3 --- /dev/null +++ b/src/__tests__/skill-use.test.ts @@ -0,0 +1,473 @@ +/** + * Skill use (#1221) through the real dispatcher and the real usage file, one + * HOME per case. Payloads are the captured runs in fixtures/skill-use, with + * paths replaced by ${HOME}/${CWD}. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { createDispatcher } from '../hook-dispatch.js'; +import { buildHandlerRegistry } from '../hook-handlers.js'; +import { isToolId, TOOLS } from '../tools.js'; +import { DEFAULT_TOOL_PATHS, type LocalConfig } from '../types.js'; +import { readUsageEvents } from '../usage-tracker.js'; +import { sendWebhook } from '../webhook.js'; + +vi.mock('../webhook.js', () => ({ + sendWebhook: vi.fn().mockResolvedValue(undefined), + loadWebhookConfig: vi.fn().mockResolvedValue({ enabled: true, endpoints: [{ url: 'http://127.0.0.1/hook' }] }), +})); + +const FIXTURES = path.resolve(__dirname, 'fixtures', 'skill-use'); +const SKILL = 'hello-skill'; + +let tmp: string; +let home: string; +let cwd: string; +let originalHome: string | undefined; +let originalCwd: string; + +beforeEach(() => { + vi.mocked(sendWebhook).mockClear(); + tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-skill-use-'))); + home = path.join(tmp, 'home'); + cwd = path.join(tmp, 'proj'); + fs.mkdirSync(home); + fs.mkdirSync(cwd); + originalHome = process.env.HOME; + originalCwd = process.cwd(); + process.env.HOME = home; + // hook-dispatch runs its handlers in the hook's cwd. + process.chdir(cwd); +}); + +afterEach(() => { + process.chdir(originalCwd); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +function config(): LocalConfig { + return { + repo: { localPath: path.join(home, '.teamai', 'team-repo'), remote: 'https://example.test/acme/team.git' }, + username: 'tester', + scope: 'user', + additionalRoles: [], + }; +} + +function fixture(name: string): Record { + const text = fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf-8') + .replaceAll('${HOME}', home) + .replaceAll('${CWD}', cwd); + const payload: unknown = JSON.parse(text); + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) throw new Error(`${name} is not an object`); + return { ...payload }; +} + +/** Install a skill under `root`, relative to HOME, or to the project with `project: true`. */ +function install(root: string, options: { project?: boolean; skill?: string } = {}): string { + const file = path.join(options.project ? cwd : home, root, options.skill ?? SKILL, 'SKILL.md'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `# ${options.skill ?? SKILL}\n`); + return file; +} + +const dispatcher = (localConfig = config()) => createDispatcher({ handlers: buildHandlerRegistry().filter((r) => r.handler.name.startsWith('skill-')), localConfig }); + +async function toolCall(payload: Record, tool: string, matcher = '*', localConfig = config()): Promise { + const result = await dispatcher(localConfig).dispatch('post-tool-use', matcher, payload, tool, 'all'); + expect(result.errors).toEqual([]); +} + +function writeTeamFile(relative: string, content: string): void { + const file = path.join(config().repo.localPath, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +async function prompt(payload: Record, tool: string): Promise { + const result = await dispatcher().dispatch('prompt-submit', '*', payload, tool, 'all'); + expect(result.errors).toEqual([]); +} + +async function credits(): Promise> { + return (await readUsageEvents(config())).map(({ skill, tool }) => ({ skill, tool })); +} + +type ToolCallCase = { fixture: string; tool: string; root: string; project?: boolean }; + +/** One captured skill load per tool, credited once to that tool. */ +const TOOL_CALL_CASES: ToolCallCase[] = [ + { fixture: 'claude-skill', tool: 'claude', root: '.claude/skills' }, + { fixture: 'codebuddy-skill', tool: 'codebuddy', root: '.codebuddy/skills' }, + { fixture: 'copilot-skill', tool: 'copilot', root: '.copilot/skills' }, + { fixture: 'muse-read-skill', tool: 'muse', root: '.config/muse/skills' }, + { fixture: 'opencode-skill', tool: 'opencode', root: '.config/opencode/skills' }, + { fixture: 'dsh-skill', tool: 'dsh', root: '.dsh/skills' }, + { fixture: 'cursor-real-read', tool: 'cursor', root: '.cursor/skills', project: true }, + { fixture: 'codex-real-a-cat', tool: 'codex', root: '.codex/skills' }, + { fixture: 'pi-read', tool: 'pi', root: '.pi/agent/skills' }, + { fixture: 'omp-read-uri', tool: 'omp', root: '.omp/agent/skills' }, +]; + +describe('skill use from a tool call (#1221)', () => { + it.each(TOOL_CALL_CASES)('$fixture credits $tool once', async ({ fixture: name, tool, root, project }) => { + install(root, { project }); + await toolCall(fixture(name), tool); + expect(await credits()).toEqual([{ skill: SKILL, tool }]); + }); + + it('has a captured case for every skill tool marked verified', () => { + const covered = new Set(TOOL_CALL_CASES.map((c) => `${c.tool}:${String(fixture(c.fixture).tool_name)}`)); + const verified = Object.keys(DEFAULT_TOOL_PATHS).filter(isToolId).flatMap((tool) => { + const load = TOOLS[tool].skillLoad; + return load === 'unknown' ? [] : load.calls.filter((call) => call.evidence === 'verified').map((call) => `${tool}:${call.name}`); + }); + expect(verified.filter((key) => !covered.has(key))).toEqual([]); + }); + + it('credits nothing for a tool whose skill loading is unknown (Hermes)', async () => { + install('.hermes/skills'); + await toolCall(fixture('hermes-skill-view'), 'hermes'); + expect(await credits()).toEqual([]); + }); + + it('credits nothing for a skill that is not installed', async () => { + await toolCall(fixture('claude-skill'), 'claude'); + await toolCall(fixture('codex-real-a-cat'), 'codex'); + expect(await credits()).toEqual([]); + }); + + it('credits nothing for a SKILL.md outside every skill root, or a plain file read', async () => { + install('.claude/skills'); + const outside = path.join(tmp, 'docs', SKILL, 'SKILL.md'); + fs.mkdirSync(path.dirname(outside), { recursive: true }); + fs.writeFileSync(outside, '# copy\n'); + const read = (file: string) => ({ session_id: 's', hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: { file_path: file } }); + await toolCall(read(outside), 'claude'); + await toolCall(read(path.join(cwd, 'README.md')), 'claude'); + expect(await credits()).toEqual([]); + }); + + it('counts a read of the shared ~/.agents/skills root', async () => { + const file = install('.agents/skills'); + await toolCall({ session_id: 's', hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: { file_path: file } }, 'cursor'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'cursor' }]); + }); + + it('credits a custom tool for a skill installed under its toolPaths entry', async () => { + writeTeamFile('teamai.yaml', 'team: acme\nrepo: https://example.test/acme/team.git\ntoolPaths:\n acme:\n skills: .acme/skills\n'); + install('.acme/skills'); + await toolCall({ hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: SKILL } }, 'acme'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'acme' }]); + }); + + it('credits a project skill read from a directory below the project root', async () => { + const project: LocalConfig = { ...config(), scope: 'project', projectRoot: cwd }; + const file = install('.claude/skills', { project: true }); + const nested = path.join(cwd, 'packages', 'app'); + fs.mkdirSync(nested, { recursive: true }); + process.chdir(nested); + await toolCall({ session_id: 's', cwd: nested, hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: { file_path: file } }, 'claude', '*', project); + expect((await readUsageEvents(project)).map(({ skill, tool }) => ({ skill, tool }))).toEqual([{ skill: SKILL, tool: 'claude' }]); + }); + + it('credits `cat ~/…/SKILL.md`, the home the shell expands', async () => { + install('.codex/skills'); + const payload = fixture('codex-real-a-cat'); + await toolCall({ ...payload, tool_input: { command: `cat ~/.codex/skills/${SKILL}/SKILL.md` } }, 'codex'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'codex' }]); + }); + + it('credits a double-quoted HOME skill read', async () => { + install('.codex/skills'); + await toolCall({ ...fixture('codex-real-a-cat'), tool_input: { command: `cat "$HOME/.codex/skills/${SKILL}/SKILL.md"` } }, 'codex'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'codex' }]); + }); + + it('gates a named Skill call on its calling tool but accepts a real read from another tool root', async () => { + const file = install('.pi/agent/skills'); + await toolCall(fixture('claude-skill'), 'claude'); + expect(await credits()).toEqual([]); + await toolCall({ ...fixture('claude-skill'), tool_name: 'Read', tool_input: { file_path: file } }, 'claude'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'claude' }]); + }); + + it('credits a custom tool id for a Skill call only, after normalizeToolName', async () => { + writeTeamFile('teamai.yaml', 'team: acme\nrepo: https://example.test/acme/team.git\ntoolPaths:\n acme-agent:\n skills: .acme/skills\n'); + const file = install('.acme/skills'); + await toolCall({ hook_event_name: 'PostToolUse', tool_name: 'skill', tool_input: { skill: SKILL } }, 'acme-agent'); + await toolCall({ hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: { file_path: file } }, 'acme-agent'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'acme-agent' }]); + }); + + it('still counts an OpenCode `Skill` from a plugin loaded before the upgrade', async () => { + install('.config/opencode/skills'); + await toolCall({ ...fixture('opencode-skill'), tool_name: 'Skill' }, 'opencode'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'opencode' }]); + }); + + it('runs on the wildcard pass only: a stale `--matcher Skill` hook credits nothing', async () => { + install('.claude/skills'); + await toolCall(fixture('claude-skill'), 'claude', 'Skill'); + expect(await credits()).toEqual([]); + }); + + it('records nothing when the team disables the retired Skill built-in', async () => { + install('.claude/skills'); + writeTeamFile('hooks/hooks.yaml', 'builtin:\n disabled:\n - Hook dispatch post-tool-use Skill\n'); + await toolCall(fixture('claude-skill'), 'claude'); + expect(await credits()).toEqual([]); + }); + + it('still counts when the builtin block disables only other built-ins', async () => { + install('.claude/skills'); + writeTeamFile('hooks/hooks.yaml', 'builtin:\n disabled:\n - Hook dispatch session-start\n'); + await toolCall(fixture('claude-skill'), 'claude'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'claude' }]); + }); + + it('still counts when an invalid builtin block does not list it', async () => { + install('.claude/skills'); + writeTeamFile('hooks/hooks.yaml', 'builtin:\n overrides:\n Hook dispatch session-start:\n timeout: "30"\n'); + await toolCall(fixture('claude-skill'), 'claude'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'claude' }]); + }); + + it('records nothing when an invalid builtin block lists it', async () => { + install('.claude/skills'); + writeTeamFile('hooks/hooks.yaml', 'builtin:\n disabled:\n - Hook dispatch post-tool-use Skill\n overrides:\n Hook dispatch session-start:\n timeout: "30"\n'); + await toolCall(fixture('claude-skill'), 'claude'); + expect(await credits()).toEqual([]); + }); + + it('records nothing when hooks.yaml is not a readable file', async () => { + install('.claude/skills'); + fs.mkdirSync(path.join(config().repo.localPath, 'hooks', 'hooks.yaml'), { recursive: true }); + await toolCall(fixture('claude-skill'), 'claude'); + expect(await credits()).toEqual([]); + }); + + it('records nothing when a hooks.yaml that disables it cannot be read', async () => { + install('.claude/skills'); + writeTeamFile('hooks/hooks.yaml', 'builtin:\n disabled:\n - Hook dispatch post-tool-use Skill\nhooks: [\n'); + await toolCall(fixture('claude-skill'), 'claude'); + expect(await credits()).toEqual([]); + }); + + it('still counts when hooks.yaml has an invalid team hook and no builtin block', async () => { + install('.claude/skills'); + writeTeamFile('hooks/hooks.yaml', 'hooks:\n - id: lint\n event: PostToolUse\n command: echo hi\n'); + await toolCall(fixture('claude-skill'), 'claude'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'claude' }]); + }); + + it('records nothing when a valid builtin block disables it beside an invalid team hook', async () => { + install('.claude/skills'); + writeTeamFile('hooks/hooks.yaml', 'builtin:\n disabled:\n - Hook dispatch post-tool-use Skill\nhooks:\n - id: lint\n event: PostToolUse\n command: echo hi\n'); + await toolCall(fixture('claude-skill'), 'claude'); + expect(await credits()).toEqual([]); + }); +}); + +describe('skill use from a prompt mention (#1221)', () => { + it('credits a Codex `$name` mention', async () => { + install('.codex/skills'); + await prompt(fixture('codex-real-b-mention'), 'codex'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'codex' }]); + }); + + it('gates Codex mentions on Codex installations', async () => { + install('.pi/agent/skills', { skill: 'pi-only' }); + const payload = { session_id: 'own-roots', turn_id: 'T1', prompt: '$pi-only', cwd }; + await prompt(payload, 'codex'); + expect(await credits()).toEqual([]); + install('.codex/skills', { skill: 'pi-only' }); + await prompt(payload, 'codex'); + expect(await credits()).toEqual([{ skill: 'pi-only', tool: 'codex' }]); + }); + + it.each(['claude', 'codex'])('uses the recorded relocated %s root for prompt mentions', async (tool) => { + const root = path.join(home, `.${tool}-work`); + const localConfig = { ...config(), toolRoots: { [tool]: root } }; + install(`.${tool}-work/skills`); + const result = await dispatcher(localConfig).dispatch('prompt-submit', '*', { + session_id: 'relocated', cwd, prompt: `${tool === 'codex' ? '$' : '/'}${SKILL}`, + }, tool, 'all'); + expect(result.errors).toEqual([]); + expect(await credits()).toEqual([{ skill: SKILL, tool }]); + }); + + it('uses COPILOT_HOME for a named skill call', async () => { + const previous = process.env.COPILOT_HOME; + process.env.COPILOT_HOME = path.join(home, 'copilot-alt'); + try { + install('copilot-alt/skills'); + await toolCall(fixture('copilot-skill'), 'copilot'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'copilot' }]); + } finally { + if (previous === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = previous; + } + }); + + it('uses custom project and userScope paths for mentions', async () => { + writeTeamFile('teamai.yaml', 'team: acme\nrepo: https://example.test/acme/team.git\ntoolPaths:\n acme:\n skills: .acme/skills\n userScope:\n skills: .acme-user/skills\n'); + install('.acme-user/skills', { skill: 'user-only' }); + install('.acme/skills', { project: true, skill: 'project-only' }); + for (const skill of ['user-only', 'project-only']) { + await prompt({ session_id: skill, cwd, prompt: `/${skill}` }, 'acme'); + } + expect(await credits()).toEqual([{ skill: 'user-only', tool: 'acme' }, { skill: 'project-only', tool: 'acme' }]); + }); + + it.each(['codex', 'muse', 'claude'])('retains shared .agents/skills for %s mentions', async (tool) => { + install('.agents/skills'); + await prompt({ session_id: 'shared', cwd, prompt: `${tool === 'codex' ? '$' : '/'}${SKILL}` }, tool); + expect(await credits()).toEqual([{ skill: SKILL, tool }]); + }); + + it.each(['muse', 'opencode'])('retains native Claude skill discovery for %s', async (tool) => { + install('.claude/skills'); + await prompt({ session_id: 'native-shared', cwd, prompt: `/${SKILL}` }, tool); + expect(await credits()).toEqual([{ skill: SKILL, tool }]); + }); + + it('gates even a Pi prompt block with a file on Pi roots', async () => { + const file = install('.codex/skills'); + await prompt({ session_id: 'pi-cross-root', cwd, prompt: `\n# copy\n` }, 'pi'); + expect(await credits()).toEqual([]); + }); + + it('credits the skill block Pi expands `/skill:name` into', async () => { + install('.pi/agent/skills'); + await prompt(fixture('pi-mention'), 'pi'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'pi' }]); + }); + + it('credits nothing for a Pi skill block whose file is outside every skill root', async () => { + install('.pi/agent/skills'); + const outside = path.join(tmp, 'docs', SKILL, 'SKILL.md'); + fs.mkdirSync(path.dirname(outside), { recursive: true }); + fs.writeFileSync(outside, '# copy\n'); + await prompt({ cwd, session_id: 'pi-run', prompt: `\n# copy\n` }, 'pi'); + expect(await credits()).toEqual([]); + }); + + it('credits `/name` at the start of the prompt for a tool without its own prefix', async () => { + install('.claude/skills'); + await prompt({ session_id: 's', hook_event_name: 'UserPromptSubmit', prompt: `/${SKILL} go` }, 'claude'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'claude' }]); + }); + + it('skips the generic `/name` for a tool with its own prefix, and an uninstalled `$HOME`', async () => { + install('.codex/skills'); + install('.pi/agent/skills'); + await prompt({ session_id: 'c', hook_event_name: 'UserPromptSubmit', prompt: `/${SKILL} echo $HOME` }, 'codex'); + await prompt({ session_id: 'p', hook_event_name: 'UserPromptSubmit', prompt: `/${SKILL}` }, 'pi'); + expect(await credits()).toEqual([]); + }); +}); + +describe('one credit per session turn (#1221)', () => { + it('sends one webhook after a slash prompt already credited the skill', async () => { + install('.claude/skills'); + const call = fixture('claude-skill'); + await prompt({ ...call, prompt: `/${SKILL}` }, 'claude'); + expect(sendWebhook).not.toHaveBeenCalled(); + await toolCall(call, 'claude'); + await toolCall(call, 'claude'); + expect(await credits()).toHaveLength(1); + expect(sendWebhook).toHaveBeenCalledTimes(1); + expect(sendWebhook).toHaveBeenCalledWith('skill-use', expect.objectContaining({ data: { skillName: SKILL } }), expect.anything()); + await prompt({ ...call, prompt_id: 'next', prompt: `/${SKILL}` }, 'claude'); + await toolCall({ ...call, prompt_id: 'next' }, 'claude'); + expect(await credits()).toHaveLength(2); + expect(sendWebhook).toHaveBeenCalledTimes(2); + }); + + it('sends one webhook for two cat reads in a turn and sends again in the next turn', async () => { + install('.codex/skills'); + const call = fixture('codex-real-c-cat'); + await toolCall(call, 'codex'); + await toolCall(call, 'codex'); + expect(sendWebhook).toHaveBeenCalledTimes(1); + await toolCall({ ...call, turn_id: 'next' }, 'codex'); + expect(sendWebhook).toHaveBeenCalledTimes(2); + expect(await credits()).toHaveLength(2); + }); + + it('counts a Codex mention and a read of the same skill in one turn once', async () => { + install('.codex/skills'); + await prompt(fixture('codex-real-c-mention'), 'codex'); + await toolCall(fixture('codex-real-c-cat'), 'codex'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'codex' }]); + }); + + it('counts a Claude Skill call and a Read of its SKILL.md in one turn once', async () => { + const file = install('.claude/skills'); + const skillCall = fixture('claude-skill'); + await toolCall(skillCall, 'claude'); + await toolCall({ ...skillCall, tool_name: 'Read', tool_input: { file_path: file } }, 'claude'); + expect(await credits()).toEqual([{ skill: SKILL, tool: 'claude' }]); + }); + + it('counts `/name` then the Skill call once, using the prompt-submit stamp when the host sends no turn id', async () => { + install('.claude/skills'); + const base = { session_id: 'stamped', cwd }; + await prompt({ ...base, hook_event_name: 'UserPromptSubmit', prompt: `/${SKILL}` }, 'claude'); + await toolCall({ ...base, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: SKILL } }, 'claude'); + expect(await credits()).toHaveLength(1); + }); + + it('counts the same skill again in the next turn', async () => { + install('.claude/skills'); + const base = { session_id: 'two-turns', cwd }; + const skillCall = { ...base, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: SKILL } }; + for (const text of ['first', 'second']) { + await prompt({ ...base, hook_event_name: 'UserPromptSubmit', prompt: text }, 'claude'); + await toolCall(skillCall, 'claude'); + await toolCall(skillCall, 'claude'); + } + expect(await credits()).toHaveLength(2); + }); + + it('counts a Skill call sent without the turn id its host gave the prompt, in a later turn', async () => { + install('.claude/skills'); + const base = { session_id: 'late-id', cwd, hook_event_name: 'UserPromptSubmit' }; + await prompt({ ...base, prompt_id: 'T1', prompt: `/${SKILL} go` }, 'claude'); + await prompt({ ...base, prompt_id: 'T2', prompt: 'no mention' }, 'claude'); + await toolCall({ session_id: 'late-id', cwd, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: SKILL } }, 'claude'); + expect(await credits()).toHaveLength(2); + }); + + it('counts every call when there is no turn key: no host id and no prompt-submit stamp', async () => { + install('.claude/skills'); + const skillCall = { session_id: 'no-turn', cwd, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: SKILL } }; + await toolCall(skillCall, 'claude'); + await toolCall(skillCall, 'claude'); + expect(await credits()).toHaveLength(2); + }); + + it('writes no claims file for a prompt that names no skill when the host sends a turn id', async () => { + await prompt({ ...fixture('codex-real-c-mention'), prompt: 'no skill here' }, 'codex'); + expect(fs.existsSync(path.join(home, '.teamai'))).toBe(false); + await prompt({ session_id: 'stamp-only', hook_event_name: 'UserPromptSubmit', prompt: 'no skill here' }, 'claude'); + expect(fs.readdirSync(path.join(home, '.teamai', 'sessions'))).toEqual(['stamp-only-skills.json']); + }); + + it('writes no claims file for a Codex prompt whose `$HOME` is no installed skill', async () => { + await prompt({ ...fixture('codex-real-c-mention'), prompt: 'echo $HOME' }, 'codex'); + expect(fs.existsSync(path.join(home, '.teamai', 'sessions'))).toBe(false); + }); + + it('keeps one claims file per session, which the session sweep reaches', async () => { + install('.claude/skills'); + await toolCall(fixture('claude-skill'), 'claude'); + expect(fs.readdirSync(path.join(home, '.teamai', 'sessions'))).toEqual(['claude-run-skills.json']); + }); +}); diff --git a/src/__tests__/tool-call.test.ts b/src/__tests__/tool-call.test.ts index 2ca538103..2abde2cdf 100644 --- a/src/__tests__/tool-call.test.ts +++ b/src/__tests__/tool-call.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'; import path from 'node:path'; import { classifyShellCommand } from '../utils/shell-command.js'; +import { getUserHome } from '../utils/home.js'; import { classifyToolCall } from '../utils/tool-call.js'; const CWD = path.resolve('/w'); @@ -68,7 +69,6 @@ describe('classifyToolCall', () => { 'cat -', 'cat *.md', 'cat $(ls)', - 'cat "$HOME/doc.md"', 'cat doc.md > copy.md', 'cat notes.txt > doc.md', 'cat < doc.md', @@ -107,6 +107,29 @@ describe('classifyToolCall', () => { expect(shell('Get-Content doc.md')).toMatchObject({ category: 'read', paths: [at('doc.md')] }); }); + it('an unquoted leading ~/, $HOME/ or ${HOME}/ is the home directory; a quoted ~ is a literal one', () => { + const home = getUserHome(); + for (const operand of ['~/doc.md', '$HOME/doc.md', '${HOME}/doc.md']) { + expect(shell(`cat ${operand}`)).toMatchObject({ category: 'read', paths: [path.join(home, 'doc.md')] }); + } + expect(shell("cat '~/doc.md' \"~/b.md\" a~/c.md")).toMatchObject({ category: 'read', paths: [at('~/doc.md'), at('~/b.md'), at('a~/c.md')] }); + expect(shell("cat '$HOME/doc.md'")).toMatchObject({ category: 'shell', paths: [] }); + }); + + it.each(['"$HOME/doc.md"', '"${HOME}/doc.md"'])('expands double-quoted HOME in %s', (operand) => { + expect(shell(`cat ${operand}`)).toMatchObject({ category: 'read', paths: [path.join(getUserHome(), 'doc.md')] }); + }); + + it.each(["'$HOME/doc.md'", "'${HOME}/doc.md'", '"\\$HOME/doc.md"'])('keeps literal HOME in %s', (operand) => { + expect(shell(`cat ${operand}`)).toMatchObject({ category: 'shell', paths: [] }); + }); + + it('a leading ~\\, $HOME\\ or ${HOME}\\ is the home directory too', () => { + for (const ref of ['~', '$HOME', '${HOME}']) { + expect(shell(`cat ${ref}\\.codex\\skills\\x\\SKILL.md`)).toMatchObject({ category: 'read', paths: [`${getUserHome()}\\.codex\\skills\\x\\SKILL.md`] }); + } + }); + it('a pipeline that starts with the reader is a read, but not a simple one', () => { expect(shell('cat doc.md 2>&1 | head -n 20')).toMatchObject({ category: 'read', paths: [at('doc.md')], simple: false }); }); diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index 4038193c8..e1a677606 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -95,6 +95,11 @@ describe('TOOLS skillLoad', () => { expect(known).toEqual(TOOL_IDS.filter(hasWildcardPostToolUse)); }); + it('stays unknown for Hermes and OpenClaw, so none of their tool calls is credited', () => { + expect(TOOLS.hermes.skillLoad).toBe('unknown'); + expect(TOOLS.openclaw.skillLoad).toBe('unknown'); + }); + it('is required on every descriptor', () => { const tools = { ...TOOLS, diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index c84e3dffb..76c8f3736 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -30,6 +30,8 @@ import { readKnownSkills, extractSkillName, skillExistsOnDisk, + skillRoots, + type SkillRootsInput, } from '../usage-tracker.js'; import { aggregateUsage, showStats } from '../stats.js'; import { mergeStats } from '../team-push.js'; @@ -101,12 +103,8 @@ function mockStdin(data: string): () => void { }; } -/** - * Helper: create a fake skill on disk so `skillExistsOnDisk()` finds it. - * Creates `~/.claude/skills//SKILL.md` under the test tmpDir. - */ -async function createFakeSkill(name: string): Promise { - const skillDir = path.join(tmpDir, '.claude', 'skills', name); +async function createFakeSkill(name: string, root = '.claude/skills'): Promise { + const skillDir = path.join(tmpDir, root, name); await fse.ensureDir(skillDir); await fse.writeFile(path.join(skillDir, 'SKILL.md'), `# ${name}\nFake skill for testing.\n`); } @@ -140,19 +138,23 @@ describe('isValidSkillName', () => { }); }); +async function installed(skillName: string, input: Partial = {}): Promise { + return skillExistsOnDisk(skillName, await skillRoots({ config: userScope(), ...input })); +} + describe('skillExistsOnDisk — relocated tool roots', () => { it('finds a skill installed only under the root the governing config records', async () => { const relocated = path.join(tmpDir, '.claude-work'); await fse.outputFile(path.join(relocated, 'skills', 'relocated-only', 'SKILL.md'), '# s'); - await expect(skillExistsOnDisk('relocated-only', { claude: relocated })).resolves.toBe(true); - await expect(skillExistsOnDisk('relocated-only')).resolves.toBe(false); + await expect(installed('relocated-only', { toolRoots: { claude: relocated } })).resolves.toBe(true); + await expect(installed('relocated-only')).resolves.toBe(false); }); it('finds a skill installed only under the recorded CODEX_HOME root', async () => { const codexHome = path.join(tmpDir, '.codex-alt'); await fse.outputFile(path.join(codexHome, 'skills', 'codex-only', 'SKILL.md'), '# s'); - await expect(skillExistsOnDisk('codex-only', { codex: codexHome })).resolves.toBe(true); - await expect(skillExistsOnDisk('codex-only')).resolves.toBe(false); + await expect(installed('codex-only', { toolRoots: { codex: codexHome } })).resolves.toBe(true); + await expect(installed('codex-only')).resolves.toBe(false); }); }); @@ -165,7 +167,7 @@ describe('skillExistsOnDisk — Copilot', () => { const previous = process.env.COPILOT_HOME; process.env.COPILOT_HOME = copilotHome; try { - await expect(skillExistsOnDisk('copilot-review')).resolves.toBe(true); + await expect(installed('copilot-review')).resolves.toBe(true); } finally { if (previous === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = previous; @@ -177,13 +179,7 @@ describe('skillExistsOnDisk — Copilot', () => { const skillDir = path.join(project, '.github', 'skills', 'copilot-test'); await fse.ensureDir(skillDir); await fse.writeFile(path.join(skillDir, 'SKILL.md'), '# Copilot test\n'); - const previousCwd = process.cwd(); - process.chdir(project); - try { - await expect(skillExistsOnDisk('copilot-test')).resolves.toBe(true); - } finally { - process.chdir(previousCwd); - } + await expect(installed('copilot-test', { hookCwd: project })).resolves.toBe(true); }); it('does not treat the home .github directory as a user skill scope', async () => { @@ -191,7 +187,7 @@ describe('skillExistsOnDisk — Copilot', () => { await fse.ensureDir(skillDir); await fse.writeFile(path.join(skillDir, 'SKILL.md'), '# Project only\n'); - await expect(skillExistsOnDisk('project-only')).resolves.toBe(false); + await expect(installed('project-only', { hookCwd: tmpDir })).resolves.toBe(false); }); }); @@ -721,6 +717,7 @@ describe('usage recorded while every scope shared ~/.teamai/usage.jsonl (#748)', describe('track', () => { it('tracks Skill tool calls', async () => { + await createFakeSkill('code-review'); await track('Skill', JSON.stringify({ skill: 'code-review' })); const events = await readUsageEvents(userScope()); @@ -736,6 +733,12 @@ describe('track', () => { expect(events).toEqual([]); }); + it('ignores a Skill call naming no installed skill', async () => { + await track('Skill', JSON.stringify({ skill: 'not-installed' })); + + expect(await readUsageEvents(userScope())).toEqual([]); + }); + it('ignores invalid skill names', async () => { await track('Skill', JSON.stringify({ skill: '../../etc/passwd' })); @@ -751,6 +754,7 @@ describe('track', () => { }); it('updates known-skills.json on successful track', async () => { + await createFakeSkill('code-review'); await track('Skill', JSON.stringify({ skill: 'code-review' })); const known = await readKnownSkills(); @@ -798,6 +802,17 @@ describe('skill tracking where teamai is not set up (#748)', () => { // ─── trackFromStdin tests ───────────────────────────── describe('trackFromStdin', () => { + beforeEach(async () => { + for (const name of ['plan-eng-review', 'tdd', 'code-review']) await createFakeSkill(name); + }); + + /** A skill installed in Cursor's user skills directory; returns its SKILL.md. */ + async function createCursorSkill(name: string): Promise { + const file = path.join(tmpDir, '.cursor', 'skills', name, 'SKILL.md'); + await fse.outputFile(file, `# ${name}\n`); + return file; + } + it('reads STDIN JSON and tracks Skill tool usage', async () => { const hookData = JSON.stringify({ tool_name: 'Skill', @@ -893,12 +908,12 @@ describe('trackFromStdin', () => { it('tracks Cursor Read tool when path is SKILL.md', async () => { const hookData = JSON.stringify({ tool_name: 'Read', - tool_input: { path: '/root/.cursor/skills/tdd/SKILL.md' }, + tool_input: { path: await createCursorSkill('tdd') }, tool_output: '# TDD Skill\n...', }); const restore = mockStdin(hookData); try { - await trackFromStdin(); + await trackFromStdin('cursor'); } finally { restore(); } @@ -912,12 +927,12 @@ describe('trackFromStdin', () => { it('tracks Cursor Read tool using file_path field (Cursor native format)', async () => { const hookData = JSON.stringify({ tool_name: 'Read', - tool_input: { file_path: '/root/.cursor/skills/code-review-expert/SKILL.md' }, + tool_input: { file_path: await createCursorSkill('code-review-expert') }, tool_output: '{"file_path":"...","content_length":194}', }); const restore = mockStdin(hookData); try { - await trackFromStdin(); + await trackFromStdin('cursor'); } finally { restore(); } @@ -948,11 +963,11 @@ describe('trackFromStdin', () => { it('extracts skill name from nested Cursor skill paths', async () => { const hookData = JSON.stringify({ tool_name: 'Read', - tool_input: { path: '/home/user/.cursor/skills/plan-eng-review/SKILL.md' }, + tool_input: { path: await createCursorSkill('plan-eng-review') }, }); const restore = mockStdin(hookData); try { - await trackFromStdin(); + await trackFromStdin('cursor'); } finally { restore(); } @@ -981,6 +996,7 @@ describe('trackFromStdin', () => { }); it('uses --tool argument as tool source when provided', async () => { + await createFakeSkill('tdd', '.claude-internal/skills'); const hookData = JSON.stringify({ tool_name: 'Skill', tool_input: { skill: 'tdd' }, @@ -998,6 +1014,7 @@ describe('trackFromStdin', () => { }); it('uses codebuddy as tool source when --tool codebuddy', async () => { + await createFakeSkill('code-review', '.codebuddy/skills'); const hookData = JSON.stringify({ tool_name: 'Skill', tool_input: { skill: 'code-review' }, @@ -1015,9 +1032,10 @@ describe('trackFromStdin', () => { }); it('uses codex-internal as tool source when --tool codex-internal', async () => { + // Codex has no skill tool: it reads the SKILL.md with a shell command. const hookData = JSON.stringify({ - tool_name: 'Skill', - tool_input: { skill: 'code-review' }, + tool_name: 'Bash', + tool_input: { command: `cat ${path.join(tmpDir, '.claude', 'skills', 'code-review', 'SKILL.md')}` }, }); const restore = mockStdin(hookData); try { @@ -1048,27 +1066,39 @@ describe('trackFromStdin', () => { expect(events[0].tool).toBe('claude'); }); - it('Read + SKILL.md always records cursor regardless of --tool argument', async () => { + it('credits a Read of SKILL.md to the calling tool, not to cursor (#1221)', async () => { const hookData = JSON.stringify({ tool_name: 'Read', - tool_input: { path: '/root/.cursor/skills/tdd/SKILL.md' }, + tool_input: { path: await createCursorSkill('tdd') }, }); const restore = mockStdin(hookData); try { - await trackFromStdin('some-other-tool'); + await trackFromStdin('claude-internal'); } finally { restore(); } const events = await readUsageEvents(userScope()); expect(events).toHaveLength(1); - expect(events[0].tool).toBe('cursor'); + expect(events[0].tool).toBe('claude-internal'); + }); + + it('credits a custom tool id only for a Skill call, not a Read of SKILL.md (#1221)', async () => { + const restore = mockStdin(JSON.stringify({ tool_name: 'Read', tool_input: { path: await createCursorSkill('tdd') } })); + try { + await trackFromStdin('some-other-tool'); + } finally { + restore(); + } + + expect(await readUsageEvents(userScope())).toEqual([]); }); it('ignores Read tool when path has no SKILL.md suffix', async () => { + await createCursorSkill('tdd'); const hookData = JSON.stringify({ tool_name: 'Read', - tool_input: { path: '/root/.cursor/skills/tdd/README.md' }, + tool_input: { path: path.join(tmpDir, '.cursor', 'skills', 'tdd', 'README.md') }, }); const restore = mockStdin(hookData); try { @@ -1262,6 +1292,7 @@ describe('getRecommendations', () => { it('excludes skills after truncation when known-skills.json exists', async () => { // Simulate: track a skill, then report+truncate + await createFakeSkill('tdd'); await track('Skill', JSON.stringify({ skill: 'tdd' })); await truncateUsageAfterReport(1, userScope()); // usage.jsonl is now empty @@ -1662,7 +1693,7 @@ describe('trackSlashCommand', () => { }); it('uses --tool argument as tool source', async () => { - await createFakeSkill('plan-eng-review'); + await createFakeSkill('plan-eng-review', '.claude-internal/skills'); const hookData = JSON.stringify({ prompt: '/plan-eng-review args', }); @@ -1679,9 +1710,9 @@ describe('trackSlashCommand', () => { }); it('uses codex-internal as tool source when --tool codex-internal', async () => { - await createFakeSkill('plan-eng-review'); + await createFakeSkill('plan-eng-review', '.codex-internal/skills'); const hookData = JSON.stringify({ - prompt: '/plan-eng-review args', + prompt: '$plan-eng-review args', }); const restore = mockStdin(hookData); try { @@ -1712,12 +1743,12 @@ describe('trackSlashCommand', () => { expect(events[0].tool).toBe('claude'); }); - it('tracks multiple slash commands in a single prompt', async () => { + it('counts only the slash command that starts the prompt, as the prompt-submit hook does (#1221)', async () => { await createFakeSkill('plan-eng-review'); await createFakeSkill('tdd'); await createFakeSkill('code-review'); const hookData = JSON.stringify({ - prompt: '/plan-eng-review /tdd /code-review', + prompt: '/plan-eng-review /tdd fix the login bug /code-review', session_id: 'sess-multi', hook_event_name: 'UserPromptSubmit', }); @@ -1728,59 +1759,19 @@ describe('trackSlashCommand', () => { restore(); } - const events = await readUsageEvents(userScope()); - expect(events).toHaveLength(3); - expect(events.map((e: UsageEvent) => e.skill).sort()).toEqual( - ['code-review', 'plan-eng-review', 'tdd'], - ); - }); - - it('tracks multiple slash commands with arguments between them', async () => { - await createFakeSkill('tdd'); - await createFakeSkill('code-review'); - const hookData = JSON.stringify({ - prompt: '/tdd fix the login bug /code-review', - session_id: 'sess-multi-args', - }); - const restore = mockStdin(hookData); - try { - await trackSlashCommand(); - } finally { - restore(); - } - - const events = await readUsageEvents(userScope()); - expect(events).toHaveLength(2); - expect(events.map((e: UsageEvent) => e.skill).sort()).toEqual( - ['code-review', 'tdd'], - ); - }); - - it('skips non-existent skills among multiple slash commands', async () => { - await createFakeSkill('tdd'); - // 'nonexistent' is NOT created on disk - const hookData = JSON.stringify({ - prompt: '/tdd /nonexistent /tdd', - session_id: 'sess-multi-phantom', - }); - const restore = mockStdin(hookData); - try { - await trackSlashCommand(); - } finally { - restore(); - } - - const events = await readUsageEvents(userScope()); - // Only 'tdd' should be tracked (twice — it appears twice in prompt) - expect(events).toHaveLength(2); - expect(events.every((e: UsageEvent) => e.skill === 'tdd')).toBe(true); + expect((await readUsageEvents(userScope())).map((e: UsageEvent) => e.skill)).toEqual(['plan-eng-review']); }); }); // ─── track() with --tool tests ──────────────────────── describe('track with tool parameter', () => { + beforeEach(async () => { + for (const name of ['tdd', 'code-review']) await createFakeSkill(name); + }); + it('uses provided tool parameter', async () => { + await createFakeSkill('code-review', '.claude-internal/skills'); await track('Skill', JSON.stringify({ skill: 'code-review' }), 'claude-internal'); const events = await readUsageEvents(userScope()); @@ -1813,11 +1804,11 @@ describe('hook command strings', () => { // Check PostToolUse hooks have --tool claude-internal const postToolUse = settings.hooks?.PostToolUse; expect(postToolUse).toBeDefined(); - const skillHook = postToolUse.find((h: { description?: string }) => - h.description?.includes('Hook dispatch post-tool-use Skill'), + const wildcardHook = postToolUse.find((h: { description?: string }) => + h.description?.includes('Hook dispatch post-tool-use wildcard'), ); - expect(skillHook).toBeDefined(); - expect(skillHook.hooks[0].command).toContain('--tool claude-internal'); + expect(wildcardHook).toBeDefined(); + expect(wildcardHook.hooks[0].command).toContain('--tool claude-internal'); // Check UserPromptSubmit hook has --tool claude-internal const userPrompt = settings.hooks?.UserPromptSubmit; @@ -1838,10 +1829,10 @@ describe('hook command strings', () => { const settings = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); const postToolUse = settings.hooks?.PostToolUse; - const skillHook = postToolUse.find((h: { description?: string }) => - h.description?.includes('Hook dispatch post-tool-use Skill'), + const wildcardHook = postToolUse.find((h: { description?: string }) => + h.description?.includes('Hook dispatch post-tool-use wildcard'), ); - expect(skillHook.hooks[0].command).toContain('--tool claude'); + expect(wildcardHook.hooks[0].command).toContain('--tool claude'); }); it('cleans up legacy hooks without description on inject', async () => { @@ -1913,12 +1904,12 @@ describe('hook command strings', () => { ); expect(observeHooks).toHaveLength(1); - // 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(result.hooks.PostToolUse.some((h: { matcher?: string }) => h.matcher === 'Skill')).toBe(false); + const wildcardHooks = result.hooks.PostToolUse.filter( + (h: { description?: string }) => h.description?.includes('Hook dispatch post-tool-use wildcard'), ); - expect(skillHooks).toHaveLength(1); - expect(skillHooks[0].hooks[0].command).toContain('--tool claude'); + expect(wildcardHooks).toHaveLength(1); + expect(wildcardHooks[0].hooks[0].command).toContain('--tool claude'); }); it('cleans up hooks with outdated description keywords', async () => { diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index 981b8ea9c..85b013a97 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { MUSE_TOOL_ID, TEAMAI_HOOK_DESCRIPTION_PREFIX } from './types.js'; +import { TEAMAI_HOOK_DESCRIPTION_PREFIX } from './types.js'; import { CODEX_TOOL_IDS, isCodexTool } from './utils/tool-names.js'; import type { HookDef } from './types.js'; import { getUserHome } from './utils/home.js'; @@ -317,7 +317,6 @@ const BUILTIN_HOOK_SPECS: BuiltinHookSpec[] = [ { key: 'Hook dispatch session-start', event: 'SessionStart', dispatchEvent: 'session-start', matcher: '*', timeoutSec: 15 }, { key: 'Hook dispatch stop', event: 'Stop', dispatchEvent: 'stop', matcher: '*', timeoutSec: 15 }, { key: 'Hook dispatch post-tool-use wildcard', event: 'PostToolUse', dispatchEvent: 'post-tool-use', matcher: '*', timeoutSec: 10 }, - { key: 'Hook dispatch post-tool-use Skill', event: 'PostToolUse', dispatchEvent: 'post-tool-use', matcher: 'Skill', timeoutSec: 10 }, { key: 'Hook dispatch post-tool-use TodoWrite', event: 'PostToolUse', dispatchEvent: 'post-tool-use', matcher: 'TodoWrite', timeoutSec: 3 }, { key: 'Hook dispatch prompt-submit', event: 'UserPromptSubmit', dispatchEvent: 'prompt-submit', matcher: '*', timeoutSec: 10 }, ]; @@ -386,9 +385,6 @@ const SUBAGENT_STOP_TOOLS = new Set([ const WRAPPER_TOOLS = SHELL_DEPENDENT_TOOLS; -/** What a tool names its Skill tool, where not `Skill`; dispatcher still uses --matcher Skill. Cursor loads skills via Read of SKILL.md (#1156). */ -const SKILL_TOOL_MATCHER: Readonly> = { cursor: 'Read', copilot: 'skill', [MUSE_TOOL_ID]: 'read_skill' }; - export function builtinHookDefs(tool: string): HookDef[] { // ZCode renders per-event timeouts from the ZCODE_TIMEOUT_MS table in its own // writer (toZcodeEntry), so def.timeout stays unset for it. @@ -410,7 +406,7 @@ export function builtinHookDefs(tool: string): HookDef[] { source: 'builtin' as const, key: spec.key, event: spec.event, - matcher: spec.matcher === 'Skill' ? SKILL_TOOL_MATCHER[tool] ?? spec.matcher : spec.matcher, + matcher: spec.matcher, command: buildCommand(spec.dispatchEvent, tool, spec.matcher), timeout: withTimeout ? spec.timeoutSec : undefined, // The team rules reach the Codex family through its start hooks (#938); @@ -439,8 +435,8 @@ const ADAPTER_BUILTIN_HOOKS: Record // the dispatch command with errors swallowed (buildReportScript). hermes: { keys: ['Hook dispatch session-start'], suffix: ' >/dev/null 2>&1 || true' }, // omp-hooks.ts subscribes to four OMP extension events and spawns the - // dispatcher with argv; `tool_result` carries no matcher, so the Skill / - // TodoWrite passes do not exist there. + // dispatcher with argv; `tool_result` carries no matcher, so the TodoWrite + // pass does not exist there. omp: { keys: [ 'Hook dispatch session-start', @@ -449,12 +445,18 @@ const ADAPTER_BUILTIN_HOOKS: Record 'Hook dispatch prompt-submit', ], }, - // opencode-hooks.ts covers the same four events plus the matcher-scoped - // post-tool-use passes (TOOL_MATCHER), i.e. the whole built-in set. - opencode: { keys: BUILTIN_HOOK_SPECS.map((spec) => spec.key) }, + // opencode-hooks.ts maps the same four events as OMP. + opencode: { + keys: [ + 'Hook dispatch session-start', + 'Hook dispatch stop', + 'Hook dispatch post-tool-use wildcard', + 'Hook dispatch prompt-submit', + ], + }, // pi-hooks.ts maps the same four lifecycle events as OMP (session_start, // agent_settled, tool_execution_end, before_agent_start); Pi has no - // Skill/TodoWrite matcher concept, so post-tool-use is wildcard-only there + // TodoWrite matcher concept, so post-tool-use is wildcard-only there // too. tool_execution_start only caches the tool input for the later // post-tool-use dispatch — it never calls hook-dispatch itself. pi: { diff --git a/src/contribute-check.ts b/src/contribute-check.ts index 0dbe8e9da..4217d4782 100644 --- a/src/contribute-check.ts +++ b/src/contribute-check.ts @@ -75,7 +75,7 @@ import { getUserHome } from './utils/home.js'; * is collision-resistant in practice because the input shape is constrained * (hex IDs or PID+cwd which is itself unique on a given machine). */ -function sanitizeSessionId(sessionId: string): string { +export function sanitizeSessionId(sessionId: string): string { return sessionId.replace(/[^a-zA-Z0-9._-]/g, '_'); } diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 7298b16bc..847796335 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -359,31 +359,21 @@ const dashboardReportHandler: HookHandler = { }, }; -const trackHandler: HookHandler = { - name: 'track', +/** + * PostToolUse: record the skill a tool call loaded, through the tool's skill + * tool or a read of its SKILL.md, once per session turn (#1221), and send the + * `skill-use` webhook for it. Detached: it reads the disk and may send. + */ +const skillUseHandler: HookHandler = { + name: 'skill-use', async execute(stdin, tool, config) { // Registered with requiresConfig: the dispatcher's scope, which knows the // project of a removed worktree (#810). if (!config) return null; - const { resolveSkillUse, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); - - const rawToolName = stdin.tool_name; - if (typeof rawToolName !== 'string') return null; - const toolName = normalizeToolName(rawToolName); - - const toolInput = stdin.tool_input; - if (!toolInput || typeof toolInput !== 'object') return null; - - // Shared resolver: Skill (Claude/CodeBuddy) or Read+SKILL.md (Cursor). - const resolved = resolveSkillUse(toolName, toolInput as Record); - if (!resolved) return null; - - await appendUsageEvent({ - skill: resolved.skillName, - timestamp: new Date().toISOString(), - tool: resolved.source ?? tool, - }, config); - await updateKnownSkills(resolved.skillName); + const { creditToolCall } = await import('./skill-use.js'); + for (const skillName of await creditToolCall(stdin, tool, config)) { + await sendWebhookEvent('skill-use', stdin, tool, config, { skillName }); + } return null; }, }; @@ -405,29 +395,14 @@ const recallAttributionHandler: HookHandler = { }, }; -const trackSlashHandler: HookHandler = { - name: 'track-slash', +/** UserPromptSubmit: open the session turn and record the skills the prompt mentions (`/name`, `$name`, `/skill:name`). */ +const skillMentionHandler: HookHandler = { + name: 'skill-mention', async execute(stdin, tool, config) { // Registered with requiresConfig: the dispatcher's scope (#810). if (!config) return null; - const { 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 "/". Character class must match - // SKILL_NAME_REGEX (types.ts) — the CLI path (trackSlashCommand) already - // uses the full set; this handler was narrower, silently truncating names - // that contain dots or colons (both valid per the schema). Require a whole - // token so a prompt beginning with an absolute path is not a skill use. - const match = prompt.match(/^\/([a-zA-Z0-9_\-:.]+)(?=\s|$)/); - if (!match) return null; - - const skillName = match[1]; - if (!isValidSkillName(skillName)) return null; - - await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool }, config); - await updateKnownSkills(skillName); + const { creditPromptMentions } = await import('./skill-use.js'); + await creditPromptMentions(stdin, tool, config); return null; }, }; @@ -1069,7 +1044,6 @@ const WEBHOOK_EVENT_BY_HOOK: Record = { sessionstart: 'session-start', stop: 'session-stop', sessionend: 'session-stop', - posttooluse: 'skill-use', }; /** @@ -1080,26 +1054,10 @@ const WEBHOOK_EVENT_BY_HOOK: Record = { * (which can carry private tool output) are never included (#701). The result is * additionally deep-redacted at the send boundary (see sendWebhook). */ -async function buildWebhookData( +function buildWebhookData( event: string, stdin: Record, -): Promise> { - if (event === 'skill-use') { - const rawToolName = stdin.tool_name; - const toolInput = stdin.tool_input; - if (typeof rawToolName !== 'string' || !toolInput || typeof toolInput !== 'object') return {}; - const { resolveSkillUse } = await import('./usage-tracker.js'); - // Same resolver trackHandler uses, so the webhook reaches parity: it fires - // for Claude/CodeBuddy `Skill` AND Cursor's `Read` of a SKILL.md path, and - // never for a normal file Read (#702 follow-up). The resolver already - // validates the name with isValidSkillName, so a tool-arg string cannot - // escape as skillName (#701). - const resolved = resolveSkillUse( - normalizeToolName(rawToolName), - toolInput as Record, - ); - return resolved ? { skillName: resolved.skillName } : {}; - } +): Record { if (event === 'session-start' || event === 'session-stop') { const sessionId = deriveSessionId(stdin); return sessionId ? { sessionId } : {}; @@ -1107,37 +1065,43 @@ async function buildWebhookData( return {}; } -/** Webhook notification handler — sends events to configured endpoints. */ +/** Send one webhook event to the team's configured endpoints; a failure is logged, never thrown. */ +async function sendWebhookEvent( + event: string, + stdin: Record, + tool: string, + localConfig: LocalConfig, + data: Record, +): Promise { + const { sendWebhook, loadWebhookConfig } = await import('./webhook.js'); + try { + const config = await loadWebhookConfig(localConfig); + if (!config.enabled || config.endpoints.length === 0) return; + const payload = { + tool, + sessionId: deriveSessionId(stdin), + cwd: resolveHookCwd(stdin), + username: typeof stdin.username === 'string' ? stdin.username : undefined, + data, + }; + await sendWebhook(event, payload, config); + } catch (error) { + log.debug(`Webhook dispatch failed: ${(error as Error).message}`); + } +} + +/** Webhook notification handler — sends session events to configured endpoints. */ const webhookHandler: HookHandler = { name: 'webhook-dispatch', async execute(stdin, tool, localConfig) { if (!localConfig) return null; - const { sendWebhook, loadWebhookConfig } = await import('./webhook.js'); - - try { - const config = await loadWebhookConfig(localConfig); - if (!config.enabled || config.endpoints.length === 0) return null; - - const hookEventName = typeof stdin.hook_event_name === 'string' ? stdin.hook_event_name : ''; - // Case-insensitive so both PascalCase (Claude) and camelCase (Cursor/ - // CodeBuddy) hook names resolve (#702). - const event = WEBHOOK_EVENT_BY_HOOK[hookEventName.toLowerCase()]; - // Only forward events we can map to a canonical name — never emit `unknown` (#702). - if (!event) return null; - - const payload = { - tool, - sessionId: deriveSessionId(stdin), - cwd: resolveHookCwd(stdin), - username: typeof stdin.username === 'string' ? stdin.username : undefined, - data: await buildWebhookData(event, stdin), - }; - - await sendWebhook(event, payload, config); - } catch (error) { - log.debug(`Webhook dispatch failed: ${(error as Error).message}`); - } - + const hookEventName = typeof stdin.hook_event_name === 'string' ? stdin.hook_event_name : ''; + // Case-insensitive so both PascalCase (Claude) and camelCase (Cursor/ + // CodeBuddy) hook names resolve (#702). + const event = WEBHOOK_EVENT_BY_HOOK[hookEventName.toLowerCase()]; + // Only forward events we can map to a canonical name — never emit `unknown` (#702). + if (!event) return null; + await sendWebhookEvent(event, stdin, tool, localConfig, buildWebhookData(event, stdin)); return null; }, }; @@ -1214,16 +1178,15 @@ export function buildHandlerRegistry(): HandlerRegistration[] { // (#1216). Detached, the inline pass for a plain call is empty and the // host-visible cost is bundle load + spawn. { event: 'post-tool-use', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, - { event: 'post-tool-use', matcher: 'Skill', handler: trackHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'post-tool-use', matcher: '*', handler: recallAttributionHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, { event: 'post-tool-use', matcher: 'TodoWrite', handler: todowriteHintHandler, timeoutMs: TODOWRITE_HINT_TIMEOUT_MS, requiresConfig: true }, { event: 'post-tool-use', matcher: '*', handler: localAgentHandler, timeoutMs: LOCAL_AGENT_TIMEOUT_MS, background: true }, - { event: 'post-tool-use', matcher: 'Skill', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, + { event: 'post-tool-use', matcher: '*', handler: skillUseHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, // ─── UserPromptSubmit ───────────────────────────── { event: 'prompt-submit', matcher: '*', handler: pendingHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true, requiresConfig: true }, { event: 'prompt-submit', matcher: '*', handler: packagePendingHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, - { event: 'prompt-submit', matcher: '*', handler: trackSlashHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, + { event: 'prompt-submit', matcher: '*', handler: skillMentionHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'prompt-submit', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'prompt-submit', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, diff --git a/src/opencode-hooks.ts b/src/opencode-hooks.ts index bf8f412a6..0d03b8b35 100644 --- a/src/opencode-hooks.ts +++ b/src/opencode-hooks.ts @@ -13,8 +13,8 @@ * built-in hook set as closely as OpenCode's event model allows: * - `session.created` → session-start (report / sync / ack) * - `session.idle` → stop (update / votes / contribute) - * - `chat.message` → prompt-submit (track-slash / dashboard) - * - `tool.execute.after` → post-tool-use (dashboard; matcher-scoped track) + * - `chat.message` → prompt-submit (skill mentions / dashboard) + * - `tool.execute.after` → post-tool-use (dashboard / skill use) * * The generated plugin is fire-and-forget: OpenCode's `event` hook has no * channel to inject a hook's stdout back into the session, so — like the Hermes @@ -68,15 +68,15 @@ export function resolveOpencodePluginDir(baseDir: string, scope: 'project' | 'us * Build the teamai OpenCode plugin source. * * The plugin subscribes to OpenCode events and shells out to `teamai - * hook-dispatch --tool opencode [--matcher ]`, feeding the same JSON + * hook-dispatch --tool opencode`, feeding the same JSON * payload on STDIN that every other agent's hooks send (`cwd`, `tool_name`, * `tool_input`, `prompt`). It uses Node's `child_process.spawn` with * `windowsHide: true` so a missing `teamai` binary or a non-zero exit never * surfaces as an error inside the agent session or opens a console window. * - * Two OpenCode-specific bridges are required: - * - STDIN payload: hook-dispatch's track / track-slash / todowrite-hint - * handlers read `tool_name` / `tool_input` / `prompt` off STDIN, and the + * One OpenCode-specific bridge is required: + * - STDIN payload: hook-dispatch's skill-use / skill-mention handlers read + * `tool_name` / `tool_input` / `prompt` off STDIN, and the * provider-config gate reads `cwd` to pick the project-scope config. Without * a payload those handlers no-op and project gating falls back to the user * config. We forward the plugin's `directory` as `cwd` plus the per-event @@ -86,10 +86,6 @@ export function resolveOpencodePluginDir(baseDir: string, scope: 'project' | 'us * the subagent's child session to its parent. `shell.env` names the session * in V1's bash environment (`TEAMAI_AGENT_SESSION_ID`); V2's shell supplies * its native `OPENCODE_SESSION_ID`, read by the same session resolver. - * - Tool-id casing: OpenCode passes lowercase tool ids (`skill`, `todowrite`), - * but the handler registry keys matchers on Claude's PascalCase names - * (`Skill`, `TodoWrite`). We map the id back before dispatching a - * matcher-scoped pass. */ export function buildPluginSource(userConfigDir = path.join(getUserHome(), '.config', 'opencode')): string { return `// ${TEAMAI_MARKER} hooks plugin — generated by teamai, do not edit by hand. @@ -105,9 +101,6 @@ export function buildPluginSource(userConfigDir = path.join(getUserHome(), '.con // the dispatch is not truly fire-and-forget for those two — see the timeout // note below. -/** OpenCode lowercase tool ids → Claude PascalCase matcher names. */ -const TOOL_MATCHER = { skill: 'Skill', todowrite: 'TodoWrite' }; - /** A host field read defensively: a non-empty string, or undefined. */ const nonEmpty = (value) => (typeof value === 'string' && value ? value : undefined); @@ -116,13 +109,10 @@ const TeamaiHooks = async ({ directory, worktree }) => { const cwd = directory || worktree; // Dispatch one hook event, forwarding a JSON payload on STDIN. \`payload\` // fields (cwd / tool_name / tool_input / prompt) match what hook-dispatch's - // handlers read; \`matcher\` scopes PostToolUse handlers (Skill / TodoWrite). - const dispatch = async (event, matcher, payload) => { + // handlers read. + const dispatch = async (event, payload) => { try { const args = ['hook-dispatch', event, '--tool', 'opencode']; - if (matcher) { - args.push('--matcher', matcher); - } const stdin = JSON.stringify({ cwd, ...(payload || {}) }); const { spawn } = await import('node:child_process'); await new Promise((resolve) => { @@ -153,14 +143,13 @@ const TeamaiHooks = async ({ directory, worktree }) => { const props = (event && event.properties) || {}; const session = { session_id: nonEmpty(props.sessionID) || nonEmpty(props.info && props.info.id) }; if (event.type === 'session.created') { - await dispatch('session-start', undefined, session); + await dispatch('session-start', session); } else if (event.type === 'session.idle') { - await dispatch('stop', undefined, session); + await dispatch('stop', session); } }, // A new user message maps to Claude's UserPromptSubmit. The prompt text - // lives in output.parts (text parts); forward it so track-slash can see - // slash-command usage. + // lives in output.parts (text parts); forward it so a skill mention counts. 'chat.message': async (input, output) => { let prompt = ''; const parts = (output && output.parts) || []; @@ -169,22 +158,20 @@ const TeamaiHooks = async ({ directory, worktree }) => { prompt += part.text; } } - await dispatch('prompt-submit', undefined, { session_id: nonEmpty(input && input.sessionID), prompt }); + await dispatch('prompt-submit', { session_id: nonEmpty(input && input.sessionID), prompt }); }, - // Fires after every tool call. Dispatch the wildcard matcher always, plus a - // matcher-scoped pass so Skill / TodoWrite handlers can fire. input.tool is - // the lowercase OpenCode tool id; input.args is the tool input; output.output + // Fires after every tool call. input.tool is the lowercase OpenCode tool + // id; input.args is the tool input; output.output // is what the model saw. There is no error flag: only bash reports an exit // code, so any other tool's status is unknown. A task call names the // child session its subagent ran in, which links it to this session. 'tool.execute.after': async (input, output) => { const tool = (input && input.tool) || ''; - const matcher = TOOL_MATCHER[tool]; const metadata = (output && output.metadata) || {}; const exit = tool === 'bash' ? metadata.exit : undefined; const payload = { session_id: nonEmpty(input && input.sessionID), - tool_name: matcher || tool, + tool_name: tool, tool_input: (input && input.args) || {}, tool_response: typeof (output && output.output) === 'string' ? output.output : undefined, tool_status: typeof exit === 'number' ? (exit === 0 ? 'success' : 'failure') : (output && output.status) || 'unknown', @@ -194,10 +181,7 @@ const TeamaiHooks = async ({ directory, worktree }) => { if (child && parent) { payload.session_link = { child, parent }; } - await dispatch('post-tool-use', undefined, payload); - if (matcher) { - await dispatch('post-tool-use', matcher, payload); - } + await dispatch('post-tool-use', payload); }, // Names the session in the bash tool's environment, so teamai recall // run there records its run under the session its hooks carry. diff --git a/src/resources/hooks.ts b/src/resources/hooks.ts index def3b51a3..f726230c3 100644 --- a/src/resources/hooks.ts +++ b/src/resources/hooks.ts @@ -152,6 +152,30 @@ export async function readBuiltinOverride(localConfig: LocalConfig): Promise { + const file = await readEntryFileText(teamHooksYamlPath(localConfig.repo.localPath), entryFilePath('hooks', null)); + if (!file.ok) return 'unreadable'; + if (file.text === null) return 'not-listed'; + let raw: unknown; + try { + raw = YAML.parse(file.text); + } catch { + return 'unreadable'; + } + const parsed = RawBuiltinDisabledSchema.safeParse(raw); + return parsed.success && parsed.data.builtin.disabled.includes(key) ? 'listed' : 'not-listed'; +} + // ─── Security gate (§6) ───────────────────────────────────── // // Team hooks are arbitrary shell run on session events — a supply-chain diff --git a/src/skill-use.ts b/src/skill-use.ts new file mode 100644 index 000000000..128eb96db --- /dev/null +++ b/src/skill-use.ts @@ -0,0 +1,261 @@ +/** + * Skill use (#1221): which skill a hook event shows the agent loaded, credited + * to the calling tool once per session turn. + * + * post-tool-use the tool's skill tool (TOOLS[tool].skillLoad.calls) by name, + * or a read of a SKILL.md under a skill root (classifyToolCall), + * or OMP's `read skill://` + * prompt-submit a mention: `/name` at the start, Codex `$name`, or the + * `` block Pi expands `/skill:name` into + * + * Each candidate must be an installed skill, then claims its (session, turn) + * slot in `~/.teamai/sessions/-skills.json`; only a first claim is + * recorded. + */ +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { z } from 'zod'; +import { sanitizeSessionId } from './contribute-check.js'; +import { resolveMemberToolRoots } from './config.js'; +import { isToolId, TOOLS, type SkillLoad } from './tools.js'; +import { SKILL_NAME_CHARS, type LocalConfig } from './types.js'; +import { withFileLock } from './update.js'; +import { + appendUsageEvent, + extractSkillName, + isInstalledSkillFile, + isValidSkillName, + skillExistsOnDisk, + skillRoots, + updateKnownSkills, +} from './usage-tracker.js'; +import { readJson, writeJson } from './utils/fs.js'; +import { getUserHome } from './utils/home.js'; +import { resolveHookCwd } from './utils/hook-cwd.js'; +import { log } from './utils/logger.js'; +import { deriveSessionId } from './utils/session-id.js'; +import { classifyToolCall } from './utils/tool-call.js'; +import { normalizeToolName } from './utils/tool-names.js'; + +type Candidate = { readonly skill: string; readonly file?: string }; + +/** The built-in that carried skill tracking before #1221: a team that disables it keeps tool calls uncounted. */ +const RETIRED_SKILL_HOOK_KEY = 'Hook dispatch post-tool-use Skill'; + +/** + * The OpenCode plugin before #1221 renamed `skill` to `Skill`, and a running + * OpenCode keeps the loaded plugin until it restarts. Drop after one release. + */ +const LEGACY_PLUGIN_SKILL_CALL: Readonly> = { opencode: 'Skill' }; + +const NAME = `${SKILL_NAME_CHARS}+`; +const SKILL_MD = /[\\/]([^\\/]+)[\\/]SKILL\.md$/i; +const SKILL_URI = /^skill:\/\/([^/]+)\/?$/; +/** The block Pi's `_expandSkillCommand` puts first in the prompt: ``. */ +const PI_SKILL_BLOCK = new RegExp(`^`); + +function asObject(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? { ...value } : null; +} + +function skillLoadOf(tool: string): SkillLoad | undefined { + return isToolId(tool) ? TOOLS[tool].skillLoad : undefined; +} + +function isSkillCall(tool: string, hostName: string): boolean { + const load = skillLoadOf(tool); + if (load === undefined) return normalizeToolName(hostName) === 'Skill'; + if (load === 'unknown') return false; + return load.calls.some((call) => call.name === hostName) || LEGACY_PLUGIN_SKILL_CALL[tool] === hostName; +} + +/** The skills one PostToolUse payload shows loaded, before the existence gate. */ +function toolCallCandidates(stdin: Record, tool: string): Candidate[] { + const hostName = typeof stdin.tool_name === 'string' ? stdin.tool_name : ''; + const input = asObject(stdin.tool_input); + if (!hostName || !input) return []; + if (isSkillCall(tool, hostName)) { + const skill = extractSkillName(input); + return skill ? [{ skill }] : []; + } + const load = skillLoadOf(tool); + if (load === undefined || load === 'unknown') return []; + const call = classifyToolCall(stdin, tool); + if (call.category !== 'read' || call.status === 'failure') return []; + const uri = typeof input.path === 'string' ? SKILL_URI.exec(input.path.trim()) : null; + if (uri) return [{ skill: uri[1] }]; + return call.paths.flatMap((file) => { + const match = SKILL_MD.exec(file); + return match ? [{ skill: match[1], file }] : []; + }); +} + +function promptFormOf(tool: string): '$name' | 'skill-block' | '/name' { + const load = skillLoadOf(tool); + return load === undefined || load === 'unknown' ? '/name' : load.promptForm ?? '/name'; +} + +/** The skills a prompt mentions, before the existence gate. */ +function promptCandidates(prompt: string, tool: string): Candidate[] { + const form = promptFormOf(tool); + let pattern: RegExp; + switch (form) { + case '$name': + // Codex resolves a `$name` anywhere in the prompt. + pattern = new RegExp(`(?:^|\\s)\\$(${NAME})`, 'g'); + break; + case 'skill-block': { + const block = PI_SKILL_BLOCK.exec(prompt); + return block ? [{ skill: block[1], file: block[2] }] : []; + } + case '/name': + // A whole token at the start, so a prompt beginning with an absolute path is not a skill use. + pattern = new RegExp(`^/(${NAME})(?=\\s|$)`, 'g'); + break; + default: { + const unhandled: never = form; + return unhandled; + } + } + return [...prompt.matchAll(pattern)].map((match) => ({ skill: match[1] })); +} + +/** The candidates that name an installed skill, each once. */ +async function installedSkills(candidates: Candidate[], stdin: Record, tool: string, config: LocalConfig, source: 'prompt' | 'tool-call'): Promise { + const named = candidates.filter((c) => isValidSkillName(c.skill)); + if (!named.length) return []; + const hookCwd = resolveHookCwd(stdin); + // The same root resolution import and the local agent use: the scope's own + // record first, since a removed worktree's cwd leads nowhere (#810). + const toolRoots = config.toolRoots ?? await resolveMemberToolRoots(hookCwd); + const roots = await skillRoots({ config, toolRoots, hookCwd, tool }); + const readRoots = source === 'tool-call' && named.some((c) => c.file !== undefined) + ? await skillRoots({ config, toolRoots, hookCwd }) : roots; + const skills = new Set(); + for (const { skill, file } of named) { + if (skills.has(skill)) continue; + const installed = file !== undefined + ? await isInstalledSkillFile(skill, file, readRoots) + : await skillExistsOnDisk(skill, roots); + if (installed) skills.add(skill); + else log.debug(`"${skill}" is not an installed skill, so its use is not tracked`); + } + return [...skills]; +} + +// ─── Once per session turn ────────────────────────────── + +/** + * The host's own turn id, read the same way on both events: Codex and Muse + * send `turn_id`, Claude Code `prompt_id`, Cursor and CodeBuddy `generation_id`. + */ +function hostTurnKey(stdin: Record): string | undefined { + for (const field of ['turn_id', 'prompt_id', 'generation_id']) { + const value = stdin[field]; + if (typeof value === 'string' && value) return value; + } + return undefined; +} + +/** + * The session a claim belongs to, or undefined when the host sent none: a + * `pid-` id is deriveSessionId's fallback, which differs between hook runs. + */ +function claimSessionId(stdin: Record): string | undefined { + const sessionId = deriveSessionId(stdin); + return sessionId.startsWith('pid-') ? undefined : sessionId; +} + +/** Turns a claims file keeps; a detached hook of an earlier turn can still arrive late. */ +const KEPT_TURNS = 16; + +const SkillClaimsSchema = z.object({ + /** The turn the last prompt-submit opened, for hosts that send no turn id. */ + stamp: z.string().optional(), + /** Skills credited per turn key, oldest turn first. */ + turns: z.record(z.string(), z.array(z.string())), + webhooks: z.record(z.string(), z.array(z.string())).default({}), +}); +type SkillClaims = z.infer; + +function claimsPath(sessionId: string): string { + // Ends in `.json`, so cleanupStaleSessions sweeps it with the session's other files. + return path.join(getUserHome(), '.teamai', 'sessions', `${sanitizeSessionId(sessionId)}-skills.json`); +} + +/** A prompt-submit and the detached post-tool-use hooks of its turn can claim at once. */ +const CLAIMS_LOCK_WAIT = { attempts: 20, delayMs: 50 }; + +async function claimTurnSlots(stdin: Record, skills: string[], source: 'prompt' | 'tool-call'): Promise<{ usage: string[]; webhooks: string[] }> { + const opensTurn = source === 'prompt'; + const unchecked = { usage: skills, webhooks: opensTurn ? [] : skills }; + const sessionId = claimSessionId(stdin); + if (sessionId === undefined) return unchecked; + const file = claimsPath(sessionId); + const lockPath = `${file}.lock`; + const claimed = await withFileLock(lockPath, CLAIMS_LOCK_WAIT, async () => { + const parsed = SkillClaimsSchema.safeParse(await readJson(file)); + const claims: SkillClaims = parsed.success ? parsed.data : { turns: {}, webhooks: {} }; + if (opensTurn && hostTurnKey(stdin) === undefined) claims.stamp = randomUUID(); + const turn = hostTurnKey(stdin) ?? claims.stamp; + if (turn === undefined) return unchecked; + const credited = claims.turns[turn] ?? []; + const won = skills.filter((skill) => !credited.includes(skill)); + const notified = claims.webhooks[turn] ?? []; + const webhooks = opensTurn ? [] : skills.filter((skill) => !notified.includes(skill)); + if (won.length || webhooks.length || opensTurn) { + delete claims.turns[turn]; + claims.turns[turn] = [...credited, ...won]; + if (webhooks.length) { + delete claims.webhooks[turn]; + claims.webhooks[turn] = [...notified, ...webhooks]; + } + for (const slots of [claims.turns, claims.webhooks]) { + for (const old of Object.keys(slots).slice(0, -KEPT_TURNS)) delete slots[old]; + } + await writeJson(file, claims); + } + return { usage: won, webhooks }; + }); + if (claimed.held) return claimed.value; + log.debug(`${lockPath} is still held, so ${skills.join(', ') || 'this turn'} is recorded without the once-per-turn check`); + return unchecked; +} + +async function record(skills: string[], tool: string, config: LocalConfig): Promise { + for (const skill of skills) { + await appendUsageEvent({ skill, timestamp: new Date().toISOString(), tool }, config); + await updateKnownSkills(skill); + } +} + +async function toolCallTrackingDisabled(config: LocalConfig): Promise { + const { readBuiltinDisabledListing } = await import('./resources/hooks.js'); + const listing = await readBuiltinDisabledListing(config, RETIRED_SKILL_HOOK_KEY); + if (listing === 'unreadable') { + log.debug('hooks/hooks.yaml cannot be read, so skill use from tool calls is not tracked'); + } + return listing !== 'not-listed'; +} + +/** Record tool-call usage and separately claim the skills to notify in the background. */ +export async function creditToolCall(stdin: Record, tool: string, config: LocalConfig): Promise { + const candidates = toolCallCandidates(stdin, tool); + if (!candidates.length || await toolCallTrackingDisabled(config)) return []; + const claims = await claimTurnSlots(stdin, await installedSkills(candidates, stdin, tool, config, 'tool-call'), 'tool-call'); + await record(claims.usage, tool, config); + return claims.webhooks; +} + +/** + * Open the session turn and record the skills a UserPromptSubmit prompt + * mentions. A host that sends its own turn id needs no stamp, so a prompt + * that names no installed skill there touches no file. + */ +export async function creditPromptMentions(stdin: Record, tool: string, config: LocalConfig): Promise { + const prompt = typeof stdin.prompt === 'string' ? stdin.prompt : ''; + const skills = await installedSkills(promptCandidates(prompt, tool), stdin, tool, config, 'prompt'); + if (!skills.length && hostTurnKey(stdin) !== undefined) return; + const claims = await claimTurnSlots(stdin, skills, 'prompt'); + await record(claims.usage, tool, config); +} diff --git a/src/tools.ts b/src/tools.ts index 74113f278..a77a50b13 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -6,7 +6,7 @@ export type Evidence = 'verified' | 'source' | 'docs' | 'assumed'; export type SkillCall = { readonly name: string; readonly evidence: Evidence }; export type SkillLoad = - | { readonly calls: readonly SkillCall[]; readonly mention?: '$' | '/skill:' } + | { readonly calls: readonly SkillCall[]; readonly promptForm?: '$name' | 'skill-block' } | 'unknown'; export type ToolDescriptor = { readonly home: { readonly dir: string }; readonly skillLoad: SkillLoad }; @@ -15,8 +15,10 @@ export type ToolDescriptor = { readonly home: { readonly dir: string }; readonly * What teamai knows about each built-in tool. `home.dir` is relative to `$HOME`. * * `calls` lists only a dedicated skill tool; a read of a SKILL.md path is not - * one. `mention` is a prompt prefix that loads a skill with no tool call. - * `'unknown'` records nothing. Evidence per call: + * one. `promptForm` is how a prompt that loads a skill with no tool call reaches + * the prompt-submit hook: Codex's `$name` as typed, or the leading + * `` block Pi expands `/skill:name` into first. + * `'unknown'` credits no tool call or read. Evidence per call: * - verified: a recorded run of the tool, or its own loader run on teamai's output * - source: the tool's source or shipped bundle * - docs: the tool's documentation @@ -24,11 +26,11 @@ export type ToolDescriptor = { readonly home: { readonly dir: string }; readonly */ export const TOOLS = { claude: { home: { dir: '.claude' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'verified' }] } }, - codex: { home: { dir: '.codex' }, skillLoad: { calls: [], mention: '$' } }, - 'codex-internal': { home: { dir: '.codex-internal' }, skillLoad: { calls: [], mention: '$' } }, + codex: { home: { dir: '.codex' }, skillLoad: { calls: [], promptForm: '$name' } }, + 'codex-internal': { home: { dir: '.codex-internal' }, skillLoad: { calls: [], promptForm: '$name' } }, 'claude-internal': { home: { dir: '.claude-internal' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'assumed' }] } }, tclaude: { home: { dir: '.tclaude' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'assumed' }] } }, - tcodex: { home: { dir: '.tcodex' }, skillLoad: { calls: [], mention: '$' } }, + tcodex: { home: { dir: '.tcodex' }, skillLoad: { calls: [], promptForm: '$name' } }, cursor: { home: { dir: '.cursor' }, skillLoad: { calls: [] } }, copilot: { home: { dir: '.copilot' }, skillLoad: { calls: [{ name: 'skill', evidence: 'verified' }] } }, joycode: { home: { dir: '.joycode' }, skillLoad: 'unknown' }, @@ -39,7 +41,7 @@ export const TOOLS = { 'trae-cn': { home: { dir: '.trae-cn' }, skillLoad: 'unknown' }, zcode: { home: { dir: '.zcode' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'source' }] } }, omp: { home: { dir: '.omp' }, skillLoad: { calls: [] } }, - pi: { home: { dir: '.pi' }, skillLoad: { calls: [], mention: '/skill:' } }, + pi: { home: { dir: '.pi' }, skillLoad: { calls: [], promptForm: 'skill-block' } }, // The CodeBuddy IDE sends `skill` (src/utils/tool-names.ts). codebuddy: { home: { dir: '.codebuddy' }, skillLoad: { calls: [{ name: 'Skill', evidence: 'verified' }, { name: 'skill', evidence: 'assumed' }] } }, // Hermes (`skill_view`) and OpenClaw (`skills_read`) have skill tools, but teamai installs no post-tool-use hook for them yet. diff --git a/src/types.ts b/src/types.ts index 57f3709b7..a16d734aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1563,8 +1563,11 @@ export const TEAMAI_RECALL_RULES_END = ''; // ─── Usage tracking ──────────────────────────────────── -/** Regex for valid skill names: alphanumeric, hyphens, underscores, colons, dots. Max 200 chars. */ -export const SKILL_NAME_REGEX = /^[a-zA-Z0-9_\-:.]{1,200}$/; +/** The characters a skill name may hold: alphanumeric, hyphens, underscores, colons, dots. */ +export const SKILL_NAME_CHARS = '[a-zA-Z0-9_\\-:.]'; + +/** Regex for valid skill names: {@link SKILL_NAME_CHARS}, at most 200 of them. */ +export const SKILL_NAME_REGEX = new RegExp(`^${SKILL_NAME_CHARS}{1,200}$`); // TEAMAI_USAGE_PATH / TEAMAI_KNOWN_SKILLS_PATH / TEAMAI_PUSHIGNORE_PATH were // module-load consts with no live consumers — the code uses runtime getters @@ -2720,7 +2723,7 @@ export function applyToolRoots( * HOME-relative member root has nothing to say about. */ export function scopedToolPaths( - teamConfig: TeamaiConfig, + teamConfig: Pick, localConfig: { scope?: Scope; toolRoots?: Record }, ): Record> { if (localConfig.scope !== 'user') return teamConfig.toolPaths; diff --git a/src/update.ts b/src/update.ts index bb3fcf033..d77325839 100644 --- a/src/update.ts +++ b/src/update.ts @@ -471,6 +471,29 @@ export async function releaseLock(lockPath?: string): Promise { } } +/** + * Run `body` holding `lockPath`, trying up to `wait.attempts` times with + * `wait.delayMs` between tries. `held: false` means the lock stayed busy and + * `body` did not run. + */ +export async function withFileLock( + lockPath: string, + wait: { attempts: number; delayMs: number }, + body: () => Promise, +): Promise<{ held: true; value: T } | { held: false }> { + for (let attempt = 0; attempt < wait.attempts; attempt++) { + if (await acquireLock(lockPath)) { + try { + return { held: true, value: await body() }; + } finally { + await releaseLock(lockPath); + } + } + await new Promise((r) => setTimeout(r, wait.delayMs)); + } + return { held: false }; +} + /** * Synchronously remove the locks this process still holds when it exits — * the same owner-verified removal `releaseLock` does, but with sync fs calls, diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index 53abdfb79..7d25174c5 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -1,22 +1,22 @@ import fs from 'node:fs'; import path from 'node:path'; import { log } from './utils/logger.js'; -import { normalizeToolName } from './utils/tool-names.js'; import { - getCopilotHome, + DEFAULT_TOOL_PATHS, getDataHome, getTeamaiHomeDir, SKILL_NAME_REGEX, type LocalConfig, type UsageEvent, - resolveToolRootDir, - RELOCATABLE_TOOLS, + resolveToolBaseDir, + scopedToolPaths, } from './types.js'; import { readJson, writeJson, writeFileAtomic, pathExists } from './utils/fs.js'; import { appendJsonl, readJsonl, rewriteJsonl, type JsonlStoreOptions } from './utils/jsonl-store.js'; import { getUserHome } from './utils/home.js'; -import { resolveHookCwd } from './utils/hook-cwd.js'; -import { resolveConfigForDir, resolveMemberToolRoots } from './config.js'; +import { withFileLock } from './update.js'; +import { loadTeamConfig, resolveConfigForDir } from './config.js'; +import { samePath } from './utils/agent-path.js'; /** * The usage JSONL of one scope: `/usage.jsonl`, so each scope reports @@ -42,49 +42,17 @@ function getKnownSkillsPath(): string { // ─── Data flow ───────────────────────────────────────── // -// Claude Code / Claude Internal / CodeBuddy Cursor -// ───────────────────────────────────────── ────── -// PostToolUse hook (matcher: "Skill") PostToolUse hook (matcher: "Read") -// │ │ -// ▼ ▼ -// { tool_name: "Skill", { tool_name: "Read", -// tool_input: { skill: "tdd" } } tool_input: { path: "…/SKILL.md" } } -// │ │ -// └────────────────┬──────────────────────────────┘ -// ▼ -// teamai track --stdin --tool -// │ -// ▼ -// [extract & validate skill name] -// [toolArg → toolSource; Read+SKILL.md → 'cursor'] -// │ -// ▼ -// [resolveHookConfig(payload)] ─null─▶ skip (#748) -// │ -// ▼ -// appendFile(, JSON line) -// │ -// ▼ -// updateKnownSkills(skill) → known-skills.json -// -// ─── Slash command tracking (Claude Code only) ──────── -// -// UserPromptSubmit hook (matcher: "*") -// │ -// ▼ -// { prompt: "/plan-eng-review args..." } -// │ -// ▼ -// teamai track-slash --stdin --tool -// │ -// ▼ -// [starts with "/"?] ──No──▶ exit(0) -// │Yes -// ▼ -// [extract & validate skill name after "/"] -// │ -// ▼ -// appendFile() + updateKnownSkills() +// post-tool-use '*' (skill-use) prompt-submit '*' (skill-mention) +// a skill tool call or a SKILL.md read /name, Codex $name, Pi /skill:name +// │ │ +// └──────────── skill-use.ts ───────┘ +// │ +// [skill installed under a skill root?] +// [first credit this session turn?] +// │ +// ▼ +// appendUsageEvent → +// updateKnownSkills → known-skills.json // /** @@ -93,6 +61,7 @@ function getKnownSkillsPath(): string { * * Handles multiple field names that different AI tool providers may use: * - skill, name (original) + * - id (OpenCode 2.x) * - skill_name (Claude Code variant) * - command (some providers wrap skill invocation) * @@ -102,7 +71,7 @@ function getKnownSkillsPath(): string { export function extractSkillName(toolInput: string | Record): string | null { try { const parsed = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; - const raw: unknown = parsed?.skill ?? parsed?.name ?? parsed?.skill_name ?? parsed?.command ?? null; + const raw: unknown = parsed?.skill ?? parsed?.name ?? parsed?.id ?? parsed?.skill_name ?? parsed?.command ?? null; if (typeof raw !== 'string') return null; const trimmed = raw.trim(); if (!trimmed) return null; @@ -131,97 +100,69 @@ export function isValidSkillName(name: string): boolean { return SKILL_NAME_REGEX.test(name); } -/** A resolved, validated skill invocation from a PostToolUse payload. */ -export interface ResolvedSkillUse { - /** The validated skill name (passes {@link isValidSkillName}). */ - skillName: string; - /** 'cursor' for a Read of a SKILL.md path, otherwise the caller's tool. */ - source: 'cursor' | null; -} +/** What decides where skills are installed for one hook event. */ +export type SkillRootsInput = { + readonly config: LocalConfig; + /** The member's relocated tool roots (resolveMemberToolRoots). */ + readonly toolRoots?: Record; + /** The directory the hook ran in, the project when the scope names none. */ + readonly hookCwd?: string; + /** Restrict name-based loads to this tool; file reads may use every root. */ + readonly tool?: string; +}; /** - * Resolve a skill invocation from a PostToolUse hook payload — the single source - * of truth shared by the usage tracker and the webhook handler so they can never - * drift. Handles both shapes: Claude/CodeBuddy's `Skill` tool, and Cursor's - * `Read` of a `.../SKILL.md` path. Returns null for anything else — including a - * normal (non-SKILL.md) `Read`, so a plain file read never counts as skill use. - * The returned name is already validated with {@link isValidSkillName}. + * Every directory a skill can be installed in, resolved as skill delivery + * resolves a tool's skills directory (`skillsDirForTool`): each tool of the + * team's `toolPaths`, custom tools included, in user scope (under HOME with the + * member's `toolRoots`, Copilot under its home) and in project scope, plus the + * shared `.agents/skills` of both. With `tool`, keep only its configured roots + * and native shared roots. The project is the scope's `projectRoot`, + * else the hook's directory; the home `.github` belongs to no project. */ -export function resolveSkillUse( - toolName: string, - toolInput: Record, -): ResolvedSkillUse | null { - let skillName: string | null = null; - let source: 'cursor' | null = null; - - if (toolName === 'Skill') { - skillName = extractSkillName(toolInput); - } else if (toolName === 'Read') { - const filePath = - (typeof toolInput.file_path === 'string' ? toolInput.file_path : null) ?? - (typeof toolInput.filePath === 'string' ? toolInput.filePath : null) ?? - (typeof toolInput.path === 'string' ? toolInput.path : null); - // Only a Read of a SKILL.md file is skill use — a normal file read is not. - if (filePath && /\/SKILL\.md$/i.test(filePath)) { - skillName = extractSkillName({ skill: filePath }); - source = 'cursor'; +export async function skillRoots({ config, toolRoots, hookCwd, tool: callingTool }: SkillRootsInput): Promise { + const { SHARED_AGENT_SKILLS_PATH } = await import('./resources/skills.js'); + const team = { toolPaths: (await loadTeamConfig(config.repo.localPath))?.toolPaths ?? DEFAULT_TOOL_PATHS }; + const home = getUserHome(); + const userConfig: LocalConfig = { ...config, scope: 'user' }; + const roots = new Set([path.join(home, SHARED_AGENT_SKILLS_PATH)]); + for (const [tool, { skills }] of Object.entries(scopedToolPaths(team, { scope: 'user', toolRoots }))) { + if (skills && (callingTool === undefined || callingTool === tool)) roots.add(path.join(resolveToolBaseDir(tool, userConfig), skills)); + } + const project = config.scope === 'project' && config.projectRoot ? config.projectRoot : hookCwd; + if (project !== undefined && path.resolve(project) !== path.resolve(home)) { + roots.add(path.join(project, SHARED_AGENT_SKILLS_PATH)); + for (const [tool, { skills }] of Object.entries(team.toolPaths)) { + if (skills && (callingTool === undefined || callingTool === tool)) roots.add(path.join(project, skills)); } - } else { - return null; } - - if (!skillName || !isValidSkillName(skillName)) return null; - return { skillName, source }; + if (callingTool === 'opencode' || callingTool === 'muse') { + roots.add(path.join(home, '.claude/skills')); + if (callingTool === 'opencode' && project !== undefined && path.resolve(project) !== path.resolve(home)) { + roots.add(path.join(project, '.claude/skills')); + } + } + return [...roots]; } /** - * Well-known local skill directories to check for skill existence. - * Ordered by likelihood of being present. - */ -const SKILL_DIRS = [ - '.claude/skills', - '.claude-internal/skills', - '.tclaude/skills', - '.cursor/skills', - '.codebuddy/skills', - '.codex/skills', - '.codex-internal/skills', - '.tcodex/skills', - '.openclaw/skills', - '.hermes/skills', -]; -const PROJECT_SKILL_DIRS = [...SKILL_DIRS, '.github/skills']; - -/** - * Check whether a skill actually exists on disk (has a SKILL.md in any tool's skills directory). - * This prevents tracking phantom skills from typos or path inputs like "/data". - * - * Performance: Checks a bounded list of user and project directories with one stat() each. + * Whether a skill exists on disk: a SKILL.md under one of `roots` (skillRoots). + * This prevents tracking phantom skills from typos or inputs like "/data" or "$HOME". */ -export async function skillExistsOnDisk(skillName: string, toolRoots?: Record): Promise { - const home = getUserHome(); - // A relocated tool (Claude Code with CLAUDE_CONFIG_DIR, Codex with - // CODEX_HOME) keeps its skills under the recorded root, which the static list - // cannot know; the caller resolves it from the hook's directory - // (resolveMemberToolRoots). - const userSkillDirs = [ - ...Object.entries(RELOCATABLE_TOOLS).map(([tool, { defaultRoot }]) => - path.join(resolveToolRootDir(tool, defaultRoot, toolRoots), 'skills')), - ...SKILL_DIRS.map((dir) => path.join(home, dir)), - path.join(getCopilotHome(), 'skills'), - ]; - // Check user-level directories - for (const dir of userSkillDirs) { - const skillMd = path.join(dir, skillName, 'SKILL.md'); - if (await pathExists(skillMd)) return true; +export async function skillExistsOnDisk(skillName: string, roots: readonly string[]): Promise { + for (const root of roots) { + if (await pathExists(path.join(root, skillName, 'SKILL.md'))) return true; } - // Check project-level directories (cwd) - const cwd = process.cwd(); - if (path.resolve(cwd) !== path.resolve(home)) { - for (const dir of PROJECT_SKILL_DIRS) { - const skillMd = path.join(cwd, dir, skillName, 'SKILL.md'); - if (await pathExists(skillMd)) return true; - } + return false; +} + +/** Whether `file` is the SKILL.md of `skillName` under one of `roots`, however either path is spelled. */ +export async function isInstalledSkillFile(skillName: string, file: string, roots: readonly string[]): Promise { + const real = await fs.promises.realpath(file).catch(() => null); + if (real === null) return false; + for (const root of roots) { + const installed = await fs.promises.realpath(path.join(root, skillName, 'SKILL.md')).catch(() => null); + if (installed !== null && samePath(installed, real)) return true; } return false; } @@ -350,19 +291,28 @@ async function ignoreUsageSideFiles(config: LocalConfig, usagePath: string): Pro } } +/** A hook waits at most ~1 s for the known-skills lock; detached hooks of one session update it at once. */ +const KNOWN_SKILLS_LOCK_WAIT = { attempts: 20, delayMs: 50 }; + /** - * Add a skill to the known-skills set (persisted across truncations). + * Add a skill to the known-skills set (persisted across truncations), under + * the file's lock so concurrent hooks do not drop each other's skill. * Silently fails on I/O errors to avoid disrupting the AI coding session. */ export async function updateKnownSkills(skillName: string): Promise { + const knownPath = getKnownSkillsPath(); + const lockPath = `${knownPath}.lock`; try { - const knownPath = getKnownSkillsPath(); - const existing = await readJson(knownPath); - const skills = new Set(Array.isArray(existing) ? existing : []); - if (skills.has(skillName)) return; // already known - skills.add(skillName); - await writeJson(knownPath, Array.from(skills).sort()); - log.debug(`Added ${skillName} to known-skills.json`); + const updated = await withFileLock(lockPath, KNOWN_SKILLS_LOCK_WAIT, async () => { + const existing = await readJson(knownPath); + const skills = new Set(Array.isArray(existing) ? existing : []); + if (skills.has(skillName)) return; + skills.add(skillName); + await writeJson(knownPath, Array.from(skills).sort()); + log.debug(`Added ${skillName} to known-skills.json`); + }); + if (updated.held) return; + log.debug(`${lockPath} is still held, so ${skillName} was not added to known-skills.json; its usage event is recorded`); } catch (e) { log.error(`Failed to update known-skills: ${(e as Error).message}`); } @@ -414,195 +364,61 @@ async function readStdin(): Promise { } /** - * Handle the `teamai track` CLI command. - * Called by PostToolUse hook with CLI args (legacy) or STDIN JSON (current). + * Handle the hidden `teamai track ` command, which hooks + * written before hook-dispatch call until a pull replaces them. */ export async function track(rawToolName: string, toolInput: string, tool?: string): Promise { - const toolName = normalizeToolName(rawToolName); - // Only track Skill tool calls - if (toolName !== 'Skill') { - return; - } - - const skillName = extractSkillName(toolInput); - if (!skillName) { - log.debug('Could not extract skill name from tool input'); - return; - } - - if (!isValidSkillName(skillName)) { - log.debug(`Invalid skill name rejected: ${skillName.slice(0, 50)}`); + let input: unknown; + try { + input = JSON.parse(toolInput); + } catch { + log.debug('Could not parse the tool input of a track call'); return; } - const config = await resolveConfigForDir(); if (!config) return; - - const event: UsageEvent = { - skill: skillName, - timestamp: new Date().toISOString(), - tool: tool ?? 'claude', - }; - - await appendUsageEvent(event, config); - await updateKnownSkills(skillName); + const { creditToolCall } = await import('./skill-use.js'); + await creditToolCall({ tool_name: rawToolName, tool_input: input }, tool ?? 'claude', config); } -/** - * Handle the `teamai track --stdin` mode. - * Reads PostToolUse hook JSON from STDIN and extracts tool usage info. - * - * Supports two tool formats: - * - Claude Code "Skill" tool: { tool_name: "Skill", tool_input: { skill: "tdd" } } - * - Cursor "Read" tool: { tool_name: "Read", tool_input: { path: "…/SKILL.md" } } - * - * @param toolArg - Optional tool identifier from --tool CLI flag. - * When provided, used as the toolSource (e.g. 'claude-internal'). - * When absent, defaults to 'claude' for backward compatibility. - * Exception: Read + SKILL.md always overrides to 'cursor'. - */ -export async function trackFromStdin(toolArg?: string): Promise { +/** A hook payload from STDIN, or null when there is none or it is not a JSON object. */ +async function readHookStdin(): Promise | null> { const raw = await readStdin(); if (!raw.trim()) { log.debug('No STDIN data received'); - return; + return null; } - - let hookData: { tool_name?: string; tool_input?: Record; cwd?: unknown }; + let parsed: unknown; try { - hookData = JSON.parse(raw); + parsed = JSON.parse(raw); } catch { - log.error('Failed to parse STDIN JSON'); - return; - } - - const rawName = hookData.tool_name; - if (typeof rawName !== 'string') return; - const toolName = normalizeToolName(rawName); - - const toolInput = hookData.tool_input; - if (!toolInput || typeof toolInput !== 'object') { - if (toolName === 'Skill' || toolName === 'Read') { - log.debug('Missing or invalid tool_input in STDIN JSON'); - } - return; - } - - let skillName: string | null = null; - let toolSource = toolArg ?? 'claude'; - - if (toolName === 'Skill') { - skillName = extractSkillName(toolInput); - } else if (toolName === 'Read') { - const filePath = - (typeof toolInput.file_path === 'string' ? toolInput.file_path : null) ?? - (typeof toolInput.filePath === 'string' ? toolInput.filePath : null) ?? - (typeof toolInput.path === 'string' ? toolInput.path : null); - if (filePath && /\/SKILL\.md$/i.test(filePath)) { - skillName = extractSkillName({ skill: filePath }); - toolSource = 'cursor'; - } - } else { - return; - } - - if (!skillName) { - log.debug('Could not extract skill name from STDIN tool_input'); - return; - } - - if (!isValidSkillName(skillName)) { - log.debug(`Invalid skill name rejected: ${skillName.slice(0, 50)}`); - return; + parsed = null; } + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) return { ...parsed }; + log.error('Failed to parse STDIN JSON'); + return null; +} +/** Handle the hidden `teamai track --stdin` command (a pre-hook-dispatch PostToolUse hook). */ +export async function trackFromStdin(toolArg?: string): Promise { + const hookData = await readHookStdin(); + if (!hookData) return; + const tool = toolArg ?? 'claude'; const { resolveHookConfig } = await import('./dashboard-collector.js'); - const config = await resolveHookConfig(hookData, toolArg ?? 'claude'); + const config = await resolveHookConfig(hookData, tool); if (!config) return; - - const event: UsageEvent = { - skill: skillName, - timestamp: new Date().toISOString(), - tool: toolSource, - }; - - await appendUsageEvent(event, config); - await updateKnownSkills(skillName); + const { creditToolCall } = await import('./skill-use.js'); + await creditToolCall(hookData, tool, config); } -/** - * Handle the `teamai track-slash --stdin` mode. - * Reads UserPromptSubmit hook JSON from STDIN and tracks slash commands. - * - * STDIN JSON format (Claude Code UserPromptSubmit): - * { prompt: "/plan-eng-review args...", session_id: "...", hook_event_name: "UserPromptSubmit" } - * - * Extracts the first word after "/" as the skill name. - * - * @param toolArg - Optional tool identifier from --tool CLI flag. - * Defaults to 'claude' for backward compatibility. - */ +/** Handle the hidden `teamai track-slash --stdin` command (a pre-hook-dispatch UserPromptSubmit hook). */ export async function trackSlashCommand(toolArg?: string): Promise { - const raw = await readStdin(); - if (!raw.trim()) { - log.debug('No STDIN data for slash tracking'); - return; - } - - let hookData: { prompt?: string; cwd?: unknown }; - try { - hookData = JSON.parse(raw); - } catch { - log.error('Failed to parse slash command STDIN JSON'); - return; - } - - const prompt = hookData.prompt; - if (typeof prompt !== 'string' || !prompt.startsWith('/')) { - return; - } - - // Extract all skill names after "/" in the prompt - // (e.g. "/plan-eng-review some args /tdd /code-review" → ["plan-eng-review", "tdd", "code-review"]) - // Match whole whitespace-delimited tokens, never segments of paths or URLs. - const matches = [...prompt.matchAll(/(?:^|\s)\/([a-zA-Z0-9_\-:.]+)(?=\s|$)/g)]; - if (matches.length === 0) { - log.debug('Could not extract skill name from slash command'); - return; - } - - const hookCwd = resolveHookCwd(hookData); + const hookData = await readHookStdin(); + if (!hookData) return; + const tool = toolArg ?? 'claude'; const { resolveHookConfig } = await import('./dashboard-collector.js'); - const config = await resolveHookConfig(hookData, toolArg ?? 'claude'); + const config = await resolveHookConfig(hookData, tool); if (!config) return; - // The same root resolution import and the local agent use, from the hook's - // directory: a project set up before a user-scope relocation has no record - // of its own and follows user scope. - // The scope's own record first: a removed worktree's cwd leads nowhere (#810). - const toolRoots = config.toolRoots ?? await resolveMemberToolRoots(hookCwd); - - for (const match of matches) { - const skillName = match[1]; - - if (!isValidSkillName(skillName)) { - log.debug(`Invalid slash skill name rejected: ${skillName.slice(0, 50)}`); - continue; - } - - // Verify the skill actually exists on disk to avoid tracking phantom skills - // (e.g. user typing "/data" which is not a real skill) - if (!await skillExistsOnDisk(skillName, toolRoots)) { - log.debug(`Slash command "/${skillName}" is not a known skill — skipping tracking`); - continue; - } - - const event: UsageEvent = { - skill: skillName, - timestamp: new Date().toISOString(), - tool: toolArg ?? 'claude', - }; - - await appendUsageEvent(event, config); - await updateKnownSkills(skillName); - } + const { creditPromptMentions } = await import('./skill-use.js'); + await creditPromptMentions(hookData, tool, config); } diff --git a/src/utils/shell-command.ts b/src/utils/shell-command.ts index 8423103d3..a015e23e0 100644 --- a/src/utils/shell-command.ts +++ b/src/utils/shell-command.ts @@ -49,6 +49,13 @@ const REDIRECT = /^(?:&>>?|<<<|<<-?|<>|<&|>>|>&|>\||<|>)/; /** The characters an unquoted backslash escapes: whitespace and those the shell gives a meaning. */ const ESCAPABLE = /[\s'"\\$`()&;|<>*?#!]/; +const HOME_REF = /(?:~|\$HOME|\$\{HOME\})(?=[\\/])/y; + +function homeRefLength(line: string, at: number): number { + HOME_REF.lastIndex = at; + return HOME_REF.exec(line)?.[0].length ?? 0; +} + /** * The simple commands of a shell command line, split on `;`, `&&`, `||`, `|`, * `&` and newlines outside quotes, each with the operator after it. A @@ -57,10 +64,11 @@ const ESCAPABLE = /[\s'"\\$`()&;|<>*?#!]/; * of the line. Outside quotes a backslash escapes whitespace or a character * the shell gives a meaning (`my\ doc.md`), and joins a line to the next; * before any other character it is kept, so an unquoted Windows path - * (`C:\kb\x.md`) stays whole. Inside double quotes it escapes only `"` or - * `\`, and inside single quotes nothing. + * (`C:\kb\x.md`) stays whole. Inside double quotes it escapes only `"`, + * `\` or `$`, and inside single quotes nothing. Given `home`, `$HOME/` and + * `${HOME}/` expand outside single quotes; `~/` expands only unquoted. */ -export function simpleCommands(command: string): SimpleCommand[] { +export function simpleCommands(command: string, home?: string): SimpleCommand[] { const commands: SimpleCommand[] = []; let current: SimpleCommand = { words: [], redirects: [], op: null }; let word: string | null = null; @@ -89,8 +97,11 @@ export function simpleCommands(command: string): SimpleCommand[] { const next = line[i + 1]; if (quote) { if (c === quote) quote = null; - else if (c === '\\' && quote === '"' && (next === '"' || next === '\\')) word += line[++i]; - else word += c; + else if (c === '\\' && quote === '"' && (next === '"' || next === '\\' || next === '$')) word += line[++i]; + else if (quote === '"' && c === '$' && home !== undefined && homeRefLength(line, i) > 0) { + i += homeRefLength(line, i) - 1; + word += home; + } else word += c; } else if (c === '"' || c === "'") { quote = c; word ??= ''; @@ -120,6 +131,9 @@ export function simpleCommands(command: string): SimpleCommand[] { endCommand(';'); } else if (/\s/.test(c)) { endWord(); + } else if (word === null && home !== undefined && homeRefLength(line, i) > 0) { + i += homeRefLength(line, i) - 1; + word = home; } else { word = (word ?? '') + c; } @@ -267,6 +281,8 @@ const WINDOWS_PATH = /^[A-Za-z]:[\\/]|\\/; export interface ShellFlavor { /** The agent's PowerShell tool ran it. */ powershell?: boolean; + /** The home directory the shell expands a leading `~`, `$HOME` or `${HOME}` before `/` or `\` to (`simpleCommands`). */ + home?: string; } /** @@ -428,7 +444,7 @@ function classifySimple(command: SimpleCommand, simple: boolean, flavor: ShellFl * just a shell call. */ export function classifyShellCommand(command: string, flavor: ShellFlavor = {}): ShellClassification { - const commands = simpleCommands(command); + const commands = simpleCommands(command, flavor.home); const pipeline = commands.every((c, i) => c.op === (i === commands.length - 1 ? null : '|')); return pipeline && commands.length > 0 ? classifySimple(commands[0], commands.length === 1, flavor) : SHELL; } diff --git a/src/utils/tool-call.ts b/src/utils/tool-call.ts index b062dec67..23d16a987 100644 --- a/src/utils/tool-call.ts +++ b/src/utils/tool-call.ts @@ -17,6 +17,7 @@ * responseOf. A name not listed here is `unknown` and never counts. */ import { isAbsolutePath, isWithin, resolvePath, samePath, withoutReadSelector } from './agent-path.js'; +import { getUserHome } from './home.js'; import { resolveHookCwd } from './hook-cwd.js'; import { classifyShellCommand } from './shell-command.js'; import { normalizeToolName } from './tool-names.js'; @@ -276,7 +277,7 @@ export function classifyToolCall(stdin: Record, agent?: string) const command = input.command; if (typeof command !== 'string') return unknown; - const shell = classifyShellCommand(command, { powershell: name.toLowerCase() === 'powershell' }); + const shell = classifyShellCommand(command, { powershell: name.toLowerCase() === 'powershell', home: getUserHome() }); const output = outputOf(response); const optional = output !== undefined ? { output } : {}; // With no status, only the command's own error lines tell a failure apart.