Skip to content

[AI-55] fix: Capture skills invoked by slash command - #180

Draft
SaintPatrck wants to merge 2 commits into
mainfrom
feat/ai-telemetry-slash-skill-capture
Draft

[AI-55] fix: Capture skills invoked by slash command#180
SaintPatrck wants to merge 2 commits into
mainfrom
feat/ai-telemetry-slash-skill-capture

Conversation

@SaintPatrck

@SaintPatrck SaintPatrck commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

AI-55

📔 Objective

Skills invoked by slash command (/plugin:skill) produced no telemetry at all, so per-skill usage counts understated real usage.

Claude Code treats a slash invocation as a prompt expansion, not a Skill tool dispatch — it injects the skill body as a user message rather than calling the Skill tool. PostToolUse therefore never fires and tool_input.skill never exists, so emit_identity.py's Task|Agent|Skill matcher never matches. Native telemetry still emits skill_activated, which is why the gap was visible at all. UserPromptExpansion is the only hook event carrying the skill name, in command_name.

Investigation detail — measured impact and how the mechanism was established

Measured on live Datadog before this change:

activations Skill tool calls recorded by us
fleet 179 135
worst-affected user 37 13 12

Roughly 25% of activations were invisible fleet-wide, and about two thirds for the heaviest slash user — whose committing-changes count read 1 against roughly 20 real uses.

The mechanism was established by tracing all ~30 subscribable hook events during a slash invocation. Ten fired; none carried tool_name = "Skill". The only tool call in the session was an incidental Read of the skill's own reference file.

Changes

  • Subscribe to UserPromptExpansion and emit bw.identity with bw.skill taken from command_name.
  • Stamp event.timestamp (ISO-8601 UTC, millisecond precision) on every emitted record in the shared emit(), so all bw.* events carry a client clock rather than consumers falling back to collector ingest time. Millisecond precision matters because downstream deduplication keys include the timestamp.

Notes for reviewers

  • Datadog dashboards need no query change. They already read @bw.tool:Skill, so their counts correct themselves once this ships — no dashboard work is required alongside this PR.
  • No collector change either. bw.skill, bw.tool, bw.hook and event.timestamp are all already on the middleware allowlist.
  • A slash expansion is recorded with bw.tool = "Skill" even though no tool ran, so it matches the same @bw.tool:Skill queries as the tool path. bw.hook preserves the true origin (UserPromptExpansion vs PostToolUse).
  • command_name may name a plugin command rather than a skill. The two are deliberately not distinguished.
  • Expansion types other than slash_command are suppressed rather than emitted — they carry no skill identity and would write content-free bw.identity rows.
  • Built-in commands (/clear, /plugin) fire no prompt hook at all, so they cannot pollute counts.
  • A caller supplying its own non-empty event.timestamp keeps it, and the caller's dict is never mutated.

Skills invoked as /plugin:skill produced no telemetry at all. Claude Code
expands a slash invocation into the prompt instead of dispatching the Skill
tool, so PostToolUse never fires and tool_input.skill never exists. The
UserPromptExpansion hook's command_name is the only signal that carries the
name.

Measured before this change: one user had 37 native skill activations, of
which only 13 were Skill tool calls, leaving roughly two thirds of their
skill usage invisible to telemetry.

A slash expansion is recorded with bw.tool = "Skill" so it matches the same
@bw.tool:Skill queries as the tool path, with bw.hook preserving the true
origin. Plugin commands are captured alongside skills and deliberately not
distinguished. Expansion types other than slash_command are suppressed,
since they carry no skill identity and would otherwise write content-free
bw.identity rows.
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

Plugin Validation — PR #180

Plugin: bitwarden-ai-telemetry (1.0.0 → 1.1.0)
Scope: plugin structure validation, hook schema, security review. No SKILL.md, agent, or command files changed, so the skill-reviewer step was skipped.

Verdict: PASS with 1 warning to resolve before merge and 2 minor doc fixes. No critical or major errors. No secrets, no credential exposure, no permission or auto-approval risk.


Errors (must fix)

None.


Warnings (should fix)

⚠️ W1 — UserPromptExpansion hook event name could not be verified in this environment

plugins/bitwarden-ai-telemetry/hooks/hooks.json:14

This is the load-bearing claim of the PR, and it is the one thing this validation could not confirm:

  • The plugin-dev v0.1.0 hook validator this repo's CLAUDE.md prescribes hard-codes its allowed set at skills/hook-development/scripts/validate-hook-schema.sh:41 as PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, SessionStart, SessionEnd, PreCompact, Notification. UserPromptExpansion is absent, as it is from the skill's own event reference table. Anyone running the prescribed validator will see ⚠️ Unknown event type: UserPromptExpansion.
  • The official hooks documentation and the installed Claude Code bundle were not reachable from this sandbox, so the stale-reference hypothesis could not be confirmed either way.

Two possibilities: the event is newer than the pinned v0.1.0 skill (likely, given commit 6d06ed1 and the emit_identity.py:22 note "Verified empirically 2026-07-30"), or the name is wrong.

Why it warrants attention rather than a shrug: Claude Code ignores unrecognized keys in the hooks object rather than erroring, and this plugin is fail-open by design (emit.py:82-83 swallows everything, every hook exits 0). The same uncertainty extends to the payload fields the code keys off — expansion_type == "slash_command" and command_name (hooks/emit_identity.py:25-27). If any of those three names is wrong, _should_emit suppresses every record and the feature emits nothing, with no error, no log line, and no failing test — while CHANGELOG.md:15-17 claims it closed a gap where "roughly two thirds of their skill usage produced no telemetry at all." The new unit tests assert against a hand-authored fixture (test_emit_identity.py:74-82), so they pass regardless of whether the real payload matches.

Remediation — either is sufficient:

  1. Attach the evidence behind the "Verified empirically 2026-07-30" comment to the PR: a captured raw hook payload showing hook_event_name, expansion_type, and command_name, or a link to the docs section defining the event.
  2. Or record an end-to-end smoke check: install the plugin, invoke a /plugin:skill, confirm a bw.identity record with bw.hook = "UserPromptExpansion" reaches the collector.

Also worth a line in the PR body noting that plugin-dev v0.1.0's VALID_EVENTS list is stale, so reviewers running the prescribed validator aren't blocked by it.

⚠️ W2 — README event-family count contradicts its own table

plugins/bitwarden-ai-telemetry/README.md:7

Says "The hooks emit four event families"; the table at lines 9–15 lists five (bw.identity, bw.edit, bw.commit, bw.pr, bw.mcp). The miscount predates this PR, but line 7 was rewritten here to add UserPromptExpansion and the wrong number was carried through unchanged.

Fix: change "four event families" → "five event families".


Minor / suggestions

  • plugins/bitwarden-ai-telemetry/README.md:19-25 — "What it collects" enumerates tool, skill, agent, and MCP names but not slash-command names, which this PR begins emitting via command_name. For a user-defined (non-plugin) command the name is user-authored, so the caveat at line 33 about names that "embed a token or codename" applies to it too. Suggest adding "Slash command names (/plugin:skill)" to the list and extending line 33 to cover command names. Purely a documentation-completeness point — the behavior itself is sound.
  • plugins/bitwarden-ai-telemetry/README.mdevent.timestamp is now added to every record but isn't mentioned. Defensible (the README doesn't enumerate per-record attributes), but a note under "What it collects" would help consumers.
  • plugins/bitwarden-ai-telemetry/CHANGELOG.md:19### Notes isn't a Keep a Changelog category (Added / Changed / Deprecated / Removed / Fixed / Security). Consistent with the 1.0.0 entry and validate-plugin-structure.sh still passes, so treat as an accepted local convention or fold into ### Changed.
  • plugins/bitwarden-ai-telemetry/CHANGELOG.md## [1.1.0] / ## [1.0.0] use link-reference syntax with no matching [1.1.0]: <url> definitions, so they render as literal brackets. Pre-existing.
  • No CI runs these tests. .github/workflows/ contains no job that executes plugins/bitwarden-ai-telemetry/hooks/test_*.py — only the Atlassian MCP server has a test workflow. This PR adds 15 well-written unit tests that will never run automatically. Pre-existing since 1.0.0; a small python3 -m unittest discover step would make them load-bearing.
  • UserPromptExpansion is registered without a matcher, so emit_identity.py spawns a Python process on every prompt expansion, including the non-slash types that _should_emit then suppresses. Correct and cheap (1s network timeout inside a 5s hook timeout), but it is the highest-frequency hook in the plugin — worth knowing if expansion volume grows.

What passed

Manifest and versioning

  • plugin.json — valid JSON; name kebab-case and matching the directory; version 1.1.0 valid semver; description, author (object with name/url), homepage, repository, keywords all present and well-formed. No unknown fields.
  • MINOR is the correct bump for an added hook plus an added attribute, per the repo's semver policy.
  • Version consistent in all three required places: plugins/bitwarden-ai-telemetry/.claude-plugin/plugin.json:3, .claude-plugin/marketplace.json:99, and the root catalog table at README.md:9.
  • CHANGELOG.md has a ## [1.1.0] - 2026-07-31 entry with populated Added and Fixed sections, matching the manifest version — the substantive-change requirement in CLAUDE.md is satisfied.

Structure and hooks

  • Auto-discovery layout correct: .claude-plugin/plugin.json, README.md, CHANGELOG.md, hooks/hooks.json all at expected paths. No agents/, skills/, commands/, or MCP config, so those checks don't apply.
  • hooks.json is valid JSON. Every entry uses type: "command" with timeout: 5. ${CLAUDE_PLUGIN_ROOT} is used in all five commands and is correctly double-quoted (python3 "${CLAUDE_PLUGIN_ROOT}/hooks/emit_identity.py"), so paths containing spaces are safe. PostToolUse entries carry matchers; SubagentStop and UserPromptExpansion correctly omit matcher, which is tool-event-only.
  • All referenced scripts exist: hooks/emit_identity.py, hooks/emit_git.py, hooks/emit_mcp.py. The from emit import emit import at emit_identity.py:9 resolves — invoking by absolute path puts the script's directory on sys.path[0], and hooks/emit.py is a sibling.
  • No stray files (node_modules, .DS_Store, build artifacts). hooks/test_*.py ship inside the payload but are unreferenced by hooks.json and cannot execute as hooks.
  • .cspell.json updated with timespec for the new datetime call — dictionary hygiene handled.

Security

Check Result
Committed settings.local.json ✅ None anywhere in the repo
Hardcoded secrets / credentials ✅ None. Only regex hits are benign: README prose about secrets, a code comment, and a deliberate file:///etc/passwd negative-test fixture at test_emit.py:82
Permission scoping ✅ N/A — plugin ships no settings.json and requests no permissions
Dangerous command auto-approvals ✅ None. Hook commands are fixed python3 <quoted-path> invocations with no interpolation of untrusted data
Broad file access ✅ None requested

Notable positives in the changed code:

  • No prompt text leaks through the new path. The UserPromptExpansion payload carries command_args (the user's actual prompt text — visible in the test fixture at test_emit_identity.py:80), and _build_identity_attrs deliberately reads only command_name. That upholds the README's "never collects prompt text" guarantee at the exact point where it would have been easiest to break.
  • Collector allowlist is correctly implemented (emit.py:31-47): enforces https and restricts the host to bitwarden.pw / *.bitwarden.pw, checking the parsed hostname rather than a substring — which defeats the userinfo bypass https://ait.bitwarden.pw@evil.com/. urlsplit's ValueError on malformed input is caught so a bad env value fails closed instead of escaping at import.
  • emit() does not mutate the caller's dict (emit.py:65, attrs = dict(attrs)), and the guard at line 66 preserves a caller-supplied non-empty event.timestamp. Both behaviors are covered by tests.
  • Fail-open holds: every hook exits 0, urlopen is bounded at 1s inside the 5s hook timeout, all exceptions swallowed. Telemetry cannot break a session.
  • New import is datetime from the standard library — no third-party dependency added.

Test coverage added

15 new tests across the two suites, covering timestamp presence, ISO-8601/UTC shape, millisecond precision, caller-supplied and empty-string precedence, non-mutation, slash-expansion skill recovery, bw.tool query parity, origin preservation in bw.hook, tool-path precedence over expansion, and all four _should_emit branches. Genuinely thorough for the logic under test — the one gap is that the input fixture is hand-authored rather than captured (see W1).


Validation environment note

./scripts/validate-plugin-structure.sh, ./scripts/validate-marketplace.sh, pnpm run lint, and python3 -m unittest could not be executed — Bash commands were not permitted in this sandbox. Their checks were instead traced by hand against the scripts' source and the files. Both scripts are expected to pass: required files present, all required plugin.json fields present with valid version format, CHANGELOG's first version entry (1.1.0) matching the manifest, README containing the substrings the script greps for. CI remains the authority on lint.yml (prettier + cspell) and validate-plugins.yml.

@SaintPatrck SaintPatrck added the ai-review Request a Claude code review label Jul 31, 2026
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Re-reviewed after the follow-up docs commit. The UserPromptExpansion subscription recovers slash-invoked skill names from command_name, suppresses non-slash expansions before they can write content-free bw.identity rows, and preserves the true origin in bw.hook while reporting bw.tool = "Skill" for query parity with the tool path. The shared emit() now stamps event.timestamp non-mutatingly and preserves a caller-supplied value, with unit coverage for both behaviors. Version bump (1.0.0 → 1.1.0), changelog, marketplace/root README entries, and the .cspell.json addition are all consistent.

Code Review Details

No outstanding findings.

Previously raised and now closed:

  • ♻️ : Plugin README hook list and bw.identity event row were stale — addressed in 58a395b, which adds UserPromptExpansion to both.
    • plugins/bitwarden-ai-telemetry/README.md:7, plugins/bitwarden-ai-telemetry/README.md:11
  • ❓ : command_source gating for non-plugin custom commands — author confirmed the behavior is intended.
    • plugins/bitwarden-ai-telemetry/hooks/emit_identity.py:25

Comment on lines +14 to +24
"UserPromptExpansion": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/emit_identity.py\"",
"timeout": 5
}
]
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ DEBT: Plugin README still documents only PostToolUse and SubagentStop as registered hooks.

Details and fix

plugins/bitwarden-ai-telemetry/README.md is the plugin's collection-disclosure document, and it enumerates both the registered hooks and the trigger for each event family:

  • Line 7: "The plugin registers Claude Code lifecycle hooks (PostToolUse and SubagentStop) that fire after tool use and subagent completion."
  • Line 11: | `bw.identity` | `Task` / `Agent` / `Skill` tool use; subagent stop |

Both are now incomplete — UserPromptExpansion is a third registered hook, and bw.identity also fires on slash-command expansion. Suggest updating line 7 to include UserPromptExpansion and the bw.identity row to mention slash invocation (/plugin:skill), so the disclosure matches what actually ships.

Comment on lines +25 to +27
if h.get("expansion_type") != SLASH_EXPANSION:
return ""
return h.get("command_name") or ""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QUESTION: Should command_source gate this, so project/user-level custom commands aren't counted as skills?

Details

The PR notes that a plugin command vs. a skill is deliberately not distinguished — that reads reasonable, since both are plugin-authored and fleet-meaningful. But expansion_type == "slash_command" also matches personal (~/.claude/commands) and project (.claude/commands) commands, which the test fixture's "command_source": "plugin" implies are distinguishable here.

Those land in bw.skill with bw.tool = "Skill", so they join the same @bw.tool:Skill dashboards as names that correspond to no plugin skill at all — which slightly cuts against the per-skill count accuracy this PR is restoring. Was including non-plugin sources intended, or would gating on command_source == "plugin" better match the metric's purpose?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. Behavior is intended.

The README's hook list and bw.identity event description were
stale after this PR added the UserPromptExpansion hook for
slash-command invocation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant