gitvow 0.6.0__tar.gz → 0.7.1__tar.gz
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.
- {gitvow-0.6.0/src/gitvow.egg-info → gitvow-0.7.1}/PKG-INFO +1 -1
- {gitvow-0.6.0 → gitvow-0.7.1}/pyproject.toml +1 -1
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/cli.py +34 -0
- gitvow-0.7.1/src/gitvow/recall.py +285 -0
- gitvow-0.7.1/src/gitvow/transcript.py +187 -0
- {gitvow-0.6.0 → gitvow-0.7.1/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow.egg-info/SOURCES.txt +4 -1
- gitvow-0.7.1/tests/test_recall.py +107 -0
- gitvow-0.7.1/tests/test_transcript_formats.py +128 -0
- gitvow-0.6.0/src/gitvow/transcript.py +0 -49
- {gitvow-0.6.0 → gitvow-0.7.1}/LICENSE +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/README.md +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/setup.cfg +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/__init__.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/__main__.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/adapters.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/collect.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/hooks/__init__.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/install.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/policy.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/providers.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/redact.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/report.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/selftest.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow/state.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/tests/test_adapters.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/tests/test_collect.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/tests/test_hooks.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/tests/test_install_cli.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/tests/test_packaging.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/tests/test_policy.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/tests/test_providers.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/tests/test_redact.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/tests/test_report.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.1}/tests/test_snapshots.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
|
|
5
5
|
Author-email: Nikhil Bora <nikhil@wirevow.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitvow"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.1"
|
|
8
8
|
description = "Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -9,6 +9,7 @@ import re
|
|
|
9
9
|
import sys
|
|
10
10
|
|
|
11
11
|
from . import __version__
|
|
12
|
+
from . import recall as rec
|
|
12
13
|
from . import snapshots as snap
|
|
13
14
|
from .collect import collect, summarize_text
|
|
14
15
|
from .hooks import HANDLERS, LEGACY_NOTES_REF, notes_ref
|
|
@@ -211,6 +212,26 @@ def cmd_restore(a: argparse.Namespace) -> int:
|
|
|
211
212
|
return rc
|
|
212
213
|
|
|
213
214
|
|
|
215
|
+
def cmd_why(a: argparse.Namespace) -> int:
|
|
216
|
+
print(rec.why(os.getcwd(), a.path))
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cmd_trace(a: argparse.Namespace) -> int:
|
|
221
|
+
print(rec.trace(os.getcwd(), a.spec))
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def cmd_recall(a: argparse.Namespace) -> int:
|
|
226
|
+
print(rec.recall(os.getcwd(), a.words, None, a.limit))
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def cmd_handoff(a: argparse.Namespace) -> int:
|
|
231
|
+
print(rec.handoff(os.getcwd(), a.session))
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
214
235
|
def cmd_push_notes(a: argparse.Namespace) -> int:
|
|
215
236
|
import subprocess
|
|
216
237
|
|
|
@@ -304,6 +325,19 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
304
325
|
s.add_argument("n", type=int)
|
|
305
326
|
s.add_argument("--to")
|
|
306
327
|
s.set_defaults(f=cmd_restore)
|
|
328
|
+
s = sub.add_parser("why", help="which sessions shaped a file: commits, plans, attribution")
|
|
329
|
+
s.add_argument("path")
|
|
330
|
+
s.set_defaults(f=cmd_why)
|
|
331
|
+
s = sub.add_parser("trace", help="who wrote these lines: gitvow trace path[:start-end]")
|
|
332
|
+
s.add_argument("spec")
|
|
333
|
+
s.set_defaults(f=cmd_trace)
|
|
334
|
+
s = sub.add_parser("recall", help="sessions whose notes or ledger mention the words")
|
|
335
|
+
s.add_argument("words", nargs="+")
|
|
336
|
+
s.add_argument("--limit", type=int, default=10)
|
|
337
|
+
s.set_defaults(f=cmd_recall)
|
|
338
|
+
s = sub.add_parser("handoff", help="markdown summary for the next agent")
|
|
339
|
+
s.add_argument("--session")
|
|
340
|
+
s.set_defaults(f=cmd_handoff)
|
|
307
341
|
s = sub.add_parser("push-notes", help="push refs/notes/gitvow/* to a remote (default origin)")
|
|
308
342
|
s.add_argument("remote", nargs="?", default="origin")
|
|
309
343
|
s.set_defaults(f=cmd_push_notes)
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""The record answers the next session: why, trace, recall, handoff. Read-only over trailers, notes, snapshots, ledger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .hooks import LEGACY_NOTES_REF, notes_ref
|
|
12
|
+
from .report import STEP_RE, TRAILER_RE
|
|
13
|
+
from .snapshots import list_snapshots
|
|
14
|
+
from .state import git, load_state, toplevel
|
|
15
|
+
|
|
16
|
+
MAX_PLAN = 200
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _note(cwd: str, sha: str, sid: str) -> dict[str, Any] | None:
|
|
20
|
+
for ref in (notes_ref(sid), LEGACY_NOTES_REF):
|
|
21
|
+
rc, body, _ = git(["notes", f"--ref={ref}", "show", sha], cwd)
|
|
22
|
+
if rc == 0 and body.startswith("gitvow-session"):
|
|
23
|
+
try:
|
|
24
|
+
return json.loads(body.split("\n", 1)[1])
|
|
25
|
+
except (json.JSONDecodeError, IndexError):
|
|
26
|
+
return None
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _commits(cwd: str, path: str | None = None, limit: int = 200) -> list[dict[str, Any]]:
|
|
31
|
+
args = ["log", f"-{limit}", "--format=%H%x00%ad%x00%s%x00%B%x01", "--date=short"]
|
|
32
|
+
if path:
|
|
33
|
+
args += ["--follow", "--", path]
|
|
34
|
+
rc, out, _ = git(args, cwd)
|
|
35
|
+
rows: list[dict[str, Any]] = []
|
|
36
|
+
if rc != 0:
|
|
37
|
+
return rows
|
|
38
|
+
for rec in out.split("\x01"):
|
|
39
|
+
rec = rec.strip("\n")
|
|
40
|
+
if not rec.strip():
|
|
41
|
+
continue
|
|
42
|
+
sha, date, subject, body = rec.split("\x00", 3)
|
|
43
|
+
m = TRAILER_RE.search(body)
|
|
44
|
+
row: dict[str, Any] = {
|
|
45
|
+
"sha": sha,
|
|
46
|
+
"short": sha[:7],
|
|
47
|
+
"date": date,
|
|
48
|
+
"subject": subject,
|
|
49
|
+
"kind": "person",
|
|
50
|
+
"session": None,
|
|
51
|
+
"step": None,
|
|
52
|
+
"note": None,
|
|
53
|
+
}
|
|
54
|
+
if m:
|
|
55
|
+
sm = STEP_RE.search(body)
|
|
56
|
+
row.update(
|
|
57
|
+
{
|
|
58
|
+
"kind": "agent",
|
|
59
|
+
"session": m.group(1),
|
|
60
|
+
"step": int(sm.group(1)) if sm else None,
|
|
61
|
+
"note": _note(cwd, sha, m.group(1)),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
rows.append(row)
|
|
65
|
+
return rows
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _plan(note: dict[str, Any] | None) -> str:
|
|
69
|
+
p = ((note or {}).get("last_stated_plan") or "").strip().replace("\n", " ")
|
|
70
|
+
return (p[: MAX_PLAN - 1] + "…") if len(p) > MAX_PLAN else p
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def why(cwd: str, path: str) -> str:
|
|
74
|
+
top = toplevel(cwd) or cwd
|
|
75
|
+
rel = os.path.relpath(os.path.abspath(path), top) if os.path.isabs(path) else path
|
|
76
|
+
rows = _commits(top, rel)
|
|
77
|
+
if not rows:
|
|
78
|
+
return f"{rel}: no commits found"
|
|
79
|
+
agent = [r for r in rows if r["kind"] == "agent"]
|
|
80
|
+
sessions = {r["session"] for r in agent}
|
|
81
|
+
lines = [
|
|
82
|
+
f"{rel} · {len(agent)} agent commit{'s' if len(agent) != 1 else ''}, {len(rows) - len(agent)} human commit{'s' if len(rows) - len(agent) != 1 else ''}, {len(sessions)} session{'s' if len(sessions) != 1 else ''}",
|
|
83
|
+
"",
|
|
84
|
+
]
|
|
85
|
+
snaps = {s["ref"] for s in list_snapshots(top) if s.get("file") == rel}
|
|
86
|
+
for r in rows[:30]:
|
|
87
|
+
if r["kind"] == "person":
|
|
88
|
+
lines.append(f"{r['date']} {r['short']} (person) {r['subject'][:70]}")
|
|
89
|
+
continue
|
|
90
|
+
att = (r["note"] or {}).get("attribution") or {}
|
|
91
|
+
share = att.get("agent_share")
|
|
92
|
+
human = att.get("lines_changed_by_human_after_agent") or 0
|
|
93
|
+
extra = f"agent share {share:.2f}" if isinstance(share, (int, float)) else "attribution n/a"
|
|
94
|
+
if human:
|
|
95
|
+
extra += f" · {human} line{'s' if human != 1 else ''} changed by a person afterwards"
|
|
96
|
+
step = f"step {r['step']}" if r["step"] is not None else ""
|
|
97
|
+
lines.append(f"{r['date']} {r['short']} session {r['session'][:8]} {step:<8} {extra}")
|
|
98
|
+
plan = _plan(r["note"])
|
|
99
|
+
lines.append(
|
|
100
|
+
f" {plan}"
|
|
101
|
+
if plan
|
|
102
|
+
else " (no plan stated before committing)"
|
|
103
|
+
if r["note"]
|
|
104
|
+
else " (session note not available; run gitvow push-notes on the author's machine)"
|
|
105
|
+
)
|
|
106
|
+
if snaps:
|
|
107
|
+
lines += [
|
|
108
|
+
"",
|
|
109
|
+
f"{len(snaps)} snapshot{'s' if len(snaps) != 1 else ''} of this file from agent edits; see gitvow snapshots",
|
|
110
|
+
]
|
|
111
|
+
return "\n".join(lines)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def trace(cwd: str, spec: str) -> str:
|
|
115
|
+
top = toplevel(cwd) or cwd
|
|
116
|
+
m = re.match(r"^(.*?)(?::(\d+)(?:-(\d+))?)?$", spec)
|
|
117
|
+
path = m.group(1) if m else spec
|
|
118
|
+
rel = os.path.relpath(os.path.abspath(path), top) if os.path.isabs(path) else path
|
|
119
|
+
args = ["blame", "--line-porcelain"]
|
|
120
|
+
if m and m.group(2):
|
|
121
|
+
args += ["-L", f"{m.group(2)},{m.group(3) or m.group(2)}"]
|
|
122
|
+
rc, out, err = git([*args, "--", rel], top)
|
|
123
|
+
if rc != 0:
|
|
124
|
+
return err or f"cannot blame {rel}"
|
|
125
|
+
shas: list[str] = []
|
|
126
|
+
line_no = int(m.group(2)) if m and m.group(2) else 1
|
|
127
|
+
lines_by_sha: dict[str, list[int]] = {}
|
|
128
|
+
for ln in out.splitlines():
|
|
129
|
+
if re.match(r"^[0-9a-f]{40} ", ln):
|
|
130
|
+
sha = ln.split()[0]
|
|
131
|
+
parts = ln.split()
|
|
132
|
+
final = int(parts[2]) if len(parts) > 2 else line_no
|
|
133
|
+
lines_by_sha.setdefault(sha, []).append(final)
|
|
134
|
+
if sha not in shas:
|
|
135
|
+
shas.append(sha)
|
|
136
|
+
info: dict[str, dict[str, Any]] = {}
|
|
137
|
+
for sha in shas:
|
|
138
|
+
rc, body, _ = git(["log", "-1", "--format=%B", sha], top)
|
|
139
|
+
tm = TRAILER_RE.search(body or "")
|
|
140
|
+
if tm:
|
|
141
|
+
sm = STEP_RE.search(body)
|
|
142
|
+
info[sha] = {
|
|
143
|
+
"kind": "agent",
|
|
144
|
+
"session": tm.group(1),
|
|
145
|
+
"step": int(sm.group(1)) if sm else None,
|
|
146
|
+
"note": _note(top, sha, tm.group(1)),
|
|
147
|
+
}
|
|
148
|
+
else:
|
|
149
|
+
info[sha] = {"kind": "person"}
|
|
150
|
+
# group consecutive lines per sha
|
|
151
|
+
out_lines = [f"{rel}" + (f":{m.group(2)}-{m.group(3) or m.group(2)}" if m and m.group(2) else "")]
|
|
152
|
+
for sha in shas:
|
|
153
|
+
nums = sorted(lines_by_sha[sha])
|
|
154
|
+
ranges, start, prev = [], nums[0], nums[0]
|
|
155
|
+
for n in nums[1:]:
|
|
156
|
+
if n != prev + 1:
|
|
157
|
+
ranges.append((start, prev))
|
|
158
|
+
start = n
|
|
159
|
+
prev = n
|
|
160
|
+
ranges.append((start, prev))
|
|
161
|
+
rtxt = ", ".join(f"{a}-{b}" if a != b else str(a) for a, b in ranges[:6]) + (" …" if len(ranges) > 6 else "")
|
|
162
|
+
i = info[sha]
|
|
163
|
+
if i["kind"] == "agent":
|
|
164
|
+
step = f"step {i['step']}" if i["step"] is not None else ""
|
|
165
|
+
plan = _plan(i["note"])
|
|
166
|
+
out_lines.append(
|
|
167
|
+
f"lines {rtxt:<14} agent {sha[:7]} session {i['session'][:8]} {step}"
|
|
168
|
+
+ (f' "{plan}"' if plan else "")
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
out_lines.append(f"lines {rtxt:<14} person {sha[:7]}")
|
|
172
|
+
return "\n".join(out_lines)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def recall(cwd: str, words: list[str], home: str | None = None, limit: int = 10) -> str:
|
|
176
|
+
top = toplevel(cwd) or cwd
|
|
177
|
+
terms = [w.lower() for w in words if w.strip()]
|
|
178
|
+
if not terms:
|
|
179
|
+
return "give one or more words to recall"
|
|
180
|
+
hits: dict[str, dict[str, Any]] = {}
|
|
181
|
+
for r in _commits(top, None, 2000):
|
|
182
|
+
if r["kind"] != "agent":
|
|
183
|
+
continue
|
|
184
|
+
hay = " ".join(
|
|
185
|
+
[
|
|
186
|
+
r["subject"],
|
|
187
|
+
_plan(r["note"]),
|
|
188
|
+
" ".join((r["note"] or {}).get("files_written_by_agent_this_session") or []),
|
|
189
|
+
]
|
|
190
|
+
).lower()
|
|
191
|
+
score = sum(1 for t in terms if t in hay)
|
|
192
|
+
if not score:
|
|
193
|
+
continue
|
|
194
|
+
h = hits.setdefault(
|
|
195
|
+
r["session"],
|
|
196
|
+
{"session": r["session"], "date": r["date"], "commits": 0, "plan": "", "score": 0, "source": "notes"},
|
|
197
|
+
)
|
|
198
|
+
h["commits"] += 1
|
|
199
|
+
h["score"] = max(h["score"], score)
|
|
200
|
+
h["plan"] = h["plan"] or _plan(r["note"]) or r["subject"]
|
|
201
|
+
led = os.path.join(home or os.path.expanduser("~"), ".gitvow", "ledger")
|
|
202
|
+
for f in glob.glob(os.path.join(led, "*.json")):
|
|
203
|
+
try:
|
|
204
|
+
rec = json.load(open(f)) # noqa: SIM115
|
|
205
|
+
except (OSError, json.JSONDecodeError):
|
|
206
|
+
continue
|
|
207
|
+
if os.path.realpath(rec.get("repo") or "") != os.path.realpath(top):
|
|
208
|
+
continue
|
|
209
|
+
hay = (
|
|
210
|
+
" ".join([rec.get("last_stated_plan") or ""] + [t.get("arg", "") for t in rec.get("tool_calls", [])])
|
|
211
|
+
).lower()
|
|
212
|
+
score = sum(1 for t in terms if t in hay)
|
|
213
|
+
if score and rec.get("session_id") not in hits:
|
|
214
|
+
hits[rec["session_id"]] = {
|
|
215
|
+
"session": rec["session_id"],
|
|
216
|
+
"date": (rec.get("started") or "")[:10],
|
|
217
|
+
"commits": len(rec.get("commits_during_session") or []),
|
|
218
|
+
"plan": _plan(rec),
|
|
219
|
+
"score": score,
|
|
220
|
+
"source": "ledger",
|
|
221
|
+
}
|
|
222
|
+
if not hits:
|
|
223
|
+
return f"nothing recorded mentions: {' '.join(words)}"
|
|
224
|
+
rows = sorted(hits.values(), key=lambda h: (-h["score"], h["date"]), reverse=False)[:limit]
|
|
225
|
+
out = [f"{len(hits)} session{'s' if len(hits) != 1 else ''} mention {' '.join(words)}:"]
|
|
226
|
+
for h in rows:
|
|
227
|
+
out.append(
|
|
228
|
+
f'session {h["session"][:8]} {h["date"]} {h["commits"]} commit{"s" if h["commits"] != 1 else ""} "{h["plan"]}"'
|
|
229
|
+
+ (" (ledger only)" if h["source"] == "ledger" else "")
|
|
230
|
+
)
|
|
231
|
+
return "\n".join(out)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def handoff(cwd: str, session_id: str | None = None, home: str | None = None) -> str:
|
|
235
|
+
top = toplevel(cwd) or cwd
|
|
236
|
+
st = load_state(top)
|
|
237
|
+
sid = session_id or st.get("session_id")
|
|
238
|
+
led_dir = os.path.join(home or os.path.expanduser("~"), ".gitvow", "ledger")
|
|
239
|
+
rec: dict[str, Any] = {}
|
|
240
|
+
if sid and os.path.exists(os.path.join(led_dir, f"{sid}.json")):
|
|
241
|
+
try:
|
|
242
|
+
rec = json.load(open(os.path.join(led_dir, f"{sid}.json"))) # noqa: SIM115
|
|
243
|
+
except (OSError, json.JSONDecodeError):
|
|
244
|
+
rec = {}
|
|
245
|
+
if not sid:
|
|
246
|
+
return "no session known in this repository; pass --session <id>"
|
|
247
|
+
commits = [r for r in _commits(top, None, 500) if r["session"] == sid]
|
|
248
|
+
files = sorted(
|
|
249
|
+
{f for r in commits for f in ((r["note"] or {}).get("files_written_by_agent_this_session") or [])}
|
|
250
|
+
| set(st.get("agent_blobs", {}).keys() if st.get("session_id") == sid else [])
|
|
251
|
+
)
|
|
252
|
+
_, dirty, _ = git(["diff", "--shortstat", "HEAD"], top)
|
|
253
|
+
snaps = list_snapshots(top, sid)
|
|
254
|
+
plan = _plan(rec) or next((_plan(r["note"]) for r in commits if _plan(r["note"])), "")
|
|
255
|
+
confirms = []
|
|
256
|
+
gd_log = os.path.join(top, ".git", "gitvow-hooks.log")
|
|
257
|
+
if os.path.exists(gd_log):
|
|
258
|
+
with open(gd_log) as fh:
|
|
259
|
+
for ln in fh:
|
|
260
|
+
try:
|
|
261
|
+
e = json.loads(ln)
|
|
262
|
+
except json.JSONDecodeError:
|
|
263
|
+
continue
|
|
264
|
+
if e.get("kind") == "confirm_required" and e.get("session_id") == sid:
|
|
265
|
+
confirms.append(f"{e.get('detail', '')[:60]} ({e.get('reason', '')[:60]})")
|
|
266
|
+
out = [f"# Handoff · session {sid[:8]} · repo {os.path.basename(top)}", ""]
|
|
267
|
+
out.append(f"Last stated plan: {plan or '(none recorded)'}")
|
|
268
|
+
out.append(
|
|
269
|
+
"Commits this session: "
|
|
270
|
+
+ (", ".join(f"{r['short']} {r['subject'][:50]}" for r in commits) if commits else "none")
|
|
271
|
+
)
|
|
272
|
+
out.append("Files the agent wrote: " + (", ".join(files[:20]) if files else "none recorded"))
|
|
273
|
+
out.append(
|
|
274
|
+
f"Uncommitted now: {dirty.strip() or 'clean'}" + (f" · last snapshot {snaps[-1]['ref']}" if snaps else "")
|
|
275
|
+
)
|
|
276
|
+
out.append(
|
|
277
|
+
f"Open confirmations: {len(confirms)}" + ("".join(f"\n - {c}" for c in confirms[-5:]) if confirms else "")
|
|
278
|
+
)
|
|
279
|
+
if rec.get("tool_calls"):
|
|
280
|
+
out.append(f"Tool calls in session: {len(rec['tool_calls'])}")
|
|
281
|
+
out += [
|
|
282
|
+
"",
|
|
283
|
+
"Next agent: read `gitvow why <file>` for any file above before changing it; snapshots hold the exact versions this session produced.",
|
|
284
|
+
]
|
|
285
|
+
return "\n".join(out)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Structural, redacted summary of an agent transcript (Claude Code JSONL). Tool output is never read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .redact import redact
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _detect(first: dict[str, Any]) -> str:
|
|
14
|
+
"""Which agent wrote this transcript: claude (default), codex (rollout records), gemini (chat recording)."""
|
|
15
|
+
t = first.get("type")
|
|
16
|
+
if t in ("session_meta", "response_item", "event_msg", "turn_context", "compacted") and "payload" in first:
|
|
17
|
+
return "codex"
|
|
18
|
+
if "sessionId" in first and "projectHash" in first:
|
|
19
|
+
return "gemini"
|
|
20
|
+
if first.get("type") in ("user", "gemini", "info", "error") and "content" in first and "message" not in first:
|
|
21
|
+
return "gemini"
|
|
22
|
+
return "claude"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _text_of(content: Any) -> str:
|
|
26
|
+
if isinstance(content, str):
|
|
27
|
+
return content
|
|
28
|
+
if isinstance(content, list):
|
|
29
|
+
parts = []
|
|
30
|
+
for c in content:
|
|
31
|
+
if isinstance(c, dict):
|
|
32
|
+
if c.get("type") in ("text", "output_text", "input_text") and c.get("text"):
|
|
33
|
+
parts.append(str(c["text"]))
|
|
34
|
+
elif "text" in c and isinstance(c["text"], str):
|
|
35
|
+
parts.append(c["text"])
|
|
36
|
+
elif isinstance(c, str):
|
|
37
|
+
parts.append(c)
|
|
38
|
+
return "\n".join(parts)
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _patch_files(text: str) -> list[str]:
|
|
43
|
+
import re
|
|
44
|
+
|
|
45
|
+
return re.findall(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$", text or "", re.M)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _codex_tool(name: str, arguments: Any) -> tuple[str, str, list[str]]:
|
|
49
|
+
"""Codex function names -> (gitvow tool, brief argument, files written)."""
|
|
50
|
+
args: dict[str, Any] = {}
|
|
51
|
+
if isinstance(arguments, str):
|
|
52
|
+
try:
|
|
53
|
+
args = json.loads(arguments) if arguments.strip().startswith("{") else {"input": arguments}
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
args = {"input": arguments}
|
|
56
|
+
elif isinstance(arguments, dict):
|
|
57
|
+
args = arguments
|
|
58
|
+
if name in ("exec_command", "shell", "container.exec", "local_shell"):
|
|
59
|
+
cmd = args.get("cmd") or args.get("command") or ""
|
|
60
|
+
cmd = " ".join(cmd) if isinstance(cmd, list) else str(cmd)
|
|
61
|
+
return "Bash", cmd, []
|
|
62
|
+
if name == "apply_patch":
|
|
63
|
+
patch = str(args.get("input") or args.get("patch") or args.get("command") or "")
|
|
64
|
+
files = _patch_files(patch)
|
|
65
|
+
return "Edit", files[0] if files else "apply_patch", files
|
|
66
|
+
return name, json.dumps(args)[:160] if args else "", []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _gemini_tool(name: str, args: Any) -> tuple[str, str, list[str]]:
|
|
70
|
+
a = args if isinstance(args, dict) else {}
|
|
71
|
+
if name == "run_shell_command":
|
|
72
|
+
return "Bash", str(a.get("command") or ""), []
|
|
73
|
+
if name in ("write_file", "replace", "edit"):
|
|
74
|
+
fp = str(a.get("file_path") or a.get("path") or "")
|
|
75
|
+
return ("Write" if name == "write_file" else "Edit"), fp, [fp] if fp else []
|
|
76
|
+
return name, json.dumps(a)[:160] if a else "", []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def summarize(path: str | None, max_tools: int = 500, rules: Iterable[tuple[str, str]] = ()) -> dict[str, Any]:
|
|
80
|
+
"""Structural summary of a transcript. Reads Claude Code, Codex and Gemini CLI formats; tool output is never read."""
|
|
81
|
+
out: dict[str, Any] = {
|
|
82
|
+
"turns": 0,
|
|
83
|
+
"tool_calls": [],
|
|
84
|
+
"last_assistant_text": "",
|
|
85
|
+
"files_written": [],
|
|
86
|
+
"format": "claude",
|
|
87
|
+
}
|
|
88
|
+
if not path or not os.path.exists(path):
|
|
89
|
+
return out
|
|
90
|
+
rules = list(rules)
|
|
91
|
+
written: set[str] = set()
|
|
92
|
+
fmt: str | None = None
|
|
93
|
+
with open(path, errors="ignore") as fh:
|
|
94
|
+
for line in fh:
|
|
95
|
+
try:
|
|
96
|
+
ev = json.loads(line)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
continue
|
|
99
|
+
if not isinstance(ev, dict):
|
|
100
|
+
continue
|
|
101
|
+
if fmt is None:
|
|
102
|
+
fmt = _detect(ev)
|
|
103
|
+
out["format"] = fmt
|
|
104
|
+
if fmt == "codex":
|
|
105
|
+
_codex_event(ev, out, written, rules, max_tools)
|
|
106
|
+
elif fmt == "gemini":
|
|
107
|
+
_gemini_event(ev, out, written, rules, max_tools)
|
|
108
|
+
else:
|
|
109
|
+
_claude_event(ev, out, written, rules, max_tools)
|
|
110
|
+
out["files_written"] = sorted(written)
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _add_tool(out: dict[str, Any], tool: str, brief: str, rules: list[tuple[str, str]], max_tools: int) -> None:
|
|
115
|
+
if len(out["tool_calls"]) < max_tools:
|
|
116
|
+
out["tool_calls"].append({"tool": tool, "arg": redact(str(brief), rules)[:160]})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _claude_event(
|
|
120
|
+
ev: dict[str, Any], out: dict[str, Any], written: set[str], rules: list[tuple[str, str]], max_tools: int
|
|
121
|
+
) -> None:
|
|
122
|
+
msg = ev.get("message") or {}
|
|
123
|
+
role = msg.get("role") or ev.get("type")
|
|
124
|
+
content = msg.get("content")
|
|
125
|
+
if role != "assistant":
|
|
126
|
+
return
|
|
127
|
+
out["turns"] += 1
|
|
128
|
+
if isinstance(content, str):
|
|
129
|
+
if content.strip():
|
|
130
|
+
out["last_assistant_text"] = redact(content, rules)[:600]
|
|
131
|
+
return
|
|
132
|
+
if not isinstance(content, list):
|
|
133
|
+
return
|
|
134
|
+
for c in content:
|
|
135
|
+
if not isinstance(c, dict):
|
|
136
|
+
continue
|
|
137
|
+
if c.get("type") == "tool_use":
|
|
138
|
+
inp = c.get("input") or {}
|
|
139
|
+
brief = inp.get("command") or inp.get("file_path") or inp.get("query") or inp.get("pattern") or ""
|
|
140
|
+
_add_tool(out, str(c.get("name")), brief, rules, max_tools)
|
|
141
|
+
if c.get("name") in ("Edit", "Write", "MultiEdit", "NotebookEdit") and inp.get("file_path"):
|
|
142
|
+
written.add(str(inp["file_path"]))
|
|
143
|
+
elif c.get("type") == "text" and str(c.get("text", "")).strip():
|
|
144
|
+
out["last_assistant_text"] = redact(c["text"], rules)[:600]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _codex_event(
|
|
148
|
+
ev: dict[str, Any], out: dict[str, Any], written: set[str], rules: list[tuple[str, str]], max_tools: int
|
|
149
|
+
) -> None:
|
|
150
|
+
t, p = ev.get("type"), ev.get("payload") or {}
|
|
151
|
+
if not isinstance(p, dict):
|
|
152
|
+
return
|
|
153
|
+
if t == "response_item":
|
|
154
|
+
pt = p.get("type")
|
|
155
|
+
if pt == "message" and p.get("role") == "assistant":
|
|
156
|
+
out["turns"] += 1
|
|
157
|
+
text = _text_of(p.get("content"))
|
|
158
|
+
if text.strip():
|
|
159
|
+
out["last_assistant_text"] = redact(text, rules)[:600]
|
|
160
|
+
elif pt in ("function_call", "custom_tool_call", "local_shell_call"):
|
|
161
|
+
name = str(p.get("name") or ("local_shell" if pt == "local_shell_call" else ""))
|
|
162
|
+
arguments = p.get("arguments") if pt == "function_call" else (p.get("input") or p.get("action") or "")
|
|
163
|
+
tool, brief, files = _codex_tool(name, arguments)
|
|
164
|
+
_add_tool(out, tool, brief, rules, max_tools)
|
|
165
|
+
written.update(files)
|
|
166
|
+
elif t == "event_msg":
|
|
167
|
+
pt = p.get("type")
|
|
168
|
+
if pt == "agent_message" and p.get("message"):
|
|
169
|
+
out["turns"] += 1
|
|
170
|
+
out["last_assistant_text"] = redact(str(p["message"]), rules)[:600]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _gemini_event(
|
|
174
|
+
ev: dict[str, Any], out: dict[str, Any], written: set[str], rules: list[tuple[str, str]], max_tools: int
|
|
175
|
+
) -> None:
|
|
176
|
+
if ev.get("type") != "gemini":
|
|
177
|
+
return
|
|
178
|
+
out["turns"] += 1
|
|
179
|
+
text = _text_of(ev.get("content"))
|
|
180
|
+
if text.strip():
|
|
181
|
+
out["last_assistant_text"] = redact(text, rules)[:600]
|
|
182
|
+
for tc in ev.get("toolCalls") or []:
|
|
183
|
+
if not isinstance(tc, dict):
|
|
184
|
+
continue
|
|
185
|
+
tool, brief, files = _gemini_tool(str(tc.get("name") or ""), tc.get("args"))
|
|
186
|
+
_add_tool(out, tool, brief, rules, max_tools)
|
|
187
|
+
written.update(files)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
|
|
5
5
|
Author-email: Nikhil Bora <nikhil@wirevow.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -10,6 +10,7 @@ src/gitvow/default_policy.json
|
|
|
10
10
|
src/gitvow/install.py
|
|
11
11
|
src/gitvow/policy.py
|
|
12
12
|
src/gitvow/providers.py
|
|
13
|
+
src/gitvow/recall.py
|
|
13
14
|
src/gitvow/redact.py
|
|
14
15
|
src/gitvow/report.py
|
|
15
16
|
src/gitvow/selftest.py
|
|
@@ -30,6 +31,8 @@ tests/test_install_cli.py
|
|
|
30
31
|
tests/test_packaging.py
|
|
31
32
|
tests/test_policy.py
|
|
32
33
|
tests/test_providers.py
|
|
34
|
+
tests/test_recall.py
|
|
33
35
|
tests/test_redact.py
|
|
34
36
|
tests/test_report.py
|
|
35
|
-
tests/test_snapshots.py
|
|
37
|
+
tests/test_snapshots.py
|
|
38
|
+
tests/test_transcript_formats.py
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from gitvow import cli
|
|
4
|
+
from gitvow.hooks import post_tool_use, pre_tool_use, session_start, stop
|
|
5
|
+
from gitvow.install import _write_git_hook
|
|
6
|
+
from tests.conftest import git
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _agent_session(repo, home, payload, sid, file, content, subject, transcript=""):
|
|
10
|
+
p = payload("SessionStart", transcript=transcript)
|
|
11
|
+
p["session_id"] = sid
|
|
12
|
+
session_start(p, str(home))
|
|
13
|
+
(repo / file).write_text(content)
|
|
14
|
+
post_tool_use({**p, "tool_name": "Edit", "tool_input": {"file_path": str(repo / file)}}, str(home))
|
|
15
|
+
git(repo, "add", file)
|
|
16
|
+
pre_tool_use(
|
|
17
|
+
{**p, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}, "transcript_path": transcript},
|
|
18
|
+
str(home),
|
|
19
|
+
)
|
|
20
|
+
git(repo, "commit", "-qm", subject)
|
|
21
|
+
post_tool_use(
|
|
22
|
+
{**p, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}, "transcript_path": transcript},
|
|
23
|
+
str(home),
|
|
24
|
+
)
|
|
25
|
+
stop({**p, "transcript_path": transcript}, str(home))
|
|
26
|
+
return p
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_why_trace_recall_handoff(repo, home, payload, transcript, monkeypatch, capsys, tmp_path):
|
|
30
|
+
_write_git_hook(str(repo / ".gitvow" / "git-hooks"))
|
|
31
|
+
git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
|
|
32
|
+
# a transcript whose plan mentions "cache key" for session one
|
|
33
|
+
t2 = tmp_path / "t2.jsonl"
|
|
34
|
+
t2.write_text(
|
|
35
|
+
json.dumps(
|
|
36
|
+
{
|
|
37
|
+
"type": "assistant",
|
|
38
|
+
"message": {
|
|
39
|
+
"role": "assistant",
|
|
40
|
+
"content": [{"type": "text", "text": "Plan: change the cache key to include week start."}],
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
+ "\n"
|
|
45
|
+
)
|
|
46
|
+
_agent_session(
|
|
47
|
+
repo, home, payload, "sess-one", "calc.py", "def add(a, b):\n return a + b\n", "Add calc", str(t2)
|
|
48
|
+
)
|
|
49
|
+
# a person edits calc.py and commits without a session
|
|
50
|
+
(repo / "calc.py").write_text("def add(a, b):\n return a + b\n\ndef human():\n return 1\n")
|
|
51
|
+
git(repo, "commit", "-qam", "human adds helper")
|
|
52
|
+
# second agent session touches another file and mentions "export"
|
|
53
|
+
_agent_session(
|
|
54
|
+
repo, home, payload, "sess-two", "orders.py", "route = '/v1/orders/export'\n", "Add export", transcript
|
|
55
|
+
)
|
|
56
|
+
monkeypatch.chdir(repo)
|
|
57
|
+
|
|
58
|
+
assert cli.main(["why", "calc.py"]) == 0
|
|
59
|
+
out = capsys.readouterr().out
|
|
60
|
+
assert "1 agent commit, 1 human commit, 1 session" in out
|
|
61
|
+
assert "session sess-one" in out and "cache key" in out and "(person)" in out and "agent share 1.00" in out
|
|
62
|
+
assert "1 snapshot of this file" in out
|
|
63
|
+
|
|
64
|
+
assert cli.main(["trace", "calc.py:1-5"]) == 0
|
|
65
|
+
out = capsys.readouterr().out
|
|
66
|
+
assert "agent" in out and "session sess-one" in out and "person" in out
|
|
67
|
+
assert cli.main(["trace", "calc.py"]) == 0
|
|
68
|
+
assert cli.main(["trace", "nope.py"]) == 0 # reports the git error instead of crashing
|
|
69
|
+
|
|
70
|
+
assert cli.main(["recall", "cache", "key"]) == 0
|
|
71
|
+
out = capsys.readouterr().out
|
|
72
|
+
assert "1 session mention" in out and "sess-one" in out and "sess-two" not in out
|
|
73
|
+
assert cli.main(["recall", "export"]) == 0
|
|
74
|
+
assert "sess-two" in capsys.readouterr().out
|
|
75
|
+
assert cli.main(["recall", "zzzunlikely"]) == 0
|
|
76
|
+
assert "nothing recorded" in capsys.readouterr().out
|
|
77
|
+
# a session that ended without committing is found through the ledger alone
|
|
78
|
+
p3 = payload("SessionStart", transcript=str(t2))
|
|
79
|
+
p3["session_id"] = "sess-three"
|
|
80
|
+
session_start(p3, str(home))
|
|
81
|
+
pre_tool_use({**p3, "tool_name": "Bash", "tool_input": {"command": "kubectl apply -f x.yaml"}}, str(home))
|
|
82
|
+
stop(p3, str(home))
|
|
83
|
+
assert cli.main(["recall", "week", "start"]) == 0
|
|
84
|
+
out = capsys.readouterr().out
|
|
85
|
+
assert "2 sessions mention" in out and "sess-thr" in out and "(ledger only)" in out
|
|
86
|
+
assert cli.main(["handoff", "--session", "sess-three"]) == 0
|
|
87
|
+
out = capsys.readouterr().out
|
|
88
|
+
assert "Open confirmations: 1" in out and "kubectl apply" in out and "Commits this session: none" in out
|
|
89
|
+
|
|
90
|
+
(repo / "orders.py").write_text("route = '/v1/orders/export'\nx = 1\n") # uncommitted work
|
|
91
|
+
p2 = payload("SessionStart")
|
|
92
|
+
p2["session_id"] = "sess-two"
|
|
93
|
+
session_start(p2, str(home)) # resume session two so handoff defaults to it
|
|
94
|
+
assert cli.main(["handoff"]) == 0
|
|
95
|
+
out = capsys.readouterr().out
|
|
96
|
+
assert out.startswith("# Handoff · session sess-two") and "Add export" in out and "orders.py" in out
|
|
97
|
+
assert "Uncommitted now: 1 file changed" in out and "last snapshot refs/gitvow/snapshots/sess-two/1" in out
|
|
98
|
+
assert cli.main(["handoff", "--session", "sess-one"]) == 0
|
|
99
|
+
assert "Add calc" in capsys.readouterr().out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_handoff_without_session(repo, monkeypatch, capsys):
|
|
103
|
+
monkeypatch.chdir(repo)
|
|
104
|
+
assert cli.main(["handoff"]) == 0
|
|
105
|
+
assert "no session known" in capsys.readouterr().out
|
|
106
|
+
assert cli.main(["why", "a.txt"]) == 0
|
|
107
|
+
assert "0 agent commits, 1 human commit" in capsys.readouterr().out
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from gitvow.transcript import summarize
|
|
4
|
+
|
|
5
|
+
CODEX = [
|
|
6
|
+
{
|
|
7
|
+
"timestamp": "t",
|
|
8
|
+
"type": "session_meta",
|
|
9
|
+
"payload": {"id": "abc", "cwd": "/r", "cli_version": "0.130.0", "git": {"branch": "main"}},
|
|
10
|
+
},
|
|
11
|
+
{"timestamp": "t", "type": "turn_context", "payload": {"cwd": "/r", "model": "gpt-5"}},
|
|
12
|
+
{
|
|
13
|
+
"timestamp": "t",
|
|
14
|
+
"type": "response_item",
|
|
15
|
+
"payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "fix add"}]},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"timestamp": "t",
|
|
19
|
+
"type": "response_item",
|
|
20
|
+
"payload": {
|
|
21
|
+
"type": "message",
|
|
22
|
+
"role": "assistant",
|
|
23
|
+
"content": [
|
|
24
|
+
{
|
|
25
|
+
"type": "output_text",
|
|
26
|
+
"text": "I will fix add() in calc.py; token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 stays out.",
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"timestamp": "t",
|
|
33
|
+
"type": "response_item",
|
|
34
|
+
"payload": {
|
|
35
|
+
"type": "function_call",
|
|
36
|
+
"name": "exec_command",
|
|
37
|
+
"arguments": json.dumps({"cmd": ["git", "status"]}),
|
|
38
|
+
"call_id": "c1",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"timestamp": "t",
|
|
43
|
+
"type": "response_item",
|
|
44
|
+
"payload": {"type": "function_call_output", "call_id": "c1", "output": "clean SECRET=hunter2 (never read)"},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"timestamp": "t",
|
|
48
|
+
"type": "response_item",
|
|
49
|
+
"payload": {
|
|
50
|
+
"type": "custom_tool_call",
|
|
51
|
+
"name": "apply_patch",
|
|
52
|
+
"input": "*** Begin Patch\n*** Update File: calc.py\n- return a - b\n+ return a + b\n*** End Patch\n",
|
|
53
|
+
"call_id": "c2",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"timestamp": "t",
|
|
58
|
+
"type": "event_msg",
|
|
59
|
+
"payload": {"type": "agent_message", "message": "Done: fixed add() in calc.py."},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"timestamp": "t",
|
|
63
|
+
"type": "event_msg",
|
|
64
|
+
"payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 10}}},
|
|
65
|
+
},
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
GEMINI = [
|
|
69
|
+
{"sessionId": "g1", "projectHash": "h", "startTime": "t", "lastUpdated": "t", "kind": "main"},
|
|
70
|
+
{"id": "m1", "timestamp": "t", "type": "user", "content": "fix add"},
|
|
71
|
+
{
|
|
72
|
+
"id": "m2",
|
|
73
|
+
"timestamp": "t",
|
|
74
|
+
"type": "gemini",
|
|
75
|
+
"content": [{"text": "Fixing add() in calc.py now."}],
|
|
76
|
+
"model": "gemini-2.5-pro",
|
|
77
|
+
"toolCalls": [
|
|
78
|
+
{"id": "t1", "name": "run_shell_command", "args": {"command": "git status"}, "status": "success"},
|
|
79
|
+
{
|
|
80
|
+
"id": "t2",
|
|
81
|
+
"name": "replace",
|
|
82
|
+
"args": {"file_path": "/r/calc.py", "old_string": "a - b", "new_string": "a + b"},
|
|
83
|
+
"status": "success",
|
|
84
|
+
},
|
|
85
|
+
{"id": "t3", "name": "write_file", "args": {"file_path": "/r/new.py", "content": "x"}, "status": "success"},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{"$set": {"lastUpdated": "t2"}},
|
|
89
|
+
{"id": "m3", "timestamp": "t", "type": "gemini", "content": "All done in calc.py and new.py."},
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _write(tmp_path, name, rows):
|
|
94
|
+
p = tmp_path / name
|
|
95
|
+
p.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
|
|
96
|
+
return str(p)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_codex_rollout_is_read(tmp_path):
|
|
100
|
+
s = summarize(_write(tmp_path, "rollout.jsonl", CODEX))
|
|
101
|
+
assert s["format"] == "codex" and s["turns"] == 2
|
|
102
|
+
assert [t["tool"] for t in s["tool_calls"]] == ["Bash", "Edit"]
|
|
103
|
+
assert s["tool_calls"][0]["arg"] == "git status" and s["tool_calls"][1]["arg"] == "calc.py"
|
|
104
|
+
assert s["files_written"] == ["calc.py"]
|
|
105
|
+
assert s["last_assistant_text"] == "Done: fixed add() in calc.py."
|
|
106
|
+
assert "hunter2" not in json.dumps(s) # tool output is never read
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_gemini_chat_recording_is_read(tmp_path):
|
|
110
|
+
s = summarize(_write(tmp_path, "session-1.jsonl", GEMINI))
|
|
111
|
+
assert s["format"] == "gemini" and s["turns"] == 2
|
|
112
|
+
assert [t["tool"] for t in s["tool_calls"]] == ["Bash", "Edit", "Write"]
|
|
113
|
+
assert sorted(s["files_written"]) == ["/r/calc.py", "/r/new.py"]
|
|
114
|
+
assert s["last_assistant_text"] == "All done in calc.py and new.py."
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_claude_format_still_default(transcript):
|
|
118
|
+
s = summarize(transcript)
|
|
119
|
+
assert (
|
|
120
|
+
s["format"] == "claude"
|
|
121
|
+
and s["tool_calls"][0]["tool"] == "Bash"
|
|
122
|
+
and "[github-token]" in s["last_assistant_text"]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_redaction_applies_to_all_formats(tmp_path):
|
|
127
|
+
s = summarize(_write(tmp_path, "rollout.jsonl", CODEX[:4]))
|
|
128
|
+
assert "[github-token]" in s["last_assistant_text"] and "ghp_" not in s["last_assistant_text"]
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
"""Structural, redacted summary of an agent transcript (Claude Code JSONL). Tool output is never read."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
from collections.abc import Iterable
|
|
8
|
-
from typing import Any
|
|
9
|
-
|
|
10
|
-
from .redact import redact
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def summarize(path: str | None, max_tools: int = 500, rules: Iterable[tuple[str, str]] = ()) -> dict[str, Any]:
|
|
14
|
-
out: dict[str, Any] = {"turns": 0, "tool_calls": [], "last_assistant_text": "", "files_written": []}
|
|
15
|
-
if not path or not os.path.exists(path):
|
|
16
|
-
return out
|
|
17
|
-
written: set[str] = set()
|
|
18
|
-
rules = list(rules)
|
|
19
|
-
with open(path, errors="ignore") as fh:
|
|
20
|
-
for line in fh:
|
|
21
|
-
try:
|
|
22
|
-
ev = json.loads(line)
|
|
23
|
-
except json.JSONDecodeError:
|
|
24
|
-
continue
|
|
25
|
-
msg = ev.get("message") or {}
|
|
26
|
-
role = msg.get("role") or ev.get("type")
|
|
27
|
-
content = msg.get("content")
|
|
28
|
-
if role != "assistant":
|
|
29
|
-
continue
|
|
30
|
-
out["turns"] += 1
|
|
31
|
-
if isinstance(content, str):
|
|
32
|
-
if content.strip():
|
|
33
|
-
out["last_assistant_text"] = redact(content, rules)[:600]
|
|
34
|
-
continue
|
|
35
|
-
if not isinstance(content, list):
|
|
36
|
-
continue
|
|
37
|
-
for c in content:
|
|
38
|
-
if not isinstance(c, dict):
|
|
39
|
-
continue
|
|
40
|
-
if c.get("type") == "tool_use" and len(out["tool_calls"]) < max_tools:
|
|
41
|
-
inp = c.get("input") or {}
|
|
42
|
-
brief = inp.get("command") or inp.get("file_path") or inp.get("query") or inp.get("pattern") or ""
|
|
43
|
-
out["tool_calls"].append({"tool": c.get("name"), "arg": redact(str(brief), rules)[:160]})
|
|
44
|
-
if c.get("name") in ("Edit", "Write", "MultiEdit", "NotebookEdit") and inp.get("file_path"):
|
|
45
|
-
written.add(str(inp["file_path"]))
|
|
46
|
-
elif c.get("type") == "text" and str(c.get("text", "")).strip():
|
|
47
|
-
out["last_assistant_text"] = redact(c["text"], rules)[:600]
|
|
48
|
-
out["files_written"] = sorted(written)
|
|
49
|
-
return out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|