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
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