gitvow 0.7.1__tar.gz → 0.8.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 (40) hide show
  1. {gitvow-0.7.1/src/gitvow.egg-info → gitvow-0.8.0}/PKG-INFO +1 -1
  2. {gitvow-0.7.1 → gitvow-0.8.0}/pyproject.toml +1 -1
  3. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/adapters.py +82 -4
  4. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/cli.py +4 -4
  5. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/install.py +24 -2
  6. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/selftest.py +44 -4
  7. {gitvow-0.7.1 → gitvow-0.8.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  8. {gitvow-0.7.1 → gitvow-0.8.0}/tests/test_adapters.py +125 -0
  9. {gitvow-0.7.1 → gitvow-0.8.0}/LICENSE +0 -0
  10. {gitvow-0.7.1 → gitvow-0.8.0}/README.md +0 -0
  11. {gitvow-0.7.1 → gitvow-0.8.0}/setup.cfg +0 -0
  12. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/__init__.py +0 -0
  13. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/__main__.py +0 -0
  14. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/collect.py +0 -0
  15. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/default_policy.json +0 -0
  16. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/hooks/__init__.py +0 -0
  17. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/policy.py +0 -0
  18. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/providers.py +0 -0
  19. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/recall.py +0 -0
  20. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/redact.py +0 -0
  21. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/report.py +0 -0
  22. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/snapshots.py +0 -0
  23. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/state.py +0 -0
  24. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow/transcript.py +0 -0
  25. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow.egg-info/SOURCES.txt +0 -0
  26. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  27. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  28. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow.egg-info/requires.txt +0 -0
  29. {gitvow-0.7.1 → gitvow-0.8.0}/src/gitvow.egg-info/top_level.txt +0 -0
  30. {gitvow-0.7.1 → gitvow-0.8.0}/tests/test_collect.py +0 -0
  31. {gitvow-0.7.1 → gitvow-0.8.0}/tests/test_hooks.py +0 -0
  32. {gitvow-0.7.1 → gitvow-0.8.0}/tests/test_install_cli.py +0 -0
  33. {gitvow-0.7.1 → gitvow-0.8.0}/tests/test_packaging.py +0 -0
  34. {gitvow-0.7.1 → gitvow-0.8.0}/tests/test_policy.py +0 -0
  35. {gitvow-0.7.1 → gitvow-0.8.0}/tests/test_providers.py +0 -0
  36. {gitvow-0.7.1 → gitvow-0.8.0}/tests/test_recall.py +0 -0
  37. {gitvow-0.7.1 → gitvow-0.8.0}/tests/test_redact.py +0 -0
  38. {gitvow-0.7.1 → gitvow-0.8.0}/tests/test_report.py +0 -0
  39. {gitvow-0.7.1 → gitvow-0.8.0}/tests/test_snapshots.py +0 -0
  40. {gitvow-0.7.1 → gitvow-0.8.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.7.1
3
+ Version: 0.8.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.1"
7
+ version = "0.8.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
- inp = dict(payload.get("tool_input") or {})
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
@@ -268,13 +268,13 @@ def main(argv: list[str] | None = None) -> int:
268
268
  p.add_argument("--version", action="version", version=f"gitvow {__version__}")
269
269
  sub = p.add_subparsers(dest="cmd", required=True)
270
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")
271
+ s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor", "copilot", "factory"], default="claude")
272
272
  s.add_argument("event")
273
273
  s.set_defaults(f=cmd_hook)
274
274
  s = sub.add_parser("install", help="install per user (--user) or into a repo")
275
275
  s.add_argument("repo", nargs="?", default=".")
276
276
  s.add_argument("--user", action="store_true")
277
- s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
277
+ s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor", "copilot", "factory"], default="claude")
278
278
  s.set_defaults(f=cmd_install)
279
279
  s = sub.add_parser("uninstall", help="remove what install added")
280
280
  s.add_argument("repo", nargs="?", default=".")
@@ -283,7 +283,7 @@ def main(argv: list[str] | None = None) -> int:
283
283
  s.add_argument("--purge-policy", action="store_true")
284
284
  s.add_argument("--purge-ledger", action="store_true")
285
285
  s.add_argument("--purge-snapshots", action="store_true")
286
- s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor"], default="claude")
286
+ s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor", "copilot", "factory"], default="claude")
287
287
  s.set_defaults(f=cmd_uninstall)
288
288
  s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
289
289
  s.add_argument("command", nargs="*")
@@ -348,7 +348,7 @@ def main(argv: list[str] | None = None) -> int:
348
348
  s.add_argument("dir")
349
349
  s.set_defaults(f=cmd_summarize)
350
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")
351
+ s.add_argument("--agent", choices=["claude", "codex", "gemini", "cursor", "copilot", "factory"], default="claude")
352
352
  s.set_defaults(f=cmd_selftest)
353
353
  a = p.parse_args(argv)
354
354
  return a.f(a)
@@ -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 == "cursor":
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
- blocked = (agent == "cursor" and _json.loads(out).get("permission") == "deny") or (
101
- agent != "cursor" and exit_code == 2
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.7.1
3
+ Version: 0.8.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
@@ -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()
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