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/__init__.py
ADDED
agent_trace/blame.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI blame command — show AI attribution for file lines.
|
|
3
|
+
|
|
4
|
+
Schema 2.0: attribution is binary. A line is either AI (with a matching
|
|
5
|
+
ledger segment backed by hash + content evidence) or NO_ATTRIBUTION
|
|
6
|
+
(everything else). No MIXED, HUMAN, or UNKNOWN labels — we never claim
|
|
7
|
+
"this was a human"; we only ever assert "this matches an AI trace".
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .blame_git import _git, _git_blame_porcelain, _group_into_segments, _parse_blame_porcelain
|
|
18
|
+
from .blame_meta import (
|
|
19
|
+
_extract_trace_meta,
|
|
20
|
+
_load_conversation_preview,
|
|
21
|
+
_load_local_traces,
|
|
22
|
+
)
|
|
23
|
+
from .git_notes import ledger_from_note_for_blame, read_note
|
|
24
|
+
from .ledger import load_local_ledgers
|
|
25
|
+
from .storage import resolve_project_id
|
|
26
|
+
from .summary import latest_summary_by_id
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _merge_ledgers_from_git_notes(
|
|
30
|
+
git_root: str,
|
|
31
|
+
segments: list[dict[str, Any]],
|
|
32
|
+
ledgers: dict[str, dict[str, Any]],
|
|
33
|
+
) -> dict[str, dict[str, Any]]:
|
|
34
|
+
"""Fill in ledgers from ``refs/notes/agent-trace`` when missing locally."""
|
|
35
|
+
merged = dict(ledgers)
|
|
36
|
+
missing = {seg.get("commit_sha") for seg in segments if seg.get("commit_sha") not in merged}
|
|
37
|
+
for sha in missing:
|
|
38
|
+
if not sha:
|
|
39
|
+
continue
|
|
40
|
+
note = read_note(sha, git_root)
|
|
41
|
+
if not note:
|
|
42
|
+
continue
|
|
43
|
+
mini = ledger_from_note_for_blame(note)
|
|
44
|
+
if mini:
|
|
45
|
+
merged[sha] = mini
|
|
46
|
+
return merged
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _attribution_type_label(attr_type: str) -> str:
|
|
50
|
+
if attr_type == "ai":
|
|
51
|
+
return "AI"
|
|
52
|
+
return "No attribution"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _ledger_kind(attr_type: str) -> str:
|
|
56
|
+
if attr_type == "ai":
|
|
57
|
+
return "AI"
|
|
58
|
+
return "NO_ATTRIBUTION"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ranges_overlap(
|
|
62
|
+
attr_start: int, attr_end: int,
|
|
63
|
+
seg_start: int, seg_end: int,
|
|
64
|
+
) -> bool:
|
|
65
|
+
return attr_start <= seg_end and attr_end >= seg_start
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _attribute_from_ledger(
|
|
69
|
+
blame_segments: list[dict[str, Any]],
|
|
70
|
+
ledgers: dict[str, dict[str, Any]],
|
|
71
|
+
file_path: str,
|
|
72
|
+
traces: list[dict[str, Any]] | None = None,
|
|
73
|
+
summary_by_id: dict[str, str] | None = None,
|
|
74
|
+
project_id: str | None = None,
|
|
75
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
76
|
+
attributed: list[dict[str, Any]] = []
|
|
77
|
+
remaining: list[dict[str, Any]] = []
|
|
78
|
+
|
|
79
|
+
trace_by_id: dict[str, dict[str, Any]] = {}
|
|
80
|
+
if traces:
|
|
81
|
+
for t in traces:
|
|
82
|
+
tid = t.get("id")
|
|
83
|
+
if tid:
|
|
84
|
+
trace_by_id[tid] = t
|
|
85
|
+
|
|
86
|
+
for seg in blame_segments:
|
|
87
|
+
commit_sha = seg["commit_sha"]
|
|
88
|
+
ledger = ledgers.get(commit_sha)
|
|
89
|
+
|
|
90
|
+
if ledger and file_path in ledger.get("files", {}):
|
|
91
|
+
file_ledger = ledger["files"][file_path]
|
|
92
|
+
line_attrs = file_ledger.get("line_attributions", [])
|
|
93
|
+
|
|
94
|
+
orig_start = seg.get("orig_start_line", seg["start_line"])
|
|
95
|
+
orig_end = seg.get("orig_end_line", seg["end_line"])
|
|
96
|
+
offset = seg["start_line"] - orig_start
|
|
97
|
+
|
|
98
|
+
overlapping: list[tuple[int, int, dict[str, Any]]] = []
|
|
99
|
+
for la in sorted(line_attrs, key=lambda x: x.get("start_line", 0)):
|
|
100
|
+
la_start = la.get("start_line", 0)
|
|
101
|
+
la_end = la.get("end_line", 0)
|
|
102
|
+
if _ranges_overlap(la_start, la_end, orig_start, orig_end):
|
|
103
|
+
clamped_start = max(la_start, orig_start)
|
|
104
|
+
clamped_end = min(la_end, orig_end)
|
|
105
|
+
overlapping.append((clamped_start, clamped_end, la))
|
|
106
|
+
|
|
107
|
+
if overlapping:
|
|
108
|
+
for clamped_orig_start, clamped_orig_end, la in overlapping:
|
|
109
|
+
final_start = clamped_orig_start + offset
|
|
110
|
+
final_end = clamped_orig_end + offset
|
|
111
|
+
attr_type = la.get("type", "ai")
|
|
112
|
+
# Schema 2.0 only allows "ai" segments; anything else is
|
|
113
|
+
# invalid data and is skipped (treated as NO_ATTRIBUTION).
|
|
114
|
+
if attr_type != "ai":
|
|
115
|
+
continue
|
|
116
|
+
kind = _ledger_kind(attr_type)
|
|
117
|
+
trace_id = la.get("trace_id")
|
|
118
|
+
trace_rec = trace_by_id.get(trace_id) if trace_id else None
|
|
119
|
+
label = _attribution_type_label(attr_type)
|
|
120
|
+
meta = (
|
|
121
|
+
_extract_trace_meta(trace_rec, file_path, (final_start + final_end) // 2)
|
|
122
|
+
if trace_rec
|
|
123
|
+
else {}
|
|
124
|
+
)
|
|
125
|
+
conv_id = la.get("conversation_id") or meta.get("conversation_id")
|
|
126
|
+
conv_sha = meta.get("content_sha256")
|
|
127
|
+
conv_summary = (summary_by_id or {}).get(conv_id) if conv_id else None
|
|
128
|
+
conv_preview = (
|
|
129
|
+
_load_conversation_preview(project_id, conv_sha)
|
|
130
|
+
if not conv_summary
|
|
131
|
+
else None
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
attributed.append({
|
|
135
|
+
"start_line": final_start,
|
|
136
|
+
"end_line": final_end,
|
|
137
|
+
"kind": kind,
|
|
138
|
+
"attribution_label": label,
|
|
139
|
+
"trace_id": trace_id,
|
|
140
|
+
"timestamp": trace_rec.get("timestamp") if trace_rec else None,
|
|
141
|
+
"model_id": la.get("model_id") or meta.get("model_id"),
|
|
142
|
+
"contributor_type": attr_type,
|
|
143
|
+
"tool": trace_rec.get("tool") if trace_rec else None,
|
|
144
|
+
"conversation_id": conv_id,
|
|
145
|
+
"conversation_summary": conv_summary,
|
|
146
|
+
"conversation_preview": conv_preview,
|
|
147
|
+
"matched_range": {
|
|
148
|
+
"start_line": la.get("start_line"),
|
|
149
|
+
"end_line": la.get("end_line"),
|
|
150
|
+
},
|
|
151
|
+
"evidence": la.get("evidence"),
|
|
152
|
+
"commit_sha": commit_sha,
|
|
153
|
+
"signals": ["ledger"],
|
|
154
|
+
"source": "ledger",
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
covered_orig_start = overlapping[0][0]
|
|
158
|
+
covered_orig_end = overlapping[-1][1]
|
|
159
|
+
if orig_start < covered_orig_start:
|
|
160
|
+
gap_seg = dict(seg)
|
|
161
|
+
gap_seg["start_line"] = seg["start_line"]
|
|
162
|
+
gap_seg["end_line"] = covered_orig_start + offset - 1
|
|
163
|
+
gap_seg["orig_start_line"] = orig_start
|
|
164
|
+
gap_seg["orig_end_line"] = covered_orig_start - 1
|
|
165
|
+
n_lines = covered_orig_start - orig_start
|
|
166
|
+
gap_seg["content_lines"] = seg["content_lines"][:n_lines]
|
|
167
|
+
remaining.append(gap_seg)
|
|
168
|
+
if covered_orig_end < orig_end:
|
|
169
|
+
gap_seg = dict(seg)
|
|
170
|
+
n_before = covered_orig_end - orig_start + 1
|
|
171
|
+
gap_seg["start_line"] = covered_orig_end + offset + 1
|
|
172
|
+
gap_seg["end_line"] = seg["end_line"]
|
|
173
|
+
gap_seg["orig_start_line"] = covered_orig_end + 1
|
|
174
|
+
gap_seg["orig_end_line"] = orig_end
|
|
175
|
+
gap_seg["content_lines"] = seg["content_lines"][n_before:]
|
|
176
|
+
remaining.append(gap_seg)
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
remaining.append(seg)
|
|
180
|
+
|
|
181
|
+
return attributed, remaining
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _no_attribution_entry(seg: dict[str, Any]) -> dict[str, Any]:
|
|
185
|
+
return {
|
|
186
|
+
"start_line": seg["start_line"],
|
|
187
|
+
"end_line": seg["end_line"],
|
|
188
|
+
"kind": "NO_ATTRIBUTION",
|
|
189
|
+
"attribution_label": "No attribution",
|
|
190
|
+
"trace_id": None,
|
|
191
|
+
"timestamp": None,
|
|
192
|
+
"model_id": None,
|
|
193
|
+
"contributor_type": None,
|
|
194
|
+
"tool": None,
|
|
195
|
+
"conversation_id": None,
|
|
196
|
+
"conversation_summary": None,
|
|
197
|
+
"conversation_preview": None,
|
|
198
|
+
"matched_range": None,
|
|
199
|
+
"commit_sha": seg["commit_sha"],
|
|
200
|
+
"signals": [],
|
|
201
|
+
"source": None,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _attribute_deterministic(
|
|
206
|
+
blame_segments: list[dict[str, Any]],
|
|
207
|
+
file_path: str,
|
|
208
|
+
ledgers: dict[str, dict[str, Any]],
|
|
209
|
+
traces: list[dict[str, Any]],
|
|
210
|
+
summary_by_id: dict[str, str] | None = None,
|
|
211
|
+
project_id: str | None = None,
|
|
212
|
+
) -> list[dict[str, Any]]:
|
|
213
|
+
ledger_results: list[dict[str, Any]] = []
|
|
214
|
+
remaining = blame_segments
|
|
215
|
+
if ledgers:
|
|
216
|
+
ledger_results, remaining = _attribute_from_ledger(
|
|
217
|
+
blame_segments, ledgers, file_path, traces=traces,
|
|
218
|
+
summary_by_id=summary_by_id, project_id=project_id,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
gap_results = [_no_attribution_entry(seg) for seg in remaining]
|
|
222
|
+
|
|
223
|
+
all_results = ledger_results + gap_results
|
|
224
|
+
all_results.sort(key=lambda a: (a.get("start_line", 0), a.get("end_line", 0)))
|
|
225
|
+
return all_results
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _merge_attributions(attributions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
229
|
+
if not attributions:
|
|
230
|
+
return []
|
|
231
|
+
merged: list[dict[str, Any]] = []
|
|
232
|
+
for entry in attributions:
|
|
233
|
+
if merged:
|
|
234
|
+
prev = merged[-1]
|
|
235
|
+
if (
|
|
236
|
+
prev["end_line"] + 1 >= entry["start_line"]
|
|
237
|
+
and prev.get("kind") == entry.get("kind")
|
|
238
|
+
and prev.get("trace_id") == entry.get("trace_id")
|
|
239
|
+
and prev.get("source") == entry.get("source")
|
|
240
|
+
):
|
|
241
|
+
prev["end_line"] = entry["end_line"]
|
|
242
|
+
continue
|
|
243
|
+
merged.append(dict(entry))
|
|
244
|
+
return merged
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_BOLD = "\033[1m"
|
|
248
|
+
_DIM = "\033[2m"
|
|
249
|
+
_GREEN = "\033[32m"
|
|
250
|
+
_RESET = "\033[0m"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _format_line_range(start: int, end: int) -> str:
|
|
254
|
+
if start == end:
|
|
255
|
+
return f"L{start}"
|
|
256
|
+
return f"L{start}-{end}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _format_terminal(file_path: str, attributions: list[dict[str, Any]]) -> str:
|
|
260
|
+
lines: list[str] = []
|
|
261
|
+
lines.append("")
|
|
262
|
+
lines.append(f" {_BOLD}{file_path}{_RESET}")
|
|
263
|
+
lines.append("")
|
|
264
|
+
|
|
265
|
+
for attr in attributions:
|
|
266
|
+
start = attr.get("start_line", 0)
|
|
267
|
+
end = attr.get("end_line", 0)
|
|
268
|
+
lr = _format_line_range(start, end)
|
|
269
|
+
kind = attr.get("kind", "NO_ATTRIBUTION")
|
|
270
|
+
|
|
271
|
+
if kind == "AI":
|
|
272
|
+
tag = f"{_GREEN}[AI]{_RESET}"
|
|
273
|
+
else:
|
|
274
|
+
tag = f"{_DIM}[NO ATTRIBUTION]{_RESET}"
|
|
275
|
+
|
|
276
|
+
model_id = attr.get("model_id") or ""
|
|
277
|
+
tool = attr.get("tool")
|
|
278
|
+
tool_name = ""
|
|
279
|
+
if isinstance(tool, dict):
|
|
280
|
+
tool_name = tool.get("name", "")
|
|
281
|
+
elif isinstance(tool, str):
|
|
282
|
+
tool_name = tool
|
|
283
|
+
|
|
284
|
+
model_tool = model_id
|
|
285
|
+
if tool_name:
|
|
286
|
+
model_tool = f"{model_id} via {tool_name}" if model_id else tool_name
|
|
287
|
+
|
|
288
|
+
if kind == "NO_ATTRIBUTION":
|
|
289
|
+
lines.append(f" {lr:<12}{tag}")
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
lines.append(f" {lr:<12}{tag} {model_tool}")
|
|
293
|
+
|
|
294
|
+
if model_id:
|
|
295
|
+
lines.append(f" {_DIM}model: {model_id}{_RESET}")
|
|
296
|
+
|
|
297
|
+
conv_summary = attr.get("conversation_summary") or ""
|
|
298
|
+
conv_preview = attr.get("conversation_preview") or ""
|
|
299
|
+
conv_id = attr.get("conversation_id") or ""
|
|
300
|
+
if conv_summary:
|
|
301
|
+
lines.append(f" summary: {conv_summary}")
|
|
302
|
+
if conv_id:
|
|
303
|
+
lines.append(f" {_DIM}conversation: {conv_id[:16]}{_RESET}")
|
|
304
|
+
elif conv_preview:
|
|
305
|
+
preview_line = conv_preview.replace("\n", " ").strip()
|
|
306
|
+
if len(preview_line) > 120:
|
|
307
|
+
preview_line = preview_line[:120] + "..."
|
|
308
|
+
lines.append(f" conversation: \"{preview_line}\"")
|
|
309
|
+
elif conv_id:
|
|
310
|
+
lines.append(f" conversation: {conv_id[:16]}")
|
|
311
|
+
|
|
312
|
+
trace_id = attr.get("trace_id") or ""
|
|
313
|
+
commit_sha = attr.get("commit_sha") or ""
|
|
314
|
+
detail_parts = []
|
|
315
|
+
if trace_id:
|
|
316
|
+
detail_parts.append(f"trace: {trace_id}")
|
|
317
|
+
if commit_sha:
|
|
318
|
+
detail_parts.append(f"commit: {commit_sha[:12]}")
|
|
319
|
+
if detail_parts:
|
|
320
|
+
lines.append(f" {_DIM}{' | '.join(detail_parts)}{_RESET}")
|
|
321
|
+
|
|
322
|
+
lines.append("")
|
|
323
|
+
return "\n".join(lines)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _format_json(file_path: str, attributions: list[dict[str, Any]]) -> str:
|
|
327
|
+
clean: list[dict[str, Any]] = []
|
|
328
|
+
for attr in attributions:
|
|
329
|
+
entry: dict[str, Any] = {
|
|
330
|
+
"start_line": attr.get("start_line"),
|
|
331
|
+
"end_line": attr.get("end_line"),
|
|
332
|
+
"kind": attr.get("kind"),
|
|
333
|
+
}
|
|
334
|
+
if attr.get("trace_id"):
|
|
335
|
+
entry["trace_id"] = attr["trace_id"]
|
|
336
|
+
model_id = attr.get("model_id")
|
|
337
|
+
if model_id:
|
|
338
|
+
entry["model_id"] = model_id
|
|
339
|
+
contributor_type = attr.get("contributor_type")
|
|
340
|
+
if contributor_type:
|
|
341
|
+
entry["contributor_type"] = contributor_type
|
|
342
|
+
tool = attr.get("tool")
|
|
343
|
+
if isinstance(tool, dict):
|
|
344
|
+
entry["tool"] = {"name": tool.get("name", ""), "version": tool.get("version", "")}
|
|
345
|
+
elif isinstance(tool, str):
|
|
346
|
+
entry["tool"] = {"name": tool, "version": ""}
|
|
347
|
+
if attr.get("timestamp"):
|
|
348
|
+
entry["timestamp"] = attr["timestamp"]
|
|
349
|
+
if attr.get("commit_sha"):
|
|
350
|
+
entry["commit_sha"] = attr["commit_sha"]
|
|
351
|
+
if attr.get("conversation_id"):
|
|
352
|
+
entry["conversation_id"] = attr["conversation_id"]
|
|
353
|
+
if attr.get("conversation_summary"):
|
|
354
|
+
entry["conversation_summary"] = attr["conversation_summary"]
|
|
355
|
+
if attr.get("conversation_preview"):
|
|
356
|
+
entry["conversation_preview"] = attr["conversation_preview"]
|
|
357
|
+
if attr.get("signals"):
|
|
358
|
+
entry["signals"] = attr["signals"]
|
|
359
|
+
if attr.get("source"):
|
|
360
|
+
entry["source"] = attr["source"]
|
|
361
|
+
if attr.get("attribution_label"):
|
|
362
|
+
entry["attribution_label"] = attr["attribution_label"]
|
|
363
|
+
if attr.get("evidence"):
|
|
364
|
+
entry["evidence"] = attr["evidence"]
|
|
365
|
+
clean.append(entry)
|
|
366
|
+
|
|
367
|
+
return json.dumps({"file": file_path, "attributions": clean}, indent=2)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _filter_no_attribution(
|
|
371
|
+
attributions: list[dict[str, Any]],
|
|
372
|
+
show_no_attribution: bool,
|
|
373
|
+
) -> list[dict[str, Any]]:
|
|
374
|
+
if show_no_attribution:
|
|
375
|
+
return attributions
|
|
376
|
+
return [a for a in attributions if a.get("kind") != "NO_ATTRIBUTION"]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _has_no_attribution(attributions: list[dict[str, Any]]) -> bool:
|
|
380
|
+
return any(a.get("kind") == "NO_ATTRIBUTION" for a in attributions)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def blame_file(
|
|
384
|
+
file_path: str,
|
|
385
|
+
*,
|
|
386
|
+
line: int | None = None,
|
|
387
|
+
start_line: int | None = None,
|
|
388
|
+
end_line: int | None = None,
|
|
389
|
+
show_no_attribution: bool = False,
|
|
390
|
+
require_attribution: bool = False,
|
|
391
|
+
json_output: bool = False,
|
|
392
|
+
project_dir: str | None = None,
|
|
393
|
+
) -> str | None:
|
|
394
|
+
cwd = project_dir if project_dir else os.getcwd()
|
|
395
|
+
abs_path = (
|
|
396
|
+
os.path.abspath(file_path)
|
|
397
|
+
if os.path.isabs(file_path)
|
|
398
|
+
else os.path.abspath(os.path.join(cwd, file_path))
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
if not os.path.isfile(abs_path):
|
|
402
|
+
if json_output:
|
|
403
|
+
return None
|
|
404
|
+
print(f"agent-trace blame: file not found: {file_path}", file=sys.stderr)
|
|
405
|
+
sys.exit(1)
|
|
406
|
+
|
|
407
|
+
file_dir = os.path.dirname(abs_path) or cwd
|
|
408
|
+
git_root = _git("rev-parse", "--show-toplevel", cwd=file_dir)
|
|
409
|
+
if git_root is None:
|
|
410
|
+
if json_output:
|
|
411
|
+
return None
|
|
412
|
+
print("agent-trace blame: not a git repository", file=sys.stderr)
|
|
413
|
+
sys.exit(1)
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
rel_path = os.path.relpath(abs_path, git_root)
|
|
417
|
+
except ValueError:
|
|
418
|
+
rel_path = file_path
|
|
419
|
+
|
|
420
|
+
if line is not None:
|
|
421
|
+
start_line = line
|
|
422
|
+
end_line = line
|
|
423
|
+
|
|
424
|
+
raw = _git_blame_porcelain(
|
|
425
|
+
rel_path,
|
|
426
|
+
start_line=start_line,
|
|
427
|
+
end_line=end_line,
|
|
428
|
+
cwd=git_root,
|
|
429
|
+
)
|
|
430
|
+
if raw is None:
|
|
431
|
+
if json_output:
|
|
432
|
+
return None
|
|
433
|
+
print(f"agent-trace blame: git blame failed for {file_path}", file=sys.stderr)
|
|
434
|
+
sys.exit(1)
|
|
435
|
+
|
|
436
|
+
records = _parse_blame_porcelain(raw)
|
|
437
|
+
if not records:
|
|
438
|
+
if json_output:
|
|
439
|
+
return None
|
|
440
|
+
print(f"agent-trace blame: no blame data for {file_path}", file=sys.stderr)
|
|
441
|
+
sys.exit(1)
|
|
442
|
+
|
|
443
|
+
segments = _group_into_segments(records)
|
|
444
|
+
|
|
445
|
+
traces = _load_local_traces(git_root)
|
|
446
|
+
ledgers = load_local_ledgers(git_root)
|
|
447
|
+
ledgers = _merge_ledgers_from_git_notes(git_root, segments, ledgers)
|
|
448
|
+
|
|
449
|
+
pid = resolve_project_id(git_root, create=False)
|
|
450
|
+
summary_by_id = latest_summary_by_id(pid) if pid else {}
|
|
451
|
+
|
|
452
|
+
raw_attrs = _attribute_deterministic(
|
|
453
|
+
segments, rel_path, ledgers, traces,
|
|
454
|
+
summary_by_id=summary_by_id, project_id=pid,
|
|
455
|
+
)
|
|
456
|
+
attributions = _merge_attributions(raw_attrs)
|
|
457
|
+
|
|
458
|
+
if require_attribution and _has_no_attribution(attributions):
|
|
459
|
+
print(
|
|
460
|
+
"agent-trace blame: no AI attribution for one or more lines "
|
|
461
|
+
"(ledger missing, or lines were not written by a tracked AI tool).",
|
|
462
|
+
file=sys.stderr,
|
|
463
|
+
)
|
|
464
|
+
sys.exit(1)
|
|
465
|
+
|
|
466
|
+
out = _filter_no_attribution(attributions, show_no_attribution)
|
|
467
|
+
|
|
468
|
+
if json_output:
|
|
469
|
+
return _format_json(rel_path, out)
|
|
470
|
+
print(_format_terminal(rel_path, out))
|
|
471
|
+
return None
|
agent_trace/blame_git.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Git blame --porcelain parsing and segment grouping (internal)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _git(*args: str, cwd: str | None = None) -> str | None:
|
|
10
|
+
try:
|
|
11
|
+
result = subprocess.run(
|
|
12
|
+
["git", *args],
|
|
13
|
+
capture_output=True, text=True, cwd=cwd, timeout=30,
|
|
14
|
+
)
|
|
15
|
+
if result.returncode == 0:
|
|
16
|
+
return result.stdout.strip()
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _git_blame_porcelain(
|
|
23
|
+
file_path: str,
|
|
24
|
+
*,
|
|
25
|
+
start_line: int | None = None,
|
|
26
|
+
end_line: int | None = None,
|
|
27
|
+
cwd: str | None = None,
|
|
28
|
+
) -> str | None:
|
|
29
|
+
args = ["blame", "--porcelain"]
|
|
30
|
+
if start_line is not None and end_line is not None:
|
|
31
|
+
args.extend(["-L", f"{start_line},{end_line}"])
|
|
32
|
+
elif start_line is not None:
|
|
33
|
+
args.extend(["-L", f"{start_line},{start_line}"])
|
|
34
|
+
args.append(file_path)
|
|
35
|
+
return _git(*args, cwd=cwd)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_blame_porcelain(raw: str) -> list[dict[str, Any]]:
|
|
39
|
+
lines = raw.split("\n")
|
|
40
|
+
records: list[dict[str, Any]] = []
|
|
41
|
+
commit_info: dict[str, dict[str, Any]] = {}
|
|
42
|
+
i = 0
|
|
43
|
+
while i < len(lines):
|
|
44
|
+
line = lines[i]
|
|
45
|
+
if not line:
|
|
46
|
+
i += 1
|
|
47
|
+
continue
|
|
48
|
+
parts = line.split()
|
|
49
|
+
if len(parts) < 3:
|
|
50
|
+
i += 1
|
|
51
|
+
continue
|
|
52
|
+
sha = parts[0]
|
|
53
|
+
if len(sha) != 40 or not all(c in "0123456789abcdef" for c in sha):
|
|
54
|
+
i += 1
|
|
55
|
+
continue
|
|
56
|
+
orig_line = int(parts[1])
|
|
57
|
+
final_line = int(parts[2])
|
|
58
|
+
i += 1
|
|
59
|
+
if sha not in commit_info:
|
|
60
|
+
info: dict[str, Any] = {}
|
|
61
|
+
while i < len(lines):
|
|
62
|
+
hline = lines[i]
|
|
63
|
+
if hline.startswith("\t"):
|
|
64
|
+
break
|
|
65
|
+
if hline.startswith("author "):
|
|
66
|
+
info["author"] = hline[7:]
|
|
67
|
+
elif hline.startswith("author-time "):
|
|
68
|
+
try:
|
|
69
|
+
info["author_time"] = int(hline[12:])
|
|
70
|
+
except ValueError:
|
|
71
|
+
pass
|
|
72
|
+
elif hline.startswith("summary "):
|
|
73
|
+
info["summary"] = hline[8:]
|
|
74
|
+
elif hline.startswith("filename "):
|
|
75
|
+
info["filename"] = hline[9:]
|
|
76
|
+
i += 1
|
|
77
|
+
commit_info[sha] = info
|
|
78
|
+
else:
|
|
79
|
+
while i < len(lines) and not lines[i].startswith("\t"):
|
|
80
|
+
hline = lines[i]
|
|
81
|
+
if hline.startswith("filename "):
|
|
82
|
+
commit_info[sha]["filename"] = hline[9:]
|
|
83
|
+
i += 1
|
|
84
|
+
content = ""
|
|
85
|
+
if i < len(lines) and lines[i].startswith("\t"):
|
|
86
|
+
content = lines[i][1:]
|
|
87
|
+
i += 1
|
|
88
|
+
info = commit_info.get(sha, {})
|
|
89
|
+
records.append({
|
|
90
|
+
"commit_sha": sha,
|
|
91
|
+
"orig_line": orig_line,
|
|
92
|
+
"final_line": final_line,
|
|
93
|
+
"content": content,
|
|
94
|
+
"author": info.get("author", ""),
|
|
95
|
+
"author_time": info.get("author_time"),
|
|
96
|
+
"summary": info.get("summary", ""),
|
|
97
|
+
"filename": info.get("filename", ""),
|
|
98
|
+
})
|
|
99
|
+
return records
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _group_into_segments(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
103
|
+
if not records:
|
|
104
|
+
return []
|
|
105
|
+
segments: list[dict[str, Any]] = []
|
|
106
|
+
current: dict[str, Any] | None = None
|
|
107
|
+
for rec in records:
|
|
108
|
+
if (
|
|
109
|
+
current is not None
|
|
110
|
+
and current["commit_sha"] == rec["commit_sha"]
|
|
111
|
+
and current["end_line"] + 1 == rec["final_line"]
|
|
112
|
+
):
|
|
113
|
+
current["end_line"] = rec["final_line"]
|
|
114
|
+
current["orig_end_line"] = rec["orig_line"]
|
|
115
|
+
current["content_lines"].append(rec["content"])
|
|
116
|
+
else:
|
|
117
|
+
if current is not None:
|
|
118
|
+
segments.append(current)
|
|
119
|
+
current = {
|
|
120
|
+
"commit_sha": rec["commit_sha"],
|
|
121
|
+
"start_line": rec["final_line"],
|
|
122
|
+
"end_line": rec["final_line"],
|
|
123
|
+
"orig_start_line": rec["orig_line"],
|
|
124
|
+
"orig_end_line": rec["orig_line"],
|
|
125
|
+
"content_lines": [rec["content"]],
|
|
126
|
+
"author": rec.get("author", ""),
|
|
127
|
+
"author_time": rec.get("author_time"),
|
|
128
|
+
"summary": rec.get("summary", ""),
|
|
129
|
+
"filename": rec.get("filename", ""),
|
|
130
|
+
}
|
|
131
|
+
if current is not None:
|
|
132
|
+
segments.append(current)
|
|
133
|
+
return segments
|