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.

Files changed (39) hide show
  1. {gitvow-0.6.0/src/gitvow.egg-info → gitvow-0.7.0}/PKG-INFO +1 -1
  2. {gitvow-0.6.0 → gitvow-0.7.0}/pyproject.toml +1 -1
  3. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/cli.py +34 -0
  4. gitvow-0.7.0/src/gitvow/recall.py +285 -0
  5. {gitvow-0.6.0 → gitvow-0.7.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  6. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow.egg-info/SOURCES.txt +2 -0
  7. gitvow-0.7.0/tests/test_recall.py +107 -0
  8. {gitvow-0.6.0 → gitvow-0.7.0}/LICENSE +0 -0
  9. {gitvow-0.6.0 → gitvow-0.7.0}/README.md +0 -0
  10. {gitvow-0.6.0 → gitvow-0.7.0}/setup.cfg +0 -0
  11. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/__init__.py +0 -0
  12. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/__main__.py +0 -0
  13. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/adapters.py +0 -0
  14. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/collect.py +0 -0
  15. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/default_policy.json +0 -0
  16. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/hooks/__init__.py +0 -0
  17. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/install.py +0 -0
  18. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/policy.py +0 -0
  19. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/providers.py +0 -0
  20. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/redact.py +0 -0
  21. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/report.py +0 -0
  22. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/selftest.py +0 -0
  23. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/snapshots.py +0 -0
  24. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/state.py +0 -0
  25. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow/transcript.py +0 -0
  26. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  27. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  28. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow.egg-info/requires.txt +0 -0
  29. {gitvow-0.6.0 → gitvow-0.7.0}/src/gitvow.egg-info/top_level.txt +0 -0
  30. {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_adapters.py +0 -0
  31. {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_collect.py +0 -0
  32. {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_hooks.py +0 -0
  33. {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_install_cli.py +0 -0
  34. {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_packaging.py +0 -0
  35. {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_policy.py +0 -0
  36. {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_providers.py +0 -0
  37. {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_redact.py +0 -0
  38. {gitvow-0.6.0 → gitvow-0.7.0}/tests/test_report.py +0 -0
  39. {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.6.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.6.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.6.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