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,226 @@
1
+ """
2
+ Git post-commit hook logic — links commits to AI traces.
3
+
4
+ Called by the git post-commit hook (via ``agent-trace commit-link``).
5
+ Matches the newly-created commit against traces that were active at
6
+ the parent revision, then records the link locally.
7
+
8
+ Hooks write *only* to local JSONL. Network sync happens via
9
+ ``agent-trace push`` / ``agent-trace pull``.
10
+
11
+ No external dependencies — stdlib only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+ from .config import get_project_config
24
+ from .ledger import build_attribution_ledger, store_ledger_local
25
+
26
+
27
+ # -------------------------------------------------------------------
28
+ # Git helpers
29
+ # -------------------------------------------------------------------
30
+
31
+ def _git(*args: str, cwd: str | None = None) -> str | None:
32
+ """Run a git command and return stripped stdout, or None on failure."""
33
+ try:
34
+ result = subprocess.run(
35
+ ["git", *args],
36
+ capture_output=True, text=True, cwd=cwd, timeout=10,
37
+ )
38
+ if result.returncode == 0:
39
+ return result.stdout.strip()
40
+ except Exception:
41
+ pass
42
+ return None
43
+
44
+
45
+ def _get_commit_sha(cwd: str | None = None) -> str | None:
46
+ return _git("rev-parse", "HEAD", cwd=cwd)
47
+
48
+
49
+ def _get_parent_sha(cwd: str | None = None) -> str | None:
50
+ return _git("rev-parse", "HEAD^", cwd=cwd)
51
+
52
+
53
+ def _get_changed_files(cwd: str | None = None) -> list[str]:
54
+ """Files changed between HEAD^ and HEAD."""
55
+ # Handle initial commit (no parent)
56
+ parent = _get_parent_sha(cwd)
57
+ if parent is None:
58
+ # First commit — diff against empty tree
59
+ out = _git("diff", "--name-only", "--diff-filter=ACMR",
60
+ "4b825dc642cb6eb9a060e54bf899d15f3f4b7b18", "HEAD",
61
+ cwd=cwd)
62
+ else:
63
+ out = _git("diff", "--name-only", "HEAD^", "HEAD", cwd=cwd)
64
+
65
+ if out is None:
66
+ return []
67
+ return [f for f in out.splitlines() if f.strip()]
68
+
69
+
70
+ def _get_commit_date(cwd: str | None = None) -> str | None:
71
+ """Author date of HEAD in ISO-8601 format."""
72
+ return _git("log", "-1", "--format=%aI", "HEAD", cwd=cwd)
73
+
74
+
75
+ # -------------------------------------------------------------------
76
+ # Trace matching
77
+ # -------------------------------------------------------------------
78
+
79
+ def _load_local_traces(project_dir: str) -> list[dict]:
80
+ """Load all traces from ``<AGENT_TRACE_HOME>/projects/<id>/traces.jsonl``."""
81
+ from .storage import get_traces_path, resolve_project_id
82
+
83
+ pid = resolve_project_id(project_dir, create=False)
84
+ if not pid:
85
+ return []
86
+ traces_path = get_traces_path(pid)
87
+ if not traces_path.exists():
88
+ return []
89
+ traces = []
90
+ try:
91
+ for line in traces_path.read_text().splitlines():
92
+ line = line.strip()
93
+ if line:
94
+ try:
95
+ traces.append(json.loads(line))
96
+ except json.JSONDecodeError:
97
+ continue
98
+ except OSError:
99
+ pass
100
+ return traces
101
+
102
+
103
+ def _trace_matches(trace: dict, parent_sha: str | None, changed_files: set[str]) -> bool:
104
+ """Check if a trace matches the parent revision and touches any changed file."""
105
+ # Must have a VCS revision matching the parent
106
+ vcs = trace.get("vcs", {})
107
+ revision = vcs.get("revision", "")
108
+ if not revision or not parent_sha:
109
+ return False
110
+ if revision != parent_sha:
111
+ return False
112
+
113
+ # Must touch at least one changed file
114
+ for fe in trace.get("files", []):
115
+ fpath = fe.get("path", "")
116
+ if fpath in changed_files:
117
+ return True
118
+
119
+ return False
120
+
121
+
122
+ def _find_matching_traces_local(
123
+ project_dir: str,
124
+ parent_sha: str | None,
125
+ changed_files: list[str],
126
+ ) -> list[str]:
127
+ """Find trace IDs that match the parent SHA and touch changed files."""
128
+ if parent_sha is None:
129
+ return []
130
+ traces = _load_local_traces(project_dir)
131
+ changed_set = set(changed_files)
132
+ return [
133
+ t["id"]
134
+ for t in traces
135
+ if t.get("id") and _trace_matches(t, parent_sha, changed_set)
136
+ ]
137
+
138
+
139
+ # -------------------------------------------------------------------
140
+ # Storage
141
+ # -------------------------------------------------------------------
142
+
143
+ def _store_local(commit_link: dict, project_dir: str) -> None:
144
+ """Append commit link to ``<AGENT_TRACE_HOME>/projects/<id>/commit-links.jsonl``."""
145
+ from .storage import ensure_project_dir, get_commit_links_path, resolve_project_id
146
+
147
+ pid = resolve_project_id(project_dir, create=True)
148
+ if not pid:
149
+ return
150
+ ensure_project_dir(pid)
151
+ path = get_commit_links_path(pid)
152
+ with open(path, "a") as f:
153
+ f.write(json.dumps(commit_link) + "\n")
154
+
155
+
156
+ # -------------------------------------------------------------------
157
+ # Main entry point
158
+ # -------------------------------------------------------------------
159
+
160
+ def create_commit_link(project_dir: str | None = None) -> dict | None:
161
+ """Create a commit-trace link for the current HEAD commit.
162
+
163
+ Algorithm:
164
+ 1. git rev-parse HEAD → commit SHA
165
+ 2. git rev-parse HEAD^ → parent SHA (handle first commit)
166
+ 3. git diff --name-only HEAD^ HEAD → changed files
167
+ 4. git log -1 --format=%aI HEAD → commit author date
168
+ 5. Find matching traces (local or remote)
169
+ 6. Build and store the commit link record
170
+
171
+ Returns the commit link dict, or None if no matching traces found.
172
+ """
173
+ if project_dir is None:
174
+ project_dir = os.getcwd()
175
+
176
+ commit_sha = _get_commit_sha(project_dir)
177
+ if not commit_sha:
178
+ return None
179
+
180
+ parent_sha = _get_parent_sha(project_dir)
181
+ changed_files = _get_changed_files(project_dir)
182
+ committed_at = _get_commit_date(project_dir)
183
+
184
+ if not changed_files:
185
+ return None
186
+
187
+ # Find matching traces from local storage only.
188
+ trace_ids = _find_matching_traces_local(
189
+ project_dir, parent_sha, changed_files
190
+ )
191
+
192
+ # Always build the attribution ledger — even when nothing is AI-attributed.
193
+ ledger = None
194
+ try:
195
+ ledger = build_attribution_ledger(project_dir)
196
+ except Exception:
197
+ pass
198
+
199
+ # Store ledger locally regardless of whether traces matched.
200
+ if ledger:
201
+ try:
202
+ store_ledger_local(ledger, project_dir)
203
+ except Exception:
204
+ pass
205
+ try:
206
+ from .git_notes import attach_note_after_ledger
207
+
208
+ attach_note_after_ledger(project_dir, ledger)
209
+ except Exception:
210
+ pass
211
+
212
+ if not trace_ids:
213
+ return None
214
+
215
+ # Build commit link record
216
+ commit_link: dict = {
217
+ "commit_sha": commit_sha,
218
+ "parent_sha": parent_sha,
219
+ "trace_ids": trace_ids,
220
+ "files_changed": changed_files,
221
+ "committed_at": committed_at,
222
+ "created_at": datetime.now(timezone.utc).isoformat(),
223
+ }
224
+
225
+ _store_local(commit_link, project_dir)
226
+ return commit_link
agent_trace/config.py ADDED
@@ -0,0 +1,137 @@
1
+ """
2
+ Configuration management for agent-trace.
3
+
4
+ Two-tier layout:
5
+
6
+ 1. Global — <AGENT_TRACE_HOME>/config.json (global auth token, preferences)
7
+ 2. Per-project — <AGENT_TRACE_HOME>/projects/<id>/project-config.json
8
+ (notes.*, summary.*, remote.default)
9
+
10
+ Project identity is derived from the git repo root (a sanitized absolute path),
11
+ so no in-repo state is needed. Existence of the project-config.json file under
12
+ the global project dir determines whether a repo is "initialized".
13
+
14
+ ``AGENT_TRACE_HOME`` env var overrides the default ``~/.agent-trace`` (used by tests).
15
+
16
+ No external dependencies — stdlib only.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import stat
24
+ from pathlib import Path
25
+
26
+ from .storage import (
27
+ ensure_project_dir,
28
+ get_agent_trace_home,
29
+ get_global_config_file,
30
+ get_project_config_path,
31
+ resolve_project_id,
32
+ )
33
+
34
+
35
+ # -------------------------------------------------------------------
36
+ # Back-compat shims (computed lazily so tests can override AGENT_TRACE_HOME)
37
+ # -------------------------------------------------------------------
38
+
39
+ class _GlobalConfigDirProxy:
40
+ """Acts like the old ``GLOBAL_CONFIG_DIR`` Path but re-reads env each use."""
41
+
42
+ def __fspath__(self) -> str:
43
+ return os.fspath(get_agent_trace_home())
44
+
45
+ def __str__(self) -> str:
46
+ return str(get_agent_trace_home())
47
+
48
+ def __truediv__(self, other: str) -> Path:
49
+ return get_agent_trace_home() / other
50
+
51
+ def mkdir(self, *args, **kwargs): # noqa: D401
52
+ return get_agent_trace_home().mkdir(*args, **kwargs)
53
+
54
+
55
+ GLOBAL_CONFIG_DIR = _GlobalConfigDirProxy()
56
+
57
+
58
+ # -------------------------------------------------------------------
59
+ # Global config
60
+ # -------------------------------------------------------------------
61
+
62
+ def get_global_config() -> dict:
63
+ """Load <AGENT_TRACE_HOME>/config.json (returns {} if missing)."""
64
+ f = get_global_config_file()
65
+ if f.exists():
66
+ try:
67
+ return json.loads(f.read_text())
68
+ except (json.JSONDecodeError, OSError):
69
+ return {}
70
+ return {}
71
+
72
+
73
+ def save_global_config(config: dict) -> None:
74
+ """Write <AGENT_TRACE_HOME>/config.json."""
75
+ f = get_global_config_file()
76
+ f.parent.mkdir(parents=True, exist_ok=True)
77
+ f.write_text(json.dumps(config, indent=2) + "\n")
78
+ try:
79
+ f.chmod(stat.S_IRUSR | stat.S_IWUSR)
80
+ except OSError:
81
+ pass
82
+
83
+
84
+ # -------------------------------------------------------------------
85
+ # Project config (lives in the global project dir, keyed by project_id)
86
+ # -------------------------------------------------------------------
87
+
88
+ def get_project_config(project_dir: str | None = None) -> dict | None:
89
+ """Load per-project settings. Returns ``None`` when the project is not initialised."""
90
+ if project_dir is None:
91
+ project_dir = os.getcwd()
92
+
93
+ pid = resolve_project_id(project_dir, create=False)
94
+ if not pid:
95
+ return None
96
+
97
+ cfg_path = get_project_config_path(pid)
98
+ if cfg_path.is_file():
99
+ try:
100
+ return json.loads(cfg_path.read_text())
101
+ except (json.JSONDecodeError, OSError):
102
+ return None
103
+ return None
104
+
105
+
106
+ def save_project_config(config: dict, project_dir: str | None = None) -> None:
107
+ """Persist per-project settings under ``<AGENT_TRACE_HOME>/projects/<id>/``."""
108
+ if project_dir is None:
109
+ project_dir = os.getcwd()
110
+
111
+ pid = resolve_project_id(project_dir, create=True)
112
+ if not pid:
113
+ raise RuntimeError(
114
+ f"agent-trace: cannot resolve project_id for {project_dir} "
115
+ "(not a git repository and no registry entry)",
116
+ )
117
+
118
+ ensure_project_dir(pid)
119
+ cfg_path = get_project_config_path(pid)
120
+ cfg_path.write_text(json.dumps(config, indent=2) + "\n")
121
+
122
+
123
+ # -------------------------------------------------------------------
124
+ # Auth token resolution (global only — projects don't store tokens anymore)
125
+ # -------------------------------------------------------------------
126
+
127
+ def get_auth_token() -> str | None:
128
+ """Resolve auth token: env → global config."""
129
+ env = os.environ.get("AGENT_TRACE_TOKEN")
130
+ if env:
131
+ return env
132
+
133
+ global_cfg = get_global_config()
134
+ if global_cfg.get("auth_token"):
135
+ return global_cfg["auth_token"]
136
+
137
+ return None