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,130 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/ujjalsharma100/agent-trace-cli/main/schemas/trace-record.schema.json",
4
+ "title": "TraceRecord",
5
+ "description": "One line in traces.jsonl. Schema 2.0: per-line full SHA-256 hashes give cryptographic uniqueness for deterministic attribution.",
6
+ "type": "object",
7
+ "required": ["version", "id", "timestamp", "tool", "files"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "version": {
11
+ "type": "string",
12
+ "enum": ["2.0"]
13
+ },
14
+ "id": { "type": "string", "minLength": 1 },
15
+ "timestamp": { "type": "string", "description": "ISO-8601 timestamp" },
16
+ "tool": { "$ref": "#/$defs/tool" },
17
+ "files": {
18
+ "type": "array",
19
+ "minItems": 1,
20
+ "items": { "$ref": "#/$defs/fileEntry" }
21
+ },
22
+ "vcs": { "$ref": "#/$defs/vcs" },
23
+ "metadata": { "$ref": "#/$defs/metadata" }
24
+ },
25
+ "$defs": {
26
+ "tool": {
27
+ "type": "object",
28
+ "required": ["name"],
29
+ "additionalProperties": true,
30
+ "properties": {
31
+ "name": { "type": "string" },
32
+ "version": { "type": "string" }
33
+ }
34
+ },
35
+ "vcs": {
36
+ "type": "object",
37
+ "required": ["type", "revision"],
38
+ "additionalProperties": false,
39
+ "properties": {
40
+ "type": { "type": "string", "const": "git" },
41
+ "revision": { "type": "string", "minLength": 1 }
42
+ }
43
+ },
44
+ "metadata": {
45
+ "type": "object",
46
+ "additionalProperties": true,
47
+ "properties": {
48
+ "edit_sequence": { "type": "integer" },
49
+ "session_id": { "type": "string" },
50
+ "tool_name": { "type": "string" },
51
+ "tool_use_id": { "type": "string" },
52
+ "is_creation": { "type": "boolean" }
53
+ }
54
+ },
55
+ "lineHash": {
56
+ "type": "object",
57
+ "required": ["line_offset", "hash"],
58
+ "additionalProperties": false,
59
+ "properties": {
60
+ "line_offset": { "type": "integer", "minimum": 0 },
61
+ "hash": {
62
+ "type": "string",
63
+ "pattern": "^sha256:[0-9a-f]{64}$"
64
+ }
65
+ }
66
+ },
67
+ "range": {
68
+ "type": "object",
69
+ "required": ["start_line", "end_line"],
70
+ "additionalProperties": false,
71
+ "properties": {
72
+ "start_line": { "type": "integer", "minimum": 1 },
73
+ "end_line": { "type": "integer", "minimum": 1 },
74
+ "content_hash": {
75
+ "type": "string",
76
+ "pattern": "^sha256:[0-9a-f]{64}$"
77
+ },
78
+ "line_hashes": {
79
+ "type": "array",
80
+ "items": { "$ref": "#/$defs/lineHash" }
81
+ }
82
+ }
83
+ },
84
+ "contributor": {
85
+ "type": "object",
86
+ "required": ["type"],
87
+ "additionalProperties": false,
88
+ "properties": {
89
+ "type": { "type": "string" },
90
+ "model_id": { "type": "string" }
91
+ }
92
+ },
93
+ "conversation": {
94
+ "type": "object",
95
+ "required": ["contributor", "ranges"],
96
+ "additionalProperties": false,
97
+ "properties": {
98
+ "contributor": { "$ref": "#/$defs/contributor" },
99
+ "ranges": {
100
+ "type": "array",
101
+ "minItems": 1,
102
+ "items": { "$ref": "#/$defs/range" }
103
+ },
104
+ "id": {
105
+ "type": "string",
106
+ "pattern": "^[0-9a-f]{64}$",
107
+ "description": "Opaque conversation identifier (sha256 over the original local transcript URL). Stable per-(machine, session); referenced everywhere transcripts are addressed."
108
+ },
109
+ "content_sha256": {
110
+ "type": "string",
111
+ "pattern": "^[0-9a-f]{64}$",
112
+ "description": "SHA-256 of the transcript bytes at the moment this trace was recorded. Resolves to a blob in the per-project content-addressed cache."
113
+ }
114
+ }
115
+ },
116
+ "fileEntry": {
117
+ "type": "object",
118
+ "required": ["path", "conversations"],
119
+ "additionalProperties": false,
120
+ "properties": {
121
+ "path": { "type": "string", "minLength": 1 },
122
+ "conversations": {
123
+ "type": "array",
124
+ "minItems": 1,
125
+ "items": { "$ref": "#/$defs/conversation" }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
agent_trace/session.py ADDED
@@ -0,0 +1,136 @@
1
+ """
2
+ Per-session manifest (~/.agent-trace/sessions/<session_id>.json) — project
3
+ touch counts for cross-repo sessions (Phase 1b).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ from typing import Any
14
+
15
+ from .storage import get_sessions_dir
16
+
17
+
18
+ def _sessions_dir():
19
+ return get_sessions_dir()
20
+
21
+
22
+ class _SessionLock:
23
+ def __init__(self, session_id: str) -> None:
24
+ self.session_id = session_id
25
+ self._fp: Any = None
26
+
27
+ def __enter__(self) -> None:
28
+ _sessions_dir().mkdir(parents=True, exist_ok=True)
29
+ path = _sessions_dir() / f".{self.session_id}.lock"
30
+ self._fp = open(path, "a+")
31
+ if sys.platform != "win32":
32
+ import fcntl
33
+
34
+ fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
35
+ return self
36
+
37
+ def __exit__(self, *args: object) -> None:
38
+ if self._fp:
39
+ if sys.platform != "win32":
40
+ import fcntl
41
+
42
+ fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
43
+ self._fp.close()
44
+ self._fp = None
45
+
46
+
47
+ def _atomic_write(path: os.PathLike[str], text: str) -> None:
48
+ p = os.fspath(path)
49
+ d = os.path.dirname(p)
50
+ os.makedirs(d, exist_ok=True)
51
+ fd, tmp = tempfile.mkstemp(prefix=".sess.", dir=d, text=True)
52
+ try:
53
+ with os.fdopen(fd, "w") as f:
54
+ f.write(text)
55
+ os.replace(tmp, p)
56
+ except Exception:
57
+ try:
58
+ os.unlink(tmp)
59
+ except OSError:
60
+ pass
61
+ raise
62
+
63
+
64
+ def primary_project_id_for_session(session_id: str) -> str | None:
65
+ """Project id that received the most trace touches this session (nested-repo safe).
66
+
67
+ Used so summary hooks attribute to the same project as file-based traces when
68
+ the IDE workspace cwd is a parent folder but edits targeted an inner git repo.
69
+ """
70
+ if not session_id:
71
+ return None
72
+ path = _sessions_dir() / f"{session_id}.json"
73
+ if not path.is_file():
74
+ return None
75
+ try:
76
+ data = json.loads(path.read_text())
77
+ except (json.JSONDecodeError, OSError):
78
+ return None
79
+ counts = data.get("edit_counts") if isinstance(data.get("edit_counts"), dict) else {}
80
+ if counts:
81
+ best: str | None = None
82
+ best_n = -1
83
+ for pid, n in counts.items():
84
+ if not isinstance(pid, str) or not pid:
85
+ continue
86
+ try:
87
+ v = int(n)
88
+ except (TypeError, ValueError):
89
+ continue
90
+ if v > best_n:
91
+ best_n = v
92
+ best = pid
93
+ if best is not None:
94
+ return best
95
+ projects = data.get("projects") if isinstance(data.get("projects"), list) else []
96
+ if len(projects) == 1 and isinstance(projects[0], str) and projects[0]:
97
+ return projects[0]
98
+ return None
99
+
100
+
101
+ def touch_session_project(
102
+ session_id: str,
103
+ project_id: str,
104
+ *,
105
+ tool_name: str | None = None,
106
+ transcript_path: str | None = None,
107
+ ) -> None:
108
+ """Record that this session touched a project; bump edit counter."""
109
+ if not session_id or not project_id:
110
+ return
111
+ path = _sessions_dir() / f"{session_id}.json"
112
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
113
+ with _SessionLock(session_id):
114
+ data: dict[str, Any]
115
+ if path.is_file():
116
+ try:
117
+ data = json.loads(path.read_text())
118
+ except (json.JSONDecodeError, OSError):
119
+ data = {}
120
+ else:
121
+ data = {}
122
+ data.setdefault("session_id", session_id)
123
+ data.setdefault("started_at", now)
124
+ data["ended_at"] = None
125
+ if tool_name:
126
+ data.setdefault("tool", {})["name"] = tool_name
127
+ if transcript_path:
128
+ data["transcript_path"] = transcript_path
129
+ projects: list[str] = list(data.get("projects") or [])
130
+ if project_id not in projects:
131
+ projects.append(project_id)
132
+ data["projects"] = projects
133
+ counts: dict[str, int] = dict(data.get("edit_counts") or {})
134
+ counts[project_id] = int(counts.get(project_id, 0)) + 1
135
+ data["edit_counts"] = counts
136
+ _atomic_write(path, json.dumps(data, indent=2) + "\n")
agent_trace/storage.py ADDED
@@ -0,0 +1,229 @@
1
+ """
2
+ Central path resolution for agent-trace.
3
+
4
+ All per-project runtime data lives under ``<AGENT_TRACE_HOME>/projects/<project_id>/``.
5
+
6
+ ``project_id`` is anchored to the repo's ``.git`` directory: a single-line
7
+ ``agent-trace-id`` file under ``git rev-parse --git-common-dir``. Worktrees
8
+ of the same repo share the anchor (and therefore the data directory), and a
9
+ rename of the repo on disk preserves the id since the file lives inside
10
+ ``.git``. Re-clones legitimately get a fresh id — fresh clones rely on
11
+ ``refs/notes/agent-trace`` for attribution, not on local data.
12
+
13
+ When the anchor doesn't exist yet, we fall back to a path-derived id so
14
+ ``status`` / ``blame`` from un-init'd repos still resolve to *something*.
15
+ ``init`` and any other ``create=True`` resolution will write the anchor.
16
+
17
+ ``AGENT_TRACE_HOME`` overrides the default ``~/.agent-trace`` (used by tests).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import subprocess
24
+ import uuid
25
+ from pathlib import Path
26
+
27
+
28
+ def get_agent_trace_home() -> Path:
29
+ """Root of all global agent-trace state (respects ``AGENT_TRACE_HOME``)."""
30
+ env = os.environ.get("AGENT_TRACE_HOME")
31
+ if env:
32
+ return Path(env).expanduser()
33
+ return Path.home() / ".agent-trace"
34
+
35
+
36
+ def get_global_config_file() -> Path:
37
+ return get_agent_trace_home() / "config.json"
38
+
39
+
40
+ def get_projects_registry_file() -> Path:
41
+ return get_agent_trace_home() / "projects.json"
42
+
43
+
44
+ def get_sessions_dir() -> Path:
45
+ return get_agent_trace_home() / "sessions"
46
+
47
+
48
+ def get_detached_base_dir() -> Path:
49
+ return get_agent_trace_home() / "detached"
50
+
51
+
52
+ def _sanitize_id(project_id: str) -> str:
53
+ """Defensive sanitization — path-derived ids are already dash-only,
54
+ but anything unexpected (``:``, ``\\``, residual ``/``) gets flattened."""
55
+ return project_id.replace(":", "_").replace("/", "-").replace("\\", "-")
56
+
57
+
58
+ def path_to_project_id(repo_root: str) -> str:
59
+ """Derive a project_id from an absolute repo path (fallback only).
60
+
61
+ ``/Users/jane/Desktop/foo`` → ``-Users-jane-Desktop-foo`` (Claude-Code convention).
62
+ Used when no ``.git/agent-trace-id`` anchor exists. Once a project is
63
+ initialised, the anchor takes over and the path is no longer load-bearing.
64
+ """
65
+ canon = os.path.realpath(repo_root)
66
+ return canon.replace(os.sep, "-")
67
+
68
+
69
+ # -------------------------------------------------------------------
70
+ # Anchor (.git/agent-trace-id) — stable, worktree-aware project identity
71
+ # -------------------------------------------------------------------
72
+
73
+ ANCHOR_FILENAME = "agent-trace-id"
74
+
75
+
76
+ def _git_common_dir(repo_dir: str) -> str | None:
77
+ """Return the shared ``.git`` directory for ``repo_dir``.
78
+
79
+ For ordinary repos this is ``<repo>/.git``. For linked worktrees,
80
+ ``--git-common-dir`` resolves to the main repo's ``.git`` so all
81
+ worktrees of the same repo share the anchor (and therefore the
82
+ project_id).
83
+ """
84
+ try:
85
+ r = subprocess.run(
86
+ ["git", "rev-parse", "--git-common-dir"],
87
+ cwd=repo_dir, capture_output=True, text=True, timeout=10,
88
+ )
89
+ if r.returncode != 0:
90
+ return None
91
+ p = r.stdout.strip()
92
+ if not p:
93
+ return None
94
+ if not os.path.isabs(p):
95
+ p = os.path.join(os.path.realpath(repo_dir), p)
96
+ return os.path.realpath(p)
97
+ except Exception:
98
+ return None
99
+
100
+
101
+ def _anchor_path(git_common_dir: str) -> Path:
102
+ return Path(git_common_dir) / ANCHOR_FILENAME
103
+
104
+
105
+ def _read_anchor(git_common_dir: str) -> str | None:
106
+ p = _anchor_path(git_common_dir)
107
+ try:
108
+ if not p.is_file():
109
+ return None
110
+ s = p.read_text().strip()
111
+ return s or None
112
+ except OSError:
113
+ return None
114
+
115
+
116
+ def _generate_project_id() -> str:
117
+ """Fresh opaque project_id. ``at-<32 hex>``; safe as a directory name."""
118
+ return f"at-{uuid.uuid4().hex}"
119
+
120
+
121
+ def _write_anchor(git_common_dir: str, project_id: str) -> bool:
122
+ try:
123
+ p = _anchor_path(git_common_dir)
124
+ p.parent.mkdir(parents=True, exist_ok=True)
125
+ p.write_text(project_id + "\n")
126
+ return True
127
+ except OSError:
128
+ return False
129
+
130
+
131
+ def get_project_dir(project_id: str) -> Path:
132
+ return get_agent_trace_home() / "projects" / _sanitize_id(project_id)
133
+
134
+
135
+ def ensure_project_dir(project_id: str) -> Path:
136
+ d = get_project_dir(project_id)
137
+ d.mkdir(parents=True, exist_ok=True)
138
+ return d
139
+
140
+
141
+ # -------------------------------------------------------------------
142
+ # Per-project file paths
143
+ # -------------------------------------------------------------------
144
+
145
+ def get_traces_path(project_id: str) -> Path:
146
+ return get_project_dir(project_id) / "traces.jsonl"
147
+
148
+
149
+ def get_ledgers_path(project_id: str) -> Path:
150
+ return get_project_dir(project_id) / "ledgers.jsonl"
151
+
152
+
153
+ def get_commit_links_path(project_id: str) -> Path:
154
+ return get_project_dir(project_id) / "commit-links.jsonl"
155
+
156
+
157
+ def get_session_state_path(project_id: str) -> Path:
158
+ return get_project_dir(project_id) / "session-state.json"
159
+
160
+
161
+ def get_session_summaries_path(project_id: str) -> Path:
162
+ """Append-only JSONL of per-session LLM summaries."""
163
+ return get_project_dir(project_id) / "session-summaries.jsonl"
164
+
165
+
166
+ def get_project_config_path(project_id: str) -> Path:
167
+ return get_project_dir(project_id) / "project-config.json"
168
+
169
+
170
+ def get_attribution_state_path(project_id: str) -> Path:
171
+ """Per-project attribution-window cursor (last commit timestamp seen)."""
172
+ return get_project_dir(project_id) / "attribution-state.json"
173
+
174
+
175
+ # -------------------------------------------------------------------
176
+ # Repo-dir → project_id resolution
177
+ # -------------------------------------------------------------------
178
+
179
+ def resolve_project_id(
180
+ repo_dir: str | os.PathLike[str] | None,
181
+ *,
182
+ create: bool = False,
183
+ ) -> str | None:
184
+ """Resolve a stable ``project_id`` for a repo directory.
185
+
186
+ Resolution order:
187
+ 1. ``.git/agent-trace-id`` anchor (shared across worktrees of the
188
+ same repo). Survives ``mv`` of the repo on disk.
189
+ 2. With ``create=True`` and no anchor present: generate a fresh
190
+ opaque id (``at-<32 hex>``) and write the anchor.
191
+ 3. Path-derived fallback (``/Users/x/foo`` → ``-Users-x-foo``) for
192
+ un-init'd repos and non-git directories.
193
+
194
+ When ``create`` is True the registry also gets a metadata entry.
195
+ """
196
+ if repo_dir is None:
197
+ return None
198
+
199
+ from .trace import git_repo_root_for_path
200
+
201
+ root = git_repo_root_for_path(str(repo_dir))
202
+
203
+ if root:
204
+ # Inside a git repo — try the anchor.
205
+ common = _git_common_dir(root) or os.path.join(root, ".git")
206
+ pid = _read_anchor(common)
207
+ if pid is None and create:
208
+ pid = _generate_project_id()
209
+ _write_anchor(common, pid)
210
+ if pid is None:
211
+ # Un-init'd repo: behave like the legacy path-derived form so
212
+ # status/blame from a fresh checkout still resolves.
213
+ pid = path_to_project_id(root)
214
+
215
+ if create:
216
+ from .registry import register_project_metadata
217
+
218
+ register_project_metadata(root, pid)
219
+ return pid
220
+
221
+ # Not inside a git repo at all — keep the path-derived id (used by
222
+ # detached-edit handling and tests for non-repo paths).
223
+ real = os.path.realpath(str(repo_dir))
224
+ pid = path_to_project_id(real)
225
+ if create:
226
+ from .registry import register_project_metadata
227
+
228
+ register_project_metadata(real, pid)
229
+ return pid