gitvow 0.7.0__tar.gz → 0.7.1__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 (41) hide show
  1. {gitvow-0.7.0/src/gitvow.egg-info → gitvow-0.7.1}/PKG-INFO +1 -1
  2. {gitvow-0.7.0 → gitvow-0.7.1}/pyproject.toml +1 -1
  3. gitvow-0.7.1/src/gitvow/transcript.py +187 -0
  4. {gitvow-0.7.0 → gitvow-0.7.1/src/gitvow.egg-info}/PKG-INFO +1 -1
  5. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow.egg-info/SOURCES.txt +2 -1
  6. gitvow-0.7.1/tests/test_transcript_formats.py +128 -0
  7. gitvow-0.7.0/src/gitvow/transcript.py +0 -49
  8. {gitvow-0.7.0 → gitvow-0.7.1}/LICENSE +0 -0
  9. {gitvow-0.7.0 → gitvow-0.7.1}/README.md +0 -0
  10. {gitvow-0.7.0 → gitvow-0.7.1}/setup.cfg +0 -0
  11. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/__init__.py +0 -0
  12. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/__main__.py +0 -0
  13. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/adapters.py +0 -0
  14. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/cli.py +0 -0
  15. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/collect.py +0 -0
  16. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/default_policy.json +0 -0
  17. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/hooks/__init__.py +0 -0
  18. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/install.py +0 -0
  19. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/policy.py +0 -0
  20. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/providers.py +0 -0
  21. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/recall.py +0 -0
  22. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/redact.py +0 -0
  23. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/report.py +0 -0
  24. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/selftest.py +0 -0
  25. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/snapshots.py +0 -0
  26. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow/state.py +0 -0
  27. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow.egg-info/dependency_links.txt +0 -0
  28. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow.egg-info/entry_points.txt +0 -0
  29. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow.egg-info/requires.txt +0 -0
  30. {gitvow-0.7.0 → gitvow-0.7.1}/src/gitvow.egg-info/top_level.txt +0 -0
  31. {gitvow-0.7.0 → gitvow-0.7.1}/tests/test_adapters.py +0 -0
  32. {gitvow-0.7.0 → gitvow-0.7.1}/tests/test_collect.py +0 -0
  33. {gitvow-0.7.0 → gitvow-0.7.1}/tests/test_hooks.py +0 -0
  34. {gitvow-0.7.0 → gitvow-0.7.1}/tests/test_install_cli.py +0 -0
  35. {gitvow-0.7.0 → gitvow-0.7.1}/tests/test_packaging.py +0 -0
  36. {gitvow-0.7.0 → gitvow-0.7.1}/tests/test_policy.py +0 -0
  37. {gitvow-0.7.0 → gitvow-0.7.1}/tests/test_providers.py +0 -0
  38. {gitvow-0.7.0 → gitvow-0.7.1}/tests/test_recall.py +0 -0
  39. {gitvow-0.7.0 → gitvow-0.7.1}/tests/test_redact.py +0 -0
  40. {gitvow-0.7.0 → gitvow-0.7.1}/tests/test_report.py +0 -0
  41. {gitvow-0.7.0 → gitvow-0.7.1}/tests/test_snapshots.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.7.0
3
+ Version: 0.7.1
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.0"
7
+ version = "0.7.1"
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,187 @@
1
+ """Structural, redacted summary of an agent transcript (Claude Code JSONL). Tool output is never read."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Iterable
8
+ from typing import Any
9
+
10
+ from .redact import redact
11
+
12
+
13
+ def _detect(first: dict[str, Any]) -> str:
14
+ """Which agent wrote this transcript: claude (default), codex (rollout records), gemini (chat recording)."""
15
+ t = first.get("type")
16
+ if t in ("session_meta", "response_item", "event_msg", "turn_context", "compacted") and "payload" in first:
17
+ return "codex"
18
+ if "sessionId" in first and "projectHash" in first:
19
+ return "gemini"
20
+ if first.get("type") in ("user", "gemini", "info", "error") and "content" in first and "message" not in first:
21
+ return "gemini"
22
+ return "claude"
23
+
24
+
25
+ def _text_of(content: Any) -> str:
26
+ if isinstance(content, str):
27
+ return content
28
+ if isinstance(content, list):
29
+ parts = []
30
+ for c in content:
31
+ if isinstance(c, dict):
32
+ if c.get("type") in ("text", "output_text", "input_text") and c.get("text"):
33
+ parts.append(str(c["text"]))
34
+ elif "text" in c and isinstance(c["text"], str):
35
+ parts.append(c["text"])
36
+ elif isinstance(c, str):
37
+ parts.append(c)
38
+ return "\n".join(parts)
39
+ return ""
40
+
41
+
42
+ def _patch_files(text: str) -> list[str]:
43
+ import re
44
+
45
+ return re.findall(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$", text or "", re.M)
46
+
47
+
48
+ def _codex_tool(name: str, arguments: Any) -> tuple[str, str, list[str]]:
49
+ """Codex function names -> (gitvow tool, brief argument, files written)."""
50
+ args: dict[str, Any] = {}
51
+ if isinstance(arguments, str):
52
+ try:
53
+ args = json.loads(arguments) if arguments.strip().startswith("{") else {"input": arguments}
54
+ except json.JSONDecodeError:
55
+ args = {"input": arguments}
56
+ elif isinstance(arguments, dict):
57
+ args = arguments
58
+ if name in ("exec_command", "shell", "container.exec", "local_shell"):
59
+ cmd = args.get("cmd") or args.get("command") or ""
60
+ cmd = " ".join(cmd) if isinstance(cmd, list) else str(cmd)
61
+ return "Bash", cmd, []
62
+ if name == "apply_patch":
63
+ patch = str(args.get("input") or args.get("patch") or args.get("command") or "")
64
+ files = _patch_files(patch)
65
+ return "Edit", files[0] if files else "apply_patch", files
66
+ return name, json.dumps(args)[:160] if args else "", []
67
+
68
+
69
+ def _gemini_tool(name: str, args: Any) -> tuple[str, str, list[str]]:
70
+ a = args if isinstance(args, dict) else {}
71
+ if name == "run_shell_command":
72
+ return "Bash", str(a.get("command") or ""), []
73
+ if name in ("write_file", "replace", "edit"):
74
+ fp = str(a.get("file_path") or a.get("path") or "")
75
+ return ("Write" if name == "write_file" else "Edit"), fp, [fp] if fp else []
76
+ return name, json.dumps(a)[:160] if a else "", []
77
+
78
+
79
+ def summarize(path: str | None, max_tools: int = 500, rules: Iterable[tuple[str, str]] = ()) -> dict[str, Any]:
80
+ """Structural summary of a transcript. Reads Claude Code, Codex and Gemini CLI formats; tool output is never read."""
81
+ out: dict[str, Any] = {
82
+ "turns": 0,
83
+ "tool_calls": [],
84
+ "last_assistant_text": "",
85
+ "files_written": [],
86
+ "format": "claude",
87
+ }
88
+ if not path or not os.path.exists(path):
89
+ return out
90
+ rules = list(rules)
91
+ written: set[str] = set()
92
+ fmt: str | None = None
93
+ with open(path, errors="ignore") as fh:
94
+ for line in fh:
95
+ try:
96
+ ev = json.loads(line)
97
+ except json.JSONDecodeError:
98
+ continue
99
+ if not isinstance(ev, dict):
100
+ continue
101
+ if fmt is None:
102
+ fmt = _detect(ev)
103
+ out["format"] = fmt
104
+ if fmt == "codex":
105
+ _codex_event(ev, out, written, rules, max_tools)
106
+ elif fmt == "gemini":
107
+ _gemini_event(ev, out, written, rules, max_tools)
108
+ else:
109
+ _claude_event(ev, out, written, rules, max_tools)
110
+ out["files_written"] = sorted(written)
111
+ return out
112
+
113
+
114
+ def _add_tool(out: dict[str, Any], tool: str, brief: str, rules: list[tuple[str, str]], max_tools: int) -> None:
115
+ if len(out["tool_calls"]) < max_tools:
116
+ out["tool_calls"].append({"tool": tool, "arg": redact(str(brief), rules)[:160]})
117
+
118
+
119
+ def _claude_event(
120
+ ev: dict[str, Any], out: dict[str, Any], written: set[str], rules: list[tuple[str, str]], max_tools: int
121
+ ) -> None:
122
+ msg = ev.get("message") or {}
123
+ role = msg.get("role") or ev.get("type")
124
+ content = msg.get("content")
125
+ if role != "assistant":
126
+ return
127
+ out["turns"] += 1
128
+ if isinstance(content, str):
129
+ if content.strip():
130
+ out["last_assistant_text"] = redact(content, rules)[:600]
131
+ return
132
+ if not isinstance(content, list):
133
+ return
134
+ for c in content:
135
+ if not isinstance(c, dict):
136
+ continue
137
+ if c.get("type") == "tool_use":
138
+ inp = c.get("input") or {}
139
+ brief = inp.get("command") or inp.get("file_path") or inp.get("query") or inp.get("pattern") or ""
140
+ _add_tool(out, str(c.get("name")), brief, rules, max_tools)
141
+ if c.get("name") in ("Edit", "Write", "MultiEdit", "NotebookEdit") and inp.get("file_path"):
142
+ written.add(str(inp["file_path"]))
143
+ elif c.get("type") == "text" and str(c.get("text", "")).strip():
144
+ out["last_assistant_text"] = redact(c["text"], rules)[:600]
145
+
146
+
147
+ def _codex_event(
148
+ ev: dict[str, Any], out: dict[str, Any], written: set[str], rules: list[tuple[str, str]], max_tools: int
149
+ ) -> None:
150
+ t, p = ev.get("type"), ev.get("payload") or {}
151
+ if not isinstance(p, dict):
152
+ return
153
+ if t == "response_item":
154
+ pt = p.get("type")
155
+ if pt == "message" and p.get("role") == "assistant":
156
+ out["turns"] += 1
157
+ text = _text_of(p.get("content"))
158
+ if text.strip():
159
+ out["last_assistant_text"] = redact(text, rules)[:600]
160
+ elif pt in ("function_call", "custom_tool_call", "local_shell_call"):
161
+ name = str(p.get("name") or ("local_shell" if pt == "local_shell_call" else ""))
162
+ arguments = p.get("arguments") if pt == "function_call" else (p.get("input") or p.get("action") or "")
163
+ tool, brief, files = _codex_tool(name, arguments)
164
+ _add_tool(out, tool, brief, rules, max_tools)
165
+ written.update(files)
166
+ elif t == "event_msg":
167
+ pt = p.get("type")
168
+ if pt == "agent_message" and p.get("message"):
169
+ out["turns"] += 1
170
+ out["last_assistant_text"] = redact(str(p["message"]), rules)[:600]
171
+
172
+
173
+ def _gemini_event(
174
+ ev: dict[str, Any], out: dict[str, Any], written: set[str], rules: list[tuple[str, str]], max_tools: int
175
+ ) -> None:
176
+ if ev.get("type") != "gemini":
177
+ return
178
+ out["turns"] += 1
179
+ text = _text_of(ev.get("content"))
180
+ if text.strip():
181
+ out["last_assistant_text"] = redact(text, rules)[:600]
182
+ for tc in ev.get("toolCalls") or []:
183
+ if not isinstance(tc, dict):
184
+ continue
185
+ tool, brief, files = _gemini_tool(str(tc.get("name") or ""), tc.get("args"))
186
+ _add_tool(out, tool, brief, rules, max_tools)
187
+ written.update(files)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.7.0
3
+ Version: 0.7.1
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
@@ -34,4 +34,5 @@ tests/test_providers.py
34
34
  tests/test_recall.py
35
35
  tests/test_redact.py
36
36
  tests/test_report.py
37
- tests/test_snapshots.py
37
+ tests/test_snapshots.py
38
+ tests/test_transcript_formats.py
@@ -0,0 +1,128 @@
1
+ import json
2
+
3
+ from gitvow.transcript import summarize
4
+
5
+ CODEX = [
6
+ {
7
+ "timestamp": "t",
8
+ "type": "session_meta",
9
+ "payload": {"id": "abc", "cwd": "/r", "cli_version": "0.130.0", "git": {"branch": "main"}},
10
+ },
11
+ {"timestamp": "t", "type": "turn_context", "payload": {"cwd": "/r", "model": "gpt-5"}},
12
+ {
13
+ "timestamp": "t",
14
+ "type": "response_item",
15
+ "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "fix add"}]},
16
+ },
17
+ {
18
+ "timestamp": "t",
19
+ "type": "response_item",
20
+ "payload": {
21
+ "type": "message",
22
+ "role": "assistant",
23
+ "content": [
24
+ {
25
+ "type": "output_text",
26
+ "text": "I will fix add() in calc.py; token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 stays out.",
27
+ }
28
+ ],
29
+ },
30
+ },
31
+ {
32
+ "timestamp": "t",
33
+ "type": "response_item",
34
+ "payload": {
35
+ "type": "function_call",
36
+ "name": "exec_command",
37
+ "arguments": json.dumps({"cmd": ["git", "status"]}),
38
+ "call_id": "c1",
39
+ },
40
+ },
41
+ {
42
+ "timestamp": "t",
43
+ "type": "response_item",
44
+ "payload": {"type": "function_call_output", "call_id": "c1", "output": "clean SECRET=hunter2 (never read)"},
45
+ },
46
+ {
47
+ "timestamp": "t",
48
+ "type": "response_item",
49
+ "payload": {
50
+ "type": "custom_tool_call",
51
+ "name": "apply_patch",
52
+ "input": "*** Begin Patch\n*** Update File: calc.py\n- return a - b\n+ return a + b\n*** End Patch\n",
53
+ "call_id": "c2",
54
+ },
55
+ },
56
+ {
57
+ "timestamp": "t",
58
+ "type": "event_msg",
59
+ "payload": {"type": "agent_message", "message": "Done: fixed add() in calc.py."},
60
+ },
61
+ {
62
+ "timestamp": "t",
63
+ "type": "event_msg",
64
+ "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 10}}},
65
+ },
66
+ ]
67
+
68
+ GEMINI = [
69
+ {"sessionId": "g1", "projectHash": "h", "startTime": "t", "lastUpdated": "t", "kind": "main"},
70
+ {"id": "m1", "timestamp": "t", "type": "user", "content": "fix add"},
71
+ {
72
+ "id": "m2",
73
+ "timestamp": "t",
74
+ "type": "gemini",
75
+ "content": [{"text": "Fixing add() in calc.py now."}],
76
+ "model": "gemini-2.5-pro",
77
+ "toolCalls": [
78
+ {"id": "t1", "name": "run_shell_command", "args": {"command": "git status"}, "status": "success"},
79
+ {
80
+ "id": "t2",
81
+ "name": "replace",
82
+ "args": {"file_path": "/r/calc.py", "old_string": "a - b", "new_string": "a + b"},
83
+ "status": "success",
84
+ },
85
+ {"id": "t3", "name": "write_file", "args": {"file_path": "/r/new.py", "content": "x"}, "status": "success"},
86
+ ],
87
+ },
88
+ {"$set": {"lastUpdated": "t2"}},
89
+ {"id": "m3", "timestamp": "t", "type": "gemini", "content": "All done in calc.py and new.py."},
90
+ ]
91
+
92
+
93
+ def _write(tmp_path, name, rows):
94
+ p = tmp_path / name
95
+ p.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
96
+ return str(p)
97
+
98
+
99
+ def test_codex_rollout_is_read(tmp_path):
100
+ s = summarize(_write(tmp_path, "rollout.jsonl", CODEX))
101
+ assert s["format"] == "codex" and s["turns"] == 2
102
+ assert [t["tool"] for t in s["tool_calls"]] == ["Bash", "Edit"]
103
+ assert s["tool_calls"][0]["arg"] == "git status" and s["tool_calls"][1]["arg"] == "calc.py"
104
+ assert s["files_written"] == ["calc.py"]
105
+ assert s["last_assistant_text"] == "Done: fixed add() in calc.py."
106
+ assert "hunter2" not in json.dumps(s) # tool output is never read
107
+
108
+
109
+ def test_gemini_chat_recording_is_read(tmp_path):
110
+ s = summarize(_write(tmp_path, "session-1.jsonl", GEMINI))
111
+ assert s["format"] == "gemini" and s["turns"] == 2
112
+ assert [t["tool"] for t in s["tool_calls"]] == ["Bash", "Edit", "Write"]
113
+ assert sorted(s["files_written"]) == ["/r/calc.py", "/r/new.py"]
114
+ assert s["last_assistant_text"] == "All done in calc.py and new.py."
115
+
116
+
117
+ def test_claude_format_still_default(transcript):
118
+ s = summarize(transcript)
119
+ assert (
120
+ s["format"] == "claude"
121
+ and s["tool_calls"][0]["tool"] == "Bash"
122
+ and "[github-token]" in s["last_assistant_text"]
123
+ )
124
+
125
+
126
+ def test_redaction_applies_to_all_formats(tmp_path):
127
+ s = summarize(_write(tmp_path, "rollout.jsonl", CODEX[:4]))
128
+ assert "[github-token]" in s["last_assistant_text"] and "ghp_" not in s["last_assistant_text"]
@@ -1,49 +0,0 @@
1
- """Structural, redacted summary of an agent transcript (Claude Code JSONL). Tool output is never read."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- from collections.abc import Iterable
8
- from typing import Any
9
-
10
- from .redact import redact
11
-
12
-
13
- def summarize(path: str | None, max_tools: int = 500, rules: Iterable[tuple[str, str]] = ()) -> dict[str, Any]:
14
- out: dict[str, Any] = {"turns": 0, "tool_calls": [], "last_assistant_text": "", "files_written": []}
15
- if not path or not os.path.exists(path):
16
- return out
17
- written: set[str] = set()
18
- rules = list(rules)
19
- with open(path, errors="ignore") as fh:
20
- for line in fh:
21
- try:
22
- ev = json.loads(line)
23
- except json.JSONDecodeError:
24
- continue
25
- msg = ev.get("message") or {}
26
- role = msg.get("role") or ev.get("type")
27
- content = msg.get("content")
28
- if role != "assistant":
29
- continue
30
- out["turns"] += 1
31
- if isinstance(content, str):
32
- if content.strip():
33
- out["last_assistant_text"] = redact(content, rules)[:600]
34
- continue
35
- if not isinstance(content, list):
36
- continue
37
- for c in content:
38
- if not isinstance(c, dict):
39
- continue
40
- if c.get("type") == "tool_use" and len(out["tool_calls"]) < max_tools:
41
- inp = c.get("input") or {}
42
- brief = inp.get("command") or inp.get("file_path") or inp.get("query") or inp.get("pattern") or ""
43
- out["tool_calls"].append({"tool": c.get("name"), "arg": redact(str(brief), rules)[:160]})
44
- if c.get("name") in ("Edit", "Write", "MultiEdit", "NotebookEdit") and inp.get("file_path"):
45
- written.add(str(inp["file_path"]))
46
- elif c.get("type") == "text" and str(c.get("text", "")).strip():
47
- out["last_assistant_text"] = redact(c["text"], rules)[:600]
48
- out["files_written"] = sorted(written)
49
- return 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