agent-trace-cli 0.1.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 (42) hide show
  1. agent_trace/__init__.py +3 -0
  2. agent_trace/blame.py +471 -0
  3. agent_trace/blame_git.py +133 -0
  4. agent_trace/blame_meta.py +167 -0
  5. agent_trace/cli.py +2680 -0
  6. agent_trace/commit_link.py +226 -0
  7. agent_trace/config.py +137 -0
  8. agent_trace/context.py +385 -0
  9. agent_trace/conversations.py +358 -0
  10. agent_trace/git_notes.py +491 -0
  11. agent_trace/hooks/__init__.py +163 -0
  12. agent_trace/hooks/base.py +233 -0
  13. agent_trace/hooks/claude.py +483 -0
  14. agent_trace/hooks/codex.py +232 -0
  15. agent_trace/hooks/cursor.py +278 -0
  16. agent_trace/hooks/git.py +144 -0
  17. agent_trace/ledger.py +955 -0
  18. agent_trace/models.py +618 -0
  19. agent_trace/record.py +417 -0
  20. agent_trace/registry.py +230 -0
  21. agent_trace/remote.py +673 -0
  22. agent_trace/rewrite.py +176 -0
  23. agent_trace/rules.py +209 -0
  24. agent_trace/schemas/commit-link.schema.json +34 -0
  25. agent_trace/schemas/git-note.schema.json +88 -0
  26. agent_trace/schemas/ledger.schema.json +97 -0
  27. agent_trace/schemas/remotes.schema.json +30 -0
  28. agent_trace/schemas/sync-state.schema.json +49 -0
  29. agent_trace/schemas/trace-record.schema.json +130 -0
  30. agent_trace/session.py +136 -0
  31. agent_trace/storage.py +229 -0
  32. agent_trace/summary.py +465 -0
  33. agent_trace/summary_presets.py +188 -0
  34. agent_trace/sync.py +1182 -0
  35. agent_trace/telemetry.py +142 -0
  36. agent_trace/trace.py +460 -0
  37. agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
  38. agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
  39. agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
  40. agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
  41. agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  42. agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,483 @@
1
+ """
2
+ Claude Code adapter — hooks injection, event translators, summary
3
+ lifecycle, and the Claude-specific session-model tracking helpers.
4
+
5
+ Claude's ``PostToolUse`` hook payload doesn't include a model id, so we
6
+ keep a per-session model cache:
7
+ - ``SessionStart`` writes the model into the cache
8
+ - ``Stop`` / ``stop`` runs the pre-summary hook to refresh the cache
9
+ from the transcript tail (handles mid-session ``/model`` changes)
10
+ - ``PostToolUse`` reads the cached model
11
+
12
+ All of this lives here, not in ``record.py``, so adding a new agent
13
+ never requires touching Claude code.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+
22
+ from .base import AGENT_TRACE_CMD, CodingAgentAdapter
23
+
24
+
25
+ CLAUDE_SETTINGS_FILE = ".claude/settings.json"
26
+ CLAUDE_GLOBAL_SETTINGS_FILE = Path.home() / ".claude" / "settings.json"
27
+
28
+ # Claude session JSONL can be large; only scan the tail for the latest
29
+ # ``message.model`` field.
30
+ _TRANSCRIPT_TAIL_BYTES = 512 * 1024
31
+
32
+
33
+ # ---------------------------------------------------------------------
34
+ # Claude session-model cache (persisted under
35
+ # ``<AGENT_TRACE_HOME>/projects/<id>/session-state.json`` as
36
+ # ``model:<session_id>``).
37
+ # ---------------------------------------------------------------------
38
+
39
+ def _remember_session_model(session_id: str, model: str | None, project_dir: str | None = None) -> None:
40
+ from ..storage import (
41
+ ensure_project_dir,
42
+ get_session_state_path,
43
+ resolve_project_id,
44
+ )
45
+ from ..trace import get_workspace_root
46
+
47
+ if not session_id or not model:
48
+ return
49
+ if project_dir is None:
50
+ project_dir = get_workspace_root()
51
+ pid = resolve_project_id(project_dir, create=True)
52
+ if not pid:
53
+ return
54
+ ensure_project_dir(pid)
55
+ state_path = get_session_state_path(pid)
56
+ state_path.parent.mkdir(parents=True, exist_ok=True)
57
+ state: dict = {}
58
+ if state_path.exists():
59
+ try:
60
+ state = json.loads(state_path.read_text())
61
+ except (json.JSONDecodeError, OSError):
62
+ state = {}
63
+ state[f"model:{session_id}"] = model
64
+ try:
65
+ state_path.write_text(json.dumps(state))
66
+ except OSError:
67
+ pass
68
+
69
+
70
+ def _read_transcript_tail_text(path: Path, max_bytes: int) -> str:
71
+ try:
72
+ size = path.stat().st_size
73
+ if size <= max_bytes:
74
+ return path.read_text(encoding="utf-8", errors="replace")
75
+ with open(path, "rb") as f:
76
+ f.seek(max(0, size - max_bytes))
77
+ f.readline() # discard incomplete first line
78
+ return f.read().decode("utf-8", errors="replace")
79
+ except OSError:
80
+ return ""
81
+
82
+
83
+ def _extract_model_from_transcript_obj(obj: object, depth: int = 0) -> str | None:
84
+ if depth > 8 or not isinstance(obj, dict):
85
+ return None
86
+ msg = obj.get("message")
87
+ if isinstance(msg, dict):
88
+ for key in ("model", "modelId", "model_id"):
89
+ v = msg.get(key)
90
+ if isinstance(v, str) and v.strip():
91
+ return v.strip()
92
+ inner = _extract_model_from_transcript_obj(msg, depth + 1)
93
+ if inner:
94
+ return inner
95
+ for key in ("model", "modelId", "model_id"):
96
+ v = obj.get(key)
97
+ if isinstance(v, str) and v.strip():
98
+ return v.strip()
99
+ for nest in ("session", "metadata", "config"):
100
+ inner = obj.get(nest)
101
+ if isinstance(inner, dict):
102
+ got = _extract_model_from_transcript_obj(inner, depth + 1)
103
+ if got:
104
+ return got
105
+ return None
106
+
107
+
108
+ def _model_from_transcript_tail(
109
+ transcript_path: str | None,
110
+ *,
111
+ max_bytes: int = _TRANSCRIPT_TAIL_BYTES,
112
+ ) -> str | None:
113
+ """Latest model id seen in a Claude transcript JSONL file."""
114
+ if not transcript_path:
115
+ return None
116
+ try:
117
+ p = Path(transcript_path)
118
+ if not p.is_file():
119
+ return None
120
+ text = _read_transcript_tail_text(p, max_bytes)
121
+ if not text:
122
+ return None
123
+ for line in reversed(text.splitlines()):
124
+ line = line.strip()
125
+ if not line.startswith("{"):
126
+ continue
127
+ try:
128
+ obj = json.loads(line)
129
+ except json.JSONDecodeError:
130
+ continue
131
+ m = _extract_model_from_transcript_obj(obj)
132
+ if m:
133
+ return m
134
+ return None
135
+ except OSError:
136
+ return None
137
+
138
+
139
+ def _session_model_from_state(session_id: str, project_dir: str | None = None) -> str | None:
140
+ from ..storage import get_session_state_path, resolve_project_id
141
+ from ..trace import get_workspace_root
142
+
143
+ if not session_id:
144
+ return None
145
+ if project_dir is None:
146
+ project_dir = get_workspace_root()
147
+ pid = resolve_project_id(project_dir, create=False)
148
+ if not pid:
149
+ return None
150
+ state_path = get_session_state_path(pid)
151
+ if not state_path.exists():
152
+ return None
153
+ try:
154
+ state = json.loads(state_path.read_text())
155
+ except (json.JSONDecodeError, OSError):
156
+ return None
157
+ v = state.get(f"model:{session_id}")
158
+ return str(v) if v else None
159
+
160
+
161
+ def _claude_model(d: dict, project_dir: str | None) -> str | None:
162
+ """Resolve model: hook fields, then transcript, then cached SessionStart."""
163
+ from ..record import transcript_path_from_hook
164
+
165
+ m = d.get("model") or d.get("model_id")
166
+ if m:
167
+ return str(m)
168
+ sid = d.get("session_id") or ""
169
+ m = _model_from_transcript_tail(transcript_path_from_hook(d))
170
+ if m:
171
+ if sid and project_dir:
172
+ prev = _session_model_from_state(sid, project_dir)
173
+ if prev != m:
174
+ _remember_session_model(sid, m, project_dir)
175
+ return m
176
+ return _session_model_from_state(sid, project_dir)
177
+
178
+
179
+ # ---------------------------------------------------------------------
180
+ # Event translators (hook payload → trace record)
181
+ # ---------------------------------------------------------------------
182
+
183
+ def _PostToolUse(d):
184
+ from ..record import (
185
+ _file_existed_before,
186
+ _get_next_sequence,
187
+ _ranges_from_edit,
188
+ _ranges_from_multiedit,
189
+ _ranges_from_notebook,
190
+ _ranges_from_write,
191
+ transcript_path_from_hook,
192
+ )
193
+ from ..trace import create_trace, resolve_file_project
194
+
195
+ tn = d.get("tool_name", "")
196
+ if tn == "Bash":
197
+ ti = d.get("tool_input", {})
198
+ session_id = d.get("session_id") or ""
199
+ anchor = ti.get("cwd") or d.get("cwd") or os.getcwd()
200
+ res = resolve_file_project(".shell-history", anchor_path=anchor)
201
+ seq = _get_next_sequence(session_id, res.repo_root if res else None) if session_id else None
202
+ return create_trace(
203
+ "ai", ".shell-history",
204
+ model=_claude_model(d, res.repo_root if res else None),
205
+ transcript=transcript_path_from_hook(d),
206
+ metadata={
207
+ "session_id": d.get("session_id"),
208
+ "tool_name": tn,
209
+ "tool_use_id": d.get("tool_use_id"),
210
+ "command": ti.get("command"),
211
+ },
212
+ edit_sequence=seq,
213
+ anchor_path=anchor,
214
+ resolution=res,
215
+ ), "PostToolUse"
216
+
217
+ ti = d.get("tool_input", {})
218
+ fp = ti.get("file_path") or ti.get("notebook_path") or ".unknown"
219
+ anchor = d.get("cwd") or os.getcwd()
220
+
221
+ if tn == "Write":
222
+ existed = _file_existed_before(fp)
223
+ rp, rc = _ranges_from_write(fp, ti.get("content", ""))
224
+ meta_extra: dict = {"is_creation": not existed}
225
+ elif tn == "Edit":
226
+ rp, rc = _ranges_from_edit(fp, ti.get("old_string", ""), ti.get("new_string", ""))
227
+ meta_extra = {}
228
+ elif tn == "MultiEdit":
229
+ rp, rc = _ranges_from_multiedit(fp, ti.get("edits", []))
230
+ meta_extra = {}
231
+ elif tn == "NotebookEdit":
232
+ fp = ti.get("notebook_path", fp)
233
+ rp, rc = _ranges_from_notebook(fp, ti)
234
+ cell_id = ti.get("cell_id")
235
+ meta_extra = {"cell_id": cell_id} if cell_id is not None else {}
236
+ else:
237
+ return None, "PostToolUse"
238
+
239
+ if rp is None or rc is None:
240
+ return None, "PostToolUse"
241
+
242
+ session_id = d.get("session_id") or ""
243
+ res = resolve_file_project(fp, anchor_path=anchor)
244
+ seq = _get_next_sequence(session_id, res.repo_root if res else None) if session_id else None
245
+
246
+ metadata: dict = {
247
+ "session_id": d.get("session_id"),
248
+ "tool_name": tn,
249
+ "tool_use_id": d.get("tool_use_id"),
250
+ }
251
+ metadata = {k: v for k, v in metadata.items() if v is not None}
252
+ metadata.update(meta_extra)
253
+
254
+ return create_trace(
255
+ "ai", fp,
256
+ model=_claude_model(d, res.repo_root if res else None),
257
+ range_positions=rp,
258
+ range_contents=rc,
259
+ transcript=transcript_path_from_hook(d),
260
+ metadata=metadata,
261
+ edit_sequence=seq,
262
+ anchor_path=anchor,
263
+ resolution=res,
264
+ ), "PostToolUse"
265
+
266
+
267
+ def _SessionStart(d):
268
+ from ..trace import create_trace, resolve_file_project
269
+
270
+ anchor = d.get("cwd") or os.getcwd()
271
+ res = resolve_file_project(".sessions", anchor_path=anchor)
272
+ repo = res.repo_root if res else None
273
+ m = d.get("model") or d.get("model_id")
274
+ if d.get("session_id") and m and repo:
275
+ _remember_session_model(str(d["session_id"]), str(m), repo)
276
+ return create_trace(
277
+ "ai", ".sessions",
278
+ model=_claude_model(d, repo),
279
+ metadata={
280
+ "event": "session_start",
281
+ "session_id": d.get("session_id"),
282
+ "source": d.get("source"),
283
+ },
284
+ anchor_path=anchor,
285
+ resolution=res,
286
+ ), "SessionStart"
287
+
288
+
289
+ def _SessionEnd(d):
290
+ from ..trace import create_trace, resolve_file_project
291
+
292
+ anchor = d.get("cwd") or os.getcwd()
293
+ res = resolve_file_project(".sessions", anchor_path=anchor)
294
+ repo = res.repo_root if res else None
295
+ return create_trace(
296
+ "ai", ".sessions",
297
+ model=_claude_model(d, repo),
298
+ metadata={
299
+ "event": "session_end",
300
+ "session_id": d.get("session_id"),
301
+ "reason": d.get("reason"),
302
+ },
303
+ anchor_path=anchor,
304
+ resolution=res,
305
+ ), "SessionEnd"
306
+
307
+
308
+ # ---------------------------------------------------------------------
309
+ # Adapter
310
+ # ---------------------------------------------------------------------
311
+
312
+ class ClaudeAdapter(CodingAgentAdapter):
313
+ name = "claude"
314
+ display_name = "Claude Code"
315
+ rules_dir = ".claude/rules"
316
+ rule_extension = ".md"
317
+
318
+ EVENTS = {
319
+ "PostToolUse": _PostToolUse,
320
+ "SessionStart": _SessionStart,
321
+ "SessionEnd": _SessionEnd,
322
+ }
323
+
324
+ # Claude's ``Stop`` (and lowercase ``stop``) is "the agent finished
325
+ # responding". No trace, but fires the summary command — and we use
326
+ # ``pre_summary_hook`` to refresh the model cache from the transcript
327
+ # in case ``/model`` changed mid-session.
328
+ summary_only_events = ("Stop", "stop")
329
+
330
+ project_dir_env_vars = ("CLAUDE_PROJECT_DIR",)
331
+
332
+ def global_config_path(self) -> Path:
333
+ return CLAUDE_GLOBAL_SETTINGS_FILE
334
+
335
+ def project_config_paths(self) -> tuple[str, ...]:
336
+ return (CLAUDE_SETTINGS_FILE,)
337
+
338
+ def detect_tool_info(self) -> dict | None:
339
+ if os.environ.get("CLAUDE_PROJECT_DIR"):
340
+ return {"name": "claude-code"}
341
+ return None
342
+
343
+ def pre_summary_hook(self, data: dict) -> None:
344
+ """Refresh the per-session model cache from the transcript tail.
345
+
346
+ Runs before summary on ``Stop`` / ``stop`` so ``/model`` changes
347
+ that happened mid-session are reflected on subsequent traces.
348
+ """
349
+ from ..record import project_dir_from_hook, transcript_path_from_hook
350
+ from ..trace import resolve_file_project
351
+
352
+ sid = data.get("session_id")
353
+ tp = transcript_path_from_hook(data)
354
+ if not sid or not tp:
355
+ return
356
+ anchor = project_dir_from_hook(data)
357
+ res = resolve_file_project(".sessions", anchor_path=anchor)
358
+ repo = res.repo_root if res else None
359
+ if not repo:
360
+ return
361
+ m = _model_from_transcript_tail(tp)
362
+ if not m:
363
+ return
364
+ prev = _session_model_from_state(str(sid), repo)
365
+ if prev != m:
366
+ _remember_session_model(str(sid), m, repo)
367
+
368
+ def inject(
369
+ self,
370
+ record_invocation: str,
371
+ project_dir: str | None = None,
372
+ *,
373
+ global_install: bool = False,
374
+ ) -> bool:
375
+ if global_install:
376
+ settings_path = CLAUDE_GLOBAL_SETTINGS_FILE
377
+ else:
378
+ if project_dir is None:
379
+ project_dir = os.getcwd()
380
+ settings_path = Path(project_dir) / CLAUDE_SETTINGS_FILE
381
+
382
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
383
+
384
+ if settings_path.exists():
385
+ try:
386
+ config = json.loads(settings_path.read_text())
387
+ except (json.JSONDecodeError, OSError):
388
+ config = {}
389
+ else:
390
+ config = {}
391
+
392
+ config.setdefault("hooks", {})
393
+
394
+ hook_entry = {"type": "command", "command": record_invocation}
395
+
396
+ for event in ("SessionStart", "SessionEnd"):
397
+ existing = config["hooks"].get(event, [])
398
+ already = any(
399
+ any(record_invocation in h.get("command", "") for h in entry.get("hooks", []))
400
+ for entry in existing
401
+ if isinstance(entry, dict)
402
+ )
403
+ if not already:
404
+ existing.append({"hooks": [hook_entry]})
405
+ config["hooks"][event] = existing
406
+
407
+ post = config["hooks"].get("PostToolUse", [])
408
+ already = any(
409
+ any(record_invocation in h.get("command", "") for h in entry.get("hooks", []))
410
+ for entry in post
411
+ if isinstance(entry, dict)
412
+ )
413
+ if not already:
414
+ config["hooks"]["PostToolUse"] = [
415
+ {"matcher": "Write|Edit", "hooks": [hook_entry]},
416
+ {"matcher": "Bash", "hooks": [hook_entry]},
417
+ ]
418
+
419
+ stop = config["hooks"].get("Stop", [])
420
+ already = any(
421
+ any(record_invocation in h.get("command", "") for h in entry.get("hooks", []))
422
+ for entry in stop
423
+ if isinstance(entry, dict)
424
+ )
425
+ if not already:
426
+ stop.append({"hooks": [hook_entry]})
427
+ config["hooks"]["Stop"] = stop
428
+
429
+ settings_path.write_text(json.dumps(config, indent=2) + "\n")
430
+ return True
431
+
432
+ def remove(self) -> bool:
433
+ return _remove_agent_trace_from_claude(CLAUDE_GLOBAL_SETTINGS_FILE)
434
+
435
+ def is_installed(self, *, global_only: bool = True) -> bool:
436
+ try:
437
+ raw = CLAUDE_GLOBAL_SETTINGS_FILE.read_text()
438
+ except (OSError, FileNotFoundError):
439
+ return False
440
+ return AGENT_TRACE_CMD in raw
441
+
442
+
443
+ def _remove_agent_trace_from_claude(settings_path: Path) -> bool:
444
+ if not settings_path.is_file():
445
+ return False
446
+ try:
447
+ config = json.loads(settings_path.read_text())
448
+ except (json.JSONDecodeError, OSError):
449
+ return False
450
+
451
+ hooks = config.get("hooks")
452
+ if not isinstance(hooks, dict):
453
+ return False
454
+
455
+ changed = False
456
+ for event, entries in list(hooks.items()):
457
+ if not isinstance(entries, list):
458
+ continue
459
+ filtered = []
460
+ for entry in entries:
461
+ if not isinstance(entry, dict):
462
+ filtered.append(entry)
463
+ continue
464
+ inner = entry.get("hooks", [])
465
+ if isinstance(inner, list) and any(
466
+ AGENT_TRACE_CMD in h.get("command", "")
467
+ for h in inner if isinstance(h, dict)
468
+ ):
469
+ changed = True
470
+ continue
471
+ filtered.append(entry)
472
+ if len(filtered) != len(entries):
473
+ changed = True
474
+ if filtered:
475
+ hooks[event] = filtered
476
+ else:
477
+ del hooks[event]
478
+
479
+ if changed:
480
+ if not hooks:
481
+ del config["hooks"]
482
+ settings_path.write_text(json.dumps(config, indent=2) + "\n")
483
+ return changed