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/ledger.py
ADDED
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Attribution ledger — deterministic per-line AI attribution at commit time.
|
|
3
|
+
|
|
4
|
+
Schema 2.0. Built by the post-commit hook immediately after ``git commit``.
|
|
5
|
+
|
|
6
|
+
Design:
|
|
7
|
+
* Only ``ai`` segments are recorded. Anything not recorded is implicitly
|
|
8
|
+
NO_ATTRIBUTION (we never claim "this was a human" — we only ever assert
|
|
9
|
+
"this matches an AI trace").
|
|
10
|
+
* Attribution is content-driven only: a line is AI if its SHA-256 line
|
|
11
|
+
hash matches a hash in a candidate trace.
|
|
12
|
+
* Two-pass matching:
|
|
13
|
+
1. Primary pass — staging-window-scoped candidates. A trace must have
|
|
14
|
+
``vcs.revision == parent_sha`` and ``timestamp`` inside the staging
|
|
15
|
+
window. This is the strict, original path; cheap and unambiguous.
|
|
16
|
+
2. Global fallback — for lines still unmatched after the primary pass,
|
|
17
|
+
match against the union of all local traces and all local ledger
|
|
18
|
+
evidence (per-line hashes from previously-built ledgers). This
|
|
19
|
+
recovers attribution under cherry-pick, revert, squash-merge, stash
|
|
20
|
+
pop, and other operations where ``parent_sha`` does not match the
|
|
21
|
+
``vcs.revision`` recorded in the original trace. Hash equality
|
|
22
|
+
(SHA-256) is the sole criterion — still deterministic.
|
|
23
|
+
* Operation detection: at commit time we look at the commit message for
|
|
24
|
+
cherry-pick (``-x`` trailer) and revert (default message body) signals,
|
|
25
|
+
and at parent count for merges. When detected, the source commit's
|
|
26
|
+
ledger (if reachable locally) is folded into the global pool so its
|
|
27
|
+
hashes are first-class candidates. Recorded as ``derived_from`` on the
|
|
28
|
+
ledger for explainability.
|
|
29
|
+
* Each AI segment carries ``evidence`` — the matched per-line hash + the
|
|
30
|
+
verbatim line content — so attribution can be audited manually.
|
|
31
|
+
|
|
32
|
+
No external dependencies — stdlib only.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
import re
|
|
40
|
+
import subprocess
|
|
41
|
+
from datetime import datetime, timedelta, timezone
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# -------------------------------------------------------------------
|
|
47
|
+
# Git helpers
|
|
48
|
+
# -------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def _git(*args: str, cwd: str | None = None) -> str | None:
|
|
51
|
+
"""Run a git command and return stripped stdout, or None on failure."""
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
["git", *args],
|
|
55
|
+
capture_output=True, text=True, cwd=cwd, timeout=30,
|
|
56
|
+
)
|
|
57
|
+
if result.returncode == 0:
|
|
58
|
+
return result.stdout.strip()
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _git_raw(*args: str, cwd: str | None = None) -> str | None:
|
|
65
|
+
"""Run a git command and return raw stdout (not stripped), or None."""
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
["git", *args],
|
|
69
|
+
capture_output=True, text=True, cwd=cwd, timeout=30,
|
|
70
|
+
)
|
|
71
|
+
if result.returncode == 0:
|
|
72
|
+
return result.stdout
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_rename_map(
|
|
79
|
+
parent_sha: str,
|
|
80
|
+
commit_sha: str,
|
|
81
|
+
cwd: str | None = None,
|
|
82
|
+
) -> dict[str, str]:
|
|
83
|
+
"""Return ``{ new_path: old_path }`` for renames between parent and commit."""
|
|
84
|
+
out = _git_raw("diff", "--find-renames", "--name-status", parent_sha, commit_sha, cwd=cwd)
|
|
85
|
+
if not out:
|
|
86
|
+
return {}
|
|
87
|
+
renames: dict[str, str] = {}
|
|
88
|
+
for line in out.splitlines():
|
|
89
|
+
line = line.strip()
|
|
90
|
+
if not line:
|
|
91
|
+
continue
|
|
92
|
+
parts = line.split("\t")
|
|
93
|
+
if len(parts) < 3:
|
|
94
|
+
continue
|
|
95
|
+
status = parts[0]
|
|
96
|
+
if not status.startswith("R"):
|
|
97
|
+
continue
|
|
98
|
+
old_path, new_path = parts[1], parts[2]
|
|
99
|
+
renames[new_path] = old_path
|
|
100
|
+
return renames
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# -------------------------------------------------------------------
|
|
104
|
+
# Diff parsing
|
|
105
|
+
# -------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _parse_diff_ranges(diff_output: str) -> list[tuple[int, int]]:
|
|
108
|
+
"""Parse unified diff ``@@`` headers to find added/modified line ranges
|
|
109
|
+
on the *new* side. Returns ``[(start_line, end_line), …]``, 1-indexed."""
|
|
110
|
+
ranges: list[tuple[int, int]] = []
|
|
111
|
+
hunk_re = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
112
|
+
|
|
113
|
+
in_hunk = False
|
|
114
|
+
current_new_line = 0
|
|
115
|
+
add_start: int | None = None
|
|
116
|
+
|
|
117
|
+
for line in diff_output.split("\n"):
|
|
118
|
+
m = hunk_re.match(line)
|
|
119
|
+
if m:
|
|
120
|
+
if add_start is not None:
|
|
121
|
+
ranges.append((add_start, current_new_line - 1))
|
|
122
|
+
add_start = None
|
|
123
|
+
current_new_line = int(m.group(1))
|
|
124
|
+
in_hunk = True
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
if not in_hunk:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
if line.startswith("\\"):
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
if line.startswith("+"):
|
|
134
|
+
if add_start is None:
|
|
135
|
+
add_start = current_new_line
|
|
136
|
+
current_new_line += 1
|
|
137
|
+
elif line.startswith("-"):
|
|
138
|
+
if add_start is not None:
|
|
139
|
+
ranges.append((add_start, current_new_line - 1))
|
|
140
|
+
add_start = None
|
|
141
|
+
else:
|
|
142
|
+
if add_start is not None:
|
|
143
|
+
ranges.append((add_start, current_new_line - 1))
|
|
144
|
+
add_start = None
|
|
145
|
+
current_new_line += 1
|
|
146
|
+
|
|
147
|
+
if add_start is not None:
|
|
148
|
+
ranges.append((add_start, current_new_line - 1))
|
|
149
|
+
|
|
150
|
+
return ranges
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# -------------------------------------------------------------------
|
|
154
|
+
# Line hashing
|
|
155
|
+
# -------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def _line_hash(line: str) -> str:
|
|
158
|
+
h = hashlib.sha256(line.encode("utf-8")).hexdigest()
|
|
159
|
+
return f"sha256:{h}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _compute_file_lines(content: str) -> list[str]:
|
|
163
|
+
"""Return file content as a list of lines, 0-indexed (line N → result[N-1])."""
|
|
164
|
+
lines = content.split("\n")
|
|
165
|
+
if lines and lines[-1] == "":
|
|
166
|
+
lines = lines[:-1]
|
|
167
|
+
return lines
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _is_trivial(line: str) -> bool:
|
|
171
|
+
"""Empty / whitespace-only lines collide across all files; treated specially."""
|
|
172
|
+
return line.strip() == ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# -------------------------------------------------------------------
|
|
176
|
+
# Trace candidate selection (staging window)
|
|
177
|
+
# -------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def _parse_iso(s: str | None) -> datetime | None:
|
|
180
|
+
if not s:
|
|
181
|
+
return None
|
|
182
|
+
try:
|
|
183
|
+
return datetime.fromisoformat(s)
|
|
184
|
+
except (ValueError, TypeError):
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _find_candidate_traces(
|
|
189
|
+
project_dir: str,
|
|
190
|
+
parents: list[tuple[str, str | None]],
|
|
191
|
+
committed_at: str | None,
|
|
192
|
+
) -> list[dict[str, Any]]:
|
|
193
|
+
"""Return traces eligible to attribute the current commit.
|
|
194
|
+
|
|
195
|
+
``parents`` is a list of ``(parent_sha, parent_committed_at)`` tuples.
|
|
196
|
+
Empty list means first commit. Multi-element list means merge commit.
|
|
197
|
+
|
|
198
|
+
Eligibility (must satisfy all):
|
|
199
|
+
* Trace's ``vcs.revision`` equals **some** parent SHA, or trace has
|
|
200
|
+
no vcs at all (which counts only on a first commit).
|
|
201
|
+
* Trace's ``timestamp`` is strictly after **that specific parent's**
|
|
202
|
+
``committed_at``. Each parent has its own staging-window lower
|
|
203
|
+
bound — a trace from main's history shouldn't be admitted because
|
|
204
|
+
of feature's later commit time, and vice versa.
|
|
205
|
+
* Trace's ``timestamp`` is no later than ``committed_at`` plus a
|
|
206
|
+
small buffer.
|
|
207
|
+
"""
|
|
208
|
+
from .storage import get_traces_path, resolve_project_id
|
|
209
|
+
|
|
210
|
+
pid = resolve_project_id(project_dir, create=False)
|
|
211
|
+
if not pid:
|
|
212
|
+
return []
|
|
213
|
+
traces_path = get_traces_path(pid)
|
|
214
|
+
if not traces_path.exists():
|
|
215
|
+
return []
|
|
216
|
+
|
|
217
|
+
all_traces: list[dict[str, Any]] = []
|
|
218
|
+
try:
|
|
219
|
+
for raw in traces_path.read_text().splitlines():
|
|
220
|
+
raw = raw.strip()
|
|
221
|
+
if not raw:
|
|
222
|
+
continue
|
|
223
|
+
try:
|
|
224
|
+
all_traces.append(json.loads(raw))
|
|
225
|
+
except json.JSONDecodeError:
|
|
226
|
+
continue
|
|
227
|
+
except OSError:
|
|
228
|
+
return []
|
|
229
|
+
|
|
230
|
+
parent_lowers: dict[str, datetime | None] = {
|
|
231
|
+
sha: _parse_iso(ct) for sha, ct in parents
|
|
232
|
+
}
|
|
233
|
+
upper_base = _parse_iso(committed_at)
|
|
234
|
+
upper = (upper_base + timedelta(minutes=10)) if upper_base else None
|
|
235
|
+
|
|
236
|
+
candidates: list[dict[str, Any]] = []
|
|
237
|
+
for t in all_traces:
|
|
238
|
+
ts = _parse_iso(t.get("timestamp"))
|
|
239
|
+
if ts is None:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
vcs = t.get("vcs") or {}
|
|
243
|
+
rev = vcs.get("revision")
|
|
244
|
+
|
|
245
|
+
if not parents:
|
|
246
|
+
# First commit — accept traces with no vcs and undefined parent
|
|
247
|
+
# context. (Pre-init traces in a fresh repo.)
|
|
248
|
+
if rev:
|
|
249
|
+
continue
|
|
250
|
+
lower = None
|
|
251
|
+
else:
|
|
252
|
+
if not rev or rev not in parent_lowers:
|
|
253
|
+
continue
|
|
254
|
+
lower = parent_lowers[rev]
|
|
255
|
+
|
|
256
|
+
if lower is not None and ts <= lower:
|
|
257
|
+
continue
|
|
258
|
+
if upper is not None and ts > upper:
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
candidates.append(t)
|
|
262
|
+
|
|
263
|
+
return candidates
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def list_traces_in_staging_window(
|
|
267
|
+
project_dir: str,
|
|
268
|
+
parents: list[tuple[str, str | None]],
|
|
269
|
+
committed_at: str | None,
|
|
270
|
+
) -> list[dict[str, Any]]:
|
|
271
|
+
"""Return all local trace records in the same eligibility window as
|
|
272
|
+
:func:`build_attribution_ledger`.
|
|
273
|
+
|
|
274
|
+
Used for git notes ``all_session_conversations``: every conversation
|
|
275
|
+
URL from traces eligible for this commit, not only lines that ended
|
|
276
|
+
up in the ledger.
|
|
277
|
+
"""
|
|
278
|
+
return _find_candidate_traces(project_dir, parents, committed_at)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _load_all_traces(project_dir: str) -> list[dict[str, Any]]:
|
|
282
|
+
"""Load every local trace, with no parent / time filtering.
|
|
283
|
+
|
|
284
|
+
Used to seed the global fallback hash index. The primary
|
|
285
|
+
parent-filtered pass runs first; only lines it didn't match are
|
|
286
|
+
rechecked against this pool, so the wider scope cannot override
|
|
287
|
+
correct attributions — it can only fill gaps.
|
|
288
|
+
"""
|
|
289
|
+
from .storage import get_traces_path, resolve_project_id
|
|
290
|
+
|
|
291
|
+
pid = resolve_project_id(project_dir, create=False)
|
|
292
|
+
if not pid:
|
|
293
|
+
return []
|
|
294
|
+
traces_path = get_traces_path(pid)
|
|
295
|
+
if not traces_path.exists():
|
|
296
|
+
return []
|
|
297
|
+
out: list[dict[str, Any]] = []
|
|
298
|
+
try:
|
|
299
|
+
for raw in traces_path.read_text().splitlines():
|
|
300
|
+
raw = raw.strip()
|
|
301
|
+
if not raw:
|
|
302
|
+
continue
|
|
303
|
+
try:
|
|
304
|
+
out.append(json.loads(raw))
|
|
305
|
+
except json.JSONDecodeError:
|
|
306
|
+
continue
|
|
307
|
+
except OSError:
|
|
308
|
+
pass
|
|
309
|
+
return out
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# -------------------------------------------------------------------
|
|
313
|
+
# Derived-commit detection (cherry-pick, revert, merge)
|
|
314
|
+
# -------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
_CHERRY_PICK_TRAILER_RE = re.compile(
|
|
317
|
+
r"\(cherry picked from commit ([0-9a-f]{7,40})\)", re.IGNORECASE
|
|
318
|
+
)
|
|
319
|
+
_REVERT_BODY_RE = re.compile(
|
|
320
|
+
r"^This reverts commit ([0-9a-f]{7,40})\.", re.MULTILINE
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _detect_derived_from(
|
|
325
|
+
commit_sha: str, parents: list[str], project_dir: str,
|
|
326
|
+
) -> dict[str, Any] | None:
|
|
327
|
+
"""Inspect HEAD's commit message and parent count to determine whether
|
|
328
|
+
this commit was produced by cherry-pick (``-x``), revert, or merge.
|
|
329
|
+
|
|
330
|
+
Returns a dict like ``{"kind": "cherry-pick", "source_sha": "abcd..."}``
|
|
331
|
+
or ``None`` if the commit looks ordinary. ``source_sha`` is resolved to
|
|
332
|
+
a full SHA when possible.
|
|
333
|
+
"""
|
|
334
|
+
if len(parents) > 1:
|
|
335
|
+
return {"kind": "merge", "parents": parents}
|
|
336
|
+
|
|
337
|
+
msg = _git_raw("log", "-1", "--format=%B", commit_sha, cwd=project_dir) or ""
|
|
338
|
+
|
|
339
|
+
m = _CHERRY_PICK_TRAILER_RE.search(msg)
|
|
340
|
+
if m:
|
|
341
|
+
src = m.group(1)
|
|
342
|
+
full = _git("rev-parse", "--verify", src, cwd=project_dir) or src
|
|
343
|
+
return {"kind": "cherry-pick", "source_sha": full}
|
|
344
|
+
|
|
345
|
+
m = _REVERT_BODY_RE.search(msg)
|
|
346
|
+
if m:
|
|
347
|
+
src = m.group(1)
|
|
348
|
+
full = _git("rev-parse", "--verify", src, cwd=project_dir) or src
|
|
349
|
+
return {"kind": "revert", "source_sha": full}
|
|
350
|
+
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _ledger_evidence_to_index_entries(
|
|
355
|
+
ledger: dict[str, Any],
|
|
356
|
+
file_path: str,
|
|
357
|
+
alternate_paths: list[str] | None,
|
|
358
|
+
cross_file: bool,
|
|
359
|
+
) -> dict[str, dict[str, Any]]:
|
|
360
|
+
"""Turn a previously-built ledger's per-line evidence into a hash index
|
|
361
|
+
entry shape (the same shape :func:`_build_trace_hash_index` produces).
|
|
362
|
+
|
|
363
|
+
The ledger's segments already carry ``trace_id``, ``model_id``, and
|
|
364
|
+
``conversation_id``; ``evidence`` carries the per-line hashes. There
|
|
365
|
+
is no ``edit_sequence`` on the ledger side — entries built from ledger
|
|
366
|
+
evidence are tagged with ``edit_sequence=None`` so trace-derived
|
|
367
|
+
entries (which do carry edit sequence) win on tie-break.
|
|
368
|
+
"""
|
|
369
|
+
out: dict[str, dict[str, Any]] = {}
|
|
370
|
+
files = ledger.get("files") or {}
|
|
371
|
+
if not isinstance(files, dict):
|
|
372
|
+
return out
|
|
373
|
+
for fp, fl in files.items():
|
|
374
|
+
if not isinstance(fl, dict):
|
|
375
|
+
continue
|
|
376
|
+
if not cross_file and not _trace_file_matches(fp, file_path, alternate_paths):
|
|
377
|
+
continue
|
|
378
|
+
for seg in fl.get("line_attributions", []) or []:
|
|
379
|
+
if not isinstance(seg, dict):
|
|
380
|
+
continue
|
|
381
|
+
if str(seg.get("type", "")).lower() != "ai":
|
|
382
|
+
continue
|
|
383
|
+
tid = seg.get("trace_id") or ""
|
|
384
|
+
entry = {
|
|
385
|
+
"trace_id": tid,
|
|
386
|
+
"model_id": seg.get("model_id"),
|
|
387
|
+
"tool": None,
|
|
388
|
+
"conversation_id": seg.get("conversation_id"),
|
|
389
|
+
"edit_sequence": None,
|
|
390
|
+
}
|
|
391
|
+
for ev in seg.get("evidence", []) or []:
|
|
392
|
+
if not isinstance(ev, dict):
|
|
393
|
+
continue
|
|
394
|
+
h = ev.get("hash") or ""
|
|
395
|
+
if not h:
|
|
396
|
+
continue
|
|
397
|
+
# First seen wins; primary trace pool is merged on top later
|
|
398
|
+
# so trace edit_sequence still beats ledger evidence.
|
|
399
|
+
if h not in out:
|
|
400
|
+
out[h] = entry
|
|
401
|
+
return out
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def diff_signature(commit_sha: str, project_dir: str) -> str | None:
|
|
405
|
+
"""Return a SHA-256 over the unified diff ``parent..commit_sha`` (across
|
|
406
|
+
every changed file's diff for merge commits, against parent #1 — same
|
|
407
|
+
surface :func:`build_attribution_ledger` consumes).
|
|
408
|
+
|
|
409
|
+
Used by post-rewrite to detect whether an amend / rebase changed the
|
|
410
|
+
tree. If the signature differs between old and new SHAs, the existing
|
|
411
|
+
ledger row is stale and must be rebuilt from the new commit.
|
|
412
|
+
|
|
413
|
+
Returns ``None`` if the commit is not reachable or the diff cannot be
|
|
414
|
+
computed (e.g. the old SHA was already pruned).
|
|
415
|
+
"""
|
|
416
|
+
if not commit_sha:
|
|
417
|
+
return None
|
|
418
|
+
parent = _git("rev-parse", "--verify", f"{commit_sha}^", cwd=project_dir)
|
|
419
|
+
if parent:
|
|
420
|
+
diff = _git_raw("diff", parent, commit_sha, cwd=project_dir)
|
|
421
|
+
else:
|
|
422
|
+
empty_tree = "4b825dc642cb6eb9a060e54bf899d15f3f4b7b18"
|
|
423
|
+
diff = _git_raw("diff", empty_tree, commit_sha, cwd=project_dir)
|
|
424
|
+
if diff is None:
|
|
425
|
+
return None
|
|
426
|
+
return hashlib.sha256(diff.encode("utf-8")).hexdigest()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _build_global_hash_index(
|
|
430
|
+
project_dir: str,
|
|
431
|
+
file_path: str,
|
|
432
|
+
alternate_paths: list[str] | None,
|
|
433
|
+
*,
|
|
434
|
+
derived_from: dict[str, Any] | None,
|
|
435
|
+
cross_file: bool,
|
|
436
|
+
) -> dict[str, dict[str, Any]]:
|
|
437
|
+
"""Build the global fallback hash index.
|
|
438
|
+
|
|
439
|
+
Sources, in priority order (later sources fill gaps left by earlier ones):
|
|
440
|
+
1. All local traces (file-scoped or cross-file per ``cross_file`` flag).
|
|
441
|
+
2. All local ledger evidence (every previously-built ledger's per-line
|
|
442
|
+
hashes), so cherry-picks / reverts / squashes find attribution
|
|
443
|
+
even when the original traces were pruned.
|
|
444
|
+
3. If a derived commit was detected, the source commit's ledger is
|
|
445
|
+
loaded explicitly — redundant when (2) already includes it, but
|
|
446
|
+
cheap and ensures the source is in scope when ledger evidence is
|
|
447
|
+
skipped (e.g. fresh clone with notes only).
|
|
448
|
+
|
|
449
|
+
Trace-derived entries beat ledger-derived entries on hash collision
|
|
450
|
+
because they carry ``edit_sequence`` (latest edit wins).
|
|
451
|
+
"""
|
|
452
|
+
all_traces = _load_all_traces(project_dir)
|
|
453
|
+
index = _build_trace_hash_index(
|
|
454
|
+
all_traces, file_path, alternate_paths=alternate_paths, cross_file=cross_file,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Add ledger-evidence entries (don't overwrite trace entries).
|
|
458
|
+
ledgers = load_local_ledgers(project_dir)
|
|
459
|
+
for sha, ledger in ledgers.items():
|
|
460
|
+
ev_index = _ledger_evidence_to_index_entries(
|
|
461
|
+
ledger, file_path, alternate_paths, cross_file,
|
|
462
|
+
)
|
|
463
|
+
for h, entry in ev_index.items():
|
|
464
|
+
if h not in index:
|
|
465
|
+
index[h] = entry
|
|
466
|
+
|
|
467
|
+
# Explicitly include the source commit of a cherry-pick / revert.
|
|
468
|
+
if derived_from and derived_from.get("kind") in ("cherry-pick", "revert"):
|
|
469
|
+
src_sha = str(derived_from.get("source_sha") or "")
|
|
470
|
+
src_ledger = ledgers.get(src_sha)
|
|
471
|
+
if src_ledger:
|
|
472
|
+
ev_index = _ledger_evidence_to_index_entries(
|
|
473
|
+
src_ledger, file_path, alternate_paths, cross_file,
|
|
474
|
+
)
|
|
475
|
+
for h, entry in ev_index.items():
|
|
476
|
+
if h not in index:
|
|
477
|
+
index[h] = entry
|
|
478
|
+
|
|
479
|
+
return index
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# -------------------------------------------------------------------
|
|
483
|
+
# Trace hash index (with content)
|
|
484
|
+
# -------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
def _trace_file_matches(
|
|
487
|
+
fpath: str,
|
|
488
|
+
primary: str,
|
|
489
|
+
alternates: list[str] | None,
|
|
490
|
+
) -> bool:
|
|
491
|
+
"""Whether trace's file path refers to ``primary`` or an alternate (e.g. pre-rename)."""
|
|
492
|
+
if fpath == primary or fpath.endswith(primary) or primary.endswith(fpath):
|
|
493
|
+
return True
|
|
494
|
+
if alternates:
|
|
495
|
+
for alt in alternates:
|
|
496
|
+
if fpath == alt or fpath.endswith(alt) or alt.endswith(fpath):
|
|
497
|
+
return True
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _build_trace_hash_index(
|
|
502
|
+
traces: list[dict[str, Any]],
|
|
503
|
+
file_path: str,
|
|
504
|
+
alternate_paths: list[str] | None = None,
|
|
505
|
+
cross_file: bool = False,
|
|
506
|
+
) -> dict[str, dict[str, Any]]:
|
|
507
|
+
"""Map ``hash → {trace_id, model_id, tool, conversation_id, edit_sequence}``.
|
|
508
|
+
|
|
509
|
+
Full 256-bit SHA-256 hashes give cryptographic uniqueness, so a hash
|
|
510
|
+
match is sufficient for attribution — no content equality guard needed.
|
|
511
|
+
|
|
512
|
+
When multiple traces claim the same hash, the one with the highest
|
|
513
|
+
``edit_sequence`` wins (latest edit takes precedence).
|
|
514
|
+
"""
|
|
515
|
+
index: dict[str, dict[str, Any]] = {}
|
|
516
|
+
|
|
517
|
+
for trace in traces:
|
|
518
|
+
trace_id = trace.get("id", "")
|
|
519
|
+
meta = trace.get("metadata") or {}
|
|
520
|
+
edit_seq = meta.get("edit_sequence")
|
|
521
|
+
tool = trace.get("tool")
|
|
522
|
+
|
|
523
|
+
for fe in trace.get("files", []):
|
|
524
|
+
if not isinstance(fe, dict):
|
|
525
|
+
continue
|
|
526
|
+
fpath = fe.get("path", "")
|
|
527
|
+
if not cross_file and not _trace_file_matches(fpath, file_path, alternate_paths):
|
|
528
|
+
continue
|
|
529
|
+
|
|
530
|
+
model_id: str | None = None
|
|
531
|
+
conversation_id: str | None = None
|
|
532
|
+
for conv in fe.get("conversations", []):
|
|
533
|
+
if not isinstance(conv, dict):
|
|
534
|
+
continue
|
|
535
|
+
contributor = conv.get("contributor") or {}
|
|
536
|
+
if contributor.get("model_id") and not model_id:
|
|
537
|
+
model_id = contributor["model_id"]
|
|
538
|
+
if conv.get("id") and not conversation_id:
|
|
539
|
+
conversation_id = conv["id"]
|
|
540
|
+
|
|
541
|
+
for r in conv.get("ranges", []):
|
|
542
|
+
if not isinstance(r, dict):
|
|
543
|
+
continue
|
|
544
|
+
for lh in r.get("line_hashes", []):
|
|
545
|
+
if not isinstance(lh, dict):
|
|
546
|
+
continue
|
|
547
|
+
h = lh.get("hash", "")
|
|
548
|
+
if not h:
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
existing = index.get(h)
|
|
552
|
+
if existing is not None:
|
|
553
|
+
existing_seq = existing.get("edit_sequence")
|
|
554
|
+
if edit_seq is not None and (
|
|
555
|
+
existing_seq is None or edit_seq > existing_seq
|
|
556
|
+
):
|
|
557
|
+
pass # overwrite below
|
|
558
|
+
else:
|
|
559
|
+
continue
|
|
560
|
+
|
|
561
|
+
index[h] = {
|
|
562
|
+
"trace_id": trace_id,
|
|
563
|
+
"model_id": model_id,
|
|
564
|
+
"tool": tool,
|
|
565
|
+
"conversation_id": conversation_id,
|
|
566
|
+
"edit_sequence": edit_seq,
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return index
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# -------------------------------------------------------------------
|
|
573
|
+
# Ledger construction
|
|
574
|
+
# -------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
def _merge_resolution_lines_per_file(
|
|
577
|
+
parents: list[tuple[str, str | None]],
|
|
578
|
+
commit_sha: str,
|
|
579
|
+
project_dir: str,
|
|
580
|
+
) -> dict[str, set[int]]:
|
|
581
|
+
"""For a merge commit, return the set of commit-side line numbers in each
|
|
582
|
+
file that are present in ``commit_sha`` but not in **any** parent.
|
|
583
|
+
|
|
584
|
+
Algorithm: ``git diff parent_i commit_sha`` per parent gives the set of
|
|
585
|
+
commit-side line numbers added relative to that parent. A line is
|
|
586
|
+
"introduced by the merge resolution" iff it's added relative to **every**
|
|
587
|
+
parent. So we intersect the per-parent line sets per file.
|
|
588
|
+
|
|
589
|
+
Files that don't appear in every parent's diff drop out of the result —
|
|
590
|
+
if a file is identical to even one parent, all of its content is
|
|
591
|
+
attributed in that parent's history, not by the merge commit.
|
|
592
|
+
"""
|
|
593
|
+
if not parents:
|
|
594
|
+
return {}
|
|
595
|
+
|
|
596
|
+
per_parent: list[dict[str, set[int]]] = []
|
|
597
|
+
for sha, _ in parents:
|
|
598
|
+
files_out = _git("diff", "--name-only", sha, commit_sha, cwd=project_dir) or ""
|
|
599
|
+
per_file: dict[str, set[int]] = {}
|
|
600
|
+
for fp in files_out.splitlines():
|
|
601
|
+
fp = fp.strip()
|
|
602
|
+
if not fp:
|
|
603
|
+
continue
|
|
604
|
+
diff = _git_raw("diff", sha, commit_sha, "--", fp, cwd=project_dir)
|
|
605
|
+
lines: set[int] = set()
|
|
606
|
+
if diff:
|
|
607
|
+
for s, e in _parse_diff_ranges(diff):
|
|
608
|
+
for ln in range(s, e + 1):
|
|
609
|
+
lines.add(ln)
|
|
610
|
+
per_file[fp] = lines
|
|
611
|
+
per_parent.append(per_file)
|
|
612
|
+
|
|
613
|
+
common_files = set(per_parent[0].keys())
|
|
614
|
+
for d in per_parent[1:]:
|
|
615
|
+
common_files &= set(d.keys())
|
|
616
|
+
|
|
617
|
+
result: dict[str, set[int]] = {}
|
|
618
|
+
for fp in common_files:
|
|
619
|
+
common_lines = per_parent[0][fp].copy()
|
|
620
|
+
for d in per_parent[1:]:
|
|
621
|
+
common_lines &= d[fp]
|
|
622
|
+
if common_lines:
|
|
623
|
+
result[fp] = common_lines
|
|
624
|
+
return result
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def build_attribution_ledger(
|
|
628
|
+
project_dir: str | None = None,
|
|
629
|
+
commit_ref: str = "HEAD",
|
|
630
|
+
) -> dict[str, Any] | None:
|
|
631
|
+
"""Build a per-line AI-attribution ledger for the current HEAD commit.
|
|
632
|
+
|
|
633
|
+
Returns the ledger dict, or None if there is nothing changed.
|
|
634
|
+
|
|
635
|
+
Algorithm:
|
|
636
|
+
1. Resolve commit + parent SHAs and their author times.
|
|
637
|
+
2. Detect derived-commit kind (cherry-pick / revert / merge) from
|
|
638
|
+
commit message + parent count.
|
|
639
|
+
3. Find primary candidate traces in the staging window
|
|
640
|
+
(``parent_committed_at`` < trace.timestamp ≤ committed_at + 10min,
|
|
641
|
+
and ``vcs.revision`` matches parent if present).
|
|
642
|
+
4. For each changed file:
|
|
643
|
+
a. Read committed content; compute per-line hash + content.
|
|
644
|
+
b. Build a primary hash index over candidate traces (file-scoped
|
|
645
|
+
first; cross-file fallback only if file-scoped is empty).
|
|
646
|
+
c. PRIMARY pass — for each non-trivial added line, record AI iff
|
|
647
|
+
its line hash matches the primary index. Trivial lines are
|
|
648
|
+
deferred to the fill pass.
|
|
649
|
+
d. FALLBACK pass — for non-trivial lines still unmatched after
|
|
650
|
+
(c), build a global hash index from all local traces and all
|
|
651
|
+
local ledger evidence (file-scoped first; cross-file fallback
|
|
652
|
+
if empty), and try again. Recovers cherry-pick / revert /
|
|
653
|
+
squash / stash-pop attribution. Hash equality only — still
|
|
654
|
+
deterministic.
|
|
655
|
+
e. Fill pass: a trivial line attributed to AI iff both immediate
|
|
656
|
+
non-trivial neighbours are AI of the same trace.
|
|
657
|
+
f. Merge contiguous same-trace lines into segments with evidence.
|
|
658
|
+
5. If anything was attributed, store a ledger; otherwise no ledger.
|
|
659
|
+
"""
|
|
660
|
+
if project_dir is None:
|
|
661
|
+
import os
|
|
662
|
+
project_dir = os.getcwd()
|
|
663
|
+
|
|
664
|
+
commit_sha = _git("rev-parse", commit_ref, cwd=project_dir)
|
|
665
|
+
if not commit_sha:
|
|
666
|
+
return None
|
|
667
|
+
|
|
668
|
+
committed_at = _git("log", "-1", "--format=%aI", commit_sha, cwd=project_dir)
|
|
669
|
+
|
|
670
|
+
parents_out = _git(
|
|
671
|
+
"rev-list", "--parents", "-n", "1", commit_sha, cwd=project_dir,
|
|
672
|
+
) or ""
|
|
673
|
+
parent_shas: list[str] = parents_out.split()[1:] if parents_out else []
|
|
674
|
+
parents: list[tuple[str, str | None]] = []
|
|
675
|
+
for psha in parent_shas:
|
|
676
|
+
pct = _git("log", "-1", "--format=%aI", psha, cwd=project_dir)
|
|
677
|
+
parents.append((psha, pct))
|
|
678
|
+
|
|
679
|
+
is_merge = len(parents) > 1
|
|
680
|
+
first_parent_sha = parents[0][0] if parents else None
|
|
681
|
+
first_parent_at = parents[0][1] if parents else None
|
|
682
|
+
|
|
683
|
+
derived_from = _detect_derived_from(commit_sha, parent_shas, project_dir)
|
|
684
|
+
|
|
685
|
+
# Per-file changed line sets.
|
|
686
|
+
changed_lines_per_file: dict[str, set[int]] = {}
|
|
687
|
+
if is_merge:
|
|
688
|
+
# Only merge-resolution-introduced lines (added vs every parent).
|
|
689
|
+
changed_lines_per_file = _merge_resolution_lines_per_file(
|
|
690
|
+
parents, commit_sha, project_dir,
|
|
691
|
+
)
|
|
692
|
+
elif first_parent_sha:
|
|
693
|
+
files_out = _git(
|
|
694
|
+
"diff", "--name-only", first_parent_sha, commit_sha, cwd=project_dir,
|
|
695
|
+
) or ""
|
|
696
|
+
for fp in files_out.splitlines():
|
|
697
|
+
fp = fp.strip()
|
|
698
|
+
if not fp:
|
|
699
|
+
continue
|
|
700
|
+
diff = _git_raw(
|
|
701
|
+
"diff", first_parent_sha, commit_sha, "--", fp, cwd=project_dir,
|
|
702
|
+
)
|
|
703
|
+
lines: set[int] = set()
|
|
704
|
+
if diff:
|
|
705
|
+
for s, e in _parse_diff_ranges(diff):
|
|
706
|
+
for ln in range(s, e + 1):
|
|
707
|
+
lines.add(ln)
|
|
708
|
+
if lines:
|
|
709
|
+
changed_lines_per_file[fp] = lines
|
|
710
|
+
else:
|
|
711
|
+
# First commit — diff against the empty tree.
|
|
712
|
+
empty_tree = "4b825dc642cb6eb9a060e54bf899d15f3f4b7b18"
|
|
713
|
+
files_out = _git(
|
|
714
|
+
"diff", "--name-only", "--diff-filter=ACMR",
|
|
715
|
+
empty_tree, commit_sha, cwd=project_dir,
|
|
716
|
+
) or ""
|
|
717
|
+
for fp in files_out.splitlines():
|
|
718
|
+
fp = fp.strip()
|
|
719
|
+
if not fp:
|
|
720
|
+
continue
|
|
721
|
+
diff = _git_raw(
|
|
722
|
+
"diff", empty_tree, commit_sha, "--", fp, cwd=project_dir,
|
|
723
|
+
)
|
|
724
|
+
lines = set()
|
|
725
|
+
if diff:
|
|
726
|
+
for s, e in _parse_diff_ranges(diff):
|
|
727
|
+
for ln in range(s, e + 1):
|
|
728
|
+
lines.add(ln)
|
|
729
|
+
if lines:
|
|
730
|
+
changed_lines_per_file[fp] = lines
|
|
731
|
+
|
|
732
|
+
if not changed_lines_per_file:
|
|
733
|
+
return None
|
|
734
|
+
|
|
735
|
+
rename_map: dict[str, str] = (
|
|
736
|
+
_get_rename_map(first_parent_sha, commit_sha, project_dir)
|
|
737
|
+
if first_parent_sha and not is_merge else {}
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
candidates = _find_candidate_traces(project_dir, parents, committed_at)
|
|
741
|
+
|
|
742
|
+
used_trace_ids: set[str] = set()
|
|
743
|
+
files_attributions: dict[str, dict[str, Any]] = {}
|
|
744
|
+
used_fallback = False
|
|
745
|
+
|
|
746
|
+
for file_path, changed_lines in changed_lines_per_file.items():
|
|
747
|
+
alt_paths: list[str] | None = None
|
|
748
|
+
old_p = rename_map.get(file_path)
|
|
749
|
+
if old_p:
|
|
750
|
+
alt_paths = [old_p]
|
|
751
|
+
|
|
752
|
+
file_content = _git_raw(
|
|
753
|
+
"show", f"{commit_sha}:{file_path}", cwd=project_dir,
|
|
754
|
+
)
|
|
755
|
+
if file_content is None:
|
|
756
|
+
continue
|
|
757
|
+
|
|
758
|
+
file_lines = _compute_file_lines(file_content)
|
|
759
|
+
|
|
760
|
+
# Primary index from staging-window candidates.
|
|
761
|
+
primary_index = _build_trace_hash_index(
|
|
762
|
+
candidates, file_path, alternate_paths=alt_paths,
|
|
763
|
+
)
|
|
764
|
+
if not primary_index:
|
|
765
|
+
primary_index = _build_trace_hash_index(
|
|
766
|
+
candidates, file_path, alternate_paths=alt_paths, cross_file=True,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
line_attr: dict[int, dict[str, Any]] = {}
|
|
770
|
+
trivial: set[int] = set()
|
|
771
|
+
unmatched: list[int] = []
|
|
772
|
+
|
|
773
|
+
# PRIMARY pass.
|
|
774
|
+
for ln in sorted(changed_lines):
|
|
775
|
+
if ln < 1 or ln > len(file_lines):
|
|
776
|
+
continue
|
|
777
|
+
content = file_lines[ln - 1]
|
|
778
|
+
if _is_trivial(content):
|
|
779
|
+
trivial.add(ln)
|
|
780
|
+
continue
|
|
781
|
+
h = _line_hash(content)
|
|
782
|
+
entry = primary_index.get(h)
|
|
783
|
+
if entry is None:
|
|
784
|
+
unmatched.append(ln)
|
|
785
|
+
continue
|
|
786
|
+
tid = entry.get("trace_id")
|
|
787
|
+
if not tid:
|
|
788
|
+
unmatched.append(ln)
|
|
789
|
+
continue
|
|
790
|
+
line_attr[ln] = {
|
|
791
|
+
"trace_id": tid,
|
|
792
|
+
"model_id": entry.get("model_id"),
|
|
793
|
+
"conversation_id": entry.get("conversation_id"),
|
|
794
|
+
"hash": h,
|
|
795
|
+
"content": content,
|
|
796
|
+
}
|
|
797
|
+
used_trace_ids.add(tid)
|
|
798
|
+
|
|
799
|
+
# FALLBACK pass — only built if there are unmatched non-trivial lines.
|
|
800
|
+
if unmatched:
|
|
801
|
+
global_index = _build_global_hash_index(
|
|
802
|
+
project_dir, file_path, alt_paths,
|
|
803
|
+
derived_from=derived_from, cross_file=False,
|
|
804
|
+
)
|
|
805
|
+
if not global_index:
|
|
806
|
+
global_index = _build_global_hash_index(
|
|
807
|
+
project_dir, file_path, alt_paths,
|
|
808
|
+
derived_from=derived_from, cross_file=True,
|
|
809
|
+
)
|
|
810
|
+
for ln in unmatched:
|
|
811
|
+
content = file_lines[ln - 1]
|
|
812
|
+
h = _line_hash(content)
|
|
813
|
+
entry = global_index.get(h)
|
|
814
|
+
if entry is None:
|
|
815
|
+
continue
|
|
816
|
+
tid = entry.get("trace_id")
|
|
817
|
+
if not tid:
|
|
818
|
+
continue
|
|
819
|
+
line_attr[ln] = {
|
|
820
|
+
"trace_id": tid,
|
|
821
|
+
"model_id": entry.get("model_id"),
|
|
822
|
+
"conversation_id": entry.get("conversation_id"),
|
|
823
|
+
"hash": h,
|
|
824
|
+
"content": content,
|
|
825
|
+
}
|
|
826
|
+
used_trace_ids.add(tid)
|
|
827
|
+
used_fallback = True
|
|
828
|
+
|
|
829
|
+
# Fill pass: trivial line is AI iff both neighbours are AI of same trace.
|
|
830
|
+
for ln in sorted(trivial):
|
|
831
|
+
prev = line_attr.get(ln - 1)
|
|
832
|
+
nxt = line_attr.get(ln + 1)
|
|
833
|
+
if prev and nxt and prev["trace_id"] == nxt["trace_id"]:
|
|
834
|
+
line_attr[ln] = {
|
|
835
|
+
"trace_id": prev["trace_id"],
|
|
836
|
+
"model_id": prev["model_id"],
|
|
837
|
+
"conversation_id": prev["conversation_id"],
|
|
838
|
+
"hash": _line_hash(file_lines[ln - 1]),
|
|
839
|
+
"content": file_lines[ln - 1],
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if not line_attr:
|
|
843
|
+
continue
|
|
844
|
+
|
|
845
|
+
segments = _merge_into_segments(line_attr)
|
|
846
|
+
if segments:
|
|
847
|
+
files_attributions[file_path] = {"line_attributions": segments}
|
|
848
|
+
|
|
849
|
+
if not files_attributions:
|
|
850
|
+
# Nothing attributable. Skip the empty-shell ledger entirely — a
|
|
851
|
+
# non-attributing commit doesn't need a row.
|
|
852
|
+
return None
|
|
853
|
+
|
|
854
|
+
ledger: dict[str, Any] = {
|
|
855
|
+
"version": "2.0",
|
|
856
|
+
"commit_sha": commit_sha,
|
|
857
|
+
"parent_sha": first_parent_sha,
|
|
858
|
+
"parent_committed_at": first_parent_at,
|
|
859
|
+
"committed_at": committed_at,
|
|
860
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
861
|
+
"trace_ids": sorted(used_trace_ids),
|
|
862
|
+
"files": files_attributions,
|
|
863
|
+
}
|
|
864
|
+
if derived_from:
|
|
865
|
+
ledger["derived_from"] = derived_from
|
|
866
|
+
if used_fallback:
|
|
867
|
+
ledger["used_fallback"] = True
|
|
868
|
+
|
|
869
|
+
return ledger
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _merge_into_segments(line_attr: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
873
|
+
"""Merge contiguous lines with the same trace_id into AI segments with evidence."""
|
|
874
|
+
if not line_attr:
|
|
875
|
+
return []
|
|
876
|
+
|
|
877
|
+
segments: list[dict[str, Any]] = []
|
|
878
|
+
current: dict[str, Any] | None = None
|
|
879
|
+
|
|
880
|
+
for ln in sorted(line_attr.keys()):
|
|
881
|
+
la = line_attr[ln]
|
|
882
|
+
evidence_entry = {
|
|
883
|
+
"line": ln,
|
|
884
|
+
"hash": la["hash"],
|
|
885
|
+
"content": la["content"],
|
|
886
|
+
}
|
|
887
|
+
if (
|
|
888
|
+
current is not None
|
|
889
|
+
and current["end_line"] + 1 == ln
|
|
890
|
+
and current["trace_id"] == la["trace_id"]
|
|
891
|
+
):
|
|
892
|
+
current["end_line"] = ln
|
|
893
|
+
current["evidence"].append(evidence_entry)
|
|
894
|
+
else:
|
|
895
|
+
if current is not None:
|
|
896
|
+
segments.append(current)
|
|
897
|
+
current = {
|
|
898
|
+
"start_line": ln,
|
|
899
|
+
"end_line": ln,
|
|
900
|
+
"type": "ai",
|
|
901
|
+
"trace_id": la["trace_id"],
|
|
902
|
+
"model_id": la.get("model_id"),
|
|
903
|
+
"conversation_id": la.get("conversation_id"),
|
|
904
|
+
"evidence": [evidence_entry],
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if current is not None:
|
|
908
|
+
segments.append(current)
|
|
909
|
+
|
|
910
|
+
return segments
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
# -------------------------------------------------------------------
|
|
914
|
+
# Local ledger storage
|
|
915
|
+
# -------------------------------------------------------------------
|
|
916
|
+
|
|
917
|
+
def store_ledger_local(ledger: dict[str, Any], project_dir: str) -> None:
|
|
918
|
+
"""Append a ledger to ``<AGENT_TRACE_HOME>/projects/<id>/ledgers.jsonl``."""
|
|
919
|
+
from .storage import ensure_project_dir, get_ledgers_path, resolve_project_id
|
|
920
|
+
|
|
921
|
+
pid = resolve_project_id(project_dir, create=True)
|
|
922
|
+
if not pid:
|
|
923
|
+
return
|
|
924
|
+
ensure_project_dir(pid)
|
|
925
|
+
path = get_ledgers_path(pid)
|
|
926
|
+
with open(path, "a") as f:
|
|
927
|
+
f.write(json.dumps(ledger) + "\n")
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def load_local_ledgers(project_dir: str) -> dict[str, dict[str, Any]]:
|
|
931
|
+
"""Load all ledgers from disk, keyed by ``commit_sha``."""
|
|
932
|
+
from .storage import get_ledgers_path, resolve_project_id
|
|
933
|
+
|
|
934
|
+
pid = resolve_project_id(project_dir, create=False)
|
|
935
|
+
if not pid:
|
|
936
|
+
return {}
|
|
937
|
+
ledgers_path = get_ledgers_path(pid)
|
|
938
|
+
if not ledgers_path.exists():
|
|
939
|
+
return {}
|
|
940
|
+
ledgers: dict[str, dict[str, Any]] = {}
|
|
941
|
+
try:
|
|
942
|
+
for line in ledgers_path.read_text().splitlines():
|
|
943
|
+
line = line.strip()
|
|
944
|
+
if not line:
|
|
945
|
+
continue
|
|
946
|
+
try:
|
|
947
|
+
ledger = json.loads(line)
|
|
948
|
+
sha = ledger.get("commit_sha", "")
|
|
949
|
+
if sha:
|
|
950
|
+
ledgers[sha] = ledger
|
|
951
|
+
except json.JSONDecodeError:
|
|
952
|
+
continue
|
|
953
|
+
except OSError:
|
|
954
|
+
pass
|
|
955
|
+
return ledgers
|