Skip to content

Commit 862fdc8

Browse files
Report first-party skill names on skill_used, bucket the rest as custom (#959)
* Report first-party skill names on skill_used, bucket the rest as custom * Qualify skill-name privacy bullet and narrow first-party skill comment * Correct the skill_used allowlist rationale to reportable-by-name user-invocable false opts out of slash synthesis, not name reporting: seven flagged skills stay allowlisted as use_skill recipes; only the four bake-only background skills are excluded.
1 parent 7c1488e commit 862fdc8

6 files changed

Lines changed: 156 additions & 20 deletions

File tree

docs/TELEMETRY.md

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Each event carries a small set of properties:
1818
| `$ai_generation` | Once per completed turn (may be sampled); always on turn failure | `$ai_trace_id`, `$ai_provider`, `$ai_model`, `$ai_input_tokens`, `$ai_output_tokens`, `$ai_latency`, `$ai_is_error`, `$ai_error`, `$ai_cache_read_input_tokens`, `$ai_cache_creation_input_tokens`, `$ai_reasoning_tokens`, `tool_call_count`, `tool_error_count`, `subagent_call_count` |
1919
| `$ai_span` | Opt-in only — once per top-level tool call when `CORBITS_TELEMETRY_AI_SPANS` is set | `$ai_trace_id`, `$ai_span_id`, `$ai_parent_id`, `$ai_span_name`, `$ai_is_error` |
2020
| `slash_command` | A slash command is dispatched (shared product-event path) | `command_name` |
21-
| `skill_used` | `use_skill` loads a skill that resolved | (none beyond common properties) |
21+
| `skill_used` | `use_skill` loads a skill that resolved | `skill_name` |
2222
| `plugin_loaded` | First successful load of a plugin identity in this process | `origin` |
2323
| `subagent_start` | A `spawn_agent` dispatch begins | `agent_name` |
2424
| `subagent_end` | A `spawn_agent` dispatch finishes | `agent_name`, `status`, `duration_ms`, `model`, `turn_count`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, `tool_call_count`, `tool_error_count`, `stop_reason`, `parent_trace_id` |
@@ -76,13 +76,27 @@ share of prompts driven by MCP stays visible without the server key coming
7676
with it. `agent_name` on `subagent_*` is the same pattern: first-party
7777
director ids from `DIRECTOR_IDS` (and the legacy `worker` alias) are reported
7878
by id; project-defined or marketplace profile ids become `custom`.
79-
`skill_used` and `plugin_loaded` go further: there is no first-party list of
80-
skills or plugins to match against, so `skill_used` carries no name at all and
81-
`plugin_loaded` carries only `origin`, the discovery tier (`repo`, `user`,
82-
`project`, `path`). Enabled telemetry reports the same plugin identity at most
83-
once per runtime reporter, including across reloads. Disabled/no-op loads do not
84-
consume that identity, so enabling telemetry later can report the first real
85-
load.
79+
`skill_used` carries `skill_name`: a first-party skill name reportable by
80+
name from the closed `corbits-skills` allowlist (`ast-grep`, `create-issue`,
81+
`git-rebase`, `git-worktrees`, `implement`, `interview`,
82+
`linear-issue-workflow`, `opsh`, `philosophy`, `plan`,
83+
`pull-request-review`, `refactor`, `review`, `scribe`, `style`,
84+
`typescript`), or `custom` for anything else. `user-invocable: false` opts a
85+
skill out of slash synthesis, not out of name reporting: eleven bundled
86+
skills carry the flag, and seven of them (`git-rebase`, `git-worktrees`,
87+
`linear-issue-workflow`, `opsh`, `philosophy`, `style`, `typescript`)
88+
remain real `use_skill` recipes, so they stay on the allowlist — the names
89+
are ours either way. Excluded are the four bake-only background skills
90+
(`idiot-proof`, `native-integration`, `native-runtime`, `ponytail`), which
91+
are baked into agent prompts rather than invoked as skills. Unknown,
92+
project-local, and plugin-authored skill names are never transmitted —
93+
`skill_name` is the only identifying-adjacent property the event can carry.
94+
`plugin_loaded` goes further: there is no first-party list of plugins to
95+
match against, so it carries only `origin`, the discovery tier (`repo`,
96+
`user`, `project`, `path`). Enabled telemetry reports the same plugin
97+
identity at most once per runtime reporter, including across reloads.
98+
Disabled/no-op loads do not consume that identity, so enabling telemetry later
99+
can report the first real load.
86100

87101
`error_class` is bucketed the same way: only the error types defined by the
88102
language are reported by name, because an error subclass defined in
@@ -187,8 +201,10 @@ retry paths.
187201
- Prompts, model output, or any conversation content (except intentional
188202
free-text the operator types into `/feedback` — see below)
189203
- File paths, file contents, or repo/project names
190-
- Names anyone but this project chose: MCP servers, skills, plugins, agent
191-
profiles, plugin-registered slash commands, error subclasses (see above)
204+
- Names anyone but this project chose: MCP servers, skills (other than
205+
first-party `corbits-skills` names, which are sent by name per the table
206+
above), plugins, agent profiles, plugin-registered slash commands, error
207+
subclasses (see above)
192208
- Shell commands, tool arguments, or tool results
193209
- API keys, tokens, or any other credential
194210
- Anything not in the allowlist above

src/agent/use-skill.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type } from "arktype";
55

66
import { resolveSkillBody } from "../extensions/skills.js";
77
import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
8+
import { captureSkillUsed } from "../telemetry/product-events.js";
89

910
// Lazy skill loading: names are listed in the system prompt; details come from
1011
// skill_search; this tool pulls the full instructions into context when the
@@ -50,10 +51,10 @@ export function createUseSkillTool(
5051
}
5152
const body = await resolveSkillBody(cwd, name, skillDirs);
5253
if (body === undefined) return `No skill named "${name}" is available.`;
53-
// Skills are project- or plugin-authored, so the name is as identifying
54-
// as any other user-written string and never leaves the process; the
55-
// event records only that a skill was loaded.
56-
telemetry.capture("skill_used");
54+
// Skill names are project- or plugin-authored, so an unrecognised
55+
// name never leaves the process: first-party `corbits-skills` names
56+
// are reported by name, everything else as `custom`.
57+
captureSkillUsed(telemetry, name);
5758
return `Skill "${name}" — follow these instructions for this task:\n\n${body}`;
5859
},
5960
});

src/telemetry/classify.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,38 @@ const BUILT_IN_AGENT_NAMES: ReadonlySet<string> = new Set([
7171
"worker",
7272
]);
7373

74+
// First-party skills reportable by name: the bundled `corbits-skills`
75+
// skills (plugins/corbits-skills/skills) whose names we ship ourselves, so
76+
// reporting one cannot identify the operator. The manifest carries only the
77+
// plugin id and kind — no skill list — so the closed set is spelled out here
78+
// and pinned by tests/unit/telemetry-product-events.test.ts.
79+
// `user-invocable: false` is a slash-surface flag, not a telemetry flag:
80+
// eleven bundled skills carry it, and seven of them (git-rebase,
81+
// git-worktrees, linear-issue-workflow, opsh, philosophy, style, typescript)
82+
// opt out of slash synthesis yet remain real `use_skill` recipes, so they
83+
// stay reportable by name. Excluded are the four bake-only background skills
84+
// (idiot-proof, native-integration, native-runtime, ponytail), which are
85+
// baked into agent prompts rather than invoked as skills. Project- or
86+
// plugin-authored skills are never reported by name.
87+
const FIRST_PARTY_SKILL_NAMES: ReadonlySet<string> = new Set([
88+
"ast-grep",
89+
"create-issue",
90+
"git-rebase",
91+
"git-worktrees",
92+
"implement",
93+
"interview",
94+
"linear-issue-workflow",
95+
"opsh",
96+
"philosophy",
97+
"plan",
98+
"pull-request-review",
99+
"refactor",
100+
"review",
101+
"scribe",
102+
"style",
103+
"typescript",
104+
]);
105+
74106
// Error constructors defined by the language. A subclass name is application
75107
// or plugin code and can be as identifying as any other author-chosen string.
76108
const STANDARD_ERROR_NAMES: ReadonlySet<string> = new Set([
@@ -100,6 +132,10 @@ export function classifyAgentName(agentName: string): string {
100132
return BUILT_IN_AGENT_NAMES.has(agentName) ? agentName : CUSTOM;
101133
}
102134

135+
export function classifySkillName(skillName: string): string {
136+
return FIRST_PARTY_SKILL_NAMES.has(skillName) ? skillName : CUSTOM;
137+
}
138+
103139
export function classifyErrorClass(error: unknown): string {
104140
if (!(error instanceof Error)) return "non_error";
105141
return STANDARD_ERROR_NAMES.has(error.constructor.name)

src/telemetry/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,9 @@ const EVENT_PROPERTY_ALLOWLIST: Record<TelemetryEvent, readonly string[]> = {
165165
// allowlist bounds which keys travel; the classifiers bound which values
166166
// can, and the two are independent guards on purpose.
167167
slash_command: ["command_name"],
168-
// Skill names are project- or plugin-authored with no first-party set to
169-
// match against, so the event counts skill use and carries nothing else.
170-
skill_used: [],
168+
// skill_name is a first-party corbits-skills name (see classifySkillName)
169+
// or "custom" — project- or plugin-authored names never leave the process.
170+
skill_used: ["skill_name"],
171171
// origin is the discovery tier (repo/user/project/path); the manifest id is
172172
// author-chosen free text and is not sent.
173173
plugin_loaded: ["origin"],

src/telemetry/product-events.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
SubAgentTerminalReason,
77
} from "../subagent/types.js";
88
import type { Telemetry } from "./index.js";
9-
import { classifyCommandName } from "./classify.js";
9+
import { classifyCommandName, classifySkillName } from "./classify.js";
1010

1111
/** Emit slash_command with a classified first-party (or `custom`) name. */
1212
export function captureSlashCommand(
@@ -18,6 +18,16 @@ export function captureSlashCommand(
1818
});
1919
}
2020

21+
/** Emit skill_used with a classified first-party (or `custom`) skill name. */
22+
export function captureSkillUsed(
23+
telemetry: Telemetry,
24+
skillName: string,
25+
): void {
26+
telemetry.capture("skill_used", {
27+
skill_name: classifySkillName(skillName),
28+
});
29+
}
30+
2131
export interface CaptureSubagentEndArgs {
2232
agentName: string;
2333
status: string;

tests/unit/telemetry-product-events.test.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
classifyAgentName,
2929
classifyErrorClass,
3030
classifyPermissionKind,
31+
classifySkillName,
3132
} from "../../src/telemetry/classify.js";
3233

3334
import {
@@ -37,6 +38,7 @@ import {
3738
} from "../../src/telemetry/index.js";
3839
import {
3940
buildSubagentEndProperties,
41+
captureSkillUsed,
4042
captureSlashCommand,
4143
createPluginLoadReporter,
4244
} from "../../src/telemetry/product-events.js";
@@ -167,7 +169,7 @@ test("permission_prompt reports built-in tool ids by name", () => {
167169
// 2. skill_name — a project-local skill can be named after the employer
168170
// ---------------------------------------------------------------------------
169171

170-
test("skill_used carries no skill name, so an employer-named skill cannot leak", async () => {
172+
test("skill_used buckets an employer-named skill to custom so it cannot leak", async () => {
171173
const { telemetry, wire, events } = harness();
172174
const cwd = await tempDir("corbits-skill-");
173175
const skillDir = join(cwd, ".agents", "skills", "acme-internal-deploy");
@@ -190,10 +192,81 @@ test("skill_used carries no skill name, so an employer-named skill cannot leak",
190192
expect(result).toContain("Deploy the internal service");
191193
const [event] = await events();
192194
expect(event?.event).toBe("skill_used");
193-
expect(event?.properties.skill_name).toBeUndefined();
195+
expect(event?.properties.skill_name).toBe("custom");
194196
expect(await wire()).not.toContain("acme-internal");
195197
});
196198

199+
test("skill_used reports a first-party skill by name", async () => {
200+
const { telemetry, events } = harness();
201+
const cwd = await tempDir("corbits-skill-");
202+
const skillDir = join(cwd, ".agents", "skills", "review");
203+
await mkdir(skillDir, { recursive: true });
204+
await writeFile(
205+
join(skillDir, "SKILL.md"),
206+
"---\nname: review\n---\n\nReview the branch.\n",
207+
);
208+
209+
const tool = createUseSkillTool(cwd, [], telemetry);
210+
if (tool.kind !== "string")
211+
throw new Error(`expected string tool, got ${tool.kind}`);
212+
const result = await tool.handler(
213+
{ name: "review" },
214+
new AbortController().signal,
215+
);
216+
217+
expect(result).toContain("Review the branch");
218+
const [event] = await events();
219+
expect(event?.event).toBe("skill_used");
220+
expect(event?.properties.skill_name).toBe("review");
221+
});
222+
223+
test("first-party skill names are reported by name; everything else stays custom", () => {
224+
for (const name of [
225+
"ast-grep",
226+
"create-issue",
227+
"git-rebase",
228+
"git-worktrees",
229+
"implement",
230+
"interview",
231+
"linear-issue-workflow",
232+
"opsh",
233+
"philosophy",
234+
"plan",
235+
"pull-request-review",
236+
"refactor",
237+
"review",
238+
"scribe",
239+
"style",
240+
"typescript",
241+
]) {
242+
expect(classifySkillName(name)).toBe(name);
243+
}
244+
expect(classifySkillName("acme-internal-deploy")).toBe("custom");
245+
// Bundled catalog skills outside the closed allowlist are not reported by
246+
// name either — the allowlist is the closed set, not the skills directory.
247+
// All four bake-only background skills (user-invocable: false, baked into
248+
// agent prompts rather than invoked as skills) stay custom, while the
249+
// seven flagged-but-allowlisted use_skill-only recipes assert by name above.
250+
expect(classifySkillName("idiot-proof")).toBe("custom");
251+
expect(classifySkillName("native-integration")).toBe("custom");
252+
expect(classifySkillName("native-runtime")).toBe("custom");
253+
expect(classifySkillName("ponytail")).toBe("custom");
254+
expect(classifySkillName("Review")).toBe("custom");
255+
expect(classifySkillName("")).toBe("custom");
256+
});
257+
258+
test('skill_used buckets a plugin-authored skill to "custom"', async () => {
259+
const { telemetry, wire, events } = harness();
260+
261+
captureSkillUsed(telemetry, "acmecorp-deploy");
262+
captureSkillUsed(telemetry, "plan");
263+
264+
const captured = await events();
265+
expect(captured[0]?.properties.skill_name).toBe("custom");
266+
expect(captured[1]?.properties.skill_name).toBe("plan");
267+
expect(await wire()).not.toContain("acmecorp");
268+
});
269+
197270
// ---------------------------------------------------------------------------
198271
// 3. plugin_id — an author-chosen manifest id on a private local plugin
199272
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)