execweave 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- execweave/__init__.py +3 -0
- execweave/__main__.py +5 -0
- execweave/analysis.py +406 -0
- execweave/backends.py +63 -0
- execweave/benchmark.py +80 -0
- execweave/claude_adapter.py +448 -0
- execweave/claude_hook_cli.py +101 -0
- execweave/claude_record.py +106 -0
- execweave/cli.py +588 -0
- execweave/codex_adapter.py +314 -0
- execweave/codex_hook_cli.py +98 -0
- execweave/codex_record.py +111 -0
- execweave/collector.py +301 -0
- execweave/correlation.py +604 -0
- execweave/cursor_adapter.py +347 -0
- execweave/cursor_hook_cli.py +82 -0
- execweave/cursor_record.py +96 -0
- execweave/filesystem.py +103 -0
- execweave/focus.py +118 -0
- execweave/gemini_adapter.py +265 -0
- execweave/gemini_hook_cli.py +77 -0
- execweave/gemini_record.py +94 -0
- execweave/graph.py +300 -0
- execweave/graph_ops.py +446 -0
- execweave/inference_gateway.py +422 -0
- execweave/inference_gateway_cli.py +106 -0
- execweave/inference_identity.py +76 -0
- execweave/inference_identity_cli.py +60 -0
- execweave/live.py +275 -0
- execweave/model_runtime.py +535 -0
- execweave/model_runtime_cli.py +154 -0
- execweave/opencode_adapter.py +316 -0
- execweave/opencode_hook_cli.py +57 -0
- execweave/opencode_plugin_cli.py +110 -0
- execweave/opencode_record.py +96 -0
- execweave/overhead_benchmark.py +440 -0
- execweave/provider_record.py +215 -0
- execweave/schema.py +62 -0
- execweave/semantic.py +346 -0
- execweave/sink.py +33 -0
- execweave/strace_backend.py +682 -0
- execweave/validate.py +193 -0
- execweave/viewer.py +283 -0
- execweave/workflow.py +114 -0
- execweave-0.6.0.dist-info/METADATA +356 -0
- execweave-0.6.0.dist-info/RECORD +49 -0
- execweave-0.6.0.dist-info/WHEEL +4 -0
- execweave-0.6.0.dist-info/entry_points.txt +17 -0
- execweave-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
_MAX_COMMAND_CHARS = 4096
|
|
13
|
+
_MAX_LABEL_CHARS = 160
|
|
14
|
+
_SUPPORTED_EVENTS = {
|
|
15
|
+
"SessionStart",
|
|
16
|
+
"PreToolUse",
|
|
17
|
+
"PostToolUse",
|
|
18
|
+
"PostToolUseFailure",
|
|
19
|
+
"SubagentStart",
|
|
20
|
+
"SubagentStop",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _now() -> str:
|
|
25
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _entity(
|
|
29
|
+
entity_type: str,
|
|
30
|
+
entity_id: str,
|
|
31
|
+
*,
|
|
32
|
+
name: str | None = None,
|
|
33
|
+
attributes: dict[str, Any] | None = None,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
return {
|
|
36
|
+
"type": entity_type,
|
|
37
|
+
"id": entity_id,
|
|
38
|
+
"name": name,
|
|
39
|
+
"attributes": attributes or {},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _event(
|
|
44
|
+
*,
|
|
45
|
+
timestamp: str,
|
|
46
|
+
event_type: str,
|
|
47
|
+
relation: str,
|
|
48
|
+
source: dict[str, Any],
|
|
49
|
+
target: dict[str, Any],
|
|
50
|
+
attributes: dict[str, Any] | None = None,
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
merged_attributes: dict[str, Any] = {
|
|
53
|
+
"backend": "semantic",
|
|
54
|
+
"attribution": "claude_hook",
|
|
55
|
+
"evidence_source": "provider_hook",
|
|
56
|
+
"provider": "claude",
|
|
57
|
+
"causal": False,
|
|
58
|
+
}
|
|
59
|
+
if attributes:
|
|
60
|
+
merged_attributes.update(attributes)
|
|
61
|
+
return {
|
|
62
|
+
"timestamp": timestamp,
|
|
63
|
+
"event_type": event_type,
|
|
64
|
+
"relation": relation,
|
|
65
|
+
"source": source,
|
|
66
|
+
"target": target,
|
|
67
|
+
"attributes": merged_attributes,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _clean_text(value: object, *, limit: int) -> tuple[str | None, bool]:
|
|
72
|
+
if not isinstance(value, str):
|
|
73
|
+
return None, False
|
|
74
|
+
text = value.replace("\x00", "")
|
|
75
|
+
if len(text) <= limit:
|
|
76
|
+
return text, False
|
|
77
|
+
return text[:limit], True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _main_agent() -> dict[str, Any]:
|
|
81
|
+
# Matches the runtime collector's stable Claude Code agent identity.
|
|
82
|
+
return _entity("agent", "agent:Claude Code", name="Claude Code")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _actor(payload: dict[str, Any]) -> dict[str, Any]:
|
|
86
|
+
agent_id = payload.get("agent_id")
|
|
87
|
+
if isinstance(agent_id, str) and agent_id:
|
|
88
|
+
agent_type = payload.get("agent_type")
|
|
89
|
+
name = agent_type if isinstance(agent_type, str) and agent_type else "Claude subagent"
|
|
90
|
+
session_id = payload.get("session_id")
|
|
91
|
+
scope = session_id if isinstance(session_id, str) and session_id else "unknown"
|
|
92
|
+
return _entity(
|
|
93
|
+
"agent",
|
|
94
|
+
f"agent:claude:{scope}:subagent:{agent_id}",
|
|
95
|
+
name=name,
|
|
96
|
+
attributes={"provider": "claude", "agent_id": agent_id, "agent_type": name},
|
|
97
|
+
)
|
|
98
|
+
return _main_agent()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _common_attributes(payload: dict[str, Any]) -> dict[str, Any]:
|
|
102
|
+
result: dict[str, Any] = {}
|
|
103
|
+
for key in ("session_id", "prompt_id", "cwd", "permission_mode", "agent_id", "agent_type"):
|
|
104
|
+
value = payload.get(key)
|
|
105
|
+
if isinstance(value, (str, int, float, bool)) and value != "":
|
|
106
|
+
result[f"claude_{key}"] = value
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_mcp_tool(tool_name: str) -> tuple[str, str] | None:
|
|
111
|
+
if not tool_name.startswith("mcp__"):
|
|
112
|
+
return None
|
|
113
|
+
body = tool_name[5:]
|
|
114
|
+
if "__" not in body:
|
|
115
|
+
return None
|
|
116
|
+
server, tool = body.split("__", 1)
|
|
117
|
+
if not server or not tool:
|
|
118
|
+
return None
|
|
119
|
+
return server, tool
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _tool_entity(tool_name: str) -> dict[str, Any]:
|
|
123
|
+
mcp = _parse_mcp_tool(tool_name)
|
|
124
|
+
if mcp is None:
|
|
125
|
+
return _entity(
|
|
126
|
+
"tool",
|
|
127
|
+
f"tool:claude:{tool_name}",
|
|
128
|
+
name=tool_name,
|
|
129
|
+
attributes={"provider": "claude", "native_name": tool_name},
|
|
130
|
+
)
|
|
131
|
+
server, tool = mcp
|
|
132
|
+
return _entity(
|
|
133
|
+
"tool",
|
|
134
|
+
f"tool:mcp:{server}:{tool}",
|
|
135
|
+
name=tool,
|
|
136
|
+
attributes={
|
|
137
|
+
"provider": "claude",
|
|
138
|
+
"native_name": tool_name,
|
|
139
|
+
"mcp_server": server,
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _tool_call_entity(payload: dict[str, Any], tool_name: str) -> dict[str, Any]:
|
|
145
|
+
session_id = payload.get("session_id")
|
|
146
|
+
tool_use_id = payload.get("tool_use_id")
|
|
147
|
+
session = session_id if isinstance(session_id, str) and session_id else "unknown"
|
|
148
|
+
use_id = tool_use_id if isinstance(tool_use_id, str) and tool_use_id else "unknown"
|
|
149
|
+
attrs = _common_attributes(payload)
|
|
150
|
+
attrs.update({"provider": "claude", "tool_name": tool_name, "tool_use_id": use_id})
|
|
151
|
+
tool_input = payload.get("tool_input")
|
|
152
|
+
if isinstance(tool_input, dict):
|
|
153
|
+
attrs["input_keys"] = sorted(str(key) for key in tool_input)
|
|
154
|
+
return _entity(
|
|
155
|
+
"tool_call",
|
|
156
|
+
f"tool-call:claude:{session}:{use_id}",
|
|
157
|
+
name=tool_name,
|
|
158
|
+
attributes=attrs,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _command_entity(tool_input: dict[str, Any]) -> dict[str, Any] | None:
|
|
163
|
+
command, truncated = _clean_text(tool_input.get("command"), limit=_MAX_COMMAND_CHARS)
|
|
164
|
+
if not command:
|
|
165
|
+
return None
|
|
166
|
+
digest = hashlib.sha256(command.encode("utf-8", errors="replace")).hexdigest()
|
|
167
|
+
label, _ = _clean_text(command.replace("\n", " "), limit=_MAX_LABEL_CHARS)
|
|
168
|
+
return _entity(
|
|
169
|
+
"command",
|
|
170
|
+
f"command:sha256:{digest}",
|
|
171
|
+
name=label,
|
|
172
|
+
attributes={"command": command, "truncated": truncated},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _declared_file_entity(payload: dict[str, Any], tool_input: dict[str, Any]) -> dict[str, Any] | None:
|
|
177
|
+
raw = tool_input.get("file_path")
|
|
178
|
+
if not isinstance(raw, str) or not raw:
|
|
179
|
+
raw = tool_input.get("path")
|
|
180
|
+
if not isinstance(raw, str) or not raw:
|
|
181
|
+
return None
|
|
182
|
+
candidate = Path(raw).expanduser()
|
|
183
|
+
if not candidate.is_absolute():
|
|
184
|
+
cwd = payload.get("cwd")
|
|
185
|
+
if isinstance(cwd, str) and cwd:
|
|
186
|
+
candidate = Path(cwd) / candidate
|
|
187
|
+
try:
|
|
188
|
+
normalized = candidate.resolve(strict=False)
|
|
189
|
+
except OSError:
|
|
190
|
+
normalized = candidate.absolute()
|
|
191
|
+
return _entity(
|
|
192
|
+
"file",
|
|
193
|
+
f"file:{normalized}",
|
|
194
|
+
name=normalized.name or str(normalized),
|
|
195
|
+
attributes={"declared_by_provider_hook": True},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _tool_pre_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
|
|
200
|
+
tool_name = payload.get("tool_name")
|
|
201
|
+
tool_use_id = payload.get("tool_use_id")
|
|
202
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
203
|
+
raise ValueError("PreToolUse requires tool_name")
|
|
204
|
+
if not isinstance(tool_use_id, str) or not tool_use_id:
|
|
205
|
+
raise ValueError("PreToolUse requires tool_use_id")
|
|
206
|
+
|
|
207
|
+
actor = _actor(payload)
|
|
208
|
+
call = _tool_call_entity(payload, tool_name)
|
|
209
|
+
tool = _tool_entity(tool_name)
|
|
210
|
+
common = _common_attributes(payload)
|
|
211
|
+
events = [
|
|
212
|
+
_event(
|
|
213
|
+
timestamp=timestamp,
|
|
214
|
+
event_type="semantic.claude.tool.requested",
|
|
215
|
+
relation="REQUESTED_TOOL_CALL",
|
|
216
|
+
source=actor,
|
|
217
|
+
target=call,
|
|
218
|
+
attributes=common,
|
|
219
|
+
),
|
|
220
|
+
_event(
|
|
221
|
+
timestamp=timestamp,
|
|
222
|
+
event_type="semantic.claude.tool.selected",
|
|
223
|
+
relation="USES_TOOL",
|
|
224
|
+
source=call,
|
|
225
|
+
target=tool,
|
|
226
|
+
attributes=common,
|
|
227
|
+
),
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
mcp = _parse_mcp_tool(tool_name)
|
|
231
|
+
if mcp is not None:
|
|
232
|
+
server, _ = mcp
|
|
233
|
+
mcp_entity = _entity(
|
|
234
|
+
"mcp_server",
|
|
235
|
+
f"mcp-server:claude:{server}",
|
|
236
|
+
name=server,
|
|
237
|
+
attributes={"provider": "claude", "server_segment": server},
|
|
238
|
+
)
|
|
239
|
+
events.extend(
|
|
240
|
+
[
|
|
241
|
+
_event(
|
|
242
|
+
timestamp=timestamp,
|
|
243
|
+
event_type="semantic.claude.mcp.call",
|
|
244
|
+
relation="VIA_MCP",
|
|
245
|
+
source=call,
|
|
246
|
+
target=mcp_entity,
|
|
247
|
+
attributes=common,
|
|
248
|
+
),
|
|
249
|
+
_event(
|
|
250
|
+
timestamp=timestamp,
|
|
251
|
+
event_type="semantic.claude.mcp.tool",
|
|
252
|
+
relation="EXPOSES_TOOL",
|
|
253
|
+
source=mcp_entity,
|
|
254
|
+
target=tool,
|
|
255
|
+
attributes=common,
|
|
256
|
+
),
|
|
257
|
+
]
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
tool_input = payload.get("tool_input")
|
|
261
|
+
if isinstance(tool_input, dict):
|
|
262
|
+
if tool_name in {"Bash", "PowerShell"}:
|
|
263
|
+
command = _command_entity(tool_input)
|
|
264
|
+
if command is not None:
|
|
265
|
+
events.append(
|
|
266
|
+
_event(
|
|
267
|
+
timestamp=timestamp,
|
|
268
|
+
event_type="semantic.claude.command.declared",
|
|
269
|
+
relation="DECLARED_COMMAND",
|
|
270
|
+
source=call,
|
|
271
|
+
target=command,
|
|
272
|
+
attributes=common,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
if tool_name in {"Read", "Edit", "Write", "NotebookEdit"}:
|
|
276
|
+
target_file = _declared_file_entity(payload, tool_input)
|
|
277
|
+
if target_file is not None:
|
|
278
|
+
events.append(
|
|
279
|
+
_event(
|
|
280
|
+
timestamp=timestamp,
|
|
281
|
+
event_type="semantic.claude.file.declared",
|
|
282
|
+
relation="DECLARED_TARGET",
|
|
283
|
+
source=call,
|
|
284
|
+
target=target_file,
|
|
285
|
+
attributes=common,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
return events
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _tool_result_events(
|
|
292
|
+
payload: dict[str, Any],
|
|
293
|
+
*,
|
|
294
|
+
timestamp: str,
|
|
295
|
+
success: bool,
|
|
296
|
+
) -> list[dict[str, Any]]:
|
|
297
|
+
tool_name = payload.get("tool_name")
|
|
298
|
+
tool_use_id = payload.get("tool_use_id")
|
|
299
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
300
|
+
raise ValueError("tool result hook requires tool_name")
|
|
301
|
+
if not isinstance(tool_use_id, str) or not tool_use_id:
|
|
302
|
+
raise ValueError("tool result hook requires tool_use_id")
|
|
303
|
+
call = _tool_call_entity(payload, tool_name)
|
|
304
|
+
tool = _tool_entity(tool_name)
|
|
305
|
+
attrs = _common_attributes(payload)
|
|
306
|
+
duration = payload.get("duration_ms")
|
|
307
|
+
if isinstance(duration, (int, float)) and not isinstance(duration, bool):
|
|
308
|
+
attrs["duration_ms"] = duration
|
|
309
|
+
if not success:
|
|
310
|
+
attrs["is_interrupt"] = bool(payload.get("is_interrupt", False))
|
|
311
|
+
error, truncated = _clean_text(payload.get("error"), limit=1024)
|
|
312
|
+
if error:
|
|
313
|
+
attrs["error_summary"] = error
|
|
314
|
+
attrs["error_summary_truncated"] = truncated
|
|
315
|
+
return [
|
|
316
|
+
_event(
|
|
317
|
+
timestamp=timestamp,
|
|
318
|
+
event_type=(
|
|
319
|
+
"semantic.claude.tool.succeeded" if success else "semantic.claude.tool.failed"
|
|
320
|
+
),
|
|
321
|
+
relation="TOOL_CALL_SUCCEEDED" if success else "TOOL_CALL_FAILED",
|
|
322
|
+
source=call,
|
|
323
|
+
target=tool,
|
|
324
|
+
attributes=attrs,
|
|
325
|
+
)
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _session_start_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
|
|
330
|
+
model = payload.get("model")
|
|
331
|
+
if not isinstance(model, str) or not model:
|
|
332
|
+
return []
|
|
333
|
+
return [
|
|
334
|
+
_event(
|
|
335
|
+
timestamp=timestamp,
|
|
336
|
+
event_type="semantic.claude.model.observed",
|
|
337
|
+
relation="USED_MODEL",
|
|
338
|
+
source=_main_agent(),
|
|
339
|
+
target=_entity("model", f"model:claude:{model}", name=model, attributes={"provider": "claude"}),
|
|
340
|
+
attributes=_common_attributes(payload),
|
|
341
|
+
)
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _subagent_start_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
|
|
346
|
+
agent_id = payload.get("agent_id")
|
|
347
|
+
if not isinstance(agent_id, str) or not agent_id:
|
|
348
|
+
raise ValueError("SubagentStart requires agent_id")
|
|
349
|
+
child = _actor(payload)
|
|
350
|
+
return [
|
|
351
|
+
_event(
|
|
352
|
+
timestamp=timestamp,
|
|
353
|
+
event_type="semantic.claude.subagent.started",
|
|
354
|
+
relation="SPAWNED_SUBAGENT",
|
|
355
|
+
source=_main_agent(),
|
|
356
|
+
target=child,
|
|
357
|
+
attributes=_common_attributes(payload),
|
|
358
|
+
)
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _subagent_stop_events(payload: dict[str, Any], *, timestamp: str) -> list[dict[str, Any]]:
|
|
363
|
+
agent_id = payload.get("agent_id")
|
|
364
|
+
if not isinstance(agent_id, str) or not agent_id:
|
|
365
|
+
raise ValueError("SubagentStop requires agent_id")
|
|
366
|
+
child = _actor(payload)
|
|
367
|
+
return [
|
|
368
|
+
_event(
|
|
369
|
+
timestamp=timestamp,
|
|
370
|
+
event_type="semantic.claude.subagent.finished",
|
|
371
|
+
relation="RETURNED_TO",
|
|
372
|
+
source=child,
|
|
373
|
+
target=_main_agent(),
|
|
374
|
+
attributes=_common_attributes(payload),
|
|
375
|
+
)
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def claude_hook_to_semantic_events(
|
|
380
|
+
payload: dict[str, Any],
|
|
381
|
+
*,
|
|
382
|
+
timestamp: str | None = None,
|
|
383
|
+
) -> list[dict[str, Any]]:
|
|
384
|
+
hook_event = payload.get("hook_event_name")
|
|
385
|
+
if not isinstance(hook_event, str) or not hook_event:
|
|
386
|
+
raise ValueError("Claude hook payload requires hook_event_name")
|
|
387
|
+
if hook_event not in _SUPPORTED_EVENTS:
|
|
388
|
+
return []
|
|
389
|
+
observed_at = timestamp or _now()
|
|
390
|
+
if hook_event == "SessionStart":
|
|
391
|
+
return _session_start_events(payload, timestamp=observed_at)
|
|
392
|
+
if hook_event == "PreToolUse":
|
|
393
|
+
return _tool_pre_events(payload, timestamp=observed_at)
|
|
394
|
+
if hook_event == "PostToolUse":
|
|
395
|
+
return _tool_result_events(payload, timestamp=observed_at, success=True)
|
|
396
|
+
if hook_event == "PostToolUseFailure":
|
|
397
|
+
return _tool_result_events(payload, timestamp=observed_at, success=False)
|
|
398
|
+
if hook_event == "SubagentStart":
|
|
399
|
+
return _subagent_start_events(payload, timestamp=observed_at)
|
|
400
|
+
if hook_event == "SubagentStop":
|
|
401
|
+
return _subagent_stop_events(payload, timestamp=observed_at)
|
|
402
|
+
return []
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def append_semantic_records(path: str | Path, records: list[dict[str, Any]]) -> Path:
|
|
406
|
+
output = Path(path).expanduser().resolve()
|
|
407
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
408
|
+
if not records:
|
|
409
|
+
return output
|
|
410
|
+
blob = "".join(
|
|
411
|
+
json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
|
|
412
|
+
for record in records
|
|
413
|
+
)
|
|
414
|
+
lock_dir = output.with_name(output.name + ".lock")
|
|
415
|
+
deadline = time.monotonic() + 5.0
|
|
416
|
+
while True:
|
|
417
|
+
try:
|
|
418
|
+
lock_dir.mkdir()
|
|
419
|
+
break
|
|
420
|
+
except FileExistsError:
|
|
421
|
+
if time.monotonic() >= deadline:
|
|
422
|
+
raise TimeoutError(f"timed out waiting for semantic sidecar lock: {lock_dir}")
|
|
423
|
+
time.sleep(0.01)
|
|
424
|
+
try:
|
|
425
|
+
with output.open("a", encoding="utf-8", newline="\n") as handle:
|
|
426
|
+
handle.write(blob)
|
|
427
|
+
handle.flush()
|
|
428
|
+
os.fsync(handle.fileno())
|
|
429
|
+
finally:
|
|
430
|
+
try:
|
|
431
|
+
lock_dir.rmdir()
|
|
432
|
+
except OSError:
|
|
433
|
+
pass
|
|
434
|
+
return output
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def read_hook_payload(stream: Any = None) -> dict[str, Any]:
|
|
438
|
+
source = stream if stream is not None else sys.stdin
|
|
439
|
+
raw = source.read()
|
|
440
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
441
|
+
raise ValueError("Claude hook stdin is empty")
|
|
442
|
+
try:
|
|
443
|
+
payload = json.loads(raw)
|
|
444
|
+
except json.JSONDecodeError as exc:
|
|
445
|
+
raise ValueError(f"Claude hook stdin is invalid JSON: {exc.msg}") from exc
|
|
446
|
+
if not isinstance(payload, dict):
|
|
447
|
+
raise ValueError("Claude hook stdin must be one JSON object")
|
|
448
|
+
return payload
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .claude_adapter import append_semantic_records, claude_hook_to_semantic_events, read_hook_payload
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _hook_handler(command: str) -> dict[str, str]:
|
|
14
|
+
return {"type": "command", "command": command}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def claude_hook_config(command: str = "execweave-claude-hook") -> dict[str, Any]:
|
|
18
|
+
handler = _hook_handler(command)
|
|
19
|
+
tool_group = {"matcher": "*", "hooks": [handler]}
|
|
20
|
+
plain_group = {"hooks": [handler]}
|
|
21
|
+
return {
|
|
22
|
+
"hooks": {
|
|
23
|
+
"SessionStart": [plain_group],
|
|
24
|
+
"PreToolUse": [tool_group],
|
|
25
|
+
"PostToolUse": [tool_group],
|
|
26
|
+
"PostToolUseFailure": [tool_group],
|
|
27
|
+
"SubagentStart": [plain_group],
|
|
28
|
+
"SubagentStop": [plain_group],
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_sidecar(payload: dict[str, Any]) -> Path:
|
|
34
|
+
cwd = payload.get("cwd")
|
|
35
|
+
session_id = payload.get("session_id")
|
|
36
|
+
if not isinstance(cwd, str) or not cwd:
|
|
37
|
+
raise ValueError("Claude hook payload has no cwd for automatic sidecar placement")
|
|
38
|
+
if not isinstance(session_id, str) or not session_id:
|
|
39
|
+
raise ValueError("Claude hook payload has no session_id for automatic sidecar placement")
|
|
40
|
+
safe_session = "".join(
|
|
41
|
+
character if character.isalnum() or character in {"-", "_", "."} else "_"
|
|
42
|
+
for character in session_id
|
|
43
|
+
)
|
|
44
|
+
return Path(cwd) / ".execweave" / "semantic" / "claude" / f"{safe_session}.jsonl"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="execweave-claude-hook",
|
|
50
|
+
description="Capture Claude Code hook input as local ExecWeave semantic telemetry.",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--sidecar",
|
|
54
|
+
type=Path,
|
|
55
|
+
default=None,
|
|
56
|
+
help=(
|
|
57
|
+
"Semantic JSONL output path. Defaults to EXECWEAVE_SEMANTIC_SIDECAR, then "
|
|
58
|
+
"<cwd>/.execweave/semantic/claude/<Claude-session-id>.jsonl."
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--strict",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="Return non-zero on telemetry errors. Default is fail-open so tracing cannot block Claude.",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--print-config",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="Print a Claude Code settings fragment for the supported ExecWeave hooks and exit.",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--command",
|
|
73
|
+
default="execweave-claude-hook",
|
|
74
|
+
help="Hook command embedded by --print-config (default: execweave-claude-hook).",
|
|
75
|
+
)
|
|
76
|
+
return parser
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv: list[str] | None = None) -> int:
|
|
80
|
+
parser = build_parser()
|
|
81
|
+
args = parser.parse_args(argv)
|
|
82
|
+
if args.print_config:
|
|
83
|
+
print(json.dumps(claude_hook_config(args.command), indent=2, sort_keys=True))
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
payload = read_hook_payload()
|
|
88
|
+
sidecar = args.sidecar
|
|
89
|
+
if sidecar is None:
|
|
90
|
+
configured = os.environ.get("EXECWEAVE_SEMANTIC_SIDECAR")
|
|
91
|
+
sidecar = Path(configured) if configured else _default_sidecar(payload)
|
|
92
|
+
records = claude_hook_to_semantic_events(payload)
|
|
93
|
+
append_semantic_records(sidecar, records)
|
|
94
|
+
except (OSError, TimeoutError, ValueError) as exc:
|
|
95
|
+
print(f"ExecWeave Claude hook warning: {exc}", file=sys.stderr)
|
|
96
|
+
return 1 if args.strict else 0
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .backends import BackendName
|
|
8
|
+
from .provider_record import ProviderRecordResult, record_provider_to_viewer
|
|
9
|
+
|
|
10
|
+
ClaudeRecordResult = ProviderRecordResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def record_claude_to_viewer(
|
|
14
|
+
command: list[str],
|
|
15
|
+
*,
|
|
16
|
+
watch_root: str | Path,
|
|
17
|
+
output_dir: str | Path | None = None,
|
|
18
|
+
backend: BackendName = "auto",
|
|
19
|
+
poll_interval: float = 0.10,
|
|
20
|
+
collect_filesystem: bool = True,
|
|
21
|
+
collect_network: bool = True,
|
|
22
|
+
keep_raw_trace: bool = False,
|
|
23
|
+
correlation_window_ms: int = 3000,
|
|
24
|
+
open_browser: bool = False,
|
|
25
|
+
) -> ClaudeRecordResult:
|
|
26
|
+
"""Record one Claude run using the shared provider-record pipeline."""
|
|
27
|
+
return record_provider_to_viewer(
|
|
28
|
+
command,
|
|
29
|
+
provider_name="Claude",
|
|
30
|
+
watch_root=watch_root,
|
|
31
|
+
output_dir=output_dir,
|
|
32
|
+
backend=backend,
|
|
33
|
+
poll_interval=poll_interval,
|
|
34
|
+
collect_filesystem=collect_filesystem,
|
|
35
|
+
collect_network=collect_network,
|
|
36
|
+
keep_raw_trace=keep_raw_trace,
|
|
37
|
+
correlation_window_ms=correlation_window_ms,
|
|
38
|
+
open_browser=open_browser,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _clean_command(command: list[str]) -> list[str]:
|
|
43
|
+
result = list(command)
|
|
44
|
+
if result and result[0] == "--":
|
|
45
|
+
result = result[1:]
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="execweave-claude-record",
|
|
52
|
+
description=(
|
|
53
|
+
"Record runtime evidence, Claude Code hook telemetry, and conservative "
|
|
54
|
+
"Tool-to-Process correlation in one local run."
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument("--watch-root", type=Path, default=None)
|
|
58
|
+
parser.add_argument("--output-dir", type=Path, default=None)
|
|
59
|
+
parser.add_argument("--interval", type=float, default=0.10)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--backend",
|
|
62
|
+
choices=["auto", "portable", "strace"],
|
|
63
|
+
default="auto",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--correlation-window-ms",
|
|
67
|
+
type=int,
|
|
68
|
+
default=3000,
|
|
69
|
+
help="maximum Tool-to-Process correlation window in milliseconds (default: 3000)",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument("--no-files", action="store_true")
|
|
72
|
+
parser.add_argument("--no-network", action="store_true")
|
|
73
|
+
parser.add_argument("--keep-native-trace", action="store_true")
|
|
74
|
+
parser.add_argument("--open", action="store_true", dest="open_browser")
|
|
75
|
+
parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
76
|
+
return parser
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv: list[str] | None = None) -> int:
|
|
80
|
+
parser = build_parser()
|
|
81
|
+
args = parser.parse_args(argv)
|
|
82
|
+
command = _clean_command(args.command)
|
|
83
|
+
if not command:
|
|
84
|
+
parser.error("a Claude command is required, e.g. execweave-claude-record --open -- claude")
|
|
85
|
+
watch_root = (args.watch_root or Path.cwd()).expanduser().resolve()
|
|
86
|
+
try:
|
|
87
|
+
result = record_claude_to_viewer(
|
|
88
|
+
command,
|
|
89
|
+
watch_root=watch_root,
|
|
90
|
+
output_dir=args.output_dir,
|
|
91
|
+
backend=args.backend,
|
|
92
|
+
poll_interval=args.interval,
|
|
93
|
+
collect_filesystem=not args.no_files,
|
|
94
|
+
collect_network=not args.no_network,
|
|
95
|
+
keep_raw_trace=args.keep_native_trace,
|
|
96
|
+
correlation_window_ms=args.correlation_window_ms,
|
|
97
|
+
open_browser=args.open_browser,
|
|
98
|
+
)
|
|
99
|
+
except (FileExistsError, RuntimeError, ValueError, OSError) as exc:
|
|
100
|
+
parser.error(str(exc))
|
|
101
|
+
print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
|
|
102
|
+
return result.runtime.return_code
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
raise SystemExit(main())
|