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.
Files changed (49) hide show
  1. {gitvow-0.12.0/src/gitvow.egg-info → gitvow-0.12.2}/PKG-INFO +1 -1
  2. {gitvow-0.12.0 → gitvow-0.12.2}/pyproject.toml +1 -1
  3. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/cli.py +96 -1
  4. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/decisions.py +174 -9
  5. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/default_policy.json +3 -1
  6. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/digest.py +114 -3
  7. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/hooks/__init__.py +17 -3
  8. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/policy.py +4 -0
  9. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/report.py +7 -0
  10. gitvow-0.12.2/src/gitvow/rules.py +170 -0
  11. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/snapshots.py +1 -0
  12. {gitvow-0.12.0 → gitvow-0.12.2/src/gitvow.egg-info}/PKG-INFO +1 -1
  13. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow.egg-info/SOURCES.txt +2 -0
  14. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_decisions.py +48 -1
  15. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_digest.py +42 -0
  16. gitvow-0.12.2/tests/test_rules.py +131 -0
  17. {gitvow-0.12.0 → gitvow-0.12.2}/LICENSE +0 -0
  18. {gitvow-0.12.0 → gitvow-0.12.2}/README.md +0 -0
  19. {gitvow-0.12.0 → gitvow-0.12.2}/setup.cfg +0 -0
  20. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/__init__.py +0 -0
  21. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/__main__.py +0 -0
  22. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/adapters.py +0 -0
  23. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/collect.py +0 -0
  24. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/install.py +0 -0
  25. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/pricing.py +0 -0
  26. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/providers.py +0 -0
  27. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/recall.py +0 -0
  28. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/redact.py +0 -0
  29. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/selftest.py +0 -0
  30. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/state.py +0 -0
  31. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow/transcript.py +0 -0
  32. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow.egg-info/dependency_links.txt +0 -0
  33. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow.egg-info/entry_points.txt +0 -0
  34. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow.egg-info/requires.txt +0 -0
  35. {gitvow-0.12.0 → gitvow-0.12.2}/src/gitvow.egg-info/top_level.txt +0 -0
  36. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_adapters.py +0 -0
  37. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_collect.py +0 -0
  38. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_cost.py +0 -0
  39. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_external_adapter.py +0 -0
  40. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_hooks.py +0 -0
  41. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_install_cli.py +0 -0
  42. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_packaging.py +0 -0
  43. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_policy.py +0 -0
  44. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_providers.py +0 -0
  45. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_recall.py +0 -0
  46. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_redact.py +0 -0
  47. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_report.py +0 -0
  48. {gitvow-0.12.0 → gitvow-0.12.2}/tests/test_snapshots.py +0 -0
  49. {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.0
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.0"
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
- print(dec.card(cwd, for_agent=False), end="")
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
- auth = [str(a).lower() for a in (pol.get("decisions") or {}).get("authorities") or []]
82
- if not auth:
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
- return "policy" if mine & set(auth) else "none"
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
- def history(cwd: str, finding: str, limit: int = HISTORY_COMMITS) -> list[dict[str, Any]]:
104
- """Earlier decisions on the same finding in this branch's history, newest first."""
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
- if t["finding"] == finding and t["answer"] != "open":
116
- rows.append({**t, "sha": sha[:7], "date": date})
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 card(cwd: str, findings: list[dict[str, Any]] | None = None, for_agent: bool = True) -> str:
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
- "_doc": "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."
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
- if (e.get("ts") or "")[:10] < since_day:
95
+ day = (e.get("ts") or "")[:10]
96
+ if day < prev_day:
89
97
  continue
90
- if e.get("kind") in ("blocked", "confirm_required"):
91
- gate[e["kind"]] += 1
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
- log_event(cwd, "card", {"findings": len(pending), "session_id": h.get("session_id")})
122
- return 2, dec.card(cwd)
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.0
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