gitvow 0.7.1__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.7.1/src/gitvow.egg-info → gitvow-0.9.0}/PKG-INFO +1 -1
- {gitvow-0.7.1 → gitvow-0.9.0}/pyproject.toml +1 -1
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/adapters.py +82 -4
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/cli.py +15 -4
- gitvow-0.9.0/src/gitvow/digest.py +136 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/install.py +24 -2
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/selftest.py +44 -4
- {gitvow-0.7.1 → gitvow-0.9.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow.egg-info/SOURCES.txt +2 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/tests/test_adapters.py +125 -0
- gitvow-0.9.0/tests/test_digest.py +62 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/LICENSE +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/README.md +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/setup.cfg +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/hooks/__init__.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/policy.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/providers.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/recall.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/report.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/state.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/tests/test_collect.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/tests/test_hooks.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/tests/test_packaging.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/tests/test_policy.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/tests/test_providers.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/tests/test_recall.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/tests/test_redact.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/tests/test_report.py +0 -0
- {gitvow-0.7.1 → gitvow-0.9.0}/tests/test_snapshots.py +0 -0
- {gitvow-0.7.1 → 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"}
|
|
@@ -7,7 +7,7 @@ import json
|
|
|
7
7
|
import re
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
AGENTS = ("claude", "codex", "gemini", "cursor")
|
|
10
|
+
AGENTS = ("claude", "codex", "gemini", "cursor", "copilot", "factory")
|
|
11
11
|
|
|
12
12
|
# agent event -> gitvow handler
|
|
13
13
|
EVENT_MAP: dict[str, dict[str, str]] = {
|
|
@@ -24,6 +24,19 @@ EVENT_MAP: dict[str, dict[str, str]] = {
|
|
|
24
24
|
"AfterTool": "PostToolUse",
|
|
25
25
|
"SessionEnd": "Stop",
|
|
26
26
|
},
|
|
27
|
+
"copilot": {
|
|
28
|
+
"sessionStart": "SessionStart",
|
|
29
|
+
"preToolUse": "PreToolUse",
|
|
30
|
+
"postToolUse": "PostToolUse",
|
|
31
|
+
"sessionEnd": "Stop",
|
|
32
|
+
},
|
|
33
|
+
"factory": {
|
|
34
|
+
"SessionStart": "SessionStart",
|
|
35
|
+
"PreToolUse": "PreToolUse",
|
|
36
|
+
"PostToolUse": "PostToolUse",
|
|
37
|
+
"Stop": "Stop",
|
|
38
|
+
"SessionEnd": "Stop",
|
|
39
|
+
},
|
|
27
40
|
"cursor": {
|
|
28
41
|
"sessionStart": "SessionStart",
|
|
29
42
|
"preToolUse": "PreToolUse",
|
|
@@ -36,6 +49,42 @@ EVENT_MAP: dict[str, dict[str, str]] = {
|
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
GEMINI_TOOLS = {"run_shell_command": "Bash", "write_file": "Write", "replace": "Edit", "edit": "Edit"}
|
|
52
|
+
COPILOT_TOOLS = {
|
|
53
|
+
"bash": "Bash",
|
|
54
|
+
"powershell": "Bash",
|
|
55
|
+
"edit": "Edit",
|
|
56
|
+
"str_replace_editor": "Edit",
|
|
57
|
+
"apply_patch": "Edit",
|
|
58
|
+
"create": "Write",
|
|
59
|
+
}
|
|
60
|
+
FACTORY_TOOLS = {"Execute": "Bash", "Edit": "Edit", "ApplyPatch": "Edit", "Create": "Write", "MultiEdit": "MultiEdit"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _edit_input(args: dict[str, Any]) -> dict[str, Any]:
|
|
64
|
+
"""Common edit argument names across agents -> Claude Code Edit/Write input."""
|
|
65
|
+
out: dict[str, Any] = {}
|
|
66
|
+
fp = args.get("file_path") or args.get("path") or args.get("filePath") or ""
|
|
67
|
+
if fp:
|
|
68
|
+
out["file_path"] = str(fp)
|
|
69
|
+
for src, dst in (
|
|
70
|
+
("old_string", "old_string"),
|
|
71
|
+
("old_str", "old_string"),
|
|
72
|
+
("new_string", "new_string"),
|
|
73
|
+
("new_str", "new_string"),
|
|
74
|
+
("content", "content"),
|
|
75
|
+
("file_text", "content"),
|
|
76
|
+
):
|
|
77
|
+
if src in args and dst not in out:
|
|
78
|
+
out[dst] = args[src]
|
|
79
|
+
if "command" in args and isinstance(args["command"], str) and "*** Begin Patch" in args["command"]:
|
|
80
|
+
files = parse_apply_patch(args["command"])
|
|
81
|
+
if files:
|
|
82
|
+
out.setdefault("file_path", files[0]["file_path"])
|
|
83
|
+
out.setdefault("old_string", files[0]["old_string"])
|
|
84
|
+
out.setdefault("new_string", files[0]["new_string"])
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
39
88
|
PATCH_FILE_RE = re.compile(r"^\*\*\* (Update|Add|Delete) File: (.+)$")
|
|
40
89
|
|
|
41
90
|
|
|
@@ -74,7 +123,7 @@ def normalize(agent: str, event: str, payload: dict[str, Any]) -> list[tuple[str
|
|
|
74
123
|
if not gv_event:
|
|
75
124
|
return []
|
|
76
125
|
base = {
|
|
77
|
-
"session_id": payload.get("session_id") or payload.get("conversation_id") or "",
|
|
126
|
+
"session_id": payload.get("session_id") or payload.get("conversation_id") or payload.get("sessionId") or "",
|
|
78
127
|
"transcript_path": payload.get("transcript_path") or "",
|
|
79
128
|
"cwd": payload.get("cwd") or (payload.get("workspace_roots") or [None])[0] or "",
|
|
80
129
|
"hook_event_name": gv_event,
|
|
@@ -82,8 +131,32 @@ def normalize(agent: str, event: str, payload: dict[str, Any]) -> list[tuple[str
|
|
|
82
131
|
}
|
|
83
132
|
if gv_event in ("SessionStart", "Stop"):
|
|
84
133
|
return [(gv_event, base)]
|
|
85
|
-
tool = str(payload.get("tool_name") or "")
|
|
86
|
-
|
|
134
|
+
tool = str(payload.get("tool_name") or payload.get("toolName") or "")
|
|
135
|
+
raw_in = payload.get("tool_input")
|
|
136
|
+
if raw_in is None:
|
|
137
|
+
raw_in = payload.get("toolArgs")
|
|
138
|
+
inp = dict(raw_in) if isinstance(raw_in, dict) else ({"command": raw_in} if isinstance(raw_in, str) else {})
|
|
139
|
+
if agent == "copilot":
|
|
140
|
+
mapped = COPILOT_TOOLS.get(tool, tool)
|
|
141
|
+
if mapped in ("Edit", "Write"):
|
|
142
|
+
return [(gv_event, {**base, "tool_name": mapped, "tool_input": _edit_input(inp)})]
|
|
143
|
+
if mapped == "Bash":
|
|
144
|
+
return [
|
|
145
|
+
(
|
|
146
|
+
gv_event,
|
|
147
|
+
{
|
|
148
|
+
**base,
|
|
149
|
+
"tool_name": "Bash",
|
|
150
|
+
"tool_input": {"command": str(inp.get("command") or inp.get("cmd") or "")},
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
]
|
|
154
|
+
return [(gv_event, {**base, "tool_name": mapped, "tool_input": inp})]
|
|
155
|
+
if agent == "factory":
|
|
156
|
+
mapped = FACTORY_TOOLS.get(tool, tool)
|
|
157
|
+
if mapped in ("Edit", "Write", "MultiEdit"):
|
|
158
|
+
return [(gv_event, {**base, "tool_name": mapped, "tool_input": {**inp, **_edit_input(inp)}})]
|
|
159
|
+
return [(gv_event, {**base, "tool_name": mapped, "tool_input": inp})]
|
|
87
160
|
if agent == "codex":
|
|
88
161
|
if tool == "apply_patch":
|
|
89
162
|
out = []
|
|
@@ -152,4 +225,9 @@ def respond(agent: str, code: int, msg: str) -> tuple[int, str, str]:
|
|
|
152
225
|
perm = "deny" if msg.startswith("BLOCKED") else "ask"
|
|
153
226
|
return 0, json.dumps({"permission": perm, "user_message": msg, "agent_message": msg}), ""
|
|
154
227
|
return 0, json.dumps({"permission": "allow"}), msg
|
|
228
|
+
if agent == "copilot":
|
|
229
|
+
if code == 2:
|
|
230
|
+
perm = "deny" if msg.startswith("BLOCKED") else "ask"
|
|
231
|
+
return 0, json.dumps({"permissionDecision": perm, "permissionDecisionReason": msg}), ""
|
|
232
|
+
return 0, json.dumps({"permissionDecision": "allow"}), msg
|
|
155
233
|
return code, "", msg
|
|
@@ -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
|
|
|
@@ -268,13 +275,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
268
275
|
p.add_argument("--version", action="version", version=f"gitvow {__version__}")
|
|
269
276
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
270
277
|
s = sub.add_parser("hook", help="run as an agent hook (reads the agent's JSON on stdin)")
|
|
271
|
-
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
278
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor", "copilot", "factory"], default="claude")
|
|
272
279
|
s.add_argument("event")
|
|
273
280
|
s.set_defaults(f=cmd_hook)
|
|
274
281
|
s = sub.add_parser("install", help="install per user (--user) or into a repo")
|
|
275
282
|
s.add_argument("repo", nargs="?", default=".")
|
|
276
283
|
s.add_argument("--user", action="store_true")
|
|
277
|
-
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
284
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor", "copilot", "factory"], default="claude")
|
|
278
285
|
s.set_defaults(f=cmd_install)
|
|
279
286
|
s = sub.add_parser("uninstall", help="remove what install added")
|
|
280
287
|
s.add_argument("repo", nargs="?", default=".")
|
|
@@ -283,7 +290,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
283
290
|
s.add_argument("--purge-policy", action="store_true")
|
|
284
291
|
s.add_argument("--purge-ledger", action="store_true")
|
|
285
292
|
s.add_argument("--purge-snapshots", action="store_true")
|
|
286
|
-
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
293
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor", "copilot", "factory"], default="claude")
|
|
287
294
|
s.set_defaults(f=cmd_uninstall)
|
|
288
295
|
s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
|
|
289
296
|
s.add_argument("command", nargs="*")
|
|
@@ -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)
|
|
@@ -348,7 +359,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
348
359
|
s.add_argument("dir")
|
|
349
360
|
s.set_defaults(f=cmd_summarize)
|
|
350
361
|
s = sub.add_parser("selftest", help="prove the hooks work here without touching a real repo")
|
|
351
|
-
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
362
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor", "copilot", "factory"], default="claude")
|
|
352
363
|
s.set_defaults(f=cmd_selftest)
|
|
353
364
|
a = p.parse_args(argv)
|
|
354
365
|
return a.f(a)
|
|
@@ -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"
|
|
@@ -88,6 +88,8 @@ AGENT_FILES = {
|
|
|
88
88
|
"codex": (".codex/hooks.json", ".codex/hooks.json"),
|
|
89
89
|
"gemini": (".gemini/settings.json", ".gemini/settings.json"),
|
|
90
90
|
"cursor": (".cursor/hooks.json", ".cursor/hooks.json"),
|
|
91
|
+
"copilot": (".copilot/hooks/gitvow.json", ".github/hooks/gitvow.json"),
|
|
92
|
+
"factory": (".factory/hooks.json", ".factory/hooks.json"),
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
|
|
@@ -124,6 +126,26 @@ def _agent_entries(agent: str, cmd_prefix: str) -> dict[str, list[dict[str, Any]
|
|
|
124
126
|
"AfterTool": [g("AfterTool", "run_shell_command|write_file|replace|edit")],
|
|
125
127
|
"SessionEnd": [g("SessionEnd", "*")],
|
|
126
128
|
}
|
|
129
|
+
if agent == "copilot":
|
|
130
|
+
|
|
131
|
+
def cp(ev: str) -> dict[str, Any]:
|
|
132
|
+
return {"type": "command", "bash": f"{c} {ev}", "timeoutSec": 30}
|
|
133
|
+
|
|
134
|
+
return {ev: [cp(ev)] for ev in ("sessionStart", "preToolUse", "postToolUse", "sessionEnd")}
|
|
135
|
+
if agent == "factory":
|
|
136
|
+
|
|
137
|
+
def fe(ev: str, matcher: str | None) -> dict[str, Any]:
|
|
138
|
+
x: dict[str, Any] = {"hooks": [{"type": "command", "command": f"{c} {ev}", "timeout": 30}]}
|
|
139
|
+
if matcher:
|
|
140
|
+
x["matcher"] = matcher
|
|
141
|
+
return x
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
"SessionStart": [fe("SessionStart", None)],
|
|
145
|
+
"PreToolUse": [fe("PreToolUse", "Execute|Edit|Create|ApplyPatch|MultiEdit|mcp__.*")],
|
|
146
|
+
"PostToolUse": [fe("PostToolUse", "Execute|Edit|Create|ApplyPatch|MultiEdit")],
|
|
147
|
+
"Stop": [fe("Stop", None)],
|
|
148
|
+
}
|
|
127
149
|
if agent == "cursor":
|
|
128
150
|
|
|
129
151
|
def u(ev: str) -> dict[str, Any]:
|
|
@@ -145,7 +167,7 @@ def _agent_entries(agent: str, cmd_prefix: str) -> dict[str, list[dict[str, Any]
|
|
|
145
167
|
|
|
146
168
|
|
|
147
169
|
def _is_ours(entry: dict[str, Any]) -> bool:
|
|
148
|
-
if MARKER in (entry.get("command") or ""):
|
|
170
|
+
if MARKER in (entry.get("command") or "") or MARKER in (entry.get("bash") or ""):
|
|
149
171
|
return True
|
|
150
172
|
return any(MARKER in (h.get("command") or "") for h in entry.get("hooks", []))
|
|
151
173
|
|
|
@@ -155,7 +177,7 @@ def merge_agent_settings(path: str, agent: str, cmd_prefix: str) -> None:
|
|
|
155
177
|
if os.path.exists(path):
|
|
156
178
|
with open(path) as fh:
|
|
157
179
|
cur = json.load(fh)
|
|
158
|
-
if agent
|
|
180
|
+
if agent in ("cursor", "copilot"):
|
|
159
181
|
cur.setdefault("version", 1)
|
|
160
182
|
hooks = cur.setdefault("hooks", {})
|
|
161
183
|
for ev, entries in _agent_entries(agent, cmd_prefix).items():
|
|
@@ -71,6 +71,43 @@ def run_agent(agent: str) -> int:
|
|
|
71
71
|
},
|
|
72
72
|
),
|
|
73
73
|
],
|
|
74
|
+
"copilot": [
|
|
75
|
+
("sessionStart", {"sessionId": "st", "cwd": repo}),
|
|
76
|
+
(
|
|
77
|
+
"preToolUse",
|
|
78
|
+
{"sessionId": "st", "cwd": repo, "toolName": "bash", "toolArgs": {"command": "git push --force"}},
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"postToolUse",
|
|
82
|
+
{
|
|
83
|
+
"sessionId": "st",
|
|
84
|
+
"cwd": repo,
|
|
85
|
+
"toolName": "edit",
|
|
86
|
+
"toolArgs": {"path": os.path.join(repo, "a.txt"), "old_str": "a", "new_str": "b"},
|
|
87
|
+
},
|
|
88
|
+
),
|
|
89
|
+
],
|
|
90
|
+
"factory": [
|
|
91
|
+
("SessionStart", {"session_id": "st", "cwd": repo}),
|
|
92
|
+
(
|
|
93
|
+
"PreToolUse",
|
|
94
|
+
{
|
|
95
|
+
"session_id": "st",
|
|
96
|
+
"cwd": repo,
|
|
97
|
+
"tool_name": "Execute",
|
|
98
|
+
"tool_input": {"command": "git push --force"},
|
|
99
|
+
},
|
|
100
|
+
),
|
|
101
|
+
(
|
|
102
|
+
"PostToolUse",
|
|
103
|
+
{
|
|
104
|
+
"session_id": "st",
|
|
105
|
+
"cwd": repo,
|
|
106
|
+
"tool_name": "Edit",
|
|
107
|
+
"tool_input": {"file_path": os.path.join(repo, "a.txt"), "old_string": "a", "new_string": "b"},
|
|
108
|
+
},
|
|
109
|
+
),
|
|
110
|
+
],
|
|
74
111
|
"cursor": [
|
|
75
112
|
("sessionStart", {"conversation_id": "st", "workspace_roots": [repo]}),
|
|
76
113
|
(
|
|
@@ -96,10 +133,13 @@ def run_agent(agent: str) -> int:
|
|
|
96
133
|
if msg:
|
|
97
134
|
msgs.append(msg)
|
|
98
135
|
exit_code, out, _err = respond(agent, worst, "\n".join(msgs))
|
|
99
|
-
if ev in ("PreToolUse", "BeforeTool", "beforeShellExecution"):
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
136
|
+
if ev in ("PreToolUse", "BeforeTool", "beforeShellExecution", "preToolUse"):
|
|
137
|
+
if agent == "cursor":
|
|
138
|
+
blocked = _json.loads(out).get("permission") == "deny"
|
|
139
|
+
elif agent == "copilot":
|
|
140
|
+
blocked = _json.loads(out).get("permissionDecision") == "deny"
|
|
141
|
+
else:
|
|
142
|
+
blocked = exit_code == 2
|
|
103
143
|
results.append((blocked, f"{agent}: force push denied in the agent's own form"))
|
|
104
144
|
refs = subprocess.run(
|
|
105
145
|
["git", "for-each-ref", "refs/gitvow/snapshots/"], cwd=repo, capture_output=True, text=True
|
|
@@ -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
|
|
@@ -236,3 +236,128 @@ def test_install_per_agent_idempotent_and_reversible(home, repo):
|
|
|
236
236
|
assert (repo / ".codex" / "hooks.json").exists()
|
|
237
237
|
uninstall_repo(str(repo), agent="codex")
|
|
238
238
|
assert not (repo / ".codex" / "hooks.json").exists()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_copilot_and_factory_normalization_and_responses():
|
|
242
|
+
c = normalize(
|
|
243
|
+
"copilot",
|
|
244
|
+
"preToolUse",
|
|
245
|
+
{"sessionId": "cp1", "cwd": "/w", "toolName": "bash", "toolArgs": {"command": "rm -rf /"}},
|
|
246
|
+
)
|
|
247
|
+
assert (
|
|
248
|
+
c[0][0] == "PreToolUse"
|
|
249
|
+
and c[0][1]["session_id"] == "cp1"
|
|
250
|
+
and c[0][1]["tool_name"] == "Bash"
|
|
251
|
+
and c[0][1]["tool_input"] == {"command": "rm -rf /"}
|
|
252
|
+
)
|
|
253
|
+
c = normalize(
|
|
254
|
+
"copilot",
|
|
255
|
+
"postToolUse",
|
|
256
|
+
{
|
|
257
|
+
"sessionId": "cp1",
|
|
258
|
+
"cwd": "/w",
|
|
259
|
+
"toolName": "str_replace_editor",
|
|
260
|
+
"toolArgs": {"path": "/w/a.py", "old_str": "x", "new_str": "y"},
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
assert c[0][1]["tool_name"] == "Edit" and c[0][1]["tool_input"] == {
|
|
264
|
+
"file_path": "/w/a.py",
|
|
265
|
+
"old_string": "x",
|
|
266
|
+
"new_string": "y",
|
|
267
|
+
}
|
|
268
|
+
c = normalize(
|
|
269
|
+
"copilot",
|
|
270
|
+
"preToolUse",
|
|
271
|
+
{
|
|
272
|
+
"sessionId": "cp1",
|
|
273
|
+
"cwd": "/w",
|
|
274
|
+
"toolName": "create",
|
|
275
|
+
"toolArgs": {"path": "/w/n.py", "file_text": "print(1)"},
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
assert c[0][1]["tool_name"] == "Write" and c[0][1]["tool_input"]["content"] == "print(1)"
|
|
279
|
+
assert normalize("copilot", "sessionEnd", {"sessionId": "cp1", "cwd": "/w"})[0][0] == "Stop"
|
|
280
|
+
assert json.loads(respond("copilot", 2, "BLOCKED by policy (x).")[1]) == {
|
|
281
|
+
"permissionDecision": "deny",
|
|
282
|
+
"permissionDecisionReason": "BLOCKED by policy (x).",
|
|
283
|
+
}
|
|
284
|
+
assert json.loads(respond("copilot", 2, "CONFIRMATION REQUIRED (y).")[1])["permissionDecision"] == "ask"
|
|
285
|
+
assert json.loads(respond("copilot", 0, "")[1]) == {"permissionDecision": "allow"}
|
|
286
|
+
f = normalize(
|
|
287
|
+
"factory",
|
|
288
|
+
"PreToolUse",
|
|
289
|
+
{"session_id": "f1", "cwd": "/w", "tool_name": "Execute", "tool_input": {"command": "terraform destroy"}},
|
|
290
|
+
)
|
|
291
|
+
assert f[0][1]["tool_name"] == "Bash"
|
|
292
|
+
f = normalize(
|
|
293
|
+
"factory",
|
|
294
|
+
"PostToolUse",
|
|
295
|
+
{
|
|
296
|
+
"session_id": "f1",
|
|
297
|
+
"cwd": "/w",
|
|
298
|
+
"tool_name": "Create",
|
|
299
|
+
"tool_input": {"file_path": "/w/a.py", "content": "x"},
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
assert f[0][1]["tool_name"] == "Write" and f[0][1]["tool_input"]["file_path"] == "/w/a.py"
|
|
303
|
+
f = normalize(
|
|
304
|
+
"factory",
|
|
305
|
+
"PreToolUse",
|
|
306
|
+
{
|
|
307
|
+
"session_id": "f1",
|
|
308
|
+
"cwd": "/w",
|
|
309
|
+
"tool_name": "ApplyPatch",
|
|
310
|
+
"tool_input": {
|
|
311
|
+
"file_path": "/w/a.py",
|
|
312
|
+
"command": '*** Begin Patch\n*** Update File: a.py\n+"/v1/x"\n*** End Patch\n',
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
assert f[0][1]["tool_name"] == "Edit" and '"/v1/x"' in f[0][1]["tool_input"]["new_string"]
|
|
317
|
+
assert respond("factory", 2, "BLOCKED")[0] == 2
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_copilot_factory_cli_and_install(repo, home, monkeypatch, capsys):
|
|
321
|
+
monkeypatch.chdir(repo)
|
|
322
|
+
code, out, err = _run_hook(
|
|
323
|
+
monkeypatch,
|
|
324
|
+
capsys,
|
|
325
|
+
"copilot",
|
|
326
|
+
"preToolUse",
|
|
327
|
+
{"sessionId": "cp1", "cwd": str(repo), "toolName": "bash", "toolArgs": {"command": "kubectl apply -f x.yaml"}},
|
|
328
|
+
)
|
|
329
|
+
assert code == 0 and json.loads(out)["permissionDecision"] == "ask"
|
|
330
|
+
code, out, err = _run_hook(
|
|
331
|
+
monkeypatch,
|
|
332
|
+
capsys,
|
|
333
|
+
"copilot",
|
|
334
|
+
"preToolUse",
|
|
335
|
+
{"sessionId": "cp1", "cwd": str(repo), "toolName": "bash", "toolArgs": {"command": "ls"}},
|
|
336
|
+
)
|
|
337
|
+
assert json.loads(out) == {"permissionDecision": "allow"}
|
|
338
|
+
code, out, err = _run_hook(
|
|
339
|
+
monkeypatch,
|
|
340
|
+
capsys,
|
|
341
|
+
"factory",
|
|
342
|
+
"PreToolUse",
|
|
343
|
+
{"session_id": "f1", "cwd": str(repo), "tool_name": "Execute", "tool_input": {"command": "git push --force"}},
|
|
344
|
+
)
|
|
345
|
+
assert code == 2 and "BLOCKED" in err
|
|
346
|
+
for agent, rel, key in (
|
|
347
|
+
("copilot", ".copilot/hooks/gitvow.json", "preToolUse"),
|
|
348
|
+
("factory", ".factory/hooks.json", "PreToolUse"),
|
|
349
|
+
):
|
|
350
|
+
install_user(str(home), agent=agent)
|
|
351
|
+
install_user(str(home), agent=agent)
|
|
352
|
+
s = json.loads((home / rel).read_text())
|
|
353
|
+
assert len(s["hooks"][key]) == 1
|
|
354
|
+
if agent == "copilot":
|
|
355
|
+
assert s["version"] == 1 and "hook --agent copilot preToolUse" in s["hooks"][key][0]["bash"]
|
|
356
|
+
else:
|
|
357
|
+
assert "hook --agent factory PreToolUse" in s["hooks"][key][0]["hooks"][0]["command"]
|
|
358
|
+
uninstall_user(str(home), agent=agent)
|
|
359
|
+
assert not (home / rel).exists()
|
|
360
|
+
install_repo(str(repo), agent="copilot")
|
|
361
|
+
assert (repo / ".github" / "hooks" / "gitvow.json").exists()
|
|
362
|
+
uninstall_repo(str(repo), agent="copilot")
|
|
363
|
+
assert not (repo / ".github" / "hooks" / "gitvow.json").exists()
|
|
@@ -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
|