Skip to content
Open
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
16 changes: 16 additions & 0 deletions .github/hooks/shared/telemetry.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@
"timeoutSec": 10
}
],
"userPromptSubmitted": [
{
"type": "command",
"bash": ".github/hooks/shared/telemetry/telemetry-collector.sh",
"powershell": ".github/hooks/shared/telemetry/Invoke-TelemetryCollector.ps1",
"timeoutSec": 10
}
],
"userPromptSubmit": [
{
"type": "command",
Expand Down Expand Up @@ -50,6 +58,14 @@
"timeoutSec": 10
}
],
"sessionEnd": [
{
"type": "command",
"bash": ".github/hooks/shared/telemetry/telemetry-collector.sh",
"powershell": ".github/hooks/shared/telemetry/Invoke-TelemetryCollector.ps1",
"timeoutSec": 10
}
],
"stop": [
{
"type": "command",
Expand Down
47 changes: 47 additions & 0 deletions .github/hooks/shared/telemetry/_telemetry_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,41 @@ def build_session_summary(
return summary


def _infer_event_from_shape(data: dict) -> str:
"""Infer a canonical event name from payload shape.

The Copilot CLI does not send an event-name field in the hook payload
(no ``hook_event_name``/``hookEventName``/``event``), unlike VS Code. It
also fires one shared command for every manifest event, so the event must
be inferred from which keys are present. Checks are ordered so that events
sharing keys are disambiguated by their distinguishing field:

* ``toolResult`` present -> PostToolUse (also carries ``toolName``)
* ``toolName`` present -> PreToolUse
* ``prompt`` present -> UserPromptSubmit
* ``agentName`` present -> SubagentStop when ``stopReason`` is set (a
Subagent stop), otherwise SubagentStart
* ``trigger`` present -> PreCompact
* ``stopReason`` present without ``agentName`` -> Stop (session end)
* ``source`` present -> SessionStart
"""
if "toolResult" in data:
return "PostToolUse"
if "toolName" in data:
return "PreToolUse"
if "prompt" in data:
return "UserPromptSubmit"
if "agentName" in data:
return "SubagentStop" if "stopReason" in data else "SubagentStart"
if "trigger" in data:
return "PreCompact"
if "stopReason" in data:
return "Stop"
if "source" in data:
return "SessionStart"
return "unknown"


def _normalize_event(data: dict) -> str:
"""Resolve the canonical PascalCase event name from a hook payload."""
event = data.get("hook_event_name", "unknown")
Expand All @@ -682,6 +717,10 @@ def _normalize_event(data: dict) -> str:
if value and value != "unknown":
event = value
break
if event == "unknown":
# The Copilot CLI omits the event name entirely; recover it from the
# payload shape so CLI sessions record telemetry like VS Code sessions.
event = _infer_event_from_shape(data)
return EVENT_ALIASES.get(event, event)


Expand Down Expand Up @@ -771,6 +810,14 @@ def build_entry(data: dict, event: str, stack: _AgentStack) -> dict | None:
tool_input = data.get("tool_input") or data.get("toolArgs", {})
tool_result = data.get("tool_result") or data.get("toolResult", "")

# The Copilot CLI serializes tool arguments as a JSON string rather than an
# object; decode it so key extraction and file-path detection below work.
if isinstance(tool_input, str):
try:
tool_input = json.loads(tool_input)
except ValueError:
tool_input = {}

if event == "unknown":
return None

Expand Down
50 changes: 50 additions & 0 deletions .github/hooks/shared/telemetry/tests/test_telemetry_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,56 @@ def test_given_skill_path_when_build_entry_then_detects_skill(tmp_path):
assert entry["skill"] == "my-skill"


def test_given_cli_pretooluse_payload_when_normalize_event_then_infers_pretooluse():
# The CLI omits the event name and sends a tool payload without toolResult.
assert (
core._normalize_event({"sessionId": "s", "toolName": "bash", "toolArgs": "{}"})
== "PreToolUse"
)


def test_given_cli_posttooluse_payload_when_normalize_event_then_infers_posttooluse():
data = {"sessionId": "s", "toolName": "bash", "toolArgs": "{}", "toolResult": {}}
assert core._normalize_event(data) == "PostToolUse"


def test_given_cli_prompt_payload_when_normalize_event_then_infers_userpromptsubmit():
assert core._normalize_event({"sessionId": "s", "prompt": "hi"}) == "UserPromptSubmit"


def test_given_cli_subagent_start_payload_when_normalize_event_then_infers_start():
data = {"sessionId": "s", "agentName": "explore", "agentDescription": "x"}
assert core._normalize_event(data) == "SubagentStart"


def test_given_cli_subagent_stop_payload_when_normalize_event_then_infers_stop():
data = {"sessionId": "s", "agentName": "explore", "stopReason": "end_turn"}
assert core._normalize_event(data) == "SubagentStop"


def test_given_cli_session_stop_payload_when_normalize_event_then_infers_stop():
# A session-level stop carries stopReason but no agentName.
assert core._normalize_event({"sessionId": "s", "stopReason": "end_turn"}) == "Stop"


def test_given_explicit_event_name_when_normalize_event_then_shape_inference_skipped():
# VS Code supplies the event name, so inference must not override it.
data = {"hook_event_name": "SessionStart", "toolName": "bash"}
assert core._normalize_event(data) == "SessionStart"


def test_given_cli_json_string_toolargs_when_build_entry_then_extracts_keys(tmp_path):
# The CLI serializes tool arguments as a JSON string; build_entry decodes it.
stack = core._AgentStack(tmp_path / ".stacks", "sid1")
data = {
"sessionId": "s",
"toolName": "bash",
"toolArgs": json.dumps({"command": "ls", "description": "list"}),
}
entry = core.build_entry(data, "PreToolUse", stack)
assert sorted(entry["tool_input_keys"]) == ["command", "description"]


def test_given_stop_event_when_mode_collect_then_writes_entry_and_summary(tmp_path, monkeypatch):
tel_dir = tmp_path / "tel"
home = tmp_path / "home"
Expand Down
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@
".github/skills/security": true,
".github/skills/shared": true
},
"chat.hookFilesLocations": {
".github/hooks": true,
".github/hooks/shared": true
},
"github.copilot.chat.commitMessageGeneration.instructions": [
{
"file": ".github/instructions/hve-core/commit-message.instructions.md"
Expand Down
16 changes: 16 additions & 0 deletions plugins/hve-core-all/hooks/shared/telemetry.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions plugins/hve-core/hooks/shared/telemetry.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions scripts/linting/Validate-HookManifests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force

$script:HookAllowedEvents = @(
'sessionStart',
'sessionEnd',
'userPromptSubmit',
'userPromptSubmitted',
'preToolUse',
'postToolUse',
'preCompact',
Expand Down
Loading