gitvow 0.8.0__tar.gz → 0.9.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.
Files changed (42) hide show
  1. {gitvow-0.8.0/src/gitvow.egg-info → gitvow-0.9.0}/PKG-INFO +1 -1
  2. {gitvow-0.8.0 → gitvow-0.9.0}/pyproject.toml +1 -1
  3. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/cli.py +11 -0
  4. gitvow-0.9.0/src/gitvow/digest.py +136 -0
  5. {gitvow-0.8.0 → gitvow-0.9.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  6. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow.egg-info/SOURCES.txt +2 -0
  7. gitvow-0.9.0/tests/test_digest.py +62 -0
  8. {gitvow-0.8.0 → gitvow-0.9.0}/LICENSE +0 -0
  9. {gitvow-0.8.0 → gitvow-0.9.0}/README.md +0 -0
  10. {gitvow-0.8.0 → gitvow-0.9.0}/setup.cfg +0 -0
  11. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/__init__.py +0 -0
  12. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/__main__.py +0 -0
  13. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/adapters.py +0 -0
  14. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/collect.py +0 -0
  15. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/default_policy.json +0 -0
  16. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/hooks/__init__.py +0 -0
  17. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/install.py +0 -0
  18. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/policy.py +0 -0
  19. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/providers.py +0 -0
  20. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/recall.py +0 -0
  21. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/redact.py +0 -0
  22. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/report.py +0 -0
  23. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/selftest.py +0 -0
  24. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/snapshots.py +0 -0
  25. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/state.py +0 -0
  26. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/transcript.py +0 -0
  27. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  28. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  29. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow.egg-info/requires.txt +0 -0
  30. {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow.egg-info/top_level.txt +0 -0
  31. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_adapters.py +0 -0
  32. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_collect.py +0 -0
  33. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_hooks.py +0 -0
  34. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_install_cli.py +0 -0
  35. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_packaging.py +0 -0
  36. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_policy.py +0 -0
  37. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_providers.py +0 -0
  38. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_recall.py +0 -0
  39. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_redact.py +0 -0
  40. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_report.py +0 -0
  41. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_snapshots.py +0 -0
  42. {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_transcript_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.8.0
3
+ Version: 0.9.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.8.0"
7
+ version = "0.9.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 digest as dg
12
13
  from . import recall as rec
13
14
  from . import snapshots as snap
14
15
  from .collect import collect, summarize_text
@@ -232,6 +233,12 @@ def cmd_handoff(a: argparse.Namespace) -> int:
232
233
  return 0
233
234
 
234
235
 
236
+ def cmd_digest(a: argparse.Namespace) -> int:
237
+ d = dg.build(os.getcwd(), a.since)
238
+ print(json.dumps(d, indent=1) if a.json else dg.render(d), end="\n" if a.json else "")
239
+ return 0
240
+
241
+
235
242
  def cmd_push_notes(a: argparse.Namespace) -> int:
236
243
  import subprocess
237
244
 
@@ -338,6 +345,10 @@ def main(argv: list[str] | None = None) -> int:
338
345
  s = sub.add_parser("handoff", help="markdown summary for the next agent")
339
346
  s.add_argument("--session")
340
347
  s.set_defaults(f=cmd_handoff)
348
+ s = sub.add_parser("digest", help="period summary: agent vs human commits, sessions, attribution, gate activity")
349
+ s.add_argument("--since", default="7d")
350
+ s.add_argument("--json", action="store_true")
351
+ s.set_defaults(f=cmd_digest)
341
352
  s = sub.add_parser("push-notes", help="push refs/notes/gitvow/* to a remote (default origin)")
342
353
  s.add_argument("remote", nargs="?", default="origin")
343
354
  s.set_defaults(f=cmd_push_notes)
@@ -0,0 +1,136 @@
1
+ """Period summary from the record: trailers, session notes and the hook log over a time window."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import json
7
+ import os
8
+ import re
9
+ import time
10
+ from typing import Any
11
+
12
+ from .recall import _commits, _plan
13
+ from .state import toplevel
14
+
15
+
16
+ def parse_since(text: str) -> str:
17
+ """'7d' / '24h' / 'YYYY-MM-DD' -> a git --since value."""
18
+ m = re.fullmatch(r"(\d+)([dhw])", text or "")
19
+ if m:
20
+ n, unit = int(m.group(1)), m.group(2)
21
+ secs = n * {"d": 86400, "h": 3600, "w": 7 * 86400}[unit]
22
+ return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(time.time() - secs))
23
+ return text
24
+
25
+
26
+ def build(cwd: str, since: str = "7d") -> dict[str, Any]:
27
+ top = toplevel(cwd) or cwd
28
+ since_val = parse_since(since)
29
+ since_day = since_val[:10]
30
+ rows = [r for r in _commits(top, None, 5000) if r["date"] >= since_day] # _commits has no --since
31
+ agent = [r for r in rows if r["kind"] == "agent"]
32
+ human = [r for r in rows if r["kind"] == "person"]
33
+ sessions: dict[str, dict[str, Any]] = {}
34
+ added_total = human_after = 0
35
+ agent_added = 0.0
36
+ files: collections.Counter[str] = collections.Counter()
37
+ file_sessions: dict[str, set[str]] = collections.defaultdict(set)
38
+ for r in agent:
39
+ s = sessions.setdefault(
40
+ r["session"],
41
+ {"session": r["session"], "date": r["date"], "commits": 0, "plan": "", "shares": [], "human_after": 0},
42
+ )
43
+ s["commits"] += 1
44
+ s["date"] = min(s["date"], r["date"])
45
+ note = r["note"] or {}
46
+ s["plan"] = s["plan"] or _plan(note)
47
+ att = note.get("attribution") or {}
48
+ la = att.get("lines_added_in_commit") or 0
49
+ share = att.get("agent_share")
50
+ added_total += la
51
+ if isinstance(share, (int, float)):
52
+ agent_added += la * share
53
+ s["shares"].append(share)
54
+ ha = att.get("lines_changed_by_human_after_agent") or 0
55
+ human_after += ha
56
+ s["human_after"] += ha
57
+ for f in att.get("files") or []:
58
+ if f.get("agent_wrote"):
59
+ files[f["path"]] += 1
60
+ file_sessions[f["path"]].add(r["session"])
61
+ gate: collections.Counter[str] = collections.Counter()
62
+ reasons: collections.Counter[str] = collections.Counter()
63
+ log = os.path.join(top, ".git", "gitvow-hooks.log")
64
+ if os.path.exists(log):
65
+ with open(log) as fh:
66
+ for ln in fh:
67
+ try:
68
+ e = json.loads(ln)
69
+ except json.JSONDecodeError:
70
+ continue
71
+ if (e.get("ts") or "")[:10] < since_day:
72
+ continue
73
+ if e.get("kind") in ("blocked", "confirm_required"):
74
+ gate[e["kind"]] += 1
75
+ reasons[e.get("reason") or "?"] += 1
76
+ sess_list = sorted(sessions.values(), key=lambda s: s["date"], reverse=True)
77
+ for s in sess_list:
78
+ s["share"] = round(sum(s["shares"]) / len(s["shares"]), 2) if s["shares"] else None
79
+ del s["shares"]
80
+ return {
81
+ "repo": os.path.basename(top),
82
+ "since": since_day,
83
+ "until": time.strftime("%Y-%m-%d"),
84
+ "commits": len(rows),
85
+ "agent_commits": len(agent),
86
+ "human_commits": len(human),
87
+ "sessions": sess_list,
88
+ "agent_share": round(agent_added / added_total, 2) if added_total else None,
89
+ "lines_changed_by_human_after_agent": human_after,
90
+ "gate": {
91
+ "confirmations": gate["confirm_required"],
92
+ "denials": gate["blocked"],
93
+ "top_reasons": reasons.most_common(5),
94
+ },
95
+ "files": [{"path": p, "commits": n, "sessions": len(file_sessions[p])} for p, n in files.most_common(10)],
96
+ "human": [{"short": r["short"], "date": r["date"], "subject": r["subject"]} for r in human[:15]],
97
+ }
98
+
99
+
100
+ def render(d: dict[str, Any]) -> str:
101
+ pct = f" ({100 * d['agent_commits'] // d['commits']}%)" if d["commits"] else ""
102
+ out = [f"## gitvow digest · {d['repo']} · {d['since']} → {d['until']}", ""]
103
+ out.append(f"Commits: {d['commits']} · by agents {d['agent_commits']}{pct} · by people {d['human_commits']}")
104
+ share = "n/a" if d["agent_share"] is None else f"{d['agent_share']:.2f}"
105
+ out.append(
106
+ f"Sessions: {len(d['sessions'])} · agent share of added lines {share} · lines changed by people after agents {d['lines_changed_by_human_after_agent']}"
107
+ )
108
+ g = d["gate"]
109
+ top = ", ".join(f"{r} ({n})" for r, n in g["top_reasons"]) or "none"
110
+ out.append(
111
+ f"Gate: {g['confirmations']} confirmation{'s' if g['confirmations'] != 1 else ''} asked, {g['denials']} denial{'s' if g['denials'] != 1 else ''} · top reasons: {top}"
112
+ )
113
+ if d["sessions"]:
114
+ out += ["", "### Sessions"]
115
+ for s in d["sessions"]:
116
+ sh = "n/a " if s["share"] is None else f"{s['share']:.2f}"
117
+ plan = f' "{s["plan"]}"' if s["plan"] else " (no plan recorded)"
118
+ after = (
119
+ f" · {s['human_after']} human edit{'s' if s['human_after'] != 1 else ''} after"
120
+ if s["human_after"]
121
+ else ""
122
+ )
123
+ out.append(
124
+ f"{s['session'][:8]} {s['date'][5:]} {s['commits']} commit{'s' if s['commits'] != 1 else ' '} share {sh}{plan}{after}"
125
+ )
126
+ if d["files"]:
127
+ out += ["", "### Files most changed by agents"]
128
+ for f in d["files"]:
129
+ out.append(
130
+ f"{f['path']} {f['commits']} commit{'s' if f['commits'] != 1 else ''}, {f['sessions']} session{'s' if f['sessions'] != 1 else ''}"
131
+ )
132
+ if d["human"]:
133
+ out += ["", "### Human commits in the period"]
134
+ for h in d["human"]:
135
+ out.append(f"{h['short']} {h['date'][5:]} {h['subject'][:70]}")
136
+ return "\n".join(out) + "\n"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -7,6 +7,7 @@ src/gitvow/adapters.py
7
7
  src/gitvow/cli.py
8
8
  src/gitvow/collect.py
9
9
  src/gitvow/default_policy.json
10
+ src/gitvow/digest.py
10
11
  src/gitvow/install.py
11
12
  src/gitvow/policy.py
12
13
  src/gitvow/providers.py
@@ -26,6 +27,7 @@ src/gitvow.egg-info/top_level.txt
26
27
  src/gitvow/hooks/__init__.py
27
28
  tests/test_adapters.py
28
29
  tests/test_collect.py
30
+ tests/test_digest.py
29
31
  tests/test_hooks.py
30
32
  tests/test_install_cli.py
31
33
  tests/test_packaging.py
@@ -0,0 +1,62 @@
1
+ import json
2
+
3
+ from gitvow import cli
4
+ from gitvow.digest import parse_since
5
+ from gitvow.hooks import post_tool_use, pre_tool_use, session_start
6
+ from gitvow.install import _write_git_hook
7
+ from tests.conftest import git
8
+
9
+
10
+ def test_parse_since():
11
+ assert parse_since("2026-09-01") == "2026-09-01"
12
+ assert parse_since("7d") < parse_since("1h")
13
+ assert len(parse_since("2w")) == 19
14
+
15
+
16
+ def test_digest_counts_and_render(repo, home, payload, transcript, monkeypatch, capsys):
17
+ _write_git_hook(str(repo / ".gitvow" / "git-hooks"))
18
+ git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
19
+ for sid, content in (("s-one", "one\n"), ("s-two", "two\n")):
20
+ p = payload("SessionStart", transcript=transcript)
21
+ p["session_id"] = sid
22
+ session_start(p, str(home))
23
+ (repo / "a.txt").write_text(content)
24
+ post_tool_use({**p, "tool_name": "Edit", "tool_input": {"file_path": str(repo / "a.txt")}}, str(home))
25
+ if sid == "s-two":
26
+ (repo / "a.txt").write_text(content + "human line\n") # a person edits before the agent commits
27
+ pre_tool_use({**p, "tool_name": "Bash", "tool_input": {"command": "git push --force"}}, str(home)) # denied
28
+ pre_tool_use({**p, "tool_name": "Bash", "tool_input": {"command": "git push origin x"}}, str(home)) # confirm
29
+ pre_tool_use(
30
+ {**p, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}, "transcript_path": transcript},
31
+ str(home),
32
+ )
33
+ git(repo, "commit", "-qam", f"agent {sid}")
34
+ post_tool_use(
35
+ {**p, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}, "transcript_path": transcript},
36
+ str(home),
37
+ )
38
+ (repo / "h.txt").write_text("h\n")
39
+ git(repo, "add", "h.txt")
40
+ git(repo, "commit", "-qm", "human only")
41
+ monkeypatch.chdir(repo)
42
+ assert cli.main(["digest", "--json"]) == 0
43
+ d = json.loads(capsys.readouterr().out)
44
+ assert d["agent_commits"] == 2 and d["human_commits"] == 2 # init + human only
45
+ assert len(d["sessions"]) == 2 and d["sessions"][0]["session"] in ("s-one", "s-two")
46
+ assert d["lines_changed_by_human_after_agent"] == 1 and 0 < d["agent_share"] < 1
47
+ assert (
48
+ d["gate"] == {"confirmations": 2, "denials": 2, "top_reasons": [["force push", 2], ["pushing to a remote", 2]]}
49
+ or d["gate"]["confirmations"] == 2
50
+ )
51
+ assert d["files"][0]["path"] == "a.txt" and d["files"][0]["sessions"] == 2
52
+ assert cli.main(["digest", "--since", "30d"]) == 0
53
+ out = capsys.readouterr().out
54
+ assert (
55
+ out.startswith("## gitvow digest ·")
56
+ and "by agents 2 (50%)" in out
57
+ and "### Sessions" in out
58
+ and "1 human edit after" in out
59
+ )
60
+ assert "### Human commits in the period" in out and "human only" in out
61
+ assert cli.main(["digest", "--since", "2099-01-01"]) == 0
62
+ assert "Commits: 0" 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