gitvow 0.5.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {gitvow-0.5.0/src/gitvow.egg-info → gitvow-0.7.0}/PKG-INFO +1 -1
  2. {gitvow-0.5.0 → gitvow-0.7.0}/pyproject.toml +1 -1
  3. gitvow-0.7.0/src/gitvow/adapters.py +155 -0
  4. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/cli.py +77 -12
  5. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/install.py +147 -11
  6. gitvow-0.7.0/src/gitvow/recall.py +285 -0
  7. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/selftest.py +107 -0
  8. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/snapshots.py +14 -4
  9. {gitvow-0.5.0 → gitvow-0.7.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  10. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow.egg-info/SOURCES.txt +4 -0
  11. gitvow-0.7.0/tests/test_adapters.py +238 -0
  12. gitvow-0.7.0/tests/test_recall.py +107 -0
  13. {gitvow-0.5.0 → gitvow-0.7.0}/tests/test_snapshots.py +5 -2
  14. {gitvow-0.5.0 → gitvow-0.7.0}/LICENSE +0 -0
  15. {gitvow-0.5.0 → gitvow-0.7.0}/README.md +0 -0
  16. {gitvow-0.5.0 → gitvow-0.7.0}/setup.cfg +0 -0
  17. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/__init__.py +0 -0
  18. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/__main__.py +0 -0
  19. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/collect.py +0 -0
  20. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/default_policy.json +0 -0
  21. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/hooks/__init__.py +0 -0
  22. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/policy.py +0 -0
  23. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/providers.py +0 -0
  24. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/redact.py +0 -0
  25. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/report.py +0 -0
  26. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/state.py +0 -0
  27. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow/transcript.py +0 -0
  28. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  29. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  30. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow.egg-info/requires.txt +0 -0
  31. {gitvow-0.5.0 → gitvow-0.7.0}/src/gitvow.egg-info/top_level.txt +0 -0
  32. {gitvow-0.5.0 → gitvow-0.7.0}/tests/test_collect.py +0 -0
  33. {gitvow-0.5.0 → gitvow-0.7.0}/tests/test_hooks.py +0 -0
  34. {gitvow-0.5.0 → gitvow-0.7.0}/tests/test_install_cli.py +0 -0
  35. {gitvow-0.5.0 → gitvow-0.7.0}/tests/test_packaging.py +0 -0
  36. {gitvow-0.5.0 → gitvow-0.7.0}/tests/test_policy.py +0 -0
  37. {gitvow-0.5.0 → gitvow-0.7.0}/tests/test_providers.py +0 -0
  38. {gitvow-0.5.0 → gitvow-0.7.0}/tests/test_redact.py +0 -0
  39. {gitvow-0.5.0 → gitvow-0.7.0}/tests/test_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitvow"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  description = "Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -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 recall as rec
12
13
  from . import snapshots as snap
13
14
  from .collect import collect, summarize_text
14
15
  from .hooks import HANDLERS, LEGACY_NOTES_REF, notes_ref
@@ -21,31 +22,56 @@ from .state import git
21
22
 
22
23
 
23
24
  def cmd_hook(a: argparse.Namespace) -> int:
25
+ from .adapters import normalize, respond
26
+
24
27
  try:
25
28
  payload = json.load(sys.stdin)
26
29
  except json.JSONDecodeError:
27
30
  payload = {}
28
- handler = HANDLERS.get(a.event)
29
- if not handler:
30
- print(f"unknown hook event {a.event}", file=sys.stderr)
31
+ agent = a.agent or "claude"
32
+ try:
33
+ calls = normalize(agent, a.event, payload)
34
+ except ValueError as e:
35
+ print(str(e), file=sys.stderr)
31
36
  return 1
32
- code, msg = handler(payload)
33
- if msg:
34
- print(msg, file=sys.stderr)
35
- return code
37
+ if not calls:
38
+ return 0 # an event this adapter does not use
39
+ worst, messages = 0, []
40
+ for gv_event, p in calls:
41
+ handler = HANDLERS.get(gv_event)
42
+ if not handler:
43
+ print(f"unknown hook event {gv_event}", file=sys.stderr)
44
+ return 1
45
+ code, msg = handler(p)
46
+ if code == 2 and len(calls) > 1 and p.get("tool_input", {}).get("file_path"):
47
+ msg = f"{msg} [file: {p['tool_input']['file_path']}]"
48
+ worst = max(worst, code)
49
+ if msg:
50
+ messages.append(msg)
51
+ exit_code, out, err = respond(agent, worst, "\n".join(messages))
52
+ if out:
53
+ print(out)
54
+ if err:
55
+ print(err, file=sys.stderr)
56
+ return exit_code
36
57
 
37
58
 
38
59
  def cmd_install(a: argparse.Namespace) -> int:
39
- done = install_user(os.path.expanduser("~")) if a.user else install_repo(os.path.abspath(a.repo))
60
+ agent = a.agent or "claude"
61
+ if a.user:
62
+ done = install_user(os.path.expanduser("~"), agent=agent)
63
+ else:
64
+ done = install_repo(os.path.abspath(a.repo), agent=agent)
40
65
  print("\n".join(done))
41
66
  return 0
42
67
 
43
68
 
44
69
  def cmd_uninstall(a: argparse.Namespace) -> int:
70
+ agent = a.agent or "claude"
45
71
  done = (
46
- uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger)
72
+ uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger, agent=agent)
47
73
  if a.user
48
- else uninstall_repo(os.path.abspath(a.repo), a.purge_notes, a.purge_snapshots)
74
+ else uninstall_repo(os.path.abspath(a.repo), a.purge_notes, a.purge_snapshots, agent=agent)
49
75
  )
50
76
  print("\n".join(done))
51
77
  return 0
@@ -186,6 +212,26 @@ def cmd_restore(a: argparse.Namespace) -> int:
186
212
  return rc
187
213
 
188
214
 
215
+ def cmd_why(a: argparse.Namespace) -> int:
216
+ print(rec.why(os.getcwd(), a.path))
217
+ return 0
218
+
219
+
220
+ def cmd_trace(a: argparse.Namespace) -> int:
221
+ print(rec.trace(os.getcwd(), a.spec))
222
+ return 0
223
+
224
+
225
+ def cmd_recall(a: argparse.Namespace) -> int:
226
+ print(rec.recall(os.getcwd(), a.words, None, a.limit))
227
+ return 0
228
+
229
+
230
+ def cmd_handoff(a: argparse.Namespace) -> int:
231
+ print(rec.handoff(os.getcwd(), a.session))
232
+ return 0
233
+
234
+
189
235
  def cmd_push_notes(a: argparse.Namespace) -> int:
190
236
  import subprocess
191
237
 
@@ -210,8 +256,10 @@ def cmd_summarize(a: argparse.Namespace) -> int:
210
256
 
211
257
 
212
258
  def cmd_selftest(a: argparse.Namespace) -> int:
213
- from .selftest import run
259
+ from .selftest import run, run_agent
214
260
 
261
+ if a.agent and a.agent != "claude":
262
+ return run_agent(a.agent)
215
263
  return run()
216
264
 
217
265
 
@@ -219,12 +267,14 @@ def main(argv: list[str] | None = None) -> int:
219
267
  p = argparse.ArgumentParser(prog="gitvow", description="Provenance and policy gate for agent coding sessions.")
220
268
  p.add_argument("--version", action="version", version=f"gitvow {__version__}")
221
269
  sub = p.add_subparsers(dest="cmd", required=True)
222
- s = sub.add_parser("hook", help="run as a Claude Code hook (reads JSON on stdin)")
270
+ 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")
223
272
  s.add_argument("event")
224
273
  s.set_defaults(f=cmd_hook)
225
274
  s = sub.add_parser("install", help="install per user (--user) or into a repo")
226
275
  s.add_argument("repo", nargs="?", default=".")
227
276
  s.add_argument("--user", action="store_true")
277
+ s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
228
278
  s.set_defaults(f=cmd_install)
229
279
  s = sub.add_parser("uninstall", help="remove what install added")
230
280
  s.add_argument("repo", nargs="?", default=".")
@@ -233,6 +283,7 @@ def main(argv: list[str] | None = None) -> int:
233
283
  s.add_argument("--purge-policy", action="store_true")
234
284
  s.add_argument("--purge-ledger", action="store_true")
235
285
  s.add_argument("--purge-snapshots", action="store_true")
286
+ s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
236
287
  s.set_defaults(f=cmd_uninstall)
237
288
  s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
238
289
  s.add_argument("command", nargs="*")
@@ -274,6 +325,19 @@ def main(argv: list[str] | None = None) -> int:
274
325
  s.add_argument("n", type=int)
275
326
  s.add_argument("--to")
276
327
  s.set_defaults(f=cmd_restore)
328
+ s = sub.add_parser("why", help="which sessions shaped a file: commits, plans, attribution")
329
+ s.add_argument("path")
330
+ s.set_defaults(f=cmd_why)
331
+ s = sub.add_parser("trace", help="who wrote these lines: gitvow trace path[:start-end]")
332
+ s.add_argument("spec")
333
+ s.set_defaults(f=cmd_trace)
334
+ s = sub.add_parser("recall", help="sessions whose notes or ledger mention the words")
335
+ s.add_argument("words", nargs="+")
336
+ s.add_argument("--limit", type=int, default=10)
337
+ s.set_defaults(f=cmd_recall)
338
+ s = sub.add_parser("handoff", help="markdown summary for the next agent")
339
+ s.add_argument("--session")
340
+ s.set_defaults(f=cmd_handoff)
277
341
  s = sub.add_parser("push-notes", help="push refs/notes/gitvow/* to a remote (default origin)")
278
342
  s.add_argument("remote", nargs="?", default="origin")
279
343
  s.set_defaults(f=cmd_push_notes)
@@ -284,6 +348,7 @@ def main(argv: list[str] | None = None) -> int:
284
348
  s.add_argument("dir")
285
349
  s.set_defaults(f=cmd_summarize)
286
350
  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")
287
352
  s.set_defaults(f=cmd_selftest)
288
353
  a = p.parse_args(argv)
289
354
  return a.f(a)
@@ -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
- merge_settings(os.path.join(home, ".claude", "settings.json"), cmd_prefix)
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 ~/.claude/settings.json",
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(home: str, purge_policy: bool = False, purge_ledger: bool = False) -> list[str]:
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
- unmerge_settings(os.path.join(home, ".claude", "settings.json"))
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 ~/.claude/settings.json")
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
- merge_settings(os.path.join(repo, ".claude", "settings.json"), cmd_prefix)
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 .claude/settings.json",
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(repo: str, purge_notes: bool = False, purge_snapshots: bool = False) -> list[str]:
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
- unmerge_settings(os.path.join(repo, ".claude", "settings.json"))
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)