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
agent_trace/git_notes.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git notes for agent-trace (``refs/notes/agent-trace``).
|
|
3
|
+
|
|
4
|
+
Attaches JSON metadata per commit with composable sections
|
|
5
|
+
(core, ledger, summary, prompts, all_session_conversations).
|
|
6
|
+
Stdlib only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
from typing import Any, Iterator
|
|
17
|
+
|
|
18
|
+
from .models import Ledger, Trace
|
|
19
|
+
|
|
20
|
+
NOTE_REF = "agent-trace"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _git(*args: str, cwd: str | None = None) -> str | None:
|
|
24
|
+
try:
|
|
25
|
+
r = subprocess.run(
|
|
26
|
+
["git", *args],
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
cwd=cwd,
|
|
30
|
+
timeout=120,
|
|
31
|
+
)
|
|
32
|
+
if r.returncode == 0:
|
|
33
|
+
return r.stdout.strip()
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_commit(repo_dir: str, rev: str) -> str | None:
|
|
40
|
+
"""Resolve ``rev`` (e.g. ``HEAD``) to a full SHA."""
|
|
41
|
+
return _git("rev-parse", rev, cwd=repo_dir)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ledger_dict_hash(ledger: dict[str, Any]) -> str:
|
|
45
|
+
"""Canonical SHA-256 over the ledger JSON (sorted keys)."""
|
|
46
|
+
body = json.dumps(ledger, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
47
|
+
h = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
48
|
+
return f"sha256:{h}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ledger_to_dict(ledger: Ledger | dict[str, Any]) -> dict[str, Any]:
|
|
52
|
+
if isinstance(ledger, Ledger):
|
|
53
|
+
return ledger.to_dict()
|
|
54
|
+
return dict(ledger)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _stats_from_ledger(ledger: dict[str, Any]) -> dict[str, int]:
|
|
58
|
+
"""Schema 2.0: only AI lines are counted. Anything else is implicitly
|
|
59
|
+
NO_ATTRIBUTION and isn't stored, so there's nothing to tally."""
|
|
60
|
+
ai = 0
|
|
61
|
+
for _fp, fl in ledger.get("files", {}).items():
|
|
62
|
+
if not isinstance(fl, dict):
|
|
63
|
+
continue
|
|
64
|
+
for seg in fl.get("line_attributions", []):
|
|
65
|
+
if not isinstance(seg, dict):
|
|
66
|
+
continue
|
|
67
|
+
if str(seg.get("type", "")).lower() != "ai":
|
|
68
|
+
continue
|
|
69
|
+
start = int(seg.get("start_line", 0))
|
|
70
|
+
end = int(seg.get("end_line", 0))
|
|
71
|
+
n = max(0, end - start + 1) if start and end else 0
|
|
72
|
+
ai += n
|
|
73
|
+
return {"ai_lines": ai}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _prompts_from_traces(traces: list[Trace], *, max_chars: int = 500) -> list[str]:
|
|
77
|
+
seen: set[str] = set()
|
|
78
|
+
out: list[str] = []
|
|
79
|
+
for tr in traces:
|
|
80
|
+
meta = tr.metadata or {}
|
|
81
|
+
for key in ("prompt", "user_prompt", "instruction"):
|
|
82
|
+
val = meta.get(key)
|
|
83
|
+
if isinstance(val, str) and val.strip():
|
|
84
|
+
s = val.strip()[:max_chars]
|
|
85
|
+
if s not in seen:
|
|
86
|
+
seen.add(s)
|
|
87
|
+
out.append(s)
|
|
88
|
+
break
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_note(
|
|
93
|
+
ledger: Ledger | dict[str, Any],
|
|
94
|
+
traces: list[Trace],
|
|
95
|
+
*,
|
|
96
|
+
include_ledger: bool,
|
|
97
|
+
include_summary: bool,
|
|
98
|
+
include_prompts: bool,
|
|
99
|
+
summaries: dict[str, str] | None = None,
|
|
100
|
+
include_all_session_conversations: bool = False,
|
|
101
|
+
all_session_conversations: list[dict[str, Any]] | None = None,
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
"""Build the note JSON. Core fields always present; optional sections per flags."""
|
|
104
|
+
led = _ledger_to_dict(ledger)
|
|
105
|
+
trace_ids = [str(x) for x in led.get("trace_ids", [])]
|
|
106
|
+
h = ledger_dict_hash(led)
|
|
107
|
+
stats = _stats_from_ledger(led)
|
|
108
|
+
note: dict[str, Any] = {
|
|
109
|
+
"version": "2.0",
|
|
110
|
+
"trace_ids": trace_ids,
|
|
111
|
+
"ledger_hash": h,
|
|
112
|
+
"stats": stats,
|
|
113
|
+
}
|
|
114
|
+
if include_ledger:
|
|
115
|
+
files = led.get("files", {})
|
|
116
|
+
if isinstance(files, dict) and files:
|
|
117
|
+
note["ledger"] = {"files": {k: v for k, v in files.items()}}
|
|
118
|
+
if include_summary and summaries:
|
|
119
|
+
note["summary"] = dict(summaries)
|
|
120
|
+
if include_prompts:
|
|
121
|
+
pr = _prompts_from_traces(traces)
|
|
122
|
+
if pr:
|
|
123
|
+
note["prompts"] = pr
|
|
124
|
+
if include_all_session_conversations and all_session_conversations:
|
|
125
|
+
note["all_session_conversations"] = list(all_session_conversations)
|
|
126
|
+
return note
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def attach_note(commit_sha: str, note: dict[str, Any], repo_dir: str) -> bool:
|
|
130
|
+
"""Attach JSON note to ``commit_sha`` on ``refs/notes/agent-trace``."""
|
|
131
|
+
if not commit_sha or not repo_dir:
|
|
132
|
+
return False
|
|
133
|
+
try:
|
|
134
|
+
with tempfile.NamedTemporaryFile(
|
|
135
|
+
mode="w",
|
|
136
|
+
encoding="utf-8",
|
|
137
|
+
delete=False,
|
|
138
|
+
suffix=".json",
|
|
139
|
+
) as tf:
|
|
140
|
+
json.dump(note, tf, ensure_ascii=False)
|
|
141
|
+
path = tf.name
|
|
142
|
+
try:
|
|
143
|
+
r = subprocess.run(
|
|
144
|
+
[
|
|
145
|
+
"git",
|
|
146
|
+
"notes",
|
|
147
|
+
"--ref",
|
|
148
|
+
NOTE_REF,
|
|
149
|
+
"add",
|
|
150
|
+
"-f",
|
|
151
|
+
"-F",
|
|
152
|
+
path,
|
|
153
|
+
commit_sha,
|
|
154
|
+
],
|
|
155
|
+
capture_output=True,
|
|
156
|
+
text=True,
|
|
157
|
+
cwd=repo_dir,
|
|
158
|
+
timeout=60,
|
|
159
|
+
)
|
|
160
|
+
return r.returncode == 0
|
|
161
|
+
finally:
|
|
162
|
+
try:
|
|
163
|
+
os.unlink(path)
|
|
164
|
+
except OSError:
|
|
165
|
+
pass
|
|
166
|
+
except Exception:
|
|
167
|
+
return False
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def read_note(commit_sha: str, repo_dir: str) -> dict[str, Any] | None:
|
|
172
|
+
"""Read and parse the agent-trace note for ``commit_sha``, or None."""
|
|
173
|
+
raw = _git("notes", "--ref", NOTE_REF, "show", commit_sha, cwd=repo_dir)
|
|
174
|
+
if not raw:
|
|
175
|
+
return None
|
|
176
|
+
try:
|
|
177
|
+
d = json.loads(raw)
|
|
178
|
+
return d if isinstance(d, dict) else None
|
|
179
|
+
except json.JSONDecodeError:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def list_notes(repo_dir: str, range_spec: str | None = None) -> Iterator[tuple[str, dict[str, Any]]]:
|
|
184
|
+
"""Yield ``(commit_sha, note_dict)`` for commits in ``range_spec`` that have a note.
|
|
185
|
+
|
|
186
|
+
``range_spec`` is passed to ``git rev-list`` (e.g. ``HEAD~3..HEAD``). Default ``HEAD``.
|
|
187
|
+
"""
|
|
188
|
+
spec = range_spec if range_spec else "HEAD"
|
|
189
|
+
out = _git("rev-list", spec, cwd=repo_dir)
|
|
190
|
+
if not out:
|
|
191
|
+
return iter(())
|
|
192
|
+
shas = [ln.strip() for ln in out.splitlines() if ln.strip()]
|
|
193
|
+
|
|
194
|
+
def _gen() -> Iterator[tuple[str, dict[str, Any]]]:
|
|
195
|
+
for sha in shas:
|
|
196
|
+
n = read_note(sha, repo_dir)
|
|
197
|
+
if n:
|
|
198
|
+
yield (sha, n)
|
|
199
|
+
|
|
200
|
+
return _gen()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def strip_sections(commit_sha: str, sections: list[str], repo_dir: str) -> bool:
|
|
204
|
+
"""Remove optional sections (``ledger``, ``summary``, ``prompts``,
|
|
205
|
+
``all_session_conversations``) from the note."""
|
|
206
|
+
note = read_note(commit_sha, repo_dir)
|
|
207
|
+
if not note:
|
|
208
|
+
return False
|
|
209
|
+
for sec in sections:
|
|
210
|
+
if sec in ("ledger", "summary", "prompts", "all_session_conversations"):
|
|
211
|
+
note.pop(sec, None)
|
|
212
|
+
return attach_note(commit_sha, note, repo_dir)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _notes_config(project_dir: str) -> dict[str, Any]:
|
|
216
|
+
from .config import get_project_config
|
|
217
|
+
|
|
218
|
+
cfg = get_project_config(project_dir)
|
|
219
|
+
if not cfg:
|
|
220
|
+
return {}
|
|
221
|
+
raw = cfg.get("notes")
|
|
222
|
+
return raw if isinstance(raw, dict) else {}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def project_notes_flags(project_dir: str) -> tuple[bool, bool, bool]:
|
|
226
|
+
"""Default ``(include_ledger, include_summary, include_prompts)`` from project config."""
|
|
227
|
+
nc = _notes_config(project_dir)
|
|
228
|
+
return (
|
|
229
|
+
bool(nc.get("include_ledger", False)),
|
|
230
|
+
bool(nc.get("include_summary", False)),
|
|
231
|
+
bool(nc.get("include_prompts", False)),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def all_session_conversations_enabled(project_dir: str) -> bool:
|
|
236
|
+
"""Whether to embed ``all_session_conversations`` in notes (default: off)."""
|
|
237
|
+
nc = _notes_config(project_dir)
|
|
238
|
+
return bool(nc.get("all_session_conversations", False))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _load_local_traces_raw(project_dir: str) -> list[dict[str, Any]]:
|
|
242
|
+
"""Same as commit_link._load_local_traces (duplicated to avoid import cycle)."""
|
|
243
|
+
import json
|
|
244
|
+
|
|
245
|
+
from .storage import get_traces_path, resolve_project_id
|
|
246
|
+
|
|
247
|
+
pid = resolve_project_id(project_dir, create=False)
|
|
248
|
+
if not pid:
|
|
249
|
+
return []
|
|
250
|
+
traces_path = get_traces_path(pid)
|
|
251
|
+
if not traces_path.exists():
|
|
252
|
+
return []
|
|
253
|
+
traces: list[dict[str, Any]] = []
|
|
254
|
+
try:
|
|
255
|
+
for line in traces_path.read_text().splitlines():
|
|
256
|
+
line = line.strip()
|
|
257
|
+
if line:
|
|
258
|
+
try:
|
|
259
|
+
traces.append(json.loads(line))
|
|
260
|
+
except json.JSONDecodeError:
|
|
261
|
+
continue
|
|
262
|
+
except OSError:
|
|
263
|
+
pass
|
|
264
|
+
return traces
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def load_traces_for_ids(project_dir: str, trace_ids: list[str]) -> list[Trace]:
|
|
268
|
+
if not trace_ids:
|
|
269
|
+
return []
|
|
270
|
+
want = set(trace_ids)
|
|
271
|
+
out: list[Trace] = []
|
|
272
|
+
for row in _load_local_traces_raw(project_dir):
|
|
273
|
+
tid = row.get("id")
|
|
274
|
+
if tid and str(tid) in want:
|
|
275
|
+
try:
|
|
276
|
+
out.append(Trace.from_dict(row))
|
|
277
|
+
except Exception:
|
|
278
|
+
continue
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def attach_note_after_ledger(project_dir: str, ledger: dict[str, Any]) -> bool:
|
|
283
|
+
"""After a ledger is stored: build and attach a git note per project ``notes.*`` config.
|
|
284
|
+
|
|
285
|
+
Skips attachment when the ledger has zero AI lines — there's nothing
|
|
286
|
+
worth carrying on the commit, and an empty note just pollutes
|
|
287
|
+
``git log --show-notes``.
|
|
288
|
+
"""
|
|
289
|
+
try:
|
|
290
|
+
if _stats_from_ledger(_ledger_to_dict(ledger)).get("ai_lines", 0) <= 0:
|
|
291
|
+
return False
|
|
292
|
+
nc = _notes_config(project_dir)
|
|
293
|
+
if nc.get("enabled") is False:
|
|
294
|
+
return False
|
|
295
|
+
include_ledger = bool(nc.get("include_ledger", False))
|
|
296
|
+
include_summary = bool(nc.get("include_summary", False))
|
|
297
|
+
include_prompts = bool(nc.get("include_prompts", False))
|
|
298
|
+
include_asc = bool(nc.get("all_session_conversations", False))
|
|
299
|
+
from .summary import all_session_conversations_for_ledger, merge_note_summaries
|
|
300
|
+
|
|
301
|
+
static_s = nc.get("summaries") if isinstance(nc.get("summaries"), dict) else None
|
|
302
|
+
summaries = merge_note_summaries(project_dir, ledger, static_s)
|
|
303
|
+
|
|
304
|
+
tid_list = [str(x) for x in ledger.get("trace_ids", [])]
|
|
305
|
+
traces = load_traces_for_ids(project_dir, tid_list)
|
|
306
|
+
asc = all_session_conversations_for_ledger(project_dir, ledger) if include_asc else None
|
|
307
|
+
note = build_note(
|
|
308
|
+
ledger,
|
|
309
|
+
traces,
|
|
310
|
+
include_ledger=include_ledger,
|
|
311
|
+
include_summary=include_summary,
|
|
312
|
+
include_prompts=include_prompts,
|
|
313
|
+
summaries=summaries,
|
|
314
|
+
include_all_session_conversations=include_asc,
|
|
315
|
+
all_session_conversations=asc,
|
|
316
|
+
)
|
|
317
|
+
sha = str(ledger.get("commit_sha", ""))
|
|
318
|
+
if not sha:
|
|
319
|
+
return False
|
|
320
|
+
return attach_note(sha, note, project_dir)
|
|
321
|
+
except Exception:
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def rebuild_notes_for_range(
|
|
326
|
+
project_dir: str,
|
|
327
|
+
range_spec: str,
|
|
328
|
+
*,
|
|
329
|
+
include_ledger: bool | None = None,
|
|
330
|
+
include_summary: bool | None = None,
|
|
331
|
+
include_prompts: bool | None = None,
|
|
332
|
+
include_all_session_conversations: bool | None = None,
|
|
333
|
+
) -> int:
|
|
334
|
+
"""Rebuild notes from local ledgers for commits in ``range_spec``. Returns count updated."""
|
|
335
|
+
from .ledger import load_local_ledgers
|
|
336
|
+
|
|
337
|
+
nc = _notes_config(project_dir)
|
|
338
|
+
il = include_ledger if include_ledger is not None else bool(nc.get("include_ledger", False))
|
|
339
|
+
isum = include_summary if include_summary is not None else bool(nc.get("include_summary", False))
|
|
340
|
+
ipr = include_prompts if include_prompts is not None else bool(nc.get("include_prompts", False))
|
|
341
|
+
iasc = (
|
|
342
|
+
include_all_session_conversations
|
|
343
|
+
if include_all_session_conversations is not None
|
|
344
|
+
else bool(nc.get("all_session_conversations", False))
|
|
345
|
+
)
|
|
346
|
+
from .summary import all_session_conversations_for_ledger, merge_note_summaries
|
|
347
|
+
|
|
348
|
+
static_s = nc.get("summaries") if isinstance(nc.get("summaries"), dict) else None
|
|
349
|
+
|
|
350
|
+
ledgers = load_local_ledgers(project_dir)
|
|
351
|
+
out = _git("rev-list", range_spec, cwd=project_dir)
|
|
352
|
+
if not out:
|
|
353
|
+
return 0
|
|
354
|
+
shas = [ln.strip() for ln in out.splitlines() if ln.strip()]
|
|
355
|
+
count = 0
|
|
356
|
+
for sha in shas:
|
|
357
|
+
led = ledgers.get(sha)
|
|
358
|
+
if not led:
|
|
359
|
+
continue
|
|
360
|
+
tid_list = [str(x) for x in led.get("trace_ids", [])]
|
|
361
|
+
traces = load_traces_for_ids(project_dir, tid_list)
|
|
362
|
+
summaries = merge_note_summaries(project_dir, led, static_s)
|
|
363
|
+
asc = all_session_conversations_for_ledger(project_dir, led) if iasc else None
|
|
364
|
+
note = build_note(
|
|
365
|
+
led,
|
|
366
|
+
traces,
|
|
367
|
+
include_ledger=il,
|
|
368
|
+
include_summary=isum,
|
|
369
|
+
include_prompts=ipr,
|
|
370
|
+
summaries=summaries,
|
|
371
|
+
include_all_session_conversations=iasc,
|
|
372
|
+
all_session_conversations=asc,
|
|
373
|
+
)
|
|
374
|
+
if attach_note(sha, note, project_dir):
|
|
375
|
+
count += 1
|
|
376
|
+
return count
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def backfill_notes(
|
|
380
|
+
project_dir: str,
|
|
381
|
+
*,
|
|
382
|
+
since: str | None = None,
|
|
383
|
+
include_ledger: bool | None = None,
|
|
384
|
+
include_summary: bool | None = None,
|
|
385
|
+
include_prompts: bool | None = None,
|
|
386
|
+
include_all_session_conversations: bool | None = None,
|
|
387
|
+
) -> int:
|
|
388
|
+
"""Rebuild notes for commits touching the repo since a date (``git rev-list --since``)."""
|
|
389
|
+
parts: list[str] = ["rev-list"]
|
|
390
|
+
if since:
|
|
391
|
+
parts.extend(["--since", since])
|
|
392
|
+
parts.append("HEAD")
|
|
393
|
+
out = _git(*parts, cwd=project_dir)
|
|
394
|
+
if not out:
|
|
395
|
+
return 0
|
|
396
|
+
shas = [ln.strip() for ln in out.splitlines() if ln.strip()]
|
|
397
|
+
if not shas:
|
|
398
|
+
return 0
|
|
399
|
+
# rev-list outputs oldest-first; rebuild_range needs a single spec — use chained parents
|
|
400
|
+
# Simplest: rebuild each sha (inefficient but clear)
|
|
401
|
+
from .ledger import load_local_ledgers
|
|
402
|
+
|
|
403
|
+
nc = _notes_config(project_dir)
|
|
404
|
+
il = include_ledger if include_ledger is not None else bool(nc.get("include_ledger", False))
|
|
405
|
+
isum = include_summary if include_summary is not None else bool(nc.get("include_summary", False))
|
|
406
|
+
ipr = include_prompts if include_prompts is not None else bool(nc.get("include_prompts", False))
|
|
407
|
+
iasc = (
|
|
408
|
+
include_all_session_conversations
|
|
409
|
+
if include_all_session_conversations is not None
|
|
410
|
+
else bool(nc.get("all_session_conversations", False))
|
|
411
|
+
)
|
|
412
|
+
from .summary import all_session_conversations_for_ledger, merge_note_summaries
|
|
413
|
+
|
|
414
|
+
static_s = nc.get("summaries") if isinstance(nc.get("summaries"), dict) else None
|
|
415
|
+
|
|
416
|
+
ledgers = load_local_ledgers(project_dir)
|
|
417
|
+
count = 0
|
|
418
|
+
for sha in shas:
|
|
419
|
+
led = ledgers.get(sha)
|
|
420
|
+
if not led:
|
|
421
|
+
continue
|
|
422
|
+
tid_list = [str(x) for x in led.get("trace_ids", [])]
|
|
423
|
+
traces = load_traces_for_ids(project_dir, tid_list)
|
|
424
|
+
summaries = merge_note_summaries(project_dir, led, static_s)
|
|
425
|
+
asc = all_session_conversations_for_ledger(project_dir, led) if iasc else None
|
|
426
|
+
note = build_note(
|
|
427
|
+
led,
|
|
428
|
+
traces,
|
|
429
|
+
include_ledger=il,
|
|
430
|
+
include_summary=isum,
|
|
431
|
+
include_prompts=ipr,
|
|
432
|
+
summaries=summaries,
|
|
433
|
+
include_all_session_conversations=iasc,
|
|
434
|
+
all_session_conversations=asc,
|
|
435
|
+
)
|
|
436
|
+
if attach_note(sha, note, project_dir):
|
|
437
|
+
count += 1
|
|
438
|
+
return count
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def git_notes_push(repo_dir: str, remote: str = "origin") -> tuple[bool, str]:
|
|
442
|
+
"""Run ``git push`` for the agent-trace notes ref."""
|
|
443
|
+
try:
|
|
444
|
+
r = subprocess.run(
|
|
445
|
+
[
|
|
446
|
+
"git",
|
|
447
|
+
"push",
|
|
448
|
+
remote,
|
|
449
|
+
f"refs/notes/{NOTE_REF}:refs/notes/{NOTE_REF}",
|
|
450
|
+
],
|
|
451
|
+
capture_output=True,
|
|
452
|
+
text=True,
|
|
453
|
+
cwd=repo_dir,
|
|
454
|
+
timeout=120,
|
|
455
|
+
)
|
|
456
|
+
msg = (r.stderr or r.stdout or "").strip()
|
|
457
|
+
return r.returncode == 0, msg or "ok"
|
|
458
|
+
except Exception as e:
|
|
459
|
+
return False, str(e)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def git_notes_pull(repo_dir: str, remote: str = "origin") -> tuple[bool, str]:
|
|
463
|
+
"""Run ``git fetch`` for the agent-trace notes ref."""
|
|
464
|
+
try:
|
|
465
|
+
r = subprocess.run(
|
|
466
|
+
[
|
|
467
|
+
"git",
|
|
468
|
+
"fetch",
|
|
469
|
+
remote,
|
|
470
|
+
f"refs/notes/{NOTE_REF}:refs/notes/{NOTE_REF}",
|
|
471
|
+
],
|
|
472
|
+
capture_output=True,
|
|
473
|
+
text=True,
|
|
474
|
+
cwd=repo_dir,
|
|
475
|
+
timeout=120,
|
|
476
|
+
)
|
|
477
|
+
msg = (r.stderr or r.stdout or "").strip()
|
|
478
|
+
return r.returncode == 0, msg or "ok"
|
|
479
|
+
except Exception as e:
|
|
480
|
+
return False, str(e)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def ledger_from_note_for_blame(note: dict[str, Any]) -> dict[str, Any] | None:
|
|
484
|
+
"""Return a minimal ledger dict for blame if the note embeds ``ledger``."""
|
|
485
|
+
led = note.get("ledger")
|
|
486
|
+
if not isinstance(led, dict):
|
|
487
|
+
return None
|
|
488
|
+
files = led.get("files")
|
|
489
|
+
if not isinstance(files, dict) or not files:
|
|
490
|
+
return None
|
|
491
|
+
return {"files": files}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Coding-agent adapters and the registry that drives them.
|
|
2
|
+
|
|
3
|
+
To add a new agent:
|
|
4
|
+
|
|
5
|
+
1. Create ``agent_trace/hooks/<agent>.py`` with a class that subclasses
|
|
6
|
+
``CodingAgentAdapter`` (see ``base.py`` for the contract).
|
|
7
|
+
2. Implement ``inject`` / ``remove`` / ``global_config_path`` for the
|
|
8
|
+
agent's hook surface.
|
|
9
|
+
3. Optionally declare ``EVENTS`` (event-name → translator), a
|
|
10
|
+
``rules_dir``/``rule_extension`` and a ``detect_tool_info``.
|
|
11
|
+
4. Register the adapter at the bottom of this file with
|
|
12
|
+
``register_adapter(NewAdapter())``.
|
|
13
|
+
|
|
14
|
+
Nothing else in the codebase should hardcode the agent's name —
|
|
15
|
+
``cli.py``, ``record.py``, ``rules.py`` and ``trace.get_tool_info``
|
|
16
|
+
all walk the registry.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .base import AGENT_TRACE_CMD, Adapter, CodingAgentAdapter
|
|
22
|
+
from .claude import ClaudeAdapter
|
|
23
|
+
from .codex import CodexAdapter
|
|
24
|
+
from .cursor import CursorAdapter
|
|
25
|
+
|
|
26
|
+
_ADAPTERS: dict[str, CodingAgentAdapter] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register_adapter(adapter: CodingAgentAdapter) -> None:
|
|
30
|
+
_ADAPTERS[adapter.name] = adapter
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def iter_adapters() -> list[CodingAgentAdapter]:
|
|
34
|
+
return list(_ADAPTERS.values())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_adapter(name: str) -> CodingAgentAdapter | None:
|
|
38
|
+
return _ADAPTERS.get(name)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def adapter_names() -> list[str]:
|
|
42
|
+
return list(_ADAPTERS)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Built-in adapters. Order matters for CLI choices and doctor output.
|
|
46
|
+
register_adapter(CursorAdapter())
|
|
47
|
+
register_adapter(ClaudeAdapter())
|
|
48
|
+
register_adapter(CodexAdapter())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------
|
|
52
|
+
# Back-compat tool-specific shims (cursor / claude). Kept for callers
|
|
53
|
+
# that still import the named functions; new code should iterate the
|
|
54
|
+
# registry via ``iter_adapters`` / ``setup_global_hooks``.
|
|
55
|
+
# ---------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def configure_cursor_hooks(project_dir: str | None = None, *, global_install: bool = False) -> bool:
|
|
59
|
+
return _ADAPTERS["cursor"].inject(
|
|
60
|
+
AGENT_TRACE_CMD,
|
|
61
|
+
project_dir=project_dir,
|
|
62
|
+
global_install=global_install,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def configure_claude_hooks(project_dir: str | None = None, *, global_install: bool = False) -> bool:
|
|
67
|
+
return _ADAPTERS["claude"].inject(
|
|
68
|
+
AGENT_TRACE_CMD,
|
|
69
|
+
project_dir=project_dir,
|
|
70
|
+
global_install=global_install,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def has_global_cursor_hooks() -> bool:
|
|
75
|
+
return _ADAPTERS["cursor"].is_installed()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def has_global_claude_hooks() -> bool:
|
|
79
|
+
return _ADAPTERS["claude"].is_installed()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def has_global_hooks(tool: str | None = None) -> bool:
|
|
83
|
+
if tool is None:
|
|
84
|
+
return any(adapter.is_installed() for adapter in iter_adapters())
|
|
85
|
+
adapter = _ADAPTERS.get(tool)
|
|
86
|
+
return adapter.is_installed() if adapter else False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def remove_global_cursor_hooks() -> bool:
|
|
90
|
+
return _ADAPTERS["cursor"].remove()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def remove_global_claude_hooks() -> bool:
|
|
94
|
+
return _ADAPTERS["claude"].remove()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def configure_project_hooks(
|
|
98
|
+
project_dir: str | None = None,
|
|
99
|
+
tools: list[str] | None = None,
|
|
100
|
+
) -> dict[str, bool]:
|
|
101
|
+
"""Run ``inject`` (project-level) for every adapter or a subset.
|
|
102
|
+
|
|
103
|
+
Replaces the per-tool ``configure_*_hooks`` calls in ``init`` so new
|
|
104
|
+
adapters automatically participate.
|
|
105
|
+
"""
|
|
106
|
+
if tools is None:
|
|
107
|
+
tools = list(_ADAPTERS)
|
|
108
|
+
out: dict[str, bool] = {}
|
|
109
|
+
for tool in tools:
|
|
110
|
+
adapter = _ADAPTERS.get(tool)
|
|
111
|
+
if adapter is None:
|
|
112
|
+
continue
|
|
113
|
+
out[tool] = adapter.inject(
|
|
114
|
+
AGENT_TRACE_CMD,
|
|
115
|
+
project_dir=project_dir,
|
|
116
|
+
global_install=False,
|
|
117
|
+
)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def setup_global_hooks(tools: list[str] | None = None) -> dict[str, bool]:
|
|
122
|
+
if tools is None:
|
|
123
|
+
tools = list(_ADAPTERS)
|
|
124
|
+
results: dict[str, bool] = {}
|
|
125
|
+
for tool in tools:
|
|
126
|
+
adapter = _ADAPTERS.get(tool)
|
|
127
|
+
if adapter is None:
|
|
128
|
+
continue
|
|
129
|
+
results[tool] = adapter.inject(AGENT_TRACE_CMD, global_install=True)
|
|
130
|
+
return results
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def remove_global_hooks(tools: list[str] | None = None) -> dict[str, bool]:
|
|
134
|
+
if tools is None:
|
|
135
|
+
tools = list(_ADAPTERS)
|
|
136
|
+
results: dict[str, bool] = {}
|
|
137
|
+
for tool in tools:
|
|
138
|
+
adapter = _ADAPTERS.get(tool)
|
|
139
|
+
if adapter is None:
|
|
140
|
+
continue
|
|
141
|
+
results[tool] = adapter.remove()
|
|
142
|
+
return results
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = [
|
|
146
|
+
"AGENT_TRACE_CMD",
|
|
147
|
+
"Adapter",
|
|
148
|
+
"CodingAgentAdapter",
|
|
149
|
+
"register_adapter",
|
|
150
|
+
"iter_adapters",
|
|
151
|
+
"get_adapter",
|
|
152
|
+
"adapter_names",
|
|
153
|
+
"setup_global_hooks",
|
|
154
|
+
"remove_global_hooks",
|
|
155
|
+
"configure_project_hooks",
|
|
156
|
+
"configure_cursor_hooks",
|
|
157
|
+
"configure_claude_hooks",
|
|
158
|
+
"has_global_cursor_hooks",
|
|
159
|
+
"has_global_claude_hooks",
|
|
160
|
+
"has_global_hooks",
|
|
161
|
+
"remove_global_cursor_hooks",
|
|
162
|
+
"remove_global_claude_hooks",
|
|
163
|
+
]
|