Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/guide/zh-CN/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 中的路径段不算技能调用。已经记录的使用数据保持不变。
2 changes: 1 addition & 1 deletion skill-data/core/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 12 additions & 7 deletions src/__tests__/builtin-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']) {
Expand Down Expand Up @@ -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: '*',
Expand All @@ -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);
Expand Down
14 changes: 4 additions & 10 deletions src/__tests__/copilot-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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');

Expand Down
47 changes: 28 additions & 19 deletions src/__tests__/e2e/deleted-worktree-scope-810.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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'));
Expand All @@ -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');
Expand All @@ -193,23 +201,24 @@ 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,
// spawned after this session's, so theirs have had their turn.
if (breakProjectConfig) await hook('stop', { hook_event_name: 'Stop', session_id: 'control', cwd: home });
const settled = breakProjectConfig
? () => readJsonl<RecordedEvent>(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));
Expand Down Expand Up @@ -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]));
Expand All @@ -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);
});
});
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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);
});
});
Expand All @@ -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);
});
});

Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/fixtures/hooks-skill-retire/claude-team-only.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
74 changes: 74 additions & 0 deletions src/__tests__/fixtures/hooks-skill-retire/claude.after.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
Loading
Loading