gitvow 0.4.0__tar.gz → 0.6.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.4.0/src/gitvow.egg-info → gitvow-0.6.0}/PKG-INFO +1 -1
- {gitvow-0.4.0 → gitvow-0.6.0}/pyproject.toml +1 -1
- gitvow-0.6.0/src/gitvow/adapters.py +155 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/cli.py +118 -12
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/hooks/__init__.py +17 -3
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/install.py +151 -11
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/policy.py +3 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/selftest.py +117 -0
- gitvow-0.6.0/src/gitvow/snapshots.py +193 -0
- {gitvow-0.4.0 → gitvow-0.6.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow.egg-info/SOURCES.txt +5 -1
- gitvow-0.6.0/tests/test_adapters.py +238 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/tests/test_hooks.py +1 -1
- gitvow-0.6.0/tests/test_snapshots.py +139 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/LICENSE +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/README.md +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/setup.cfg +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/providers.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/report.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/state.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/tests/test_collect.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/tests/test_packaging.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/tests/test_policy.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/tests/test_providers.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/tests/test_redact.py +0 -0
- {gitvow-0.4.0 → gitvow-0.6.0}/tests/test_report.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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.6.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"}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Adapters: translate other agents' hook payloads into the Claude Code shape gitvow's handlers understand,
|
|
2
|
+
and render decisions in each agent's native form. See docs/reference/adapters.md."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
AGENTS = ("claude", "codex", "gemini", "cursor")
|
|
11
|
+
|
|
12
|
+
# agent event -> gitvow handler
|
|
13
|
+
EVENT_MAP: dict[str, dict[str, str]] = {
|
|
14
|
+
"claude": {
|
|
15
|
+
"SessionStart": "SessionStart",
|
|
16
|
+
"PreToolUse": "PreToolUse",
|
|
17
|
+
"PostToolUse": "PostToolUse",
|
|
18
|
+
"Stop": "Stop",
|
|
19
|
+
},
|
|
20
|
+
"codex": {"SessionStart": "SessionStart", "PreToolUse": "PreToolUse", "PostToolUse": "PostToolUse", "Stop": "Stop"},
|
|
21
|
+
"gemini": {
|
|
22
|
+
"SessionStart": "SessionStart",
|
|
23
|
+
"BeforeTool": "PreToolUse",
|
|
24
|
+
"AfterTool": "PostToolUse",
|
|
25
|
+
"SessionEnd": "Stop",
|
|
26
|
+
},
|
|
27
|
+
"cursor": {
|
|
28
|
+
"sessionStart": "SessionStart",
|
|
29
|
+
"preToolUse": "PreToolUse",
|
|
30
|
+
"beforeShellExecution": "PreToolUse",
|
|
31
|
+
"beforeMCPExecution": "PreToolUse",
|
|
32
|
+
"afterFileEdit": "PostToolUse",
|
|
33
|
+
"afterShellExecution": "PostToolUse",
|
|
34
|
+
"stop": "Stop",
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
GEMINI_TOOLS = {"run_shell_command": "Bash", "write_file": "Write", "replace": "Edit", "edit": "Edit"}
|
|
39
|
+
PATCH_FILE_RE = re.compile(r"^\*\*\* (Update|Add|Delete) File: (.+)$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_apply_patch(text: str) -> list[dict[str, str]]:
|
|
43
|
+
"""Codex apply_patch text -> [{file_path, old_string, new_string, op}] per touched file."""
|
|
44
|
+
files: list[dict[str, str]] = []
|
|
45
|
+
cur: dict[str, Any] | None = None
|
|
46
|
+
for line in (text or "").splitlines():
|
|
47
|
+
m = PATCH_FILE_RE.match(line.strip())
|
|
48
|
+
if m:
|
|
49
|
+
cur = {"op": m.group(1).lower(), "file_path": m.group(2).strip(), "old": [], "new": []}
|
|
50
|
+
files.append(cur)
|
|
51
|
+
continue
|
|
52
|
+
if cur is None or line.startswith("*** "):
|
|
53
|
+
continue
|
|
54
|
+
if line.startswith("+"):
|
|
55
|
+
cur["new"].append(line[1:])
|
|
56
|
+
elif line.startswith("-"):
|
|
57
|
+
cur["old"].append(line[1:])
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
"file_path": f["file_path"],
|
|
61
|
+
"op": f["op"],
|
|
62
|
+
"old_string": "\n".join(f["old"]),
|
|
63
|
+
"new_string": "\n".join(f["new"]),
|
|
64
|
+
}
|
|
65
|
+
for f in files
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize(agent: str, event: str, payload: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
|
70
|
+
"""Return one or more (gitvow_event, claude_shaped_payload). Codex patches expand to one Edit per file."""
|
|
71
|
+
if agent not in AGENTS:
|
|
72
|
+
raise ValueError(f"unknown agent {agent}")
|
|
73
|
+
gv_event = EVENT_MAP[agent].get(event)
|
|
74
|
+
if not gv_event:
|
|
75
|
+
return []
|
|
76
|
+
base = {
|
|
77
|
+
"session_id": payload.get("session_id") or payload.get("conversation_id") or "",
|
|
78
|
+
"transcript_path": payload.get("transcript_path") or "",
|
|
79
|
+
"cwd": payload.get("cwd") or (payload.get("workspace_roots") or [None])[0] or "",
|
|
80
|
+
"hook_event_name": gv_event,
|
|
81
|
+
"agent": agent,
|
|
82
|
+
}
|
|
83
|
+
if gv_event in ("SessionStart", "Stop"):
|
|
84
|
+
return [(gv_event, base)]
|
|
85
|
+
tool = str(payload.get("tool_name") or "")
|
|
86
|
+
inp = dict(payload.get("tool_input") or {})
|
|
87
|
+
if agent == "codex":
|
|
88
|
+
if tool == "apply_patch":
|
|
89
|
+
out = []
|
|
90
|
+
for f in parse_apply_patch(str(inp.get("command") or inp.get("patch") or "")):
|
|
91
|
+
out.append(
|
|
92
|
+
(
|
|
93
|
+
gv_event,
|
|
94
|
+
{
|
|
95
|
+
**base,
|
|
96
|
+
"tool_name": "Edit",
|
|
97
|
+
"tool_input": {
|
|
98
|
+
"file_path": f["file_path"],
|
|
99
|
+
"old_string": f["old_string"],
|
|
100
|
+
"new_string": f["new_string"],
|
|
101
|
+
"apply_patch_op": f["op"],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return out or [(gv_event, {**base, "tool_name": "Edit", "tool_input": {}})]
|
|
107
|
+
return [(gv_event, {**base, "tool_name": tool, "tool_input": inp})]
|
|
108
|
+
if agent == "gemini":
|
|
109
|
+
return [(gv_event, {**base, "tool_name": GEMINI_TOOLS.get(tool, tool), "tool_input": inp})]
|
|
110
|
+
if agent == "cursor":
|
|
111
|
+
if event == "beforeShellExecution" or event == "afterShellExecution":
|
|
112
|
+
return [(gv_event, {**base, "tool_name": "Bash", "tool_input": {"command": payload.get("command", "")}})]
|
|
113
|
+
if event == "afterFileEdit":
|
|
114
|
+
edits = payload.get("edits") or []
|
|
115
|
+
return [
|
|
116
|
+
(
|
|
117
|
+
gv_event,
|
|
118
|
+
{
|
|
119
|
+
**base,
|
|
120
|
+
"tool_name": "Edit",
|
|
121
|
+
"tool_input": {
|
|
122
|
+
"file_path": payload.get("file_path", ""),
|
|
123
|
+
"old_string": "\n".join(str(e.get("old_string", "")) for e in edits),
|
|
124
|
+
"new_string": "\n".join(str(e.get("new_string", "")) for e in edits),
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
]
|
|
129
|
+
if event == "beforeMCPExecution":
|
|
130
|
+
name = tool if tool.startswith("mcp__") else f"mcp__{payload.get('mcp_server_name', 'server')}__{tool}"
|
|
131
|
+
return [(gv_event, {**base, "tool_name": name, "tool_input": inp})]
|
|
132
|
+
# generic preToolUse: Cursor's own tool names
|
|
133
|
+
mapped = {
|
|
134
|
+
"Shell": "Bash",
|
|
135
|
+
"shell": "Bash",
|
|
136
|
+
"run_terminal_cmd": "Bash",
|
|
137
|
+
"edit_file": "Edit",
|
|
138
|
+
"write_file": "Write",
|
|
139
|
+
"Edit": "Edit",
|
|
140
|
+
"Write": "Write",
|
|
141
|
+
}.get(tool, tool)
|
|
142
|
+
if mapped == "Bash" and "command" not in inp and payload.get("command"):
|
|
143
|
+
inp["command"] = payload["command"]
|
|
144
|
+
return [(gv_event, {**base, "tool_name": mapped, "tool_input": inp})]
|
|
145
|
+
return [(gv_event, {**base, "tool_name": tool, "tool_input": inp})]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def respond(agent: str, code: int, msg: str) -> tuple[int, str, str]:
|
|
149
|
+
"""(exit code, stdout, stderr) in the agent's form. gitvow's code 2 means blocked; the message says DENY or CONFIRM."""
|
|
150
|
+
if agent == "cursor":
|
|
151
|
+
if code == 2:
|
|
152
|
+
perm = "deny" if msg.startswith("BLOCKED") else "ask"
|
|
153
|
+
return 0, json.dumps({"permission": perm, "user_message": msg, "agent_message": msg}), ""
|
|
154
|
+
return 0, json.dumps({"permission": "allow"}), msg
|
|
155
|
+
return code, "", msg
|
|
@@ -9,6 +9,7 @@ import re
|
|
|
9
9
|
import sys
|
|
10
10
|
|
|
11
11
|
from . import __version__
|
|
12
|
+
from . import snapshots as snap
|
|
12
13
|
from .collect import collect, summarize_text
|
|
13
14
|
from .hooks import HANDLERS, LEGACY_NOTES_REF, notes_ref
|
|
14
15
|
from .install import install_repo, install_user, uninstall_repo, uninstall_user
|
|
@@ -20,31 +21,56 @@ from .state import git
|
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def cmd_hook(a: argparse.Namespace) -> int:
|
|
24
|
+
from .adapters import normalize, respond
|
|
25
|
+
|
|
23
26
|
try:
|
|
24
27
|
payload = json.load(sys.stdin)
|
|
25
28
|
except json.JSONDecodeError:
|
|
26
29
|
payload = {}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
agent = a.agent or "claude"
|
|
31
|
+
try:
|
|
32
|
+
calls = normalize(agent, a.event, payload)
|
|
33
|
+
except ValueError as e:
|
|
34
|
+
print(str(e), file=sys.stderr)
|
|
30
35
|
return 1
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
if not calls:
|
|
37
|
+
return 0 # an event this adapter does not use
|
|
38
|
+
worst, messages = 0, []
|
|
39
|
+
for gv_event, p in calls:
|
|
40
|
+
handler = HANDLERS.get(gv_event)
|
|
41
|
+
if not handler:
|
|
42
|
+
print(f"unknown hook event {gv_event}", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
code, msg = handler(p)
|
|
45
|
+
if code == 2 and len(calls) > 1 and p.get("tool_input", {}).get("file_path"):
|
|
46
|
+
msg = f"{msg} [file: {p['tool_input']['file_path']}]"
|
|
47
|
+
worst = max(worst, code)
|
|
48
|
+
if msg:
|
|
49
|
+
messages.append(msg)
|
|
50
|
+
exit_code, out, err = respond(agent, worst, "\n".join(messages))
|
|
51
|
+
if out:
|
|
52
|
+
print(out)
|
|
53
|
+
if err:
|
|
54
|
+
print(err, file=sys.stderr)
|
|
55
|
+
return exit_code
|
|
35
56
|
|
|
36
57
|
|
|
37
58
|
def cmd_install(a: argparse.Namespace) -> int:
|
|
38
|
-
|
|
59
|
+
agent = a.agent or "claude"
|
|
60
|
+
if a.user:
|
|
61
|
+
done = install_user(os.path.expanduser("~"), agent=agent)
|
|
62
|
+
else:
|
|
63
|
+
done = install_repo(os.path.abspath(a.repo), agent=agent)
|
|
39
64
|
print("\n".join(done))
|
|
40
65
|
return 0
|
|
41
66
|
|
|
42
67
|
|
|
43
68
|
def cmd_uninstall(a: argparse.Namespace) -> int:
|
|
69
|
+
agent = a.agent or "claude"
|
|
44
70
|
done = (
|
|
45
|
-
uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger)
|
|
71
|
+
uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger, agent=agent)
|
|
46
72
|
if a.user
|
|
47
|
-
else uninstall_repo(os.path.abspath(a.repo), a.purge_notes)
|
|
73
|
+
else uninstall_repo(os.path.abspath(a.repo), a.purge_notes, a.purge_snapshots, agent=agent)
|
|
48
74
|
)
|
|
49
75
|
print("\n".join(done))
|
|
50
76
|
return 0
|
|
@@ -130,6 +156,61 @@ def cmd_report(a: argparse.Namespace) -> int:
|
|
|
130
156
|
return 0
|
|
131
157
|
|
|
132
158
|
|
|
159
|
+
def cmd_snapshots(a: argparse.Namespace) -> int:
|
|
160
|
+
cwd = os.getcwd()
|
|
161
|
+
if a.sub == "prune":
|
|
162
|
+
days = None
|
|
163
|
+
if a.older_than:
|
|
164
|
+
m = re.fullmatch(r"(\d+)([dh]?)", a.older_than)
|
|
165
|
+
if not m:
|
|
166
|
+
print("--older-than takes a number of days, e.g. 14d", file=sys.stderr)
|
|
167
|
+
return 1
|
|
168
|
+
days = int(m.group(1)) / (24 if m.group(2) == "h" else 1)
|
|
169
|
+
print(f"pruned {snap.prune(cwd, days, a.session)} snapshot(s)")
|
|
170
|
+
return 0
|
|
171
|
+
rows = snap.list_snapshots(cwd, a.session)
|
|
172
|
+
if not rows:
|
|
173
|
+
print("no snapshots" + (f" for session {a.session}" if a.session else ""))
|
|
174
|
+
return 0
|
|
175
|
+
cur = None
|
|
176
|
+
for r in rows:
|
|
177
|
+
if r["session"] != cur:
|
|
178
|
+
cur = r["session"]
|
|
179
|
+
print(f"session {cur[:8]} {sum(1 for x in rows if x['session'] == cur)} snapshots")
|
|
180
|
+
print(" n taken tool file changed vs HEAD")
|
|
181
|
+
print(
|
|
182
|
+
f" {r['n']:<4d} {r['taken']:<20s} {r['tool']:<11s} {r['file'][:37]:<37s} {r['changed_files']} file{'s' if r['changed_files'] != 1 else ''}"
|
|
183
|
+
)
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _resolve_or_fail(a: argparse.Namespace) -> str | None:
|
|
188
|
+
ref = snap.resolve(os.getcwd(), a.session, a.n)
|
|
189
|
+
if not ref:
|
|
190
|
+
print(f"no snapshot {a.n} for a unique session matching {a.session!r}", file=sys.stderr)
|
|
191
|
+
return ref
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def cmd_diff(a: argparse.Namespace) -> int:
|
|
195
|
+
ref = _resolve_or_fail(a)
|
|
196
|
+
if not ref:
|
|
197
|
+
return 1
|
|
198
|
+
print(snap.diff(os.getcwd(), ref, a.full))
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def cmd_restore(a: argparse.Namespace) -> int:
|
|
203
|
+
ref = _resolve_or_fail(a)
|
|
204
|
+
if not ref:
|
|
205
|
+
return 1
|
|
206
|
+
rc, msg = snap.restore(os.getcwd(), ref, a.to)
|
|
207
|
+
print(
|
|
208
|
+
f"restored {ref} into {msg} (detached worktree; remove with: git worktree remove {msg})" if rc == 0 else msg,
|
|
209
|
+
file=sys.stderr if rc else sys.stdout,
|
|
210
|
+
)
|
|
211
|
+
return rc
|
|
212
|
+
|
|
213
|
+
|
|
133
214
|
def cmd_push_notes(a: argparse.Namespace) -> int:
|
|
134
215
|
import subprocess
|
|
135
216
|
|
|
@@ -154,8 +235,10 @@ def cmd_summarize(a: argparse.Namespace) -> int:
|
|
|
154
235
|
|
|
155
236
|
|
|
156
237
|
def cmd_selftest(a: argparse.Namespace) -> int:
|
|
157
|
-
from .selftest import run
|
|
238
|
+
from .selftest import run, run_agent
|
|
158
239
|
|
|
240
|
+
if a.agent and a.agent != "claude":
|
|
241
|
+
return run_agent(a.agent)
|
|
159
242
|
return run()
|
|
160
243
|
|
|
161
244
|
|
|
@@ -163,12 +246,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
163
246
|
p = argparse.ArgumentParser(prog="gitvow", description="Provenance and policy gate for agent coding sessions.")
|
|
164
247
|
p.add_argument("--version", action="version", version=f"gitvow {__version__}")
|
|
165
248
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
166
|
-
s = sub.add_parser("hook", help="run as
|
|
249
|
+
s = sub.add_parser("hook", help="run as an agent hook (reads the agent's JSON on stdin)")
|
|
250
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
167
251
|
s.add_argument("event")
|
|
168
252
|
s.set_defaults(f=cmd_hook)
|
|
169
253
|
s = sub.add_parser("install", help="install per user (--user) or into a repo")
|
|
170
254
|
s.add_argument("repo", nargs="?", default=".")
|
|
171
255
|
s.add_argument("--user", action="store_true")
|
|
256
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
172
257
|
s.set_defaults(f=cmd_install)
|
|
173
258
|
s = sub.add_parser("uninstall", help="remove what install added")
|
|
174
259
|
s.add_argument("repo", nargs="?", default=".")
|
|
@@ -176,6 +261,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
176
261
|
s.add_argument("--purge-notes", action="store_true")
|
|
177
262
|
s.add_argument("--purge-policy", action="store_true")
|
|
178
263
|
s.add_argument("--purge-ledger", action="store_true")
|
|
264
|
+
s.add_argument("--purge-snapshots", action="store_true")
|
|
265
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
179
266
|
s.set_defaults(f=cmd_uninstall)
|
|
180
267
|
s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
|
|
181
268
|
s.add_argument("command", nargs="*")
|
|
@@ -199,6 +286,24 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
199
286
|
s.add_argument("--json", action="store_true")
|
|
200
287
|
s.add_argument("--require-notes", action="store_true")
|
|
201
288
|
s.set_defaults(f=cmd_report)
|
|
289
|
+
s = sub.add_parser(
|
|
290
|
+
"snapshots", help="list working-tree snapshots taken after agent edits; 'snapshots prune' deletes"
|
|
291
|
+
)
|
|
292
|
+
s.add_argument("sub", nargs="?", choices=["prune"])
|
|
293
|
+
s.add_argument("--session")
|
|
294
|
+
s.add_argument("--all", action="store_true")
|
|
295
|
+
s.add_argument("--older-than")
|
|
296
|
+
s.set_defaults(f=cmd_snapshots)
|
|
297
|
+
s = sub.add_parser("diff", help="what the agent had changed at snapshot n: gitvow diff <session> <n>")
|
|
298
|
+
s.add_argument("session")
|
|
299
|
+
s.add_argument("n", type=int)
|
|
300
|
+
s.add_argument("--full", action="store_true")
|
|
301
|
+
s.set_defaults(f=cmd_diff)
|
|
302
|
+
s = sub.add_parser("restore", help="check a snapshot out into a detached scratch worktree")
|
|
303
|
+
s.add_argument("session")
|
|
304
|
+
s.add_argument("n", type=int)
|
|
305
|
+
s.add_argument("--to")
|
|
306
|
+
s.set_defaults(f=cmd_restore)
|
|
202
307
|
s = sub.add_parser("push-notes", help="push refs/notes/gitvow/* to a remote (default origin)")
|
|
203
308
|
s.add_argument("remote", nargs="?", default="origin")
|
|
204
309
|
s.set_defaults(f=cmd_push_notes)
|
|
@@ -209,6 +314,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
209
314
|
s.add_argument("dir")
|
|
210
315
|
s.set_defaults(f=cmd_summarize)
|
|
211
316
|
s = sub.add_parser("selftest", help="prove the hooks work here without touching a real repo")
|
|
317
|
+
s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
|
|
212
318
|
s.set_defaults(f=cmd_selftest)
|
|
213
319
|
a = p.parse_args(argv)
|
|
214
320
|
return a.f(a)
|
|
@@ -8,6 +8,7 @@ import re
|
|
|
8
8
|
import time
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
+
from .. import snapshots
|
|
11
12
|
from ..policy import PolicyError, evaluate, load_policy, message_for
|
|
12
13
|
from ..redact import RedactionError, load_rules, redact
|
|
13
14
|
from ..state import git, load_state, log_event, save_state, toplevel
|
|
@@ -15,7 +16,7 @@ from ..transcript import summarize
|
|
|
15
16
|
|
|
16
17
|
NOTES_REF_PREFIX = "gitvow" # refs/notes/gitvow/<session-id>; gitvow 0.1 wrote the single ref refs/notes/sessions
|
|
17
18
|
LEGACY_NOTES_REF = "sessions"
|
|
18
|
-
NOTE_SCHEMA =
|
|
19
|
+
NOTE_SCHEMA = 3
|
|
19
20
|
COMMIT_RE = re.compile(r"\bgit\s+commit\b")
|
|
20
21
|
EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
21
22
|
REDACTION_UNAVAILABLE = "redaction rules invalid; nothing written for this event"
|
|
@@ -37,6 +38,8 @@ def session_start(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
|
|
|
37
38
|
"started": st.get("started") if same_session and st.get("started") else time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
38
39
|
"steps": st.get("steps", 0) if same_session else 0,
|
|
39
40
|
"agent_blobs": st.get("agent_blobs", {}) if same_session else {},
|
|
41
|
+
"snapshots": st.get("snapshots", 0) if same_session else 0,
|
|
42
|
+
"last_snapshot": st.get("last_snapshot") if same_session else None,
|
|
40
43
|
}
|
|
41
44
|
)
|
|
42
45
|
st.pop("pending_commit", None)
|
|
@@ -160,8 +163,18 @@ def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
|
|
|
160
163
|
tool = h.get("tool_name")
|
|
161
164
|
inp = h.get("tool_input") or {}
|
|
162
165
|
if tool in EDIT_TOOLS:
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
fp = str(inp.get("file_path") or inp.get("notebook_path") or "")
|
|
167
|
+
if fp:
|
|
168
|
+
_record_agent_blob(cwd, fp)
|
|
169
|
+
try:
|
|
170
|
+
pol = load_policy(cwd, home)
|
|
171
|
+
except PolicyError:
|
|
172
|
+
pol = {}
|
|
173
|
+
cfg = snapshots.settings(pol)
|
|
174
|
+
if cfg.get("enabled", True):
|
|
175
|
+
ref = snapshots.take(cwd, h.get("session_id") or load_state(cwd).get("session_id"), tool, fp, cfg)
|
|
176
|
+
if ref:
|
|
177
|
+
log_event(cwd, "snapshot", {"ref": ref, "tool": tool})
|
|
165
178
|
return 0, ""
|
|
166
179
|
if tool != "Bash" or not COMMIT_RE.search(inp.get("command", "")):
|
|
167
180
|
return 0, ""
|
|
@@ -194,6 +207,7 @@ def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
|
|
|
194
207
|
"files_in_commit": [ln.strip() for ln in files.splitlines()[:-1]][:50] if files else [],
|
|
195
208
|
"files_written_by_agent_this_session": [f["path"] for f in attribution["files"] if f["agent_wrote"]][:50],
|
|
196
209
|
"attribution": attribution,
|
|
210
|
+
"snapshot": st.get("last_snapshot"),
|
|
197
211
|
"transcript": "kept local; see ledger",
|
|
198
212
|
"redaction": "secrets/PII patterns, high-entropy tokens and custom rules replaced at write time",
|
|
199
213
|
}
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
import shutil
|
|
8
9
|
import sys
|
|
9
10
|
from typing import Any
|
|
@@ -82,6 +83,120 @@ def _hook_entries(cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
|
|
86
|
+
AGENT_FILES = {
|
|
87
|
+
"claude": (".claude/settings.json", ".claude/settings.json"),
|
|
88
|
+
"codex": (".codex/hooks.json", ".codex/hooks.json"),
|
|
89
|
+
"gemini": (".gemini/settings.json", ".gemini/settings.json"),
|
|
90
|
+
"cursor": (".cursor/hooks.json", ".cursor/hooks.json"),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _agent_entries(agent: str, cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
|
|
95
|
+
"""Hook entries in each agent's own schema. Every command carries MARKER so install stays idempotent."""
|
|
96
|
+
c = f"{cmd_prefix} hook --agent {agent}"
|
|
97
|
+
if agent == "claude":
|
|
98
|
+
return _hook_entries(cmd_prefix)
|
|
99
|
+
if agent == "codex":
|
|
100
|
+
|
|
101
|
+
def e(ev: str, matcher: str | None) -> dict[str, Any]:
|
|
102
|
+
x: dict[str, Any] = {"hooks": [{"type": "command", "command": f"{c} {ev}", "timeout": 30}]}
|
|
103
|
+
if matcher:
|
|
104
|
+
x["matcher"] = matcher
|
|
105
|
+
return x
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"SessionStart": [e("SessionStart", None)],
|
|
109
|
+
"PreToolUse": [e("PreToolUse", "Bash|apply_patch|mcp__.*")],
|
|
110
|
+
"PostToolUse": [e("PostToolUse", "Bash|apply_patch")],
|
|
111
|
+
"Stop": [e("Stop", None)],
|
|
112
|
+
}
|
|
113
|
+
if agent == "gemini":
|
|
114
|
+
|
|
115
|
+
def g(ev: str, matcher: str) -> dict[str, Any]:
|
|
116
|
+
return {
|
|
117
|
+
"matcher": matcher,
|
|
118
|
+
"hooks": [{"name": f"gitvow-{ev}", "type": "command", "command": f"{c} {ev}", "timeout": 30000}],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"SessionStart": [g("SessionStart", "*")],
|
|
123
|
+
"BeforeTool": [g("BeforeTool", "run_shell_command|write_file|replace|edit|mcp__.*")],
|
|
124
|
+
"AfterTool": [g("AfterTool", "run_shell_command|write_file|replace|edit")],
|
|
125
|
+
"SessionEnd": [g("SessionEnd", "*")],
|
|
126
|
+
}
|
|
127
|
+
if agent == "cursor":
|
|
128
|
+
|
|
129
|
+
def u(ev: str) -> dict[str, Any]:
|
|
130
|
+
return {"command": f"{c} {ev}", "type": "command", "timeout": 30, "failClosed": True}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
ev: [u(ev)]
|
|
134
|
+
for ev in (
|
|
135
|
+
"sessionStart",
|
|
136
|
+
"preToolUse",
|
|
137
|
+
"beforeShellExecution",
|
|
138
|
+
"beforeMCPExecution",
|
|
139
|
+
"afterFileEdit",
|
|
140
|
+
"afterShellExecution",
|
|
141
|
+
"stop",
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
raise ValueError(f"unknown agent {agent}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_ours(entry: dict[str, Any]) -> bool:
|
|
148
|
+
if MARKER in (entry.get("command") or ""):
|
|
149
|
+
return True
|
|
150
|
+
return any(MARKER in (h.get("command") or "") for h in entry.get("hooks", []))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def merge_agent_settings(path: str, agent: str, cmd_prefix: str) -> None:
|
|
154
|
+
cur: dict[str, Any] = {}
|
|
155
|
+
if os.path.exists(path):
|
|
156
|
+
with open(path) as fh:
|
|
157
|
+
cur = json.load(fh)
|
|
158
|
+
if agent == "cursor":
|
|
159
|
+
cur.setdefault("version", 1)
|
|
160
|
+
hooks = cur.setdefault("hooks", {})
|
|
161
|
+
for ev, entries in _agent_entries(agent, cmd_prefix).items():
|
|
162
|
+
kept = [x for x in hooks.get(ev, []) if not _is_ours(x)]
|
|
163
|
+
hooks[ev] = kept + entries
|
|
164
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
165
|
+
with open(path, "w") as fh:
|
|
166
|
+
json.dump(cur, fh, indent=2)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def unmerge_agent_settings(path: str) -> None:
|
|
170
|
+
if not os.path.exists(path):
|
|
171
|
+
return
|
|
172
|
+
with open(path) as fh:
|
|
173
|
+
cur = json.load(fh)
|
|
174
|
+
hooks = cur.get("hooks", {})
|
|
175
|
+
for ev in list(hooks):
|
|
176
|
+
hooks[ev] = [x for x in hooks[ev] if not _is_ours(x)]
|
|
177
|
+
if not hooks[ev]:
|
|
178
|
+
del hooks[ev]
|
|
179
|
+
if not hooks:
|
|
180
|
+
cur.pop("hooks", None)
|
|
181
|
+
if cur and cur != {"version": 1}:
|
|
182
|
+
with open(path, "w") as fh:
|
|
183
|
+
json.dump(cur, fh, indent=2)
|
|
184
|
+
else:
|
|
185
|
+
os.remove(path)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def codex_hooks_enabled_hint(home: str) -> str | None:
|
|
189
|
+
"""Codex hooks are opt-in on some versions; point at the config line if it is absent."""
|
|
190
|
+
cfg = os.path.join(home, ".codex", "config.toml")
|
|
191
|
+
txt = ""
|
|
192
|
+
if os.path.exists(cfg):
|
|
193
|
+
with open(cfg) as fh:
|
|
194
|
+
txt = fh.read()
|
|
195
|
+
if re.search(r"^\s*hooks\s*=\s*true", txt, re.M) or re.search(r"^\s*codex_hooks\s*=\s*true", txt, re.M):
|
|
196
|
+
return None
|
|
197
|
+
return f"Codex hooks may need enabling: add `hooks = true` under [features] in {cfg}"
|
|
198
|
+
|
|
199
|
+
|
|
85
200
|
def merge_settings(path: str, cmd_prefix: str) -> None:
|
|
86
201
|
cur: dict[str, Any] = {}
|
|
87
202
|
if os.path.exists(path):
|
|
@@ -125,7 +240,7 @@ def _write_git_hook(dirpath: str) -> str:
|
|
|
125
240
|
return os.path.join(dirpath, "prepare-commit-msg")
|
|
126
241
|
|
|
127
242
|
|
|
128
|
-
def install_user(home: str, cmd_prefix: str | None = None) -> list[str]:
|
|
243
|
+
def install_user(home: str, cmd_prefix: str | None = None, agent: str = "claude") -> list[str]:
|
|
129
244
|
base = os.path.join(home, ".gitvow")
|
|
130
245
|
os.makedirs(base, exist_ok=True)
|
|
131
246
|
cmd_prefix = cmd_prefix or executable_command()
|
|
@@ -137,21 +252,34 @@ def install_user(home: str, cmd_prefix: str | None = None) -> list[str]:
|
|
|
137
252
|
shutil.copy(DEFAULT_POLICY_PATH, pol)
|
|
138
253
|
done.append(f"default policy → {pol}")
|
|
139
254
|
_write_git_hook(os.path.join(base, "git-hooks"))
|
|
140
|
-
|
|
255
|
+
settings_rel = AGENT_FILES[agent][0]
|
|
256
|
+
if agent == "claude":
|
|
257
|
+
merge_settings(os.path.join(home, ".claude", "settings.json"), cmd_prefix)
|
|
258
|
+
else:
|
|
259
|
+
merge_agent_settings(os.path.join(home, settings_rel), agent, cmd_prefix)
|
|
260
|
+
if agent == "codex":
|
|
261
|
+
hint = codex_hooks_enabled_hint(home)
|
|
262
|
+
if hint:
|
|
263
|
+
done.append(hint)
|
|
141
264
|
git(["config", "--global", "core.hooksPath", os.path.join(base, "git-hooks")], home)
|
|
142
265
|
_set_notes_config(home, ["--global"])
|
|
143
266
|
done += [
|
|
144
|
-
"hooks merged into
|
|
267
|
+
f"hooks merged into ~/{settings_rel}",
|
|
145
268
|
"global core.hooksPath → ~/.gitvow/git-hooks",
|
|
146
269
|
f"global notes.displayRef / notes.rewriteRef → {NOTES_GLOB}",
|
|
147
270
|
]
|
|
148
271
|
return done
|
|
149
272
|
|
|
150
273
|
|
|
151
|
-
def uninstall_user(
|
|
274
|
+
def uninstall_user(
|
|
275
|
+
home: str, purge_policy: bool = False, purge_ledger: bool = False, agent: str = "claude"
|
|
276
|
+
) -> list[str]:
|
|
152
277
|
base = os.path.join(home, ".gitvow")
|
|
153
278
|
done = []
|
|
154
|
-
|
|
279
|
+
if agent == "claude":
|
|
280
|
+
unmerge_settings(os.path.join(home, ".claude", "settings.json"))
|
|
281
|
+
else:
|
|
282
|
+
unmerge_agent_settings(os.path.join(home, AGENT_FILES[agent][0]))
|
|
155
283
|
rc, cur, _ = git(["config", "--global", "--get", "core.hooksPath"], home)
|
|
156
284
|
if rc == 0 and cur == os.path.join(base, "git-hooks"):
|
|
157
285
|
git(["config", "--global", "--unset", "core.hooksPath"], home)
|
|
@@ -164,11 +292,11 @@ def uninstall_user(home: str, purge_policy: bool = False, purge_ledger: bool = F
|
|
|
164
292
|
if purge_ledger:
|
|
165
293
|
shutil.rmtree(os.path.join(base, "ledger"), ignore_errors=True)
|
|
166
294
|
done.append("ledger removed")
|
|
167
|
-
done.append("hook entries removed from
|
|
295
|
+
done.append(f"hook entries removed from ~/{AGENT_FILES[agent][0]}")
|
|
168
296
|
return done
|
|
169
297
|
|
|
170
298
|
|
|
171
|
-
def install_repo(repo: str, cmd_prefix: str = "gitvow") -> list[str]:
|
|
299
|
+
def install_repo(repo: str, cmd_prefix: str = "gitvow", agent: str = "claude") -> list[str]:
|
|
172
300
|
base = os.path.join(repo, ".gitvow")
|
|
173
301
|
os.makedirs(base, exist_ok=True)
|
|
174
302
|
from .policy import DEFAULT_POLICY_PATH
|
|
@@ -177,21 +305,29 @@ def install_repo(repo: str, cmd_prefix: str = "gitvow") -> list[str]:
|
|
|
177
305
|
if not os.path.exists(pol):
|
|
178
306
|
shutil.copy(DEFAULT_POLICY_PATH, pol)
|
|
179
307
|
_write_git_hook(os.path.join(base, "git-hooks"))
|
|
180
|
-
|
|
308
|
+
if agent == "claude":
|
|
309
|
+
merge_settings(os.path.join(repo, ".claude", "settings.json"), cmd_prefix)
|
|
310
|
+
else:
|
|
311
|
+
merge_agent_settings(os.path.join(repo, AGENT_FILES[agent][1]), agent, cmd_prefix)
|
|
181
312
|
git(["config", "core.hooksPath", ".gitvow/git-hooks"], repo)
|
|
182
313
|
_set_notes_config(repo, [])
|
|
183
314
|
return [
|
|
184
315
|
f"policy → {pol}",
|
|
185
|
-
"hooks merged into
|
|
316
|
+
f"hooks merged into {AGENT_FILES[agent][1]}",
|
|
186
317
|
"core.hooksPath → .gitvow/git-hooks",
|
|
187
318
|
f"notes.displayRef / notes.rewriteRef → {NOTES_GLOB}",
|
|
188
319
|
"commit .gitvow/ and .claude/settings.json to share; teammates run: git config core.hooksPath .gitvow/git-hooks",
|
|
189
320
|
]
|
|
190
321
|
|
|
191
322
|
|
|
192
|
-
def uninstall_repo(
|
|
323
|
+
def uninstall_repo(
|
|
324
|
+
repo: str, purge_notes: bool = False, purge_snapshots: bool = False, agent: str = "claude"
|
|
325
|
+
) -> list[str]:
|
|
193
326
|
done = []
|
|
194
|
-
|
|
327
|
+
if agent == "claude":
|
|
328
|
+
unmerge_settings(os.path.join(repo, ".claude", "settings.json"))
|
|
329
|
+
else:
|
|
330
|
+
unmerge_agent_settings(os.path.join(repo, AGENT_FILES[agent][1]))
|
|
195
331
|
rc, cur, _ = git(["config", "--get", "core.hooksPath"], repo)
|
|
196
332
|
if rc == 0 and cur == ".gitvow/git-hooks":
|
|
197
333
|
git(["config", "--unset", "core.hooksPath"], repo)
|
|
@@ -210,5 +346,9 @@ def uninstall_repo(repo: str, purge_notes: bool = False) -> list[str]:
|
|
|
210
346
|
for ref in refs.split():
|
|
211
347
|
git(["update-ref", "-d", ref], repo)
|
|
212
348
|
done.append(f"{len(refs.split())} local session note ref(s) deleted (remote copies untouched)")
|
|
349
|
+
if purge_snapshots:
|
|
350
|
+
from . import snapshots as _snap
|
|
351
|
+
|
|
352
|
+
done.append(f"{_snap.purge_all(repo)} snapshot ref(s) deleted")
|
|
213
353
|
done.append(".gitvow removed; commit trailers already in history remain")
|
|
214
354
|
return done
|