gitvow 0.7.0__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.
- {gitvow-0.7.0/src/gitvow.egg-info → gitvow-0.8.0}/PKG-INFO +1 -1
- {gitvow-0.7.0 → gitvow-0.8.0}/pyproject.toml +1 -1
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/adapters.py +82 -4
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/cli.py +4 -4
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/install.py +24 -2
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/selftest.py +44 -4
- gitvow-0.8.0/src/gitvow/transcript.py +187 -0
- {gitvow-0.7.0 → gitvow-0.8.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow.egg-info/SOURCES.txt +2 -1
- {gitvow-0.7.0 → gitvow-0.8.0}/tests/test_adapters.py +125 -0
- gitvow-0.8.0/tests/test_transcript_formats.py +128 -0
- gitvow-0.7.0/src/gitvow/transcript.py +0 -49
- {gitvow-0.7.0 → gitvow-0.8.0}/LICENSE +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/README.md +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/setup.cfg +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/hooks/__init__.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/policy.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/providers.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/recall.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/report.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow/state.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/tests/test_collect.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/tests/test_hooks.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/tests/test_packaging.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/tests/test_policy.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/tests/test_providers.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/tests/test_recall.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/tests/test_redact.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/tests/test_report.py +0 -0
- {gitvow-0.7.0 → gitvow-0.8.0}/tests/test_snapshots.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
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
|
+
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
|
-
|
|
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
|
|
180
|
+
if agent in ("cursor", "copilot"):
|
|
159
181
|
cur.setdefault("version", 1)
|
|
160
182
|
hooks = cur.setdefault("hooks", {})
|
|
161
183
|
for ev, entries in _agent_entries(agent, cmd_prefix).items():
|
|
@@ -71,6 +71,43 @@ def run_agent(agent: str) -> int:
|
|
|
71
71
|
},
|
|
72
72
|
),
|
|
73
73
|
],
|
|
74
|
+
"copilot": [
|
|
75
|
+
("sessionStart", {"sessionId": "st", "cwd": repo}),
|
|
76
|
+
(
|
|
77
|
+
"preToolUse",
|
|
78
|
+
{"sessionId": "st", "cwd": repo, "toolName": "bash", "toolArgs": {"command": "git push --force"}},
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"postToolUse",
|
|
82
|
+
{
|
|
83
|
+
"sessionId": "st",
|
|
84
|
+
"cwd": repo,
|
|
85
|
+
"toolName": "edit",
|
|
86
|
+
"toolArgs": {"path": os.path.join(repo, "a.txt"), "old_str": "a", "new_str": "b"},
|
|
87
|
+
},
|
|
88
|
+
),
|
|
89
|
+
],
|
|
90
|
+
"factory": [
|
|
91
|
+
("SessionStart", {"session_id": "st", "cwd": repo}),
|
|
92
|
+
(
|
|
93
|
+
"PreToolUse",
|
|
94
|
+
{
|
|
95
|
+
"session_id": "st",
|
|
96
|
+
"cwd": repo,
|
|
97
|
+
"tool_name": "Execute",
|
|
98
|
+
"tool_input": {"command": "git push --force"},
|
|
99
|
+
},
|
|
100
|
+
),
|
|
101
|
+
(
|
|
102
|
+
"PostToolUse",
|
|
103
|
+
{
|
|
104
|
+
"session_id": "st",
|
|
105
|
+
"cwd": repo,
|
|
106
|
+
"tool_name": "Edit",
|
|
107
|
+
"tool_input": {"file_path": os.path.join(repo, "a.txt"), "old_string": "a", "new_string": "b"},
|
|
108
|
+
},
|
|
109
|
+
),
|
|
110
|
+
],
|
|
74
111
|
"cursor": [
|
|
75
112
|
("sessionStart", {"conversation_id": "st", "workspace_roots": [repo]}),
|
|
76
113
|
(
|
|
@@ -96,10 +133,13 @@ def run_agent(agent: str) -> int:
|
|
|
96
133
|
if msg:
|
|
97
134
|
msgs.append(msg)
|
|
98
135
|
exit_code, out, _err = respond(agent, worst, "\n".join(msgs))
|
|
99
|
-
if ev in ("PreToolUse", "BeforeTool", "beforeShellExecution"):
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
136
|
+
if ev in ("PreToolUse", "BeforeTool", "beforeShellExecution", "preToolUse"):
|
|
137
|
+
if agent == "cursor":
|
|
138
|
+
blocked = _json.loads(out).get("permission") == "deny"
|
|
139
|
+
elif agent == "copilot":
|
|
140
|
+
blocked = _json.loads(out).get("permissionDecision") == "deny"
|
|
141
|
+
else:
|
|
142
|
+
blocked = exit_code == 2
|
|
103
143
|
results.append((blocked, f"{agent}: force push denied in the agent's own form"))
|
|
104
144
|
refs = subprocess.run(
|
|
105
145
|
["git", "for-each-ref", "refs/gitvow/snapshots/"], cwd=repo, capture_output=True, text=True
|
|
@@ -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.
|
|
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()
|
|
@@ -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
|
|
File without changes
|