#!/usr/bin/env python3 """Turn a harness hook payload into a trace event. Hooks call this, never `wikitool`. A hook fires on every tool call, and every `wikitool` invocation is counted by the Iteration Budget Gate - wiring telemetry through the CLI would let the act of observing a session end it. This entry point imports the telemetry package as a library instead, which costs no budget and needs no venv: `chemenu.telemetry` is stdlib-only on purpose. Contract with the harness: * stdin is the hook's JSON payload; a missing, empty or malformed payload is not an error worth failing a tool call over. * stdout stays empty. Claude Code and Copilot CLI both read a hook's stdout as a decision document, so anything printed here could alter what the agent does. `--dry-run` is the exception, and it is for humans and tests. * the exit code is always 0. A hook that exits non-zero can block a tool call, and an observer must never do that. Usage from a hook: tools/trace_ingest.py --source copilot-cli --event tool.pre """ from __future__ import annotations import argparse import json import os import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from chemenu.telemetry import schema # noqa: E402 from chemenu.telemetry.writer import write_event # noqa: E402 # Harness event name -> our event name. The keys are what each vendor documents; # the values are the vocabulary in schema.py. A harness that cannot report an # event class simply has no row for it - Mistral Vibe has three. EVENT_MAPS: dict[str, dict[str, str]] = { "claude-code": { "SessionStart": "session.start", "SessionEnd": "session.end", "UserPromptSubmit": "prompt.submitted", "PreToolUse": "tool.pre", "PostToolUse": "tool.post", "PostToolUseFailure": "tool.error", "SubagentStart": "subagent.start", "SubagentStop": "subagent.stop", "InstructionsLoaded": "instructions.loaded", "PreCompact": "compaction", "PostCompact": "compaction", "Stop": "turn.end", }, # Copilot CLI serves two payload dialects, chosen by how the event is # spelled in the hook config: camelCase names give camelCase fields, # PascalCase names give the VS Code / Claude snake_case shape. Both spellings # are mapped so a trace stays readable whichever a config uses. "copilot-cli": { "sessionStart": "session.start", "SessionStart": "session.start", "sessionEnd": "session.end", "SessionEnd": "session.end", "userPromptSubmitted": "prompt.submitted", "UserPromptSubmit": "prompt.submitted", "preToolUse": "tool.pre", "PreToolUse": "tool.pre", "postToolUse": "tool.post", "PostToolUse": "tool.post", "postToolUseFailure": "tool.error", "PostToolUseFailure": "tool.error", "errorOccurred": "session.error", "ErrorOccurred": "session.error", "subagentStart": "subagent.start", "subagentStop": "subagent.stop", "SubagentStop": "subagent.stop", "preCompact": "compaction", "PreCompact": "compaction", "agentStop": "turn.end", "Stop": "turn.end", }, "mistral-vibe": { "pre_tool": "tool.pre", "post_tool": "tool.post", "post_agent": "turn.end", }, } # Normalised field -> the spellings the three harnesses use, in priority order. FIELD_ALIASES: dict[str, tuple[str, ...]] = { "tool_name": ("tool_name", "toolName", "tool"), "tool_call_id": ("tool_call_id", "tool_use_id", "toolCallId", "toolUseId"), "tool_input": ("tool_input", "toolArgs", "tool_args", "toolInput"), "tool_output": ("tool_output_text", "tool_output", "tool_response", "toolResult"), "tool_status": ("tool_status", "status", "toolStatus"), "duration_ms": ("duration_ms", "durationMs"), "error": ("tool_error", "error", "errorMessage"), "error_context": ("error_context", "errorContext"), "prompt": ("prompt", "userPrompt", "promptText", "initialPrompt", "initial_prompt"), "message": ("last_assistant_message", "assistantMessage", "response", "message"), "cwd": ("cwd", "workingDirectory"), "transcript_path": ("transcript_path", "transcriptPath"), "file_path": ("file_path", "filePath"), "agent_type": ("agent_type", "agentType", "subagent_type", "agentName", "agent_name"), "parent_session_id": ("parent_session_id", "parentSessionId"), "stop_reason": ("stop_reason", "stopReason"), "reason": ("reason",), "trigger": ("trigger",), "source_kind": ("source",), } # Values that live one level down. Copilot's postToolUse wraps the text the # model saw in a result object rather than handing it over flat. NESTED_ALIASES: dict[str, tuple[tuple[str, str], ...]] = { "tool_output": ( ("toolResult", "textResultForLlm"), ("tool_result", "text_result_for_llm"), ), } SESSION_ALIASES = ("session_id", "sessionId", "session") # Leftover payload keys worth keeping. Anything else is vendor bookkeeping that # would only bloat the trace - a trace is scoring material, not an archive of # the vendor's payload. EXTRA_SCALAR_TYPES = (str, int, float, bool) def _first(payload: dict, names: tuple[str, ...]): for name in names: if name in payload and payload[name] not in (None, ""): return payload[name] return None def _as_text(value) -> str: if isinstance(value, str): return value return json.dumps(value, ensure_ascii=False, sort_keys=True) def _nested(payload: dict, paths: tuple[tuple[str, str], ...]): for outer, inner in paths: container = payload.get(outer) if isinstance(container, dict) and container.get(inner): return container[inner] return None def _error_text(value) -> str: """`postToolUseFailure` hands over a string, `errorOccurred` an object.""" if isinstance(value, dict): return value.get("message") or _as_text(value) return _as_text(value) def resolve_event(source: str, payload: dict, explicit: str | None) -> str | None: """`--event` wins: not every harness names its event inside the payload.""" if explicit: return explicit raw = payload.get("hook_event_name") or payload.get("hookEventName") if not raw: return None return EVENT_MAPS.get(source, {}).get(raw) def normalise(source: str, event: str, payload: dict) -> dict: attrs: dict = {"harness_event": payload.get("hook_event_name") or payload.get("hookEventName")} for field, aliases in FIELD_ALIASES.items(): value = _first(payload, aliases) if value is None: continue if field == "error": attrs[field] = _error_text(value) elif field in ("tool_input", "tool_output"): attrs[field] = _as_text(value) else: attrs[field] = value for field, paths in NESTED_ALIASES.items(): nested = _nested(payload, paths) if nested is not None: attrs[field] = _as_text(nested) known = set(SESSION_ALIASES) | {"hook_event_name", "hookEventName", "timestamp"} for aliases in FIELD_ALIASES.values(): known.update(aliases) for paths in NESTED_ALIASES.values(): known.update(outer for outer, _ in paths) extra = { key: value for key, value in payload.items() if key not in known and isinstance(value, EXTRA_SCALAR_TYPES) } if extra: attrs["extra"] = extra # A trace has to say what its harness *could* have reported, or a scorer # cannot tell "never happened" from "not observable here". if event == "session.start": attrs["harness"] = source attrs["completeness"] = list(schema.HARNESS_CAPABILITIES.get(source, ())) return {key: value for key, value in attrs.items() if value is not None} def build(source: str, payload: dict, explicit_event: str | None, session: str | None): event = resolve_event(source, payload, explicit_event) if event is None: return None, None, None session_id = session or _first(payload, SESSION_ALIASES) or os.environ.get( "WIKITOOL_SESSION_ID" ) return event, normalise(source, event, payload), session_id def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source", required=True, choices=sorted(EVENT_MAPS)) parser.add_argument( "--event", help="Our event name, e.g. tool.pre. Required for harnesses that do not " "name the event in the payload (Copilot CLI).", ) parser.add_argument("--session", help="Override the session id from the payload.") parser.add_argument( "--dry-run", action="store_true", help="Print the event instead of appending it. Never use this from a hook: " "some harnesses read a hook's stdout as a decision.", ) args = parser.parse_args() raw = sys.stdin.read() if not sys.stdin.isatty() else "" try: payload = json.loads(raw) if raw.strip() else {} except json.JSONDecodeError: payload = {} if not isinstance(payload, dict): payload = {} event, attrs, session = build(args.source, payload, args.event, args.session) if event is None: if args.dry_run: sys.stderr.write("no event mapping for this payload\n") return 1 return 0 if args.dry_run: record = schema.make_event( args.source, event, attrs, session_id=session or "dry-run", seq=1 ) errors = schema.validation_errors(record) if errors: sys.stderr.write("; ".join(errors) + "\n") return 1 sys.stdout.write(json.dumps(record, ensure_ascii=False) + "\n") return 0 write_event(args.source, event, attrs, session=session) return 0 if __name__ == "__main__": try: sys.exit(main()) except SystemExit: raise except BaseException: # noqa: BLE001 - an observer never breaks the observed sys.exit(0)