gitvow 0.12.0__tar.gz → 0.12.2__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.12.0/src/gitvow.egg-info → gitvow-0.12.2}/PKG-INFO +1 -1
- {gitvow-0.12.0 → gitvow-0.12.2}/pyproject.toml +1 -1
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/cli.py +96 -1
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/decisions.py +174 -9
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/default_policy.json +3 -1
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/digest.py +114 -3
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/hooks/__init__.py +17 -3
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/policy.py +4 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/report.py +7 -0
- gitvow-0.12.2/src/gitvow/rules.py +170 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/snapshots.py +1 -0
- {gitvow-0.12.0 → gitvow-0.12.2/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow.egg-info/SOURCES.txt +2 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_decisions.py +48 -1
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_digest.py +42 -0
- gitvow-0.12.2/tests/test_rules.py +131 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/LICENSE +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/README.md +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/setup.cfg +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/__init__.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/__main__.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/adapters.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/collect.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/install.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/pricing.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/providers.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/recall.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/redact.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/selftest.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/state.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/transcript.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_adapters.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_collect.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_cost.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_external_adapter.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_hooks.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_install_cli.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_packaging.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_policy.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_providers.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_recall.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_redact.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_report.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_snapshots.py +0 -0
- {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_transcript_formats.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.2
|
|
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.12.
|
|
7
|
+
version = "0.12.2"
|
|
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"}
|
|
@@ -57,6 +57,9 @@ def cmd_hook(a: argparse.Namespace) -> int:
|
|
|
57
57
|
worst = max(worst, code)
|
|
58
58
|
if msg:
|
|
59
59
|
messages.append(msg)
|
|
60
|
+
if a.event == "SessionStart" and worst == 0 and messages and not exe and agent not in ("cursor", "copilot"):
|
|
61
|
+
print("\n".join(messages)) # SessionStart stdout becomes context for the agent
|
|
62
|
+
return 0
|
|
60
63
|
try:
|
|
61
64
|
exit_code, out, err = (
|
|
62
65
|
external_respond(exe, worst, "\n".join(messages)) if exe else respond(agent, worst, "\n".join(messages))
|
|
@@ -132,7 +135,79 @@ def cmd_decisions(a: argparse.Namespace) -> int:
|
|
|
132
135
|
if a.json:
|
|
133
136
|
print(json.dumps(dec.open_findings(cwd), indent=1))
|
|
134
137
|
return 0
|
|
135
|
-
|
|
138
|
+
try:
|
|
139
|
+
pol = load_policy(cwd)
|
|
140
|
+
except PolicyError:
|
|
141
|
+
pol = None
|
|
142
|
+
print(dec.card(cwd, for_agent=False, pol=pol), end="")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_rules(a: argparse.Namespace) -> int:
|
|
147
|
+
"""Earned rules from the decision history; --write puts them into an agent instruction file."""
|
|
148
|
+
from .rules import INSTRUCTION_FILES, derive, render, write_section
|
|
149
|
+
|
|
150
|
+
cwd = os.getcwd()
|
|
151
|
+
try:
|
|
152
|
+
pol = load_policy(cwd)
|
|
153
|
+
except PolicyError as e:
|
|
154
|
+
print(f"policy error: {e}", file=sys.stderr)
|
|
155
|
+
return 2
|
|
156
|
+
d = derive(cwd, pol)
|
|
157
|
+
if a.json:
|
|
158
|
+
print(json.dumps(d, indent=1))
|
|
159
|
+
return 0
|
|
160
|
+
if a.write:
|
|
161
|
+
from .state import toplevel
|
|
162
|
+
|
|
163
|
+
top = toplevel(cwd) or cwd
|
|
164
|
+
target = a.file or INSTRUCTION_FILES.get(a.agent, "CLAUDE.md")
|
|
165
|
+
print(write_section(os.path.join(top, target), render(d)))
|
|
166
|
+
return 0
|
|
167
|
+
text = render(d, for_agent=False)
|
|
168
|
+
if not text:
|
|
169
|
+
print(
|
|
170
|
+
f"no earned rules yet: a finding becomes one after {d['threshold']} consistent answers by authorities "
|
|
171
|
+
f"(decisions.rule_threshold), confirmed within {d['decay_days']} days."
|
|
172
|
+
)
|
|
173
|
+
if d["candidates"]:
|
|
174
|
+
for r in d["candidates"]:
|
|
175
|
+
print(f" {r['finding']}: {r['answer']} {r['count']} times (last {r['last']} by {', '.join(r['by'])})")
|
|
176
|
+
return 0
|
|
177
|
+
print(text, end="")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cmd_revisit(a: argparse.Namespace) -> int:
|
|
182
|
+
"""Answer a decision already on the branch again; the record keeps both."""
|
|
183
|
+
cwd = os.getcwd()
|
|
184
|
+
try:
|
|
185
|
+
pol = load_policy(cwd)
|
|
186
|
+
rules = load_rules(cwd, os.path.expanduser("~"))
|
|
187
|
+
except (PolicyError, RedactionError) as e:
|
|
188
|
+
print(str(e), file=sys.stderr)
|
|
189
|
+
return 2
|
|
190
|
+
if not a.answer:
|
|
191
|
+
try:
|
|
192
|
+
ds = dec.decisions_of(cwd, a.commit)
|
|
193
|
+
except ValueError as e:
|
|
194
|
+
print(str(e), file=sys.stderr)
|
|
195
|
+
return 1
|
|
196
|
+
if not ds:
|
|
197
|
+
print(f"{a.commit}: no decision trailers")
|
|
198
|
+
return 0
|
|
199
|
+
for d in ds:
|
|
200
|
+
extra = (f" by {d['by']}" if d.get("by") else "") + (f" scope={d['scope']}" if d.get("scope") else "")
|
|
201
|
+
print(f"{d['n']}. {d['answer']}: {d['finding']}{extra}")
|
|
202
|
+
print("revisit with: gitvow revisit <commit> accept|decline [--finding n]", file=sys.stderr)
|
|
203
|
+
return 0
|
|
204
|
+
try:
|
|
205
|
+
head, line = dec.revisit(cwd, a.commit, a.answer, pol, a.finding, a.scope, a.reason, a.by, rules)
|
|
206
|
+
except ValueError as e:
|
|
207
|
+
print(str(e), file=sys.stderr)
|
|
208
|
+
return 1
|
|
209
|
+
print(line)
|
|
210
|
+
print(f"recorded as an empty commit {head[:7]}; the earlier trailer stays on {a.commit}", file=sys.stderr)
|
|
136
211
|
return 0
|
|
137
212
|
|
|
138
213
|
|
|
@@ -411,6 +486,26 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
411
486
|
s = sub.add_parser("decisions", help="the card: open findings in this repository with evidence and the record")
|
|
412
487
|
s.add_argument("--json", action="store_true")
|
|
413
488
|
s.set_defaults(f=cmd_decisions)
|
|
489
|
+
s = sub.add_parser(
|
|
490
|
+
"rules", help="earned rules from the decision history; --write updates the agent instruction file"
|
|
491
|
+
)
|
|
492
|
+
s.add_argument("--json", action="store_true")
|
|
493
|
+
s.add_argument("--write", action="store_true", help="write the managed section into the instruction file")
|
|
494
|
+
s.add_argument(
|
|
495
|
+
"--agent",
|
|
496
|
+
default="claude",
|
|
497
|
+
help="which agent's instruction file (claude, codex, gemini, cursor, copilot, factory)",
|
|
498
|
+
)
|
|
499
|
+
s.add_argument("--file", help="instruction file path, overriding --agent")
|
|
500
|
+
s.set_defaults(f=cmd_rules)
|
|
501
|
+
s = sub.add_parser("revisit", help="answer a decision already on the branch again: gitvow revisit <commit> accept")
|
|
502
|
+
s.add_argument("commit")
|
|
503
|
+
s.add_argument("answer", nargs="?", choices=["accept", "decline"])
|
|
504
|
+
s.add_argument("--finding", help="which decision on the commit, when it carries several")
|
|
505
|
+
s.add_argument("--scope")
|
|
506
|
+
s.add_argument("--reason")
|
|
507
|
+
s.add_argument("--by")
|
|
508
|
+
s.set_defaults(f=cmd_revisit)
|
|
414
509
|
s = sub.add_parser("decide", help="record a person's answer: gitvow decide 1 accept --scope staging")
|
|
415
510
|
s.add_argument("finding", help="finding number from `gitvow decisions`, or 'all'")
|
|
416
511
|
s.add_argument("answer", choices=["accept", "decline"])
|
|
@@ -14,6 +14,7 @@ from .redact import redact
|
|
|
14
14
|
from .state import git, load_state, save_state
|
|
15
15
|
|
|
16
16
|
TRAILER_RE = re.compile(r"^Gitvow-(Accepted|Declined|Open):\s*(.+?)(?: by (\S+))?(?: scope=(\S+))?(?:: (.*))?$", re.M)
|
|
17
|
+
REVISITS_RE = re.compile(r"^Gitvow-Revisits:\s*([0-9a-f]{7,40})\b", re.M)
|
|
17
18
|
MAX_EVIDENCE = 8
|
|
18
19
|
HISTORY_COMMITS = 3000
|
|
19
20
|
|
|
@@ -78,11 +79,13 @@ def identity(cwd: str, by: str | None = None) -> tuple[str, str, str]:
|
|
|
78
79
|
|
|
79
80
|
def authority(pol: dict[str, Any], ident: str, email: str, name: str) -> str:
|
|
80
81
|
"""'policy' when named under decisions.authorities, 'commit-access' when no list is configured, else 'none'."""
|
|
81
|
-
|
|
82
|
-
if not
|
|
82
|
+
listed = [str(a).lower() for a in (pol.get("decisions") or {}).get("authorities") or []]
|
|
83
|
+
if not listed:
|
|
83
84
|
return "commit-access"
|
|
85
|
+
auth = set(listed) | {a.split("@", 1)[0] for a in listed if "@" in a} # an email also names its local part
|
|
84
86
|
mine = {ident.lower(), email.lower(), (email.split("@", 1)[0] if email else "").lower(), name.lower()}
|
|
85
|
-
|
|
87
|
+
mine.discard("")
|
|
88
|
+
return "policy" if mine & auth else "none"
|
|
86
89
|
|
|
87
90
|
|
|
88
91
|
def parse_trailers(body: str) -> list[dict[str, Any]]:
|
|
@@ -100,8 +103,34 @@ def parse_trailers(body: str) -> list[dict[str, Any]]:
|
|
|
100
103
|
return out
|
|
101
104
|
|
|
102
105
|
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
SESSION_RE = re.compile(r"^Gitvow-Session:\s*(\S+)", re.M)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _note_decisions(cwd: str, sha: str, body: str) -> dict[str, dict[str, Any]]:
|
|
110
|
+
"""finding -> note entry for a commit, when its session note is available locally."""
|
|
111
|
+
import json
|
|
112
|
+
|
|
113
|
+
m = SESSION_RE.search(body)
|
|
114
|
+
if not m:
|
|
115
|
+
return {}
|
|
116
|
+
sid = re.sub(r"[^A-Za-z0-9._-]", "_", m.group(1))
|
|
117
|
+
rc, note, _ = git(["notes", f"--ref=gitvow/{sid}", "show", sha], cwd)
|
|
118
|
+
if rc != 0 or not note.startswith("gitvow-session"):
|
|
119
|
+
return {}
|
|
120
|
+
try:
|
|
121
|
+
data = json.loads(note.split("\n", 1)[1])
|
|
122
|
+
except (json.JSONDecodeError, IndexError):
|
|
123
|
+
return {}
|
|
124
|
+
return {d.get("finding"): d for d in data.get("decisions") or [] if isinstance(d, dict)}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def history_all(
|
|
128
|
+
cwd: str, limit: int = HISTORY_COMMITS, finding: str | None = None, pol: dict[str, Any] | None = None
|
|
129
|
+
) -> list[dict[str, Any]]:
|
|
130
|
+
"""Every decision (not open) in this branch's history, newest first, with authority from the note.
|
|
131
|
+
|
|
132
|
+
Without a note, authority is 'commit-access' when the trailer names someone: the person had commit access.
|
|
133
|
+
"""
|
|
105
134
|
rc, out, _ = git(["log", f"-{limit}", "--format=%H%x00%ad%x00%B%x01", "--date=short"], cwd)
|
|
106
135
|
rows: list[dict[str, Any]] = []
|
|
107
136
|
if rc != 0:
|
|
@@ -111,12 +140,35 @@ def history(cwd: str, finding: str, limit: int = HISTORY_COMMITS) -> list[dict[s
|
|
|
111
140
|
if not rec.strip() or "Gitvow-" not in rec:
|
|
112
141
|
continue
|
|
113
142
|
sha, date, body = rec.split("\x00", 2)
|
|
114
|
-
for t in parse_trailers(body)
|
|
115
|
-
|
|
116
|
-
|
|
143
|
+
ts = [t for t in parse_trailers(body) if t["answer"] != "open" and (finding is None or t["finding"] == finding)]
|
|
144
|
+
if not ts:
|
|
145
|
+
continue
|
|
146
|
+
notes = _note_decisions(cwd, sha, body)
|
|
147
|
+
for t in ts:
|
|
148
|
+
n = notes.get(t["finding"]) or {}
|
|
149
|
+
if n.get("authority"):
|
|
150
|
+
auth = n["authority"]
|
|
151
|
+
elif not t.get("by"):
|
|
152
|
+
auth = "none"
|
|
153
|
+
else:
|
|
154
|
+
auth = authority(pol or {}, t["by"], "", "")
|
|
155
|
+
rows.append(
|
|
156
|
+
{
|
|
157
|
+
**t,
|
|
158
|
+
"sha": sha[:7],
|
|
159
|
+
"date": date,
|
|
160
|
+
"authority": auth,
|
|
161
|
+
"kind": n.get("kind"),
|
|
162
|
+
}
|
|
163
|
+
)
|
|
117
164
|
return rows
|
|
118
165
|
|
|
119
166
|
|
|
167
|
+
def history(cwd: str, finding: str, limit: int = HISTORY_COMMITS) -> list[dict[str, Any]]:
|
|
168
|
+
"""Earlier decisions on the same finding in this branch's history, newest first."""
|
|
169
|
+
return history_all(cwd, limit, finding)
|
|
170
|
+
|
|
171
|
+
|
|
120
172
|
def proposal(hist: list[dict[str, Any]]) -> tuple[str | None, str]:
|
|
121
173
|
"""(proposed answer or None, sentence about the record)."""
|
|
122
174
|
if not hist:
|
|
@@ -138,7 +190,26 @@ def proposal(hist: list[dict[str, Any]]) -> tuple[str | None, str]:
|
|
|
138
190
|
return None, sentence + " Mixed record; no proposal."
|
|
139
191
|
|
|
140
192
|
|
|
141
|
-
def
|
|
193
|
+
def mark_proposals(cwd: str, pol: dict[str, Any] | None = None) -> int:
|
|
194
|
+
"""Store what the record proposes on each undecided finding, so the note can say whether the answer matched."""
|
|
195
|
+
st = load_state(cwd)
|
|
196
|
+
n = 0
|
|
197
|
+
for f in st.get("findings") or []:
|
|
198
|
+
if f.get("decision") or "proposed" in f:
|
|
199
|
+
continue
|
|
200
|
+
p, _ = proposal(history(cwd, f["finding"]))
|
|
201
|
+
f["proposed"] = p
|
|
202
|
+
n += 1 if p else 0
|
|
203
|
+
save_state(cwd, st)
|
|
204
|
+
return n
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def card(
|
|
208
|
+
cwd: str,
|
|
209
|
+
findings: list[dict[str, Any]] | None = None,
|
|
210
|
+
for_agent: bool = True,
|
|
211
|
+
pol: dict[str, Any] | None = None,
|
|
212
|
+
) -> str:
|
|
142
213
|
"""The one surface a developer meets: every open finding with its evidence and what the record proposes."""
|
|
143
214
|
fs = findings if findings is not None else open_findings(cwd)
|
|
144
215
|
if not fs:
|
|
@@ -169,6 +240,14 @@ def card(cwd: str, findings: list[dict[str, Any]] | None = None, for_agent: bool
|
|
|
169
240
|
if not d:
|
|
170
241
|
_, sentence = proposal(history(cwd, f["finding"]))
|
|
171
242
|
lines.append(f" record: {sentence}")
|
|
243
|
+
if pol is not None:
|
|
244
|
+
from .rules import rule_for
|
|
245
|
+
|
|
246
|
+
r = rule_for(cwd, pol, f["finding"])
|
|
247
|
+
if r:
|
|
248
|
+
lines.append(
|
|
249
|
+
f" rule: {r['answer']} {r['count']} times by authorities since {r['first']}; decays {r['expires']}."
|
|
250
|
+
)
|
|
172
251
|
return "\n".join(lines) + "\n"
|
|
173
252
|
|
|
174
253
|
|
|
@@ -255,6 +334,7 @@ def note_entries(findings: list[dict[str, Any]], card_user_turns: int | None) ->
|
|
|
255
334
|
"note": d.get("note"),
|
|
256
335
|
"decided_at": d.get("decided_at"),
|
|
257
336
|
"human_turns_after_card": turns,
|
|
337
|
+
"proposed": f.get("proposed"),
|
|
258
338
|
}
|
|
259
339
|
)
|
|
260
340
|
return out
|
|
@@ -271,3 +351,88 @@ def take_committed(cwd: str, head: str) -> list[dict[str, Any]] | None:
|
|
|
271
351
|
st.pop("last_commit", None)
|
|
272
352
|
save_state(cwd, st)
|
|
273
353
|
return lc.get("findings") or []
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def decisions_of(cwd: str, sha: str) -> list[dict[str, Any]]:
|
|
357
|
+
"""Decision trailers on one commit, numbered from 1."""
|
|
358
|
+
rc, body, _ = git(["log", "-1", "--format=%B", sha], cwd)
|
|
359
|
+
if rc != 0:
|
|
360
|
+
raise ValueError(f"no such commit: {sha}")
|
|
361
|
+
return [{"n": i, **t} for i, t in enumerate(parse_trailers(body), 1)]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def revisit(
|
|
365
|
+
cwd: str,
|
|
366
|
+
sha: str,
|
|
367
|
+
answer: str,
|
|
368
|
+
pol: dict[str, Any],
|
|
369
|
+
which: str | None = None,
|
|
370
|
+
scope: str | None = None,
|
|
371
|
+
reason: str | None = None,
|
|
372
|
+
by: str | None = None,
|
|
373
|
+
rules: list[tuple[str, str]] | None = None,
|
|
374
|
+
) -> tuple[str, str]:
|
|
375
|
+
"""Answer a decision already on the branch again.
|
|
376
|
+
|
|
377
|
+
Writes an empty commit carrying the new answer and `Gitvow-Revisits: <sha>`; the earlier trailer stays
|
|
378
|
+
where it was, so the record keeps both.
|
|
379
|
+
"""
|
|
380
|
+
if answer not in ("accept", "decline"):
|
|
381
|
+
raise ValueError("answer must be accept or decline")
|
|
382
|
+
ds = decisions_of(cwd, sha)
|
|
383
|
+
if not ds:
|
|
384
|
+
raise ValueError(f"{sha[:7]} carries no decision trailers")
|
|
385
|
+
if which is None:
|
|
386
|
+
if len(ds) > 1:
|
|
387
|
+
listing = "\n".join(f" {d['n']}. {d['answer']}: {d['finding']}" for d in ds)
|
|
388
|
+
raise ValueError("several decisions on this commit; pass --finding <n>:\n" + listing)
|
|
389
|
+
target = ds[0]
|
|
390
|
+
else:
|
|
391
|
+
try:
|
|
392
|
+
target = ds[int(which) - 1]
|
|
393
|
+
except (ValueError, IndexError) as e:
|
|
394
|
+
raise ValueError(f"no decision {which} on {sha[:7]}") from e
|
|
395
|
+
ident, _email, _name = identity(cwd, by)
|
|
396
|
+
note = redact(reason, rules)[:200] if reason and rules is not None else (reason or None)
|
|
397
|
+
f = {
|
|
398
|
+
"finding": target["finding"],
|
|
399
|
+
"decision": {
|
|
400
|
+
"answer": "accepted" if answer == "accept" else "declined",
|
|
401
|
+
"by": ident,
|
|
402
|
+
"scope": (scope or "").strip() or None,
|
|
403
|
+
"note": note,
|
|
404
|
+
},
|
|
405
|
+
}
|
|
406
|
+
_, full, _ = git(["rev-parse", sha], cwd)
|
|
407
|
+
msg = (
|
|
408
|
+
f"gitvow: revisit decision on {full[:7]}\n\n"
|
|
409
|
+
f"Was: {target['answer']} ({target['finding']}). Now: {f['decision']['answer']} by {ident}.\n\n"
|
|
410
|
+
f"Gitvow-Revisits: {full}\n{trailer_line(f)}\n"
|
|
411
|
+
)
|
|
412
|
+
rc, _, err = git(["-c", "core.hooksPath=/dev/null", "commit", "-q", "--allow-empty", "-m", msg], cwd)
|
|
413
|
+
if rc != 0:
|
|
414
|
+
raise ValueError(err or "commit failed")
|
|
415
|
+
_, head, _ = git(["rev-parse", "HEAD"], cwd)
|
|
416
|
+
return head, trailer_line(f) + f" (revisits {full[:7]})"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def open_debt(cwd: str, limit: int = HISTORY_COMMITS) -> list[dict[str, Any]]:
|
|
420
|
+
"""Gitvow-Open findings on the branch that no later revisit has answered."""
|
|
421
|
+
rc, out, _ = git(["log", f"-{limit}", "--format=%H%x00%ad%x00%B%x01", "--date=short"], cwd)
|
|
422
|
+
if rc != 0:
|
|
423
|
+
return []
|
|
424
|
+
revisited: set[tuple[str, str]] = set()
|
|
425
|
+
opens: list[dict[str, Any]] = []
|
|
426
|
+
for rec in out.split("\x01"):
|
|
427
|
+
rec = rec.strip("\n")
|
|
428
|
+
if not rec.strip() or "Gitvow-" not in rec:
|
|
429
|
+
continue
|
|
430
|
+
sha, date, body = rec.split("\x00", 2)
|
|
431
|
+
m = REVISITS_RE.search(body)
|
|
432
|
+
if m:
|
|
433
|
+
for t in parse_trailers(body):
|
|
434
|
+
revisited.add((m.group(1)[:7], t["finding"]))
|
|
435
|
+
for t in parse_trailers(body):
|
|
436
|
+
if t["answer"] == "open":
|
|
437
|
+
opens.append({"sha": sha[:7], "date": date, "finding": t["finding"]})
|
|
438
|
+
return [o for o in opens if (o["sha"], o["finding"]) not in revisited]
|
|
@@ -79,7 +79,9 @@
|
|
|
79
79
|
"mode": "open",
|
|
80
80
|
"authorities": [],
|
|
81
81
|
"production_branches": ["main", "master", "production"],
|
|
82
|
-
"
|
|
82
|
+
"rule_threshold": 3,
|
|
83
|
+
"rule_decay_days": 90,
|
|
84
|
+
"_doc": "rule_threshold: consistent answers by authorities before a finding becomes an earned rule; rule_decay_days: days without a new confirmation before it lapses. mode: open (a person's commit records Gitvow-Open for undecided findings) or strict (pre-commit refuses until gitvow decide). authorities: emails, email local parts or names whose decisions count; empty means anyone with commit access."
|
|
83
85
|
},
|
|
84
86
|
"llm_classifier": {
|
|
85
87
|
"enabled": false,
|
|
@@ -9,6 +9,7 @@ import re
|
|
|
9
9
|
import time
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
+
from . import decisions as dec
|
|
12
13
|
from .recall import _commits, _plan
|
|
13
14
|
from .state import toplevel
|
|
14
15
|
|
|
@@ -77,6 +78,12 @@ def build(cwd: str, since: str = "7d") -> dict[str, Any]:
|
|
|
77
78
|
file_sessions[f["path"]].add(r["session"])
|
|
78
79
|
gate: collections.Counter[str] = collections.Counter()
|
|
79
80
|
reasons: collections.Counter[str] = collections.Counter()
|
|
81
|
+
now: collections.Counter[str] = collections.Counter() # this period's cards, findings, restores, sessions
|
|
82
|
+
before: collections.Counter[str] = collections.Counter() # the period of the same length before it
|
|
83
|
+
span_days = max((time.mktime(time.strptime(time.strftime("%Y-%m-%d"), "%Y-%m-%d")) - _day_ts(since_day)) / 86400, 1)
|
|
84
|
+
prev_day = time.strftime("%Y-%m-%d", time.localtime(_day_ts(since_day) - span_days * 86400))
|
|
85
|
+
sess_now: set[str] = set()
|
|
86
|
+
sess_before: set[str] = set()
|
|
80
87
|
log = os.path.join(top, ".git", "gitvow-hooks.log")
|
|
81
88
|
if os.path.exists(log):
|
|
82
89
|
with open(log) as fh:
|
|
@@ -85,11 +92,34 @@ def build(cwd: str, since: str = "7d") -> dict[str, Any]:
|
|
|
85
92
|
e = json.loads(ln)
|
|
86
93
|
except json.JSONDecodeError:
|
|
87
94
|
continue
|
|
88
|
-
|
|
95
|
+
day = (e.get("ts") or "")[:10]
|
|
96
|
+
if day < prev_day:
|
|
89
97
|
continue
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
bucket, sset = (now, sess_now) if day >= since_day else (before, sess_before)
|
|
99
|
+
kind = e.get("kind")
|
|
100
|
+
if kind == "session_start" and e.get("session_id"):
|
|
101
|
+
sset.add(e["session_id"])
|
|
102
|
+
elif kind == "card":
|
|
103
|
+
bucket["cards"] += 1
|
|
104
|
+
bucket["pre_answered"] += int(e.get("proposed") or 0)
|
|
105
|
+
elif kind == "finding":
|
|
106
|
+
bucket["findings"] += int(e.get("new") or 0)
|
|
107
|
+
elif kind == "restore":
|
|
108
|
+
bucket["restores"] += 1
|
|
109
|
+
if day < since_day:
|
|
110
|
+
continue
|
|
111
|
+
if kind in ("blocked", "confirm_required"):
|
|
112
|
+
gate[kind] += 1
|
|
92
113
|
reasons[e.get("reason") or "?"] += 1
|
|
114
|
+
decided = _decisions_in_period(top, since_day)
|
|
115
|
+
debt = dec.open_debt(top)
|
|
116
|
+
from .policy import PolicyError, load_policy
|
|
117
|
+
from .rules import derive
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
rules_in_force: int | None = len(derive(top, load_policy(top))["rules"])
|
|
121
|
+
except PolicyError:
|
|
122
|
+
rules_in_force = None # the digest renders without a valid policy
|
|
93
123
|
sess_list = sorted(sessions.values(), key=lambda s: s["date"], reverse=True)
|
|
94
124
|
total_cost = 0.0
|
|
95
125
|
in_tok = out_tok = 0
|
|
@@ -125,9 +155,63 @@ def build(cwd: str, since: str = "7d") -> dict[str, Any]:
|
|
|
125
155
|
},
|
|
126
156
|
"files": [{"path": p, "commits": n, "sessions": len(file_sessions[p])} for p, n in files.most_common(10)],
|
|
127
157
|
"human": [{"short": r["short"], "date": r["date"], "subject": r["subject"]} for r in human[:15]],
|
|
158
|
+
"decisions": {
|
|
159
|
+
**decided,
|
|
160
|
+
"debt": len(debt),
|
|
161
|
+
"debt_items": debt[:10],
|
|
162
|
+
"rules_in_force": rules_in_force,
|
|
163
|
+
},
|
|
164
|
+
"questions": {
|
|
165
|
+
"cards": now["cards"],
|
|
166
|
+
"sessions": len(sess_now) or len(sessions),
|
|
167
|
+
"per_session": round(now["cards"] / (len(sess_now) or len(sessions)), 2)
|
|
168
|
+
if (sess_now or sessions)
|
|
169
|
+
else None,
|
|
170
|
+
"per_session_before": round(before["cards"] / len(sess_before), 2) if sess_before else None,
|
|
171
|
+
"findings": now["findings"],
|
|
172
|
+
"immediate": gate["confirm_required"],
|
|
173
|
+
},
|
|
174
|
+
"payback": {
|
|
175
|
+
"snapshots_restored": now["restores"],
|
|
176
|
+
"pre_answered": now["pre_answered"],
|
|
177
|
+
"answers_matching_proposal": decided["matched_proposal"],
|
|
178
|
+
},
|
|
128
179
|
}
|
|
129
180
|
|
|
130
181
|
|
|
182
|
+
def _day_ts(day: str) -> float:
|
|
183
|
+
try:
|
|
184
|
+
return time.mktime(time.strptime(day, "%Y-%m-%d"))
|
|
185
|
+
except ValueError:
|
|
186
|
+
return time.time()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _decisions_in_period(top: str, since_day: str) -> dict[str, int]:
|
|
190
|
+
"""Accepted, declined, open and revisited trailers on commits in the period; answers that matched the proposal."""
|
|
191
|
+
from .state import git
|
|
192
|
+
|
|
193
|
+
rc, out, _ = git(["log", "-5000", f"--since={since_day}", "--format=%H%x00%B%x01"], top)
|
|
194
|
+
c: collections.Counter[str] = collections.Counter()
|
|
195
|
+
if rc != 0:
|
|
196
|
+
return {"accepted": 0, "declined": 0, "open": 0, "revisited": 0, "matched_proposal": 0}
|
|
197
|
+
for rec in out.split("\x01"):
|
|
198
|
+
rec = rec.strip("\n")
|
|
199
|
+
if not rec.strip() or "Gitvow-" not in rec:
|
|
200
|
+
continue
|
|
201
|
+
sha, body = rec.split("\x00", 1)
|
|
202
|
+
if dec.REVISITS_RE.search(body):
|
|
203
|
+
c["revisited"] += 1
|
|
204
|
+
ts = dec.parse_trailers(body)
|
|
205
|
+
for t in ts:
|
|
206
|
+
c[t["answer"]] += 1
|
|
207
|
+
if any(t["answer"] != "open" for t in ts):
|
|
208
|
+
for n in dec._note_decisions(top, sha, body).values():
|
|
209
|
+
wanted = {"accept": "accepted", "decline": "declined"}.get(n.get("proposed") or "")
|
|
210
|
+
if wanted and wanted == n.get("answer"):
|
|
211
|
+
c["matched_proposal"] += 1
|
|
212
|
+
return {k: c[k] for k in ("accepted", "declined", "open", "revisited", "matched_proposal")}
|
|
213
|
+
|
|
214
|
+
|
|
131
215
|
def render(d: dict[str, Any]) -> str:
|
|
132
216
|
pct = f" ({100 * d['agent_commits'] // d['commits']}%)" if d["commits"] else ""
|
|
133
217
|
out = [f"## gitvow digest · {d['repo']} · {d['since']} → {d['until']}", ""]
|
|
@@ -151,6 +235,33 @@ def render(d: dict[str, Any]) -> str:
|
|
|
151
235
|
out.append(
|
|
152
236
|
f"Cost: ${c['estimated_usd']:.2f} estimated · {c['input_tokens'] / 1e6:.1f}M input, {c['output_tokens'] / 1e6:.1f}M output tokens{unp}"
|
|
153
237
|
)
|
|
238
|
+
ds, q, pb = d.get("decisions") or {}, d.get("questions") or {}, d.get("payback") or {}
|
|
239
|
+
if ds:
|
|
240
|
+
line = f"Decisions: {ds['accepted']} accepted · {ds['declined']} declined · {ds['open']} open"
|
|
241
|
+
if ds.get("debt"):
|
|
242
|
+
line += f" · decision debt {ds['debt']} (open findings nobody has answered)"
|
|
243
|
+
if ds.get("revisited"):
|
|
244
|
+
line += f" · {ds['revisited']} revisited"
|
|
245
|
+
if ds.get("rules_in_force") is not None:
|
|
246
|
+
line += f" · earned rules in force {ds['rules_in_force']}"
|
|
247
|
+
out.append(line)
|
|
248
|
+
if q:
|
|
249
|
+
per = "n/a" if q.get("per_session") is None else f"{q['per_session']:.2f}"
|
|
250
|
+
was = f", was {q['per_session_before']:.2f}" if q.get("per_session_before") is not None else ""
|
|
251
|
+
out.append(
|
|
252
|
+
f"Questions: {q['cards']} card{'s' if q['cards'] != 1 else ''} over {q['sessions']} session{'s' if q['sessions'] != 1 else ''} "
|
|
253
|
+
f"({per} per session{was}) · {q['findings']} findings collected · {q['immediate']} immediate confirmation{'s' if q['immediate'] != 1 else ''}"
|
|
254
|
+
)
|
|
255
|
+
if pb and any(pb.values()):
|
|
256
|
+
out.append(
|
|
257
|
+
f"Payback: {pb['snapshots_restored']} snapshot{'s' if pb['snapshots_restored'] != 1 else ''} restored · "
|
|
258
|
+
f"{pb['pre_answered']} question{'s' if pb['pre_answered'] != 1 else ''} pre-answered by the record · "
|
|
259
|
+
f"{pb['answers_matching_proposal']} answer{'s' if pb['answers_matching_proposal'] != 1 else ''} matched the proposal"
|
|
260
|
+
)
|
|
261
|
+
if ds.get("debt_items"):
|
|
262
|
+
out += ["", "### Decision debt"]
|
|
263
|
+
for o in ds["debt_items"]:
|
|
264
|
+
out.append(f"{o['sha']} {o['date'][5:]} {o['finding']} · gitvow revisit {o['sha']} accept|decline")
|
|
154
265
|
if d["sessions"]:
|
|
155
266
|
out += ["", "### Sessions"]
|
|
156
267
|
for s in d["sessions"]:
|
|
@@ -47,7 +47,20 @@ def session_start(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
|
|
|
47
47
|
st.pop("pending_commit", None)
|
|
48
48
|
save_state(cwd, st)
|
|
49
49
|
log_event(cwd, "session_start", {"session_id": h.get("session_id")})
|
|
50
|
-
return 0,
|
|
50
|
+
return 0, _rules_context(cwd, home)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _rules_context(cwd: str, home: str | None) -> str:
|
|
54
|
+
"""Earned rules as context for the agent; SessionStart stdout reaches the conversation."""
|
|
55
|
+
from ..rules import derive, render
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
pol = load_policy(cwd, home)
|
|
59
|
+
except PolicyError:
|
|
60
|
+
return ""
|
|
61
|
+
if not toplevel(cwd):
|
|
62
|
+
return ""
|
|
63
|
+
return render(derive(cwd, pol))
|
|
51
64
|
|
|
52
65
|
|
|
53
66
|
def _policy_or_empty(cwd: str, home: str | None) -> dict[str, Any]:
|
|
@@ -118,8 +131,9 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
|
|
|
118
131
|
]
|
|
119
132
|
st["card_shown_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
120
133
|
save_state(cwd, st)
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
proposed = dec.mark_proposals(cwd, pol)
|
|
135
|
+
log_event(cwd, "card", {"findings": len(pending), "proposed": proposed, "session_id": h.get("session_id")})
|
|
136
|
+
return 2, dec.card(cwd, pol=pol)
|
|
123
137
|
st = load_state(cwd)
|
|
124
138
|
st["session_id"] = h.get("session_id") or st.get("session_id")
|
|
125
139
|
st["transcript_path"] = h.get("transcript_path") or st.get("transcript_path")
|
|
@@ -79,6 +79,10 @@ def _validate(pol: dict[str, Any], path: str) -> None:
|
|
|
79
79
|
for key in ("authorities", "production_branches"):
|
|
80
80
|
if not isinstance(dec.get(key, []), list):
|
|
81
81
|
raise PolicyError(f"{path}: decisions.{key} must be a list")
|
|
82
|
+
for key in ("rule_threshold", "rule_decay_days"):
|
|
83
|
+
v = dec.get(key, 1)
|
|
84
|
+
if not isinstance(v, int) or isinstance(v, bool) or v < 1:
|
|
85
|
+
raise PolicyError(f"{path}: decisions.{key} must be a positive integer")
|
|
82
86
|
for key in ("mcp_allow", "mcp_deny"):
|
|
83
87
|
for pat in pol.get(key, []):
|
|
84
88
|
try:
|
|
@@ -60,6 +60,8 @@ def _decisions(
|
|
|
60
60
|
r["authority"] = n.get("authority")
|
|
61
61
|
r["evidence"] = n.get("evidence") or []
|
|
62
62
|
r["human_turns_after_card"] = n.get("human_turns_after_card")
|
|
63
|
+
wanted = {"accept": "accepted", "decline": "declined"}.get(n.get("proposed") or "")
|
|
64
|
+
r["pre_answered"] = bool(wanted) and wanted == r["answer"]
|
|
63
65
|
r["reopen"] = bool(
|
|
64
66
|
r.get("scope")
|
|
65
67
|
and tgt
|
|
@@ -135,6 +137,7 @@ def build(cwd: str, base: str, head: str = "HEAD", target: str | None = None) ->
|
|
|
135
137
|
"declined": sum(1 for d in alld if d["answer"] == "declined"),
|
|
136
138
|
"open": sum(1 for d in alld if d["answer"] == "open"),
|
|
137
139
|
"reopened": sum(1 for d in alld if d.get("reopen")),
|
|
140
|
+
"pre_answered": sum(1 for d in alld if d.get("pre_answered")),
|
|
138
141
|
},
|
|
139
142
|
"agent_commits": len(agent),
|
|
140
143
|
"human_commits": len(commits) - len(agent),
|
|
@@ -161,6 +164,8 @@ def render_markdown(r: dict[str, Any]) -> str:
|
|
|
161
164
|
bits.append(f"**{ds['open']} open**")
|
|
162
165
|
if ds.get("reopened"):
|
|
163
166
|
bits.append(f"**{ds['reopened']} to reopen for {r.get('target')}**")
|
|
167
|
+
if ds.get("pre_answered"):
|
|
168
|
+
bits.append(f"{ds['pre_answered']} matched the record's proposal")
|
|
164
169
|
lines += [f"**Decisions:** {' · '.join(bits)}", ""]
|
|
165
170
|
for c in r["commits"]:
|
|
166
171
|
if c["kind"] == "human":
|
|
@@ -213,6 +218,8 @@ def _decision_lines(ds: list[dict[str, Any]], target: str | None) -> list[str]:
|
|
|
213
218
|
scope = f", scope {d['scope']}" if d.get("scope") else ""
|
|
214
219
|
note = f": {d['note']}" if d.get("note") else ""
|
|
215
220
|
line = f"**{d['answer'].capitalize()}:** {d['finding']} by {who}{auth}{scope}{note}"
|
|
221
|
+
if d.get("pre_answered"):
|
|
222
|
+
line += " · matched the record's proposal"
|
|
216
223
|
if d.get("human_turns_after_card") == 0:
|
|
217
224
|
line += " · **answered without a user message in the transcript**"
|
|
218
225
|
if d.get("reopen"):
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Earned rules: findings this repository has answered the same way, by people the policy names, often enough.
|
|
2
|
+
|
|
3
|
+
A rule is context for the agent, not permission. The gate still asks; the card arrives with the rule attached.
|
|
4
|
+
Rules carry their evidence and dates and decay when nobody has confirmed them within the window.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as _dt
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .decisions import history_all
|
|
14
|
+
from .state import toplevel
|
|
15
|
+
|
|
16
|
+
DEFAULT_THRESHOLD = 3
|
|
17
|
+
DEFAULT_DECAY_DAYS = 90
|
|
18
|
+
START = "<!-- gitvow:rules -->"
|
|
19
|
+
END = "<!-- /gitvow:rules -->"
|
|
20
|
+
INSTRUCTION_FILES = {
|
|
21
|
+
"claude": "CLAUDE.md",
|
|
22
|
+
"codex": "AGENTS.md",
|
|
23
|
+
"factory": "AGENTS.md",
|
|
24
|
+
"gemini": "GEMINI.md",
|
|
25
|
+
"copilot": ".github/copilot-instructions.md",
|
|
26
|
+
"cursor": ".cursor/rules/gitvow.mdc",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def settings(pol: dict[str, Any]) -> tuple[int, int]:
|
|
31
|
+
d = pol.get("decisions") or {}
|
|
32
|
+
return int(d.get("rule_threshold") or DEFAULT_THRESHOLD), int(d.get("rule_decay_days") or DEFAULT_DECAY_DAYS)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def derive(cwd: str, pol: dict[str, Any], today: str | None = None) -> dict[str, Any]:
|
|
36
|
+
"""Rules from the branch's decision history. Only decisions by authorities count; a contradiction resets."""
|
|
37
|
+
top = toplevel(cwd) or cwd
|
|
38
|
+
threshold, decay = settings(pol)
|
|
39
|
+
today_d = _dt.date.fromisoformat(today) if today else _dt.date.today()
|
|
40
|
+
rows = history_all(top, pol=pol) # newest first, with authority from the note when available
|
|
41
|
+
by_finding: dict[str, list[dict[str, Any]]] = {}
|
|
42
|
+
for r in rows:
|
|
43
|
+
by_finding.setdefault(r["finding"], []).append(r)
|
|
44
|
+
rules, candidates, decayed = [], [], []
|
|
45
|
+
for finding, hist in by_finding.items():
|
|
46
|
+
auth = [h for h in hist if h.get("authority") in ("policy", "commit-access")]
|
|
47
|
+
if not auth:
|
|
48
|
+
continue
|
|
49
|
+
answer = auth[0]["answer"]
|
|
50
|
+
run = []
|
|
51
|
+
for h in auth: # newest first: count the unbroken run of the latest answer
|
|
52
|
+
if h["answer"] != answer:
|
|
53
|
+
break
|
|
54
|
+
run.append(h)
|
|
55
|
+
dates = sorted(h["date"] for h in run) # history order is not date order once commits are rebased or backdated
|
|
56
|
+
last = _dt.date.fromisoformat(dates[-1])
|
|
57
|
+
expires = last + _dt.timedelta(days=decay)
|
|
58
|
+
entry = {
|
|
59
|
+
"finding": finding,
|
|
60
|
+
"kind": run[0].get("kind") or _kind(finding),
|
|
61
|
+
"answer": answer,
|
|
62
|
+
"count": len(run),
|
|
63
|
+
"threshold": threshold,
|
|
64
|
+
"first": dates[0],
|
|
65
|
+
"last": dates[-1],
|
|
66
|
+
"by": sorted({h["by"] for h in run if h.get("by")}),
|
|
67
|
+
"scopes": sorted({h["scope"] for h in run if h.get("scope")}),
|
|
68
|
+
"commits": [h["sha"] for h in run[:5]],
|
|
69
|
+
"expires": expires.isoformat(),
|
|
70
|
+
"contradicted_by": len(auth) - len(run),
|
|
71
|
+
}
|
|
72
|
+
if len(run) < threshold:
|
|
73
|
+
candidates.append(entry)
|
|
74
|
+
elif expires < today_d:
|
|
75
|
+
decayed.append(entry)
|
|
76
|
+
else:
|
|
77
|
+
rules.append(entry)
|
|
78
|
+
key = lambda e: (e["last"], e["count"]) # noqa: E731
|
|
79
|
+
return {
|
|
80
|
+
"repo": top,
|
|
81
|
+
"threshold": threshold,
|
|
82
|
+
"decay_days": decay,
|
|
83
|
+
"rules": sorted(rules, key=key, reverse=True),
|
|
84
|
+
"candidates": sorted(candidates, key=key, reverse=True),
|
|
85
|
+
"decayed": sorted(decayed, key=key, reverse=True),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _kind(finding: str) -> str:
|
|
90
|
+
if finding.startswith("remove route "):
|
|
91
|
+
return "route-removal"
|
|
92
|
+
if finding.startswith("route "):
|
|
93
|
+
return "route"
|
|
94
|
+
if finding.startswith("run "):
|
|
95
|
+
return "command"
|
|
96
|
+
return "edit"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def rule_for(cwd: str, pol: dict[str, Any], finding: str) -> dict[str, Any] | None:
|
|
100
|
+
for r in derive(cwd, pol)["rules"]:
|
|
101
|
+
if r["finding"] == finding:
|
|
102
|
+
return r
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _sentence(r: dict[str, Any]) -> str:
|
|
107
|
+
who = ", ".join(r["by"][:3]) or "authorities"
|
|
108
|
+
scope = f" for {', '.join(r['scopes'])}" if r["scopes"] else ""
|
|
109
|
+
times = f"{r['count']} time{'s' if r['count'] != 1 else ''}"
|
|
110
|
+
if r["answer"] == "accepted":
|
|
111
|
+
return (
|
|
112
|
+
f"`{r['finding']}` has been accepted {times}{scope} ({r['first']} to {r['last']}, by {who}). "
|
|
113
|
+
"Expect the card to propose accept; still put it to the person."
|
|
114
|
+
)
|
|
115
|
+
return (
|
|
116
|
+
f"`{r['finding']}` has been declined {times} ({r['first']} to {r['last']}, by {who}). "
|
|
117
|
+
"Propose the alternative first; if the change still needs it, say why on the card."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def render(d: dict[str, Any], for_agent: bool = True) -> str:
|
|
122
|
+
"""Markdown block for an instruction file or SessionStart context."""
|
|
123
|
+
if not d["rules"]:
|
|
124
|
+
return ""
|
|
125
|
+
lines = [
|
|
126
|
+
START,
|
|
127
|
+
f"## What this repository has decided ({len(d['rules'])} earned rule{'s' if len(d['rules']) != 1 else ''})",
|
|
128
|
+
"",
|
|
129
|
+
"Derived by gitvow from decisions on earlier commits. Context, not permission: the gate still asks at commit.",
|
|
130
|
+
"",
|
|
131
|
+
]
|
|
132
|
+
for r in d["rules"]:
|
|
133
|
+
lines.append(f"- {_sentence(r)} Decays {r['expires']} unless confirmed again.")
|
|
134
|
+
if not for_agent and d["candidates"]:
|
|
135
|
+
lines += ["", f"Not yet rules (fewer than {d['threshold']} consistent answers by authorities):"]
|
|
136
|
+
for r in d["candidates"]:
|
|
137
|
+
lines.append(
|
|
138
|
+
f"- {r['finding']}: {r['answer']} {r['count']} times (last {r['last']} by {', '.join(r['by'])})"
|
|
139
|
+
)
|
|
140
|
+
if not for_agent and d["decayed"]:
|
|
141
|
+
lines += ["", "Decayed (no confirmation within the window):"]
|
|
142
|
+
for r in d["decayed"]:
|
|
143
|
+
lines.append(
|
|
144
|
+
f"- {r['finding']}: {r['answer']} {r['count']} times, last {r['last']}, expired {r['expires']}"
|
|
145
|
+
)
|
|
146
|
+
lines.append(END)
|
|
147
|
+
return "\n".join(lines) + "\n"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def write_section(path: str, block: str) -> str:
|
|
151
|
+
"""Replace or append the managed section in an instruction file; remove it when the block is empty."""
|
|
152
|
+
import os
|
|
153
|
+
|
|
154
|
+
existing = ""
|
|
155
|
+
if os.path.exists(path):
|
|
156
|
+
with open(path) as fh:
|
|
157
|
+
existing = fh.read()
|
|
158
|
+
pat = re.compile(re.escape(START) + r".*?" + re.escape(END) + r"\n?", re.S)
|
|
159
|
+
if pat.search(existing):
|
|
160
|
+
new = pat.sub(lambda m: block, existing)
|
|
161
|
+
action = "updated" if block else "removed"
|
|
162
|
+
elif block:
|
|
163
|
+
new = (existing.rstrip("\n") + "\n\n" if existing.strip() else "") + block
|
|
164
|
+
action = "added"
|
|
165
|
+
else:
|
|
166
|
+
return "nothing to write"
|
|
167
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
168
|
+
with open(path, "w") as fh:
|
|
169
|
+
fh.write(new)
|
|
170
|
+
return f"{action} managed section in {path}"
|
|
@@ -169,6 +169,7 @@ def restore(cwd: str, ref: str, to: str | None = None) -> tuple[int, str]:
|
|
|
169
169
|
rc, _, err = git(["worktree", "add", "--detach", to, ref], top)
|
|
170
170
|
if rc != 0:
|
|
171
171
|
return 1, err
|
|
172
|
+
log_event(top, "restore", {"ref": ref, "to": to})
|
|
172
173
|
return 0, to
|
|
173
174
|
|
|
174
175
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.2
|
|
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
|
|
@@ -16,6 +16,7 @@ src/gitvow/providers.py
|
|
|
16
16
|
src/gitvow/recall.py
|
|
17
17
|
src/gitvow/redact.py
|
|
18
18
|
src/gitvow/report.py
|
|
19
|
+
src/gitvow/rules.py
|
|
19
20
|
src/gitvow/selftest.py
|
|
20
21
|
src/gitvow/snapshots.py
|
|
21
22
|
src/gitvow/state.py
|
|
@@ -41,5 +42,6 @@ tests/test_providers.py
|
|
|
41
42
|
tests/test_recall.py
|
|
42
43
|
tests/test_redact.py
|
|
43
44
|
tests/test_report.py
|
|
45
|
+
tests/test_rules.py
|
|
44
46
|
tests/test_snapshots.py
|
|
45
47
|
tests/test_transcript_formats.py
|
|
@@ -160,7 +160,7 @@ def test_report_lists_decisions_reopens_scope_and_summarises(repo, home, payload
|
|
|
160
160
|
(repo / "a.txt").write_text("y\n")
|
|
161
161
|
git(repo, "commit", "-qam", "human commit") # Gitvow-Open
|
|
162
162
|
r = build(str(repo), base, "HEAD", target="main")
|
|
163
|
-
assert r["decisions"] == {"accepted": 1, "declined": 0, "open": 1, "reopened": 1}
|
|
163
|
+
assert r["decisions"] == {"accepted": 1, "declined": 0, "open": 1, "reopened": 1, "pre_answered": 0}
|
|
164
164
|
md = render_markdown(r)
|
|
165
165
|
assert "**Decisions:** 1 accepted · 0 declined · **1 open** · **1 to reopen for main**" in md
|
|
166
166
|
assert "accepted for staging; this pull request targets main. Accept for main?" in md
|
|
@@ -174,3 +174,50 @@ def test_report_lists_decisions_reopens_scope_and_summarises(repo, home, payload
|
|
|
174
174
|
monkeypatch.chdir(repo)
|
|
175
175
|
assert cli.main(["report", "--base", base, "--decisions-summary"]) == 0
|
|
176
176
|
assert capsys.readouterr().out == summary
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_proposal_recorded_and_revisit_keeps_both(repo, home, payload, transcript, monkeypatch, capsys):
|
|
180
|
+
f = "edit core/authz_rules.go"
|
|
181
|
+
(repo / "a.txt").write_text("p\n")
|
|
182
|
+
git(repo, "commit", "-qam", f"prior\n\nGitvow-Accepted: {f} by nikhil")
|
|
183
|
+
session_start(payload("SessionStart"), str(home))
|
|
184
|
+
pre_tool_use(payload("PreToolUse", "Edit", {"file_path": "core/authz_rules.go"}, transcript), str(home))
|
|
185
|
+
code, msg = pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}, transcript), str(home))
|
|
186
|
+
assert code == 2 and "Proposed: accept." in msg
|
|
187
|
+
st = json.loads((repo / ".git" / "gitvow-session.json").read_text())
|
|
188
|
+
assert st["findings"][0]["proposed"] == "accept"
|
|
189
|
+
assert '"kind": "card", "findings": 1, "proposed": 1' in (repo / ".git" / "gitvow-hooks.log").read_text()
|
|
190
|
+
dec.decide(str(repo), "1", "accept", {}, user_turns=2)
|
|
191
|
+
pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}, transcript), str(home))
|
|
192
|
+
_install_hooks(repo)
|
|
193
|
+
(repo / "a.txt").write_text("q\n")
|
|
194
|
+
git(repo, "commit", "-qam", "agent")
|
|
195
|
+
post_tool_use(payload("PostToolUse", "Bash", {"command": "git commit -m x"}, transcript), str(home))
|
|
196
|
+
note = json.loads(git(repo, "notes", "--ref=gitvow/sess-1", "show", "HEAD").split("\n", 1)[1])
|
|
197
|
+
assert note["decisions"][0]["proposed"] == "accept"
|
|
198
|
+
base = git(repo, "rev-parse", "HEAD~1")
|
|
199
|
+
r = build(str(repo), base, "HEAD")
|
|
200
|
+
assert r["decisions"]["pre_answered"] == 1 and "matched the record's proposal" in render_markdown(r)
|
|
201
|
+
# a human commit leaves an open finding; revisit closes the debt and keeps the earlier trailer
|
|
202
|
+
pre_tool_use(payload("PreToolUse", "Edit", {"file_path": ".github/workflows/ci.yml"}), str(home))
|
|
203
|
+
(repo / "a.txt").write_text("r\n")
|
|
204
|
+
git(repo, "commit", "-qam", "human")
|
|
205
|
+
open_sha = git(repo, "rev-parse", "--short", "HEAD")
|
|
206
|
+
assert dec.open_debt(str(repo)) == [
|
|
207
|
+
{"sha": open_sha, "date": dec.open_debt(str(repo))[0]["date"], "finding": "edit .github/workflows/ci.yml"}
|
|
208
|
+
]
|
|
209
|
+
monkeypatch.chdir(repo)
|
|
210
|
+
assert cli.main(["revisit", open_sha]) == 0
|
|
211
|
+
assert "1. open: edit .github/workflows/ci.yml" in capsys.readouterr().out
|
|
212
|
+
assert cli.main(["revisit", open_sha, "accept", "--reason", "seen it"]) == 0
|
|
213
|
+
out = capsys.readouterr().out
|
|
214
|
+
assert out.strip() == f"Gitvow-Accepted: edit .github/workflows/ci.yml by t: seen it (revisits {open_sha})"
|
|
215
|
+
body = git(repo, "log", "-1", "--format=%B")
|
|
216
|
+
assert f"Gitvow-Revisits: {git(repo, 'rev-parse', open_sha)}" in body and "Was: open" in body
|
|
217
|
+
assert "Gitvow-Open: edit .github/workflows/ci.yml" in git(repo, "log", "-1", "--format=%B", open_sha)
|
|
218
|
+
assert dec.open_debt(str(repo)) == []
|
|
219
|
+
assert git(repo, "diff", "HEAD~1", "HEAD") == "" # an empty commit
|
|
220
|
+
assert cli.main(["revisit", "HEAD", "decline", "--finding", "9"]) == 1
|
|
221
|
+
assert "no decision 9" in capsys.readouterr().err
|
|
222
|
+
# history now counts the revisit as the latest answer on that finding
|
|
223
|
+
assert dec.history(str(repo), "edit .github/workflows/ci.yml")[0]["answer"] == "accepted"
|
|
@@ -60,3 +60,45 @@ def test_digest_counts_and_render(repo, home, payload, transcript, monkeypatch,
|
|
|
60
60
|
assert "### Human commits in the period" in out and "human only" in out
|
|
61
61
|
assert cli.main(["digest", "--since", "2099-01-01"]) == 0
|
|
62
62
|
assert "Commits: 0" in capsys.readouterr().out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_digest_decisions_questions_and_payback(repo, home, payload, transcript, monkeypatch, capsys):
|
|
66
|
+
from gitvow import decisions as dec
|
|
67
|
+
from gitvow.digest import build, render
|
|
68
|
+
from gitvow.hooks import post_tool_use, pre_tool_use, session_start
|
|
69
|
+
from gitvow.install import _write_git_hook
|
|
70
|
+
from gitvow.state import log_event
|
|
71
|
+
|
|
72
|
+
session_start(payload("SessionStart"), str(home))
|
|
73
|
+
pre_tool_use(payload("PreToolUse", "Edit", {"file_path": "core/authz_rules.go"}, transcript), str(home))
|
|
74
|
+
assert pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}, transcript), str(home))[0] == 2
|
|
75
|
+
dec.decide(str(repo), "1", "accept", {}, user_turns=2)
|
|
76
|
+
pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}, transcript), str(home))
|
|
77
|
+
_write_git_hook(str(repo / ".gitvow" / "git-hooks"))
|
|
78
|
+
git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
|
|
79
|
+
(repo / "a.txt").write_text("x\n")
|
|
80
|
+
git(repo, "commit", "-qam", "agent")
|
|
81
|
+
post_tool_use(payload("PostToolUse", "Bash", {"command": "git commit -m x"}, transcript), str(home))
|
|
82
|
+
pre_tool_use(payload("PreToolUse", "Edit", {"file_path": ".github/workflows/ci.yml"}), str(home))
|
|
83
|
+
(repo / "a.txt").write_text("y\n")
|
|
84
|
+
git(repo, "commit", "-qam", "human")
|
|
85
|
+
log_event(str(repo), "restore", {"ref": "refs/gitvow/snapshots/sess-1/1"})
|
|
86
|
+
pre_tool_use(payload("PreToolUse", "Bash", {"command": "git push origin main"}), str(home)) # immediate
|
|
87
|
+
d = build(str(repo), "1d")
|
|
88
|
+
assert d["decisions"]["accepted"] == 1 and d["decisions"]["open"] == 1 and d["decisions"]["debt"] == 1
|
|
89
|
+
assert (
|
|
90
|
+
d["decisions"]["rules_in_force"] == 0
|
|
91
|
+
and d["decisions"]["debt_items"][0]["finding"] == "edit .github/workflows/ci.yml"
|
|
92
|
+
)
|
|
93
|
+
assert d["questions"]["cards"] == 1 and d["questions"]["sessions"] == 1 and d["questions"]["per_session"] == 1.0
|
|
94
|
+
assert d["questions"]["findings"] == 2 and d["questions"]["immediate"] == 1
|
|
95
|
+
assert d["payback"] == {"snapshots_restored": 1, "pre_answered": 0, "answers_matching_proposal": 0}
|
|
96
|
+
text = render(d)
|
|
97
|
+
assert "Decisions: 1 accepted · 0 declined · 1 open · decision debt 1" in text
|
|
98
|
+
assert (
|
|
99
|
+
"Questions: 1 card over 1 session (1.00 per session) · 2 findings collected · 1 immediate confirmation" in text
|
|
100
|
+
)
|
|
101
|
+
assert (
|
|
102
|
+
"Payback: 1 snapshot restored · 0 questions pre-answered by the record · 0 answers matched the proposal" in text
|
|
103
|
+
)
|
|
104
|
+
assert "### Decision debt" in text and "gitvow revisit" in text
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from gitvow import cli
|
|
5
|
+
from gitvow import decisions as dec
|
|
6
|
+
from gitvow.hooks import pre_tool_use, session_start
|
|
7
|
+
from gitvow.policy import DEFAULT_POLICY_PATH
|
|
8
|
+
from gitvow.rules import END, START, derive, render, write_section
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _decided_commit(repo, subject, trailer, date="2026-09-01T12:00:00"):
|
|
12
|
+
(repo / "a.txt").write_text(subject + "\n")
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
subprocess.run(
|
|
16
|
+
["git", "commit", "-qam", f"{subject}\n\n{trailer}"],
|
|
17
|
+
cwd=repo,
|
|
18
|
+
env={**__import__("os").environ, "GIT_AUTHOR_DATE": date, "GIT_COMMITTER_DATE": date},
|
|
19
|
+
check=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _pol():
|
|
24
|
+
return json.loads(Path(DEFAULT_POLICY_PATH).read_text())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_rules_need_threshold_authority_and_recency(repo):
|
|
28
|
+
pol = _pol()
|
|
29
|
+
f = "edit core/authz_rules.go"
|
|
30
|
+
_decided_commit(repo, "c1", f"Gitvow-Accepted: {f} by nikhil scope=staging", "2026-06-01T12:00:00")
|
|
31
|
+
_decided_commit(repo, "c2", f"Gitvow-Accepted: {f} by nikhil", "2026-06-10T12:00:00")
|
|
32
|
+
d = derive(str(repo), pol, today="2026-06-20")
|
|
33
|
+
assert d["rules"] == [] and d["candidates"][0]["count"] == 2 and d["candidates"][0]["answer"] == "accepted"
|
|
34
|
+
_decided_commit(repo, "c3", f"Gitvow-Accepted: {f} by priya", "2026-06-15T12:00:00")
|
|
35
|
+
d = derive(str(repo), pol, today="2026-06-20")
|
|
36
|
+
r = d["rules"][0]
|
|
37
|
+
assert r["count"] == 3 and r["by"] == ["nikhil", "priya"] and r["scopes"] == ["staging"]
|
|
38
|
+
assert r["first"] == "2026-06-01" and r["last"] == "2026-06-15" and r["expires"] == "2026-09-13"
|
|
39
|
+
# decayed after the window
|
|
40
|
+
d = derive(str(repo), pol, today="2026-09-20")
|
|
41
|
+
assert d["rules"] == [] and d["decayed"][0]["finding"] == f
|
|
42
|
+
# a contradiction resets the run
|
|
43
|
+
_decided_commit(repo, "c4", f"Gitvow-Declined: {f} by nikhil: not any more", "2026-06-16T12:00:00")
|
|
44
|
+
d = derive(str(repo), pol, today="2026-06-20")
|
|
45
|
+
assert (
|
|
46
|
+
d["rules"] == [] and d["candidates"][0]["answer"] == "declined" and d["candidates"][0]["contradicted_by"] == 3
|
|
47
|
+
)
|
|
48
|
+
# only authorities count once the policy names them
|
|
49
|
+
pol["decisions"]["authorities"] = ["security@example.com"]
|
|
50
|
+
_decided_commit(repo, "c5", f"Gitvow-Declined: {f} by security", "2026-06-17T12:00:00")
|
|
51
|
+
_decided_commit(repo, "c6", f"Gitvow-Declined: {f} by security", "2026-06-18T12:00:00")
|
|
52
|
+
d = derive(str(repo), pol, today="2026-06-20")
|
|
53
|
+
assert d["rules"] == [] and d["candidates"][0]["count"] == 2 # nikhil's decline is data, not a rule
|
|
54
|
+
pol["decisions"]["rule_threshold"] = 2
|
|
55
|
+
assert derive(str(repo), pol, today="2026-06-20")["rules"][0]["answer"] == "declined"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_render_and_managed_section(tmp_path):
|
|
59
|
+
d = {
|
|
60
|
+
"threshold": 3,
|
|
61
|
+
"decay_days": 90,
|
|
62
|
+
"rules": [
|
|
63
|
+
{
|
|
64
|
+
"finding": "route /v1/x in api.py",
|
|
65
|
+
"answer": "declined",
|
|
66
|
+
"count": 4,
|
|
67
|
+
"first": "2026-05-01",
|
|
68
|
+
"last": "2026-06-01",
|
|
69
|
+
"by": ["nikhil"],
|
|
70
|
+
"scopes": [],
|
|
71
|
+
"expires": "2026-08-30",
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"candidates": [{"finding": "edit ci.yml", "answer": "accepted", "count": 1, "last": "2026-06-02", "by": ["t"]}],
|
|
75
|
+
"decayed": [],
|
|
76
|
+
}
|
|
77
|
+
block = render(d)
|
|
78
|
+
assert block.startswith(START) and block.rstrip().endswith(END)
|
|
79
|
+
assert (
|
|
80
|
+
"has been declined 4 times" in block
|
|
81
|
+
and "Propose the alternative first" in block
|
|
82
|
+
and "Decays 2026-08-30" in block
|
|
83
|
+
)
|
|
84
|
+
assert "Not yet rules" not in block and "Not yet rules" in render(d, for_agent=False)
|
|
85
|
+
p = tmp_path / "CLAUDE.md"
|
|
86
|
+
p.write_text("# Project\n\nRead me.\n")
|
|
87
|
+
assert write_section(str(p), block).startswith("added")
|
|
88
|
+
assert p.read_text().startswith("# Project\n\nRead me.\n\n" + START)
|
|
89
|
+
d["rules"][0]["count"] = 5
|
|
90
|
+
assert write_section(str(p), render(d)).startswith("updated")
|
|
91
|
+
assert p.read_text().count(START) == 1 and "declined 5 times" in p.read_text()
|
|
92
|
+
assert write_section(str(p), "").startswith("removed")
|
|
93
|
+
assert START not in p.read_text() and p.read_text().startswith("# Project")
|
|
94
|
+
assert render({"rules": [], "candidates": [], "decayed": [], "threshold": 3, "decay_days": 90}) == ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_card_and_session_start_carry_the_rule(repo, home, payload, monkeypatch, capsys):
|
|
98
|
+
f = "edit core/authz_rules.go"
|
|
99
|
+
for i in range(3):
|
|
100
|
+
_decided_commit(repo, f"c{i}", f"Gitvow-Accepted: {f} by nikhil", f"2026-09-0{i + 1}T12:00:00")
|
|
101
|
+
code, msg = session_start(payload("SessionStart"), str(home))
|
|
102
|
+
assert code == 0 and START in msg and "accepted 3 times" in msg
|
|
103
|
+
pre_tool_use(payload("PreToolUse", "Edit", {"file_path": "core/authz_rules.go"}), str(home))
|
|
104
|
+
code, msg = pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}), str(home))
|
|
105
|
+
assert code == 2 and "rule: accepted 3 times by authorities since 2026-09-01; decays 2026-12-02." in msg
|
|
106
|
+
assert "Proposed: accept." in msg
|
|
107
|
+
monkeypatch.chdir(repo)
|
|
108
|
+
assert cli.main(["rules"]) == 0
|
|
109
|
+
assert "earned rule" in capsys.readouterr().out
|
|
110
|
+
assert cli.main(["rules", "--write"]) == 0
|
|
111
|
+
assert "added managed section" in capsys.readouterr().out and START in (repo / "CLAUDE.md").read_text()
|
|
112
|
+
assert cli.main(["rules", "--write", "--agent", "codex"]) == 0
|
|
113
|
+
assert START in (repo / "AGENTS.md").read_text()
|
|
114
|
+
capsys.readouterr()
|
|
115
|
+
assert cli.main(["rules", "--json"]) == 0
|
|
116
|
+
assert json.loads(capsys.readouterr().out)["rules"][0]["count"] == 3
|
|
117
|
+
# the hook command prints SessionStart context on stdout for Claude Code
|
|
118
|
+
import io
|
|
119
|
+
import sys
|
|
120
|
+
|
|
121
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload("SessionStart"))))
|
|
122
|
+
assert cli.main(["hook", "SessionStart"]) == 0
|
|
123
|
+
out = capsys.readouterr().out
|
|
124
|
+
assert START in out and "still put it to the person" in out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_no_rules_message(repo, home, monkeypatch, capsys):
|
|
128
|
+
monkeypatch.chdir(repo)
|
|
129
|
+
assert cli.main(["rules"]) == 0
|
|
130
|
+
assert "no earned rules yet" in capsys.readouterr().out
|
|
131
|
+
assert dec.history_all(str(repo)) == []
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|