gitvow 0.1.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.1.0/LICENSE +17 -0
- gitvow-0.1.0/PKG-INFO +124 -0
- gitvow-0.1.0/README.md +96 -0
- gitvow-0.1.0/pyproject.toml +61 -0
- gitvow-0.1.0/setup.cfg +4 -0
- gitvow-0.1.0/src/gitvow/__init__.py +3 -0
- gitvow-0.1.0/src/gitvow/cli.py +135 -0
- gitvow-0.1.0/src/gitvow/collect.py +103 -0
- gitvow-0.1.0/src/gitvow/hooks/__init__.py +127 -0
- gitvow-0.1.0/src/gitvow/install.py +158 -0
- gitvow-0.1.0/src/gitvow/policy.py +120 -0
- gitvow-0.1.0/src/gitvow/redact.py +71 -0
- gitvow-0.1.0/src/gitvow/selftest.py +95 -0
- gitvow-0.1.0/src/gitvow/state.py +52 -0
- gitvow-0.1.0/src/gitvow/transcript.py +47 -0
- gitvow-0.1.0/src/gitvow.egg-info/PKG-INFO +124 -0
- gitvow-0.1.0/src/gitvow.egg-info/SOURCES.txt +24 -0
- gitvow-0.1.0/src/gitvow.egg-info/dependency_links.txt +1 -0
- gitvow-0.1.0/src/gitvow.egg-info/entry_points.txt +2 -0
- gitvow-0.1.0/src/gitvow.egg-info/requires.txt +8 -0
- gitvow-0.1.0/src/gitvow.egg-info/top_level.txt +1 -0
- gitvow-0.1.0/tests/test_collect.py +26 -0
- gitvow-0.1.0/tests/test_hooks.py +81 -0
- gitvow-0.1.0/tests/test_install_cli.py +72 -0
- gitvow-0.1.0/tests/test_policy.py +102 -0
- gitvow-0.1.0/tests/test_redact.py +46 -0
gitvow-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 gitvow contributors
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
gitvow-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitvow
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Author-email: Nikhil Bora <nikhil@wirevow.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://wirevow.dev/gitvow
|
|
8
|
+
Project-URL: Documentation, https://wirevow.dev/gitvow
|
|
9
|
+
Project-URL: Repository, https://github.com/wirevow/gitvow
|
|
10
|
+
Project-URL: Security, https://github.com/wirevow/gitvow/blob/main/SECURITY.md
|
|
11
|
+
Keywords: git,provenance,ai-agents,claude-code,hooks,policy
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
24
|
+
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
25
|
+
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# gitvow
|
|
30
|
+
|
|
31
|
+
Provenance and a policy gate for AI-agent coding sessions, stored in the git you already have.
|
|
32
|
+
|
|
33
|
+
[](https://github.com/wirevow/gitvow/actions/workflows/ci.yml)
|
|
34
|
+
[](https://github.com/wirevow/gitvow/actions/workflows/codeql.yml)
|
|
35
|
+
  
|
|
36
|
+
|
|
37
|
+
**Who made this change, what were they trying to do, and was it allowed?** For code written with AI agents, git alone cannot answer. gitvow makes it answer.
|
|
38
|
+
|
|
39
|
+
- Commits made during an agent session carry the session id and a step number as **trailers**.
|
|
40
|
+
- Each such commit gets a **session note**: the agent's stated plan, the tools it used, the files it touched, how much of the commit it wrote. Redacted, stored as a git note, never in the tree.
|
|
41
|
+
- Every tool call passes a **gate** first: destructive commands are refused, risky ones require asking you, edits to gate-bearing files need a human. The rules are a JSON file you own. Missing policy fails closed.
|
|
42
|
+
- A **ledger** of the whole session stays in your home directory. Nothing leaves the machine unless you push it.
|
|
43
|
+
|
|
44
|
+
Standard-library Python and git. No runtime dependencies, no network calls, no telemetry.
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pip install gitvow
|
|
50
|
+
gitvow install --user
|
|
51
|
+
gitvow selftest
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Work in Claude Code as usual. When the agent commits:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
$ git log -1 --format=%B
|
|
58
|
+
Fix week-start cache key
|
|
59
|
+
|
|
60
|
+
Gitvow-Session: 8f3d5c71-574a-4eec-8903-9425e3a8335b
|
|
61
|
+
Gitvow-Step: 4
|
|
62
|
+
|
|
63
|
+
$ gitvow show HEAD
|
|
64
|
+
...
|
|
65
|
+
gitvow-session
|
|
66
|
+
{
|
|
67
|
+
"step": 4,
|
|
68
|
+
"tools_used": ["Bash", "Edit", "Read"],
|
|
69
|
+
"last_stated_plan": "Change the cache key to include week start so per-org settings do not collide...",
|
|
70
|
+
"files_in_commit": ["query-engine/.../QueryCacheHelper.java | 4 +++-"],
|
|
71
|
+
"files_written_by_agent_this_session": ["query-engine/.../QueryCacheHelper.java"],
|
|
72
|
+
"attribution": {"files_in_commit": 1, "touched_by_agent": 1}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Try the gate by hand:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
gitvow check -- git push --force # DENY: force push
|
|
80
|
+
gitvow check -- kubectl apply -f x.yaml # CONFIRM: cluster apply
|
|
81
|
+
gitvow check --path core/authz_rules.go # CONFIRM: edits an authorization or gate file
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Remove everything:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
gitvow uninstall --user
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Documentation
|
|
91
|
+
|
|
92
|
+
The docs site is the source of truth: **https://wirevow.dev/gitvow** (built from `docs/`).
|
|
93
|
+
|
|
94
|
+
- [Quick start](docs/quickstart.md)
|
|
95
|
+
- Concepts: [Sessions, steps and notes](docs/concepts/sessions.md) · [The gate](docs/concepts/gate.md) · [What stays out of git](docs/concepts/storage.md)
|
|
96
|
+
- Guides: [Install per user or per repo](docs/guides/install.md) · [Write a policy](docs/guides/policy.md) · [Read a commit's session](docs/guides/reading.md) · [Run a trial](docs/guides/trial.md) · [Redaction](docs/guides/redaction.md)
|
|
97
|
+
- Reference: [CLI](docs/reference/cli.md) · [Hook payloads](docs/reference/hooks.md) · [Note schema](docs/reference/note.md) · [Policy schema](docs/reference/policy.md)
|
|
98
|
+
- [Security](docs/security.md) · [Roadmap](docs/roadmap.md) · [FAQ](docs/faq.md)
|
|
99
|
+
|
|
100
|
+
## How it works
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
Claude Code ──hook──▶ gitvow hook PreToolUse ──▶ policy ──▶ allow / confirm / deny (exit 0 / 2 / 2)
|
|
104
|
+
──hook──▶ gitvow hook PostToolUse ─▶ on `git commit`: read transcript → redact → git notes add
|
|
105
|
+
git commit ──prepare-commit-msg──▶ Gitvow-Session / Gitvow-Step trailers (from .git/gitvow-session.json)
|
|
106
|
+
Claude Code ──hook──▶ gitvow hook Stop ─────────▶ ~/.gitvow/ledger/<session>.json
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| Data | Where | Enters git? |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| session id, step | commit trailers | yes |
|
|
112
|
+
| session note (structure, redacted plan, attribution) | `refs/notes/sessions` | as a note; local until pushed |
|
|
113
|
+
| ledger, hook log, session state | `~/.gitvow/`, `<repo>/.git/` | no |
|
|
114
|
+
| transcript | untouched | never |
|
|
115
|
+
|
|
116
|
+
## Status
|
|
117
|
+
|
|
118
|
+
0.1.0. Used in a small internal trial; the [roadmap](docs/roadmap.md) lists what comes next and what is deliberately not planned. Claude Code is the only agent supported today; the hook payload is [documented](docs/reference/hooks.md) so adapters are straightforward.
|
|
119
|
+
|
|
120
|
+
## Contributing and security
|
|
121
|
+
|
|
122
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). Reproductions of redaction gaps must use synthetic secrets.
|
|
123
|
+
|
|
124
|
+
Apache-2.0.
|
gitvow-0.1.0/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# gitvow
|
|
2
|
+
|
|
3
|
+
Provenance and a policy gate for AI-agent coding sessions, stored in the git you already have.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/wirevow/gitvow/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/wirevow/gitvow/actions/workflows/codeql.yml)
|
|
7
|
+
  
|
|
8
|
+
|
|
9
|
+
**Who made this change, what were they trying to do, and was it allowed?** For code written with AI agents, git alone cannot answer. gitvow makes it answer.
|
|
10
|
+
|
|
11
|
+
- Commits made during an agent session carry the session id and a step number as **trailers**.
|
|
12
|
+
- Each such commit gets a **session note**: the agent's stated plan, the tools it used, the files it touched, how much of the commit it wrote. Redacted, stored as a git note, never in the tree.
|
|
13
|
+
- Every tool call passes a **gate** first: destructive commands are refused, risky ones require asking you, edits to gate-bearing files need a human. The rules are a JSON file you own. Missing policy fails closed.
|
|
14
|
+
- A **ledger** of the whole session stays in your home directory. Nothing leaves the machine unless you push it.
|
|
15
|
+
|
|
16
|
+
Standard-library Python and git. No runtime dependencies, no network calls, no telemetry.
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pip install gitvow
|
|
22
|
+
gitvow install --user
|
|
23
|
+
gitvow selftest
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Work in Claude Code as usual. When the agent commits:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
$ git log -1 --format=%B
|
|
30
|
+
Fix week-start cache key
|
|
31
|
+
|
|
32
|
+
Gitvow-Session: 8f3d5c71-574a-4eec-8903-9425e3a8335b
|
|
33
|
+
Gitvow-Step: 4
|
|
34
|
+
|
|
35
|
+
$ gitvow show HEAD
|
|
36
|
+
...
|
|
37
|
+
gitvow-session
|
|
38
|
+
{
|
|
39
|
+
"step": 4,
|
|
40
|
+
"tools_used": ["Bash", "Edit", "Read"],
|
|
41
|
+
"last_stated_plan": "Change the cache key to include week start so per-org settings do not collide...",
|
|
42
|
+
"files_in_commit": ["query-engine/.../QueryCacheHelper.java | 4 +++-"],
|
|
43
|
+
"files_written_by_agent_this_session": ["query-engine/.../QueryCacheHelper.java"],
|
|
44
|
+
"attribution": {"files_in_commit": 1, "touched_by_agent": 1}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Try the gate by hand:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
gitvow check -- git push --force # DENY: force push
|
|
52
|
+
gitvow check -- kubectl apply -f x.yaml # CONFIRM: cluster apply
|
|
53
|
+
gitvow check --path core/authz_rules.go # CONFIRM: edits an authorization or gate file
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Remove everything:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
gitvow uninstall --user
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Documentation
|
|
63
|
+
|
|
64
|
+
The docs site is the source of truth: **https://wirevow.dev/gitvow** (built from `docs/`).
|
|
65
|
+
|
|
66
|
+
- [Quick start](docs/quickstart.md)
|
|
67
|
+
- Concepts: [Sessions, steps and notes](docs/concepts/sessions.md) · [The gate](docs/concepts/gate.md) · [What stays out of git](docs/concepts/storage.md)
|
|
68
|
+
- Guides: [Install per user or per repo](docs/guides/install.md) · [Write a policy](docs/guides/policy.md) · [Read a commit's session](docs/guides/reading.md) · [Run a trial](docs/guides/trial.md) · [Redaction](docs/guides/redaction.md)
|
|
69
|
+
- Reference: [CLI](docs/reference/cli.md) · [Hook payloads](docs/reference/hooks.md) · [Note schema](docs/reference/note.md) · [Policy schema](docs/reference/policy.md)
|
|
70
|
+
- [Security](docs/security.md) · [Roadmap](docs/roadmap.md) · [FAQ](docs/faq.md)
|
|
71
|
+
|
|
72
|
+
## How it works
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Claude Code ──hook──▶ gitvow hook PreToolUse ──▶ policy ──▶ allow / confirm / deny (exit 0 / 2 / 2)
|
|
76
|
+
──hook──▶ gitvow hook PostToolUse ─▶ on `git commit`: read transcript → redact → git notes add
|
|
77
|
+
git commit ──prepare-commit-msg──▶ Gitvow-Session / Gitvow-Step trailers (from .git/gitvow-session.json)
|
|
78
|
+
Claude Code ──hook──▶ gitvow hook Stop ─────────▶ ~/.gitvow/ledger/<session>.json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Data | Where | Enters git? |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| session id, step | commit trailers | yes |
|
|
84
|
+
| session note (structure, redacted plan, attribution) | `refs/notes/sessions` | as a note; local until pushed |
|
|
85
|
+
| ledger, hook log, session state | `~/.gitvow/`, `<repo>/.git/` | no |
|
|
86
|
+
| transcript | untouched | never |
|
|
87
|
+
|
|
88
|
+
## Status
|
|
89
|
+
|
|
90
|
+
0.1.0. Used in a small internal trial; the [roadmap](docs/roadmap.md) lists what comes next and what is deliberately not planned. Claude Code is the only agent supported today; the hook payload is [documented](docs/reference/hooks.md) so adapters are straightforward.
|
|
91
|
+
|
|
92
|
+
## Contributing and security
|
|
93
|
+
|
|
94
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). Reproductions of redaction gaps must use synthetic secrets.
|
|
95
|
+
|
|
96
|
+
Apache-2.0.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gitvow"
|
|
7
|
+
version = "0.1.0"
|
|
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
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "Apache-2.0"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{name = "Nikhil Bora", email = "nikhil@wirevow.com"}]
|
|
13
|
+
keywords = ["git", "provenance", "ai-agents", "claude-code", "hooks", "policy"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "bandit>=1.7", "pip-audit>=2.7", "mypy>=1.10"]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
gitvow = "gitvow.cli:main"
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://wirevow.dev/gitvow"
|
|
31
|
+
Documentation = "https://wirevow.dev/gitvow"
|
|
32
|
+
Repository = "https://github.com/wirevow/gitvow"
|
|
33
|
+
Security = "https://github.com/wirevow/gitvow/blob/main/SECURITY.md"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 120
|
|
40
|
+
target-version = "py39"
|
|
41
|
+
src = ["src", "tests"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "W", "I", "B", "UP", "S", "N", "C4", "SIM", "RUF"]
|
|
45
|
+
ignore = ["S101", "S603", "S607", "E501"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint.per-file-ignores]
|
|
48
|
+
"tests/*" = ["S105", "S106", "S108"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
addopts = "-q --cov=gitvow --cov-report=term-missing --cov-fail-under=85"
|
|
53
|
+
|
|
54
|
+
[tool.mypy]
|
|
55
|
+
python_version = "3.10"
|
|
56
|
+
strict = false
|
|
57
|
+
ignore_missing_imports = true
|
|
58
|
+
|
|
59
|
+
[tool.bandit]
|
|
60
|
+
exclude_dirs = ["tests"]
|
|
61
|
+
skips = ["B404", "B603", "B607"]
|
gitvow-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""gitvow command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .collect import collect, summarize_text
|
|
12
|
+
from .hooks import HANDLERS
|
|
13
|
+
from .install import install_repo, install_user, uninstall_repo, uninstall_user
|
|
14
|
+
from .policy import PolicyError, evaluate, load_policy
|
|
15
|
+
from .state import git
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_hook(a: argparse.Namespace) -> int:
|
|
19
|
+
try:
|
|
20
|
+
payload = json.load(sys.stdin)
|
|
21
|
+
except json.JSONDecodeError:
|
|
22
|
+
payload = {}
|
|
23
|
+
handler = HANDLERS.get(a.event)
|
|
24
|
+
if not handler:
|
|
25
|
+
print(f"unknown hook event {a.event}", file=sys.stderr)
|
|
26
|
+
return 1
|
|
27
|
+
code, msg = handler(payload)
|
|
28
|
+
if msg:
|
|
29
|
+
print(msg, file=sys.stderr)
|
|
30
|
+
return code
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd_install(a: argparse.Namespace) -> int:
|
|
34
|
+
done = install_user(os.path.expanduser("~")) if a.user else install_repo(os.path.abspath(a.repo))
|
|
35
|
+
print("\n".join(done))
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_uninstall(a: argparse.Namespace) -> int:
|
|
40
|
+
done = (
|
|
41
|
+
uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger)
|
|
42
|
+
if a.user
|
|
43
|
+
else uninstall_repo(os.path.abspath(a.repo), a.purge_notes)
|
|
44
|
+
)
|
|
45
|
+
print("\n".join(done))
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_check(a: argparse.Namespace) -> int:
|
|
50
|
+
"""Dry-run the policy against a command, path or MCP tool name."""
|
|
51
|
+
try:
|
|
52
|
+
pol = load_policy(os.getcwd())
|
|
53
|
+
except PolicyError as e:
|
|
54
|
+
print(f"policy error: {e}", file=sys.stderr)
|
|
55
|
+
return 2
|
|
56
|
+
if a.path:
|
|
57
|
+
d = evaluate(pol, "Edit", {"file_path": a.path})
|
|
58
|
+
elif a.mcp:
|
|
59
|
+
d = evaluate(pol, a.mcp, {})
|
|
60
|
+
else:
|
|
61
|
+
d = evaluate(pol, "Bash", {"command": " ".join(a.command)})
|
|
62
|
+
print(d.outcome.upper() + (f": {d.reason}" if d.reason else ""))
|
|
63
|
+
return 2 if d.blocks else 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_show(a: argparse.Namespace) -> int:
|
|
67
|
+
"""Print a commit's trailers and session note."""
|
|
68
|
+
rc, msg, _ = git(["log", "-1", "--format=%H%n%s%n%b", a.commit], os.getcwd())
|
|
69
|
+
if rc != 0:
|
|
70
|
+
print(f"no such commit: {a.commit}", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
print(msg)
|
|
73
|
+
rc, note, _ = git(["notes", "--ref=sessions", "show", a.commit], os.getcwd())
|
|
74
|
+
print(note if rc == 0 else "(no session note)")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_collect(a: argparse.Namespace) -> int:
|
|
79
|
+
w = collect(os.path.expanduser("~"), a.out)
|
|
80
|
+
print(summarize_text(w))
|
|
81
|
+
print(f"collected into {w} (redacted at write time; review before sending)")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_summarize(a: argparse.Namespace) -> int:
|
|
86
|
+
print(summarize_text(a.dir))
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_selftest(a: argparse.Namespace) -> int:
|
|
91
|
+
from .selftest import run
|
|
92
|
+
|
|
93
|
+
return run()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: list[str] | None = None) -> int:
|
|
97
|
+
p = argparse.ArgumentParser(prog="gitvow", description="Provenance and policy gate for agent coding sessions.")
|
|
98
|
+
p.add_argument("--version", action="version", version=f"gitvow {__version__}")
|
|
99
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
100
|
+
s = sub.add_parser("hook", help="run as a Claude Code hook (reads JSON on stdin)")
|
|
101
|
+
s.add_argument("event")
|
|
102
|
+
s.set_defaults(f=cmd_hook)
|
|
103
|
+
s = sub.add_parser("install", help="install per user (--user) or into a repo")
|
|
104
|
+
s.add_argument("repo", nargs="?", default=".")
|
|
105
|
+
s.add_argument("--user", action="store_true")
|
|
106
|
+
s.set_defaults(f=cmd_install)
|
|
107
|
+
s = sub.add_parser("uninstall", help="remove what install added")
|
|
108
|
+
s.add_argument("repo", nargs="?", default=".")
|
|
109
|
+
s.add_argument("--user", action="store_true")
|
|
110
|
+
s.add_argument("--purge-notes", action="store_true")
|
|
111
|
+
s.add_argument("--purge-policy", action="store_true")
|
|
112
|
+
s.add_argument("--purge-ledger", action="store_true")
|
|
113
|
+
s.set_defaults(f=cmd_uninstall)
|
|
114
|
+
s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
|
|
115
|
+
s.add_argument("command", nargs="*")
|
|
116
|
+
s.add_argument("--path")
|
|
117
|
+
s.add_argument("--mcp")
|
|
118
|
+
s.set_defaults(f=cmd_check)
|
|
119
|
+
s = sub.add_parser("show", help="print a commit's trailers and session note")
|
|
120
|
+
s.add_argument("commit", nargs="?", default="HEAD")
|
|
121
|
+
s.set_defaults(f=cmd_show)
|
|
122
|
+
s = sub.add_parser("collect", help="gather ledger, logs, trailers and notes into one directory")
|
|
123
|
+
s.add_argument("--out", default=os.path.expanduser("~/Desktop"))
|
|
124
|
+
s.set_defaults(f=cmd_collect)
|
|
125
|
+
s = sub.add_parser("summarize", help="metrics from a collected directory")
|
|
126
|
+
s.add_argument("dir")
|
|
127
|
+
s.set_defaults(f=cmd_summarize)
|
|
128
|
+
s = sub.add_parser("selftest", help="prove the hooks work here without touching a real repo")
|
|
129
|
+
s.set_defaults(f=cmd_selftest)
|
|
130
|
+
a = p.parse_args(argv)
|
|
131
|
+
return a.f(a)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
sys.exit(main())
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Gather what a trial produced on this machine into one redacted directory, and compute trial metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import glob
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .state import git
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def collect(home: str, out_dir: str) -> str:
|
|
17
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
18
|
+
w = os.path.join(out_dir, f"gitvow-{stamp}")
|
|
19
|
+
os.makedirs(os.path.join(w, "ledger"), exist_ok=True)
|
|
20
|
+
os.makedirs(os.path.join(w, "repos"), exist_ok=True)
|
|
21
|
+
led = os.path.join(home, ".gitvow", "ledger")
|
|
22
|
+
repos: set[str] = set()
|
|
23
|
+
for f in glob.glob(os.path.join(led, "*.json")):
|
|
24
|
+
shutil.copy(f, os.path.join(w, "ledger"))
|
|
25
|
+
try:
|
|
26
|
+
with open(f) as fh:
|
|
27
|
+
r = json.load(fh).get("repo")
|
|
28
|
+
if r:
|
|
29
|
+
repos.add(r)
|
|
30
|
+
except (OSError, json.JSONDecodeError):
|
|
31
|
+
pass
|
|
32
|
+
for r in sorted(repos):
|
|
33
|
+
if not os.path.isdir(r):
|
|
34
|
+
continue
|
|
35
|
+
d = os.path.join(w, "repos", os.path.basename(r.rstrip("/")))
|
|
36
|
+
os.makedirs(d, exist_ok=True)
|
|
37
|
+
rc, gd, _ = git(["rev-parse", "--git-dir"], r)
|
|
38
|
+
if rc != 0:
|
|
39
|
+
continue
|
|
40
|
+
gd = gd if os.path.isabs(gd) else os.path.join(r, gd)
|
|
41
|
+
lp = os.path.join(gd, "gitvow-hooks.log")
|
|
42
|
+
if os.path.exists(lp):
|
|
43
|
+
shutil.copy(lp, os.path.join(d, "gitvow-hooks.log"))
|
|
44
|
+
for name, args in (
|
|
45
|
+
("commits-with-trailers.txt", ["log", "--format=%H %ad %s", "--date=short", "--grep=Gitvow-Session:"]),
|
|
46
|
+
("notes.txt", ["log", "--show-notes=sessions", "--format=%H%n%N%n----", "--grep=Gitvow-Session:"]),
|
|
47
|
+
("remote.txt", ["remote", "get-url", "origin"]),
|
|
48
|
+
):
|
|
49
|
+
_, out, _ = git(args, r)
|
|
50
|
+
with open(os.path.join(d, name), "w") as fh:
|
|
51
|
+
fh.write(out)
|
|
52
|
+
with open(os.path.join(w, "SUMMARY.txt"), "w") as fh:
|
|
53
|
+
fh.write(summarize_text(w))
|
|
54
|
+
return w
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def summarize(w: str) -> dict[str, Any]:
|
|
58
|
+
led = [json.load(open(f)) for f in glob.glob(os.path.join(w, "ledger", "*.json"))] # noqa: SIM115
|
|
59
|
+
tools: collections.Counter[str] = collections.Counter(t.get("tool") for s in led for t in s.get("tool_calls", []))
|
|
60
|
+
kinds: collections.Counter[str] = collections.Counter()
|
|
61
|
+
reasons: collections.Counter[tuple[str, str]] = collections.Counter()
|
|
62
|
+
trailered = notes = 0
|
|
63
|
+
for lp in glob.glob(os.path.join(w, "repos", "*", "gitvow-hooks.log")):
|
|
64
|
+
with open(lp) as fh:
|
|
65
|
+
for line in fh:
|
|
66
|
+
try:
|
|
67
|
+
e = json.loads(line)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
continue
|
|
70
|
+
kinds[e.get("kind", "?")] += 1
|
|
71
|
+
if e.get("kind") in ("blocked", "confirm_required"):
|
|
72
|
+
reasons[(e.get("kind", "?"), e.get("reason", "?"))] += 1
|
|
73
|
+
for f in glob.glob(os.path.join(w, "repos", "*", "commits-with-trailers.txt")):
|
|
74
|
+
with open(f) as fh:
|
|
75
|
+
trailered += sum(1 for ln in fh if ln.strip())
|
|
76
|
+
for f in glob.glob(os.path.join(w, "repos", "*", "notes.txt")):
|
|
77
|
+
with open(f) as fh:
|
|
78
|
+
notes += fh.read().count("gitvow-session")
|
|
79
|
+
return {
|
|
80
|
+
"sessions": len(led),
|
|
81
|
+
"repos": len({s.get("repo") for s in led}),
|
|
82
|
+
"tool_calls": sum(tools.values()),
|
|
83
|
+
"by_tool": dict(tools.most_common(8)),
|
|
84
|
+
"commits_during_sessions": sum(len(s.get("commits_during_session", [])) for s in led),
|
|
85
|
+
"hook_decisions": dict(kinds),
|
|
86
|
+
"commits_with_trailers": trailered,
|
|
87
|
+
"notes_attached": notes,
|
|
88
|
+
"gate_fired_on": [{"kind": k, "reason": r, "n": n} for (k, r), n in reasons.most_common(20)],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def summarize_text(w: str) -> str:
|
|
93
|
+
s = summarize(w)
|
|
94
|
+
lines = [
|
|
95
|
+
f"sessions: {s['sessions']} repos touched: {s['repos']}",
|
|
96
|
+
f"tool calls: {s['tool_calls']} | by tool: {s['by_tool']}",
|
|
97
|
+
f"commits during sessions: {s['commits_during_sessions']}",
|
|
98
|
+
f"hook decisions: {s['hook_decisions']}",
|
|
99
|
+
f"commits with trailers: {s['commits_with_trailers']} | notes attached: {s['notes_attached']}",
|
|
100
|
+
"gate fired on:",
|
|
101
|
+
]
|
|
102
|
+
lines += [f" {g['kind']:<17} {g['n']:>4} {g['reason']}" for g in s["gate_fired_on"]] or [" (never)"]
|
|
103
|
+
return "\n".join(lines) + "\n"
|