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.
Files changed (49) hide show
  1. execweave/__init__.py +3 -0
  2. execweave/__main__.py +5 -0
  3. execweave/analysis.py +406 -0
  4. execweave/backends.py +63 -0
  5. execweave/benchmark.py +80 -0
  6. execweave/claude_adapter.py +448 -0
  7. execweave/claude_hook_cli.py +101 -0
  8. execweave/claude_record.py +106 -0
  9. execweave/cli.py +588 -0
  10. execweave/codex_adapter.py +314 -0
  11. execweave/codex_hook_cli.py +98 -0
  12. execweave/codex_record.py +111 -0
  13. execweave/collector.py +301 -0
  14. execweave/correlation.py +604 -0
  15. execweave/cursor_adapter.py +347 -0
  16. execweave/cursor_hook_cli.py +82 -0
  17. execweave/cursor_record.py +96 -0
  18. execweave/filesystem.py +103 -0
  19. execweave/focus.py +118 -0
  20. execweave/gemini_adapter.py +265 -0
  21. execweave/gemini_hook_cli.py +77 -0
  22. execweave/gemini_record.py +94 -0
  23. execweave/graph.py +300 -0
  24. execweave/graph_ops.py +446 -0
  25. execweave/inference_gateway.py +422 -0
  26. execweave/inference_gateway_cli.py +106 -0
  27. execweave/inference_identity.py +76 -0
  28. execweave/inference_identity_cli.py +60 -0
  29. execweave/live.py +275 -0
  30. execweave/model_runtime.py +535 -0
  31. execweave/model_runtime_cli.py +154 -0
  32. execweave/opencode_adapter.py +316 -0
  33. execweave/opencode_hook_cli.py +57 -0
  34. execweave/opencode_plugin_cli.py +110 -0
  35. execweave/opencode_record.py +96 -0
  36. execweave/overhead_benchmark.py +440 -0
  37. execweave/provider_record.py +215 -0
  38. execweave/schema.py +62 -0
  39. execweave/semantic.py +346 -0
  40. execweave/sink.py +33 -0
  41. execweave/strace_backend.py +682 -0
  42. execweave/validate.py +193 -0
  43. execweave/viewer.py +283 -0
  44. execweave/workflow.py +114 -0
  45. execweave-0.6.0.dist-info/METADATA +356 -0
  46. execweave-0.6.0.dist-info/RECORD +49 -0
  47. execweave-0.6.0.dist-info/WHEEL +4 -0
  48. execweave-0.6.0.dist-info/entry_points.txt +17 -0
  49. 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())