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.
- {gitvow-0.8.0/src/gitvow.egg-info → gitvow-0.9.0}/PKG-INFO +1 -1
- {gitvow-0.8.0 → gitvow-0.9.0}/pyproject.toml +1 -1
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/cli.py +11 -0
- gitvow-0.9.0/src/gitvow/digest.py +136 -0
- {gitvow-0.8.0 → gitvow-0.9.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow.egg-info/SOURCES.txt +2 -0
- gitvow-0.9.0/tests/test_digest.py +62 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/LICENSE +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/README.md +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/setup.cfg +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/adapters.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/hooks/__init__.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/install.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/policy.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/providers.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/recall.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/report.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/selftest.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/state.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_adapters.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_collect.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_hooks.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_packaging.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_policy.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_providers.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_recall.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_redact.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_report.py +0 -0
- {gitvow-0.8.0 → gitvow-0.9.0}/tests/test_snapshots.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|