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.
- agent_trace/__init__.py +3 -0
- agent_trace/blame.py +471 -0
- agent_trace/blame_git.py +133 -0
- agent_trace/blame_meta.py +167 -0
- agent_trace/cli.py +2680 -0
- agent_trace/commit_link.py +226 -0
- agent_trace/config.py +137 -0
- agent_trace/context.py +385 -0
- agent_trace/conversations.py +358 -0
- agent_trace/git_notes.py +491 -0
- agent_trace/hooks/__init__.py +163 -0
- agent_trace/hooks/base.py +233 -0
- agent_trace/hooks/claude.py +483 -0
- agent_trace/hooks/codex.py +232 -0
- agent_trace/hooks/cursor.py +278 -0
- agent_trace/hooks/git.py +144 -0
- agent_trace/ledger.py +955 -0
- agent_trace/models.py +618 -0
- agent_trace/record.py +417 -0
- agent_trace/registry.py +230 -0
- agent_trace/remote.py +673 -0
- agent_trace/rewrite.py +176 -0
- agent_trace/rules.py +209 -0
- agent_trace/schemas/commit-link.schema.json +34 -0
- agent_trace/schemas/git-note.schema.json +88 -0
- agent_trace/schemas/ledger.schema.json +97 -0
- agent_trace/schemas/remotes.schema.json +30 -0
- agent_trace/schemas/sync-state.schema.json +49 -0
- agent_trace/schemas/trace-record.schema.json +130 -0
- agent_trace/session.py +136 -0
- agent_trace/storage.py +229 -0
- agent_trace/summary.py +465 -0
- agent_trace/summary_presets.py +188 -0
- agent_trace/sync.py +1182 -0
- agent_trace/telemetry.py +142 -0
- agent_trace/trace.py +460 -0
- agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
- agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
- agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|