gitvow 0.6.0__tar.gz → 0.7.0__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.
Potentially problematic release.
This version of gitvow might be problematic. Click here for more details.
- {gitvow-0.6.0/src/gitvow.egg-info → gitvow-0.7.0}/PKG-INFO +1 -1
- {gitvow-0.6.0 → gitvow-0.7.0}/pyproject.toml +1 -1
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/cli.py +34 -0
- gitvow-0.7.0/src/gitvow/recall.py +285 -0
- {gitvow-0.6.0 → gitvow-0.7.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow.egg-info/SOURCES.txt +2 -0
- gitvow-0.7.0/tests/test_recall.py +107 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/LICENSE +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/README.md +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/setup.cfg +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/adapters.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/hooks/__init__.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/install.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/policy.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/providers.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/report.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/selftest.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/state.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_adapters.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_collect.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_hooks.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_packaging.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_policy.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_providers.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_redact.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_report.py +0 -0
- {gitvow-0.6.0 → gitvow-0.7.0}/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.0
|
|
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.0"
|
|
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)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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,7 @@ 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
37
|
tests/test_snapshots.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
|
|
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
|
|
File without changes
|