diff --git a/.github/hooks/shared/telemetry.json b/.github/hooks/shared/telemetry.json index d39599b38..6af63429c 100644 --- a/.github/hooks/shared/telemetry.json +++ b/.github/hooks/shared/telemetry.json @@ -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", @@ -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", diff --git a/.github/hooks/shared/telemetry/_telemetry_core.py b/.github/hooks/shared/telemetry/_telemetry_core.py index b90543c9d..fb7ac051c 100644 --- a/.github/hooks/shared/telemetry/_telemetry_core.py +++ b/.github/hooks/shared/telemetry/_telemetry_core.py @@ -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") @@ -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) @@ -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 diff --git a/.github/hooks/shared/telemetry/tests/test_telemetry_core.py b/.github/hooks/shared/telemetry/tests/test_telemetry_core.py index 771195d55..ac4d74380 100644 --- a/.github/hooks/shared/telemetry/tests/test_telemetry_core.py +++ b/.github/hooks/shared/telemetry/tests/test_telemetry_core.py @@ -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" diff --git a/.vscode/settings.json b/.vscode/settings.json index acbe686e5..bce227a1e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" diff --git a/plugins/hve-core-all/hooks/shared/telemetry.json b/plugins/hve-core-all/hooks/shared/telemetry.json index ed7dd2eef..32cad91a6 100644 --- a/plugins/hve-core-all/hooks/shared/telemetry.json +++ b/plugins/hve-core-all/hooks/shared/telemetry.json @@ -10,6 +10,14 @@ "timeoutSec": 10 } ], + "userPromptSubmitted": [ + { + "type": "command", + "bash": "${PLUGIN_ROOT}/hooks/shared/telemetry/telemetry-collector.sh", + "powershell": "${PLUGIN_ROOT}/hooks/shared/telemetry/Invoke-TelemetryCollector.ps1", + "timeoutSec": 10 + } + ], "userPromptSubmit": [ { "type": "command", @@ -50,6 +58,14 @@ "timeoutSec": 10 } ], + "sessionEnd": [ + { + "type": "command", + "bash": "${PLUGIN_ROOT}/hooks/shared/telemetry/telemetry-collector.sh", + "powershell": "${PLUGIN_ROOT}/hooks/shared/telemetry/Invoke-TelemetryCollector.ps1", + "timeoutSec": 10 + } + ], "stop": [ { "type": "command", diff --git a/plugins/hve-core/hooks/shared/telemetry.json b/plugins/hve-core/hooks/shared/telemetry.json index ed7dd2eef..32cad91a6 100644 --- a/plugins/hve-core/hooks/shared/telemetry.json +++ b/plugins/hve-core/hooks/shared/telemetry.json @@ -10,6 +10,14 @@ "timeoutSec": 10 } ], + "userPromptSubmitted": [ + { + "type": "command", + "bash": "${PLUGIN_ROOT}/hooks/shared/telemetry/telemetry-collector.sh", + "powershell": "${PLUGIN_ROOT}/hooks/shared/telemetry/Invoke-TelemetryCollector.ps1", + "timeoutSec": 10 + } + ], "userPromptSubmit": [ { "type": "command", @@ -50,6 +58,14 @@ "timeoutSec": 10 } ], + "sessionEnd": [ + { + "type": "command", + "bash": "${PLUGIN_ROOT}/hooks/shared/telemetry/telemetry-collector.sh", + "powershell": "${PLUGIN_ROOT}/hooks/shared/telemetry/Invoke-TelemetryCollector.ps1", + "timeoutSec": 10 + } + ], "stop": [ { "type": "command", diff --git a/scripts/linting/Validate-HookManifests.ps1 b/scripts/linting/Validate-HookManifests.ps1 index 66f426408..169737a32 100644 --- a/scripts/linting/Validate-HookManifests.ps1 +++ b/scripts/linting/Validate-HookManifests.ps1 @@ -32,7 +32,9 @@ Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force $script:HookAllowedEvents = @( 'sessionStart', + 'sessionEnd', 'userPromptSubmit', + 'userPromptSubmitted', 'preToolUse', 'postToolUse', 'preCompact',