gitvow 0.29.2__tar.gz → 0.30.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. {gitvow-0.29.2/src/gitvow.egg-info → gitvow-0.30.0}/PKG-INFO +5 -4
  2. {gitvow-0.29.2 → gitvow-0.30.0}/README.md +4 -3
  3. {gitvow-0.29.2 → gitvow-0.30.0}/pyproject.toml +1 -1
  4. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/adapters.py +3 -0
  5. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/cli.py +47 -1
  6. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/decisions.py +6 -0
  7. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/default_policy.json +5 -0
  8. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/digest.py +36 -1
  9. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/hooks/__init__.py +47 -3
  10. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/install.py +15 -0
  11. gitvow-0.30.0/src/gitvow/intent.py +222 -0
  12. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/policy.py +6 -0
  13. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/recall.py +17 -1
  14. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/report.py +3 -0
  15. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/serve.py +49 -1
  16. {gitvow-0.29.2 → gitvow-0.30.0/src/gitvow.egg-info}/PKG-INFO +5 -4
  17. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow.egg-info/SOURCES.txt +2 -0
  18. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_cost.py +1 -1
  19. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_decisions.py +1 -1
  20. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_hooks.py +3 -2
  21. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_install_cli.py +1 -1
  22. gitvow-0.30.0/tests/test_intent.py +253 -0
  23. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_pack.py +1 -1
  24. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_snapshots.py +1 -1
  25. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_tiers.py +1 -1
  26. {gitvow-0.29.2 → gitvow-0.30.0}/LICENSE +0 -0
  27. {gitvow-0.29.2 → gitvow-0.30.0}/setup.cfg +0 -0
  28. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/__init__.py +0 -0
  29. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/__main__.py +0 -0
  30. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/carry.py +0 -0
  31. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/claims.py +0 -0
  32. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/collect.py +0 -0
  33. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/export.py +0 -0
  34. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/notes.py +0 -0
  35. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/pack.py +0 -0
  36. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/paths.py +0 -0
  37. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/pricing.py +0 -0
  38. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/proposals.py +0 -0
  39. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/providers.py +0 -0
  40. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/redact.py +0 -0
  41. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/rules.py +0 -0
  42. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/safewrite.py +0 -0
  43. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/scan.py +0 -0
  44. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/selftest.py +0 -0
  45. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/snapshots.py +0 -0
  46. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/state.py +0 -0
  47. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/status.py +0 -0
  48. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/sync.py +0 -0
  49. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/targets.py +0 -0
  50. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/transcript.py +0 -0
  51. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  52. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  53. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow.egg-info/requires.txt +0 -0
  54. {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow.egg-info/top_level.txt +0 -0
  55. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_adapters.py +0 -0
  56. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_answered_confirm_commit.py +0 -0
  57. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_carry.py +0 -0
  58. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_claims.py +0 -0
  59. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_collect.py +0 -0
  60. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_command_position.py +0 -0
  61. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_commit_window.py +0 -0
  62. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_digest.py +0 -0
  63. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_duplicate_hooks.py +0 -0
  64. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_e2e_globalopt.py +0 -0
  65. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_export.py +0 -0
  66. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_external_adapter.py +0 -0
  67. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_git_rules.py +0 -0
  68. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_hardening.py +0 -0
  69. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_native_question.py +0 -0
  70. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_packaging.py +0 -0
  71. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_policy.py +0 -0
  72. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_policy_refresh.py +0 -0
  73. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_proposals.py +0 -0
  74. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_providers.py +0 -0
  75. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_recall.py +0 -0
  76. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_redact.py +0 -0
  77. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_report.py +0 -0
  78. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_rules.py +0 -0
  79. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_scan.py +0 -0
  80. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_serve.py +0 -0
  81. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_status.py +0 -0
  82. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_status_environment.py +0 -0
  83. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_symlinked_paths.py +0 -0
  84. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_sync.py +0 -0
  85. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_sync_http.py +0 -0
  86. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_targets.py +0 -0
  87. {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_transcript_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.29.2
3
+ Version: 0.30.0
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -93,7 +93,7 @@ The docs site is the source of truth: **https://wirevow.dev/gitvow** (built from
93
93
 
94
94
  - [Quick start](docs/quickstart.md)
95
95
  - Concepts: [Sessions, steps and notes](docs/concepts/sessions.md) · [The gate](docs/concepts/gate.md) · [What stays out of git](docs/concepts/storage.md)
96
- - Guides: [Install per user or per repo](docs/guides/install.md) · [Write a policy](docs/guides/policy.md) · [Read a commit's session](docs/guides/reading.md) · [Run a trial](docs/guides/trial.md) · [Redaction](docs/guides/redaction.md)
96
+ - Guides: [Install per user or per repo](docs/guides/install.md) · [Write a policy](docs/guides/policy.md) · [Read a commit's session](docs/guides/reading.md) · [Answer the card](docs/guides/decisions.md) · [State the intent](docs/guides/intent.md) · [Run a trial](docs/guides/trial.md) · [Redaction](docs/guides/redaction.md)
97
97
  - Reference: [CLI](docs/reference/cli.md) · [Hook payloads](docs/reference/hooks.md) · [Note schema](docs/reference/note.md) · [Policy schema](docs/reference/policy.md)
98
98
  - [Security](docs/security.md) · [Roadmap](docs/roadmap.md) · [FAQ](docs/faq.md)
99
99
 
@@ -102,13 +102,14 @@ The docs site is the source of truth: **https://wirevow.dev/gitvow** (built from
102
102
  ```
103
103
  Claude Code ──hook──▶ gitvow hook PreToolUse ──▶ policy ──▶ allow / confirm / deny (exit 0 / 2 / 2)
104
104
  ──hook──▶ gitvow hook PostToolUse ─▶ on `git commit`: read transcript → redact → git notes add
105
- git commit ──prepare-commit-msg──▶ Gitvow-Session / Gitvow-Step trailers (from .git/gitvow-session.json)
105
+ Claude Code ──hook──▶ gitvow hook UserPromptSubmit ▶ first line of the first message → redact → the session's intent
106
+ git commit ──prepare-commit-msg──▶ Gitvow-Session / Gitvow-Step / Gitvow-Intent trailers (from .git/gitvow-session.json)
106
107
  Claude Code ──hook──▶ gitvow hook Stop ─────────▶ ~/.gitvow/ledger/<session>.json
107
108
  ```
108
109
 
109
110
  | Data | Where | Enters git? |
110
111
  |---|---|---|
111
- | session id, step | commit trailers | yes |
112
+ | session id, step, intent (one redacted line in the person's words) | commit trailers | yes |
112
113
  | session note (structure, redacted plan, attribution) | `refs/notes/sessions` | as a note; local until pushed |
113
114
  | ledger, hook log, session state | `~/.gitvow/`, `<repo>/.git/` | no |
114
115
  | transcript | untouched | never |
@@ -65,7 +65,7 @@ The docs site is the source of truth: **https://wirevow.dev/gitvow** (built from
65
65
 
66
66
  - [Quick start](docs/quickstart.md)
67
67
  - Concepts: [Sessions, steps and notes](docs/concepts/sessions.md) · [The gate](docs/concepts/gate.md) · [What stays out of git](docs/concepts/storage.md)
68
- - Guides: [Install per user or per repo](docs/guides/install.md) · [Write a policy](docs/guides/policy.md) · [Read a commit's session](docs/guides/reading.md) · [Run a trial](docs/guides/trial.md) · [Redaction](docs/guides/redaction.md)
68
+ - Guides: [Install per user or per repo](docs/guides/install.md) · [Write a policy](docs/guides/policy.md) · [Read a commit's session](docs/guides/reading.md) · [Answer the card](docs/guides/decisions.md) · [State the intent](docs/guides/intent.md) · [Run a trial](docs/guides/trial.md) · [Redaction](docs/guides/redaction.md)
69
69
  - Reference: [CLI](docs/reference/cli.md) · [Hook payloads](docs/reference/hooks.md) · [Note schema](docs/reference/note.md) · [Policy schema](docs/reference/policy.md)
70
70
  - [Security](docs/security.md) · [Roadmap](docs/roadmap.md) · [FAQ](docs/faq.md)
71
71
 
@@ -74,13 +74,14 @@ The docs site is the source of truth: **https://wirevow.dev/gitvow** (built from
74
74
  ```
75
75
  Claude Code ──hook──▶ gitvow hook PreToolUse ──▶ policy ──▶ allow / confirm / deny (exit 0 / 2 / 2)
76
76
  ──hook──▶ gitvow hook PostToolUse ─▶ on `git commit`: read transcript → redact → git notes add
77
- git commit ──prepare-commit-msg──▶ Gitvow-Session / Gitvow-Step trailers (from .git/gitvow-session.json)
77
+ Claude Code ──hook──▶ gitvow hook UserPromptSubmit ▶ first line of the first message → redact → the session's intent
78
+ git commit ──prepare-commit-msg──▶ Gitvow-Session / Gitvow-Step / Gitvow-Intent trailers (from .git/gitvow-session.json)
78
79
  Claude Code ──hook──▶ gitvow hook Stop ─────────▶ ~/.gitvow/ledger/<session>.json
79
80
  ```
80
81
 
81
82
  | Data | Where | Enters git? |
82
83
  |---|---|---|
83
- | session id, step | commit trailers | yes |
84
+ | session id, step, intent (one redacted line in the person's words) | commit trailers | yes |
84
85
  | session note (structure, redacted plan, attribution) | `refs/notes/sessions` | as a note; local until pushed |
85
86
  | ledger, hook log, session state | `~/.gitvow/`, `<repo>/.git/` | no |
86
87
  | transcript | untouched | never |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitvow"
7
- version = "0.29.2"
7
+ version = "0.30.0"
8
8
  description = "Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -18,6 +18,7 @@ AGENTS = ("claude", "codex", "gemini", "cursor", "copilot", "factory")
18
18
  EVENT_MAP: dict[str, dict[str, str]] = {
19
19
  "claude": {
20
20
  "SessionStart": "SessionStart",
21
+ "UserPromptSubmit": "UserPromptSubmit",
21
22
  "PreToolUse": "PreToolUse",
22
23
  "PostToolUse": "PostToolUse",
23
24
  "Stop": "Stop",
@@ -137,6 +138,8 @@ def normalize(agent: str, event: str, payload: dict[str, Any]) -> list[tuple[str
137
138
  }
138
139
  if gv_event in ("SessionStart", "Stop"):
139
140
  return [(gv_event, base)]
141
+ if gv_event == "UserPromptSubmit":
142
+ return [(gv_event, {**base, "prompt": str(payload.get("prompt") or "")})]
140
143
  if gv_event == "Subagent":
141
144
  return [
142
145
  (
@@ -20,7 +20,7 @@ from .policy import PolicyError, evaluate, load_policy
20
20
  from .providers import QUESTIONS, ask
21
21
  from .redact import RedactionError, load_rules, redact
22
22
  from .report import build, render_markdown
23
- from .state import git
23
+ from .state import git, toplevel
24
24
 
25
25
 
26
26
  def cmd_hook(a: argparse.Namespace) -> int:
@@ -628,6 +628,43 @@ def cmd_revisit(a: argparse.Namespace) -> int:
628
628
  return 0
629
629
 
630
630
 
631
+ def cmd_intent(a: argparse.Namespace) -> int:
632
+ """State what this session's task is for, in the person's words; or print or clear the intent recorded."""
633
+ from . import intent as im
634
+
635
+ cwd = os.getcwd()
636
+ if not toplevel(cwd):
637
+ print("not inside a git repository", file=sys.stderr)
638
+ return 2
639
+ if a.clear:
640
+ print("cleared" if im.clear(cwd) else "no intent was recorded", file=sys.stderr)
641
+ return 0
642
+ if not a.text:
643
+ i = im.current(cwd)
644
+ if a.json:
645
+ print(json.dumps(i, indent=1))
646
+ return 0
647
+ if not i:
648
+ print('no intent recorded for this session; state one with: gitvow intent "<what this task is for>"')
649
+ return 1
650
+ print(im.trailer_line(i))
651
+ return 0
652
+ try:
653
+ rules = load_rules(cwd, os.path.expanduser("~"))
654
+ except RedactionError as e:
655
+ print(f"redaction rules invalid: {e}", file=sys.stderr)
656
+ return 2
657
+ who, _, _ = dec.identity(cwd, a.by)
658
+ try:
659
+ i = im.record(cwd, " ".join(a.text), who, "stated", rules)
660
+ except ValueError as e:
661
+ print(str(e), file=sys.stderr)
662
+ return 1
663
+ print(im.trailer_line(i))
664
+ print("written as Gitvow-Intent on every commit the agent makes in this session", file=sys.stderr)
665
+ return 0
666
+
667
+
631
668
  def cmd_decide(a: argparse.Namespace) -> int:
632
669
  """Record a person's answer to one finding or all of them."""
633
670
  cwd = os.getcwd()
@@ -1065,6 +1102,15 @@ def main(argv: list[str] | None = None) -> int:
1065
1102
  s.add_argument("--by")
1066
1103
  s.add_argument("--to", help="with refer: who the question should go to")
1067
1104
  s.set_defaults(f=cmd_revisit)
1105
+ s = sub.add_parser(
1106
+ "intent",
1107
+ help='what this task is for, in the person\'s words: gitvow intent "let ops export orders"; alone, prints it',
1108
+ )
1109
+ s.add_argument("text", nargs="*", help="the intent; omit to print the one recorded")
1110
+ s.add_argument("--clear", action="store_true", help="drop the recorded intent")
1111
+ s.add_argument("--by", help="whose words these are, when not the committer")
1112
+ s.add_argument("--json", action="store_true")
1113
+ s.set_defaults(f=cmd_intent)
1068
1114
  s = sub.add_parser("decide", help="record a person's answer: gitvow decide 1 accept --scope staging")
1069
1115
  s.add_argument("finding", help="finding number from `gitvow decisions`, or 'all'")
1070
1116
  s.add_argument("answer", choices=["accept", "decline", "refer"], help="refer: not this person's call to make")
@@ -299,6 +299,8 @@ def card(
299
299
  **{r["finding"]: {**r, "state": "proposal"} for r in derived["proposals"]},
300
300
  **{r["finding"]: {**r, "state": "rule"} for r in derived["rules"]},
301
301
  }
302
+ from .intent import card_header
303
+
302
304
  lines = []
303
305
  if for_agent and mode == "open":
304
306
  lines += [
@@ -321,9 +323,12 @@ def card(
321
323
  "then run the commit again.",
322
324
  "",
323
325
  ]
326
+ lines += card_header(st.get("intent"))
324
327
  for f in fs:
325
328
  d = f.get("decision")
326
329
  head = f"{f['n']}. {f['finding']}"
330
+ if f.get("intent_covered") and not d:
331
+ head += " [within the stated intent]"
327
332
  if d:
328
333
  head += (
329
334
  f" [{d['answer']} by {d['by']}"
@@ -473,6 +478,7 @@ def note_entries(findings: list[dict[str, Any]], card_user_turns: int | None) ->
473
478
  "decided_at": d.get("decided_at"),
474
479
  "human_turns_after_card": turns,
475
480
  "proposed": f.get("proposed"),
481
+ "intent_covered": bool(f.get("intent_covered")),
476
482
  }
477
483
  )
478
484
  return out
@@ -198,6 +198,11 @@
198
198
  "commit_window_seconds": 1800,
199
199
  "_doc_commit_window": "How long after the gate sees the agent's `git commit` the git hooks still treat the commit as the agent's and write its trailers. Guards against a stale flag catching a person's later commit; too short and a harness that queues tool calls loses trailers. When a commit lands past the window, PostToolUse says so and logs commit_without_trailer."
200
200
  },
201
+ "intent": {
202
+ "from_prompt": true,
203
+ "ask_when_missing": true,
204
+ "_doc": "from_prompt: in Claude Code, the first line of the session's first message is recorded as the intent, in the person's words, redacted, at most 200 characters (source=prompt on the trailer); false records nothing until someone runs gitvow intent. ask_when_missing: the agent is told at session start to ask once, in one line, when the first message did not say what the task is for; false says nothing."
205
+ },
201
206
  "llm_classifier": {
202
207
  "enabled": false,
203
208
  "command": "",
@@ -10,7 +10,7 @@ import time
10
10
  from typing import Any
11
11
 
12
12
  from . import decisions as dec
13
- from .recall import _commits, _plan
13
+ from .recall import _commits, _intent, _plan
14
14
  from .state import toplevel
15
15
 
16
16
 
@@ -36,6 +36,7 @@ def build(cwd: str, since: str = "7d") -> dict[str, Any]:
36
36
  agent_added = 0.0
37
37
  files: collections.Counter[str] = collections.Counter()
38
38
  file_sessions: dict[str, set[str]] = collections.defaultdict(set)
39
+ covered_answers: collections.Counter[str] = collections.Counter() # answers to findings within a stated intent
39
40
  for r in agent:
40
41
  s = sessions.setdefault(
41
42
  r["session"],
@@ -49,12 +50,17 @@ def build(cwd: str, since: str = "7d") -> dict[str, Any]:
49
50
  "cost": None,
50
51
  "tokens": 0,
51
52
  "unpriced": False,
53
+ "intent": "",
52
54
  },
53
55
  )
54
56
  s["commits"] += 1
55
57
  s["date"] = min(s["date"], r["date"])
56
58
  note = r["note"] or {}
57
59
  s["plan"] = s["plan"] or _plan(note)
60
+ s["intent"] = s["intent"] or _intent(note)
61
+ for d in note.get("decisions") or []:
62
+ if d.get("intent_covered") and d.get("answer") in ("accepted", "declined"):
63
+ covered_answers[d["answer"]] += 1
58
64
  u = note.get("usage") or {}
59
65
  if u.get("total_tokens") and (s["cost"] is None or u["total_tokens"] >= s["tokens"]):
60
66
  s["tokens"] = u["total_tokens"] # notes carry running totals; the latest note is the session's figure
@@ -102,6 +108,10 @@ def build(cwd: str, since: str = "7d") -> dict[str, Any]:
102
108
  elif kind == "card":
103
109
  bucket["cards"] += 1
104
110
  bucket["pre_answered"] += int(e.get("proposed") or 0)
111
+ if e.get("intent"):
112
+ bucket["cards_with_intent"] += 1
113
+ bucket["findings_with_intent"] += int(e.get("findings") or 0)
114
+ bucket["intent_covered"] += int(e.get("intent_covered") or 0)
105
115
  elif kind == "finding":
106
116
  bucket["findings"] += int(e.get("new") or 0)
107
117
  elif kind == "restore":
@@ -185,6 +195,18 @@ def build(cwd: str, since: str = "7d") -> dict[str, Any]:
185
195
  "pre_answered": now["pre_answered"],
186
196
  "answers_matching_proposal": decided["matched_proposal"],
187
197
  },
198
+ # The experiment behind intent capture: does one line at task start make card questions unnecessary?
199
+ # "within" is a word overlap between the intent and the finding's subject, counted at card time; the
200
+ # accepted/declined split says how such findings were answered when they were. Nothing here is per person.
201
+ "intent": {
202
+ "sessions_with_intent": sum(1 for s in sess_list if s.get("intent")),
203
+ "sessions": len(sess_list),
204
+ "cards_with_intent": now["cards_with_intent"],
205
+ "findings_on_those_cards": now["findings_with_intent"],
206
+ "within_intent": now["intent_covered"],
207
+ "within_intent_accepted": covered_answers["accepted"],
208
+ "within_intent_declined": covered_answers["declined"],
209
+ },
188
210
  }
189
211
 
190
212
 
@@ -281,6 +303,17 @@ def render(d: dict[str, Any]) -> str:
281
303
  f"{pb['pre_answered']} question{'s' if pb['pre_answered'] != 1 else ''} pre-answered by the record · "
282
304
  f"{pb['answers_matching_proposal']} answer{'s' if pb['answers_matching_proposal'] != 1 else ''} matched the proposal"
283
305
  )
306
+ it = d.get("intent") or {}
307
+ if it.get("sessions"):
308
+ line = f"Intent: {it['sessions_with_intent']} of {it['sessions']} session{'s' if it['sessions'] != 1 else ''} stated one"
309
+ if it.get("cards_with_intent"):
310
+ line += (
311
+ f" · {it['within_intent']} of {it['findings_on_those_cards']} card finding{'s' if it['findings_on_those_cards'] != 1 else ''} "
312
+ f"fell within it"
313
+ )
314
+ if it.get("within_intent_accepted") or it.get("within_intent_declined"):
315
+ line += f" ({it['within_intent_accepted']} accepted, {it['within_intent_declined']} declined)"
316
+ out.append(line)
284
317
  if ds.get("debt_items"):
285
318
  out += ["", "### Decision debt"]
286
319
  for o in ds["debt_items"]:
@@ -298,6 +331,8 @@ def render(d: dict[str, Any]) -> str:
298
331
  for s in d["sessions"]:
299
332
  sh = "n/a " if s["share"] is None else f"{s['share']:.2f}"
300
333
  plan = f' "{s["plan"]}"' if s["plan"] else " (no plan recorded)"
334
+ if s.get("intent"):
335
+ plan = f' intent "{s["intent"]}"' + plan
301
336
  after = (
302
337
  f" · {s['human_after']} human edit{'s' if s['human_after'] != 1 else ''} after"
303
338
  if s["human_after"]
@@ -9,6 +9,7 @@ import time
9
9
  from typing import Any
10
10
 
11
11
  from .. import decisions as dec
12
+ from .. import intent as intent_mod
12
13
  from .. import snapshots
13
14
  from ..paths import is_credential_path
14
15
  from ..policy import PolicyError, confirm_message, evaluate, load_policy, message_for
@@ -19,7 +20,7 @@ from ..transcript import summarize
19
20
 
20
21
  NOTES_REF_PREFIX = "gitvow" # refs/notes/gitvow/<session-id>; gitvow 0.1 wrote the single ref refs/notes/sessions
21
22
  LEGACY_NOTES_REF = "sessions"
22
- NOTE_SCHEMA = 7 # 7 (0.18): decisions[].answer may be "observed"; note gains edits_outside_repository. 6 added `to` to decisions[]: a referral may name who the question should have gone to
23
+ NOTE_SCHEMA = 8 # 8 (0.30): note gains `intent`, decisions[] gain `intent_covered`. 7 (0.18): decisions[].answer may be "observed"; note gains edits_outside_repository. 6 added `to` to decisions[]: a referral may name who the question should have gone to
23
24
  # `git commit`, including the global options that may sit between the two words. `git -c k=v commit` and
24
25
  # `git -C dir commit` are the same act and used to slip past a `\bgit\s+commit\b` match entirely, which made
25
26
  # the card trivially avoidable by anyone who knew it. Options are enumerated rather than matched loosely so
@@ -59,9 +60,40 @@ def session_start(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
59
60
  )
60
61
  st.pop("pending_commit", None)
61
62
  st.pop("card_ack", None)
63
+ if not same_session:
64
+ st.pop("intent", None) # an intent belongs to one session; the next task states its own
62
65
  save_state(cwd, st)
63
66
  log_event(cwd, "session_start", {"session_id": h.get("session_id")})
64
- return 0, _rules_context(cwd, home)
67
+ ctx = _rules_context(cwd, home)
68
+ if toplevel(cwd):
69
+ ctx += intent_mod.context(cwd, _policy_or_empty(cwd, home), h.get("session_id"))
70
+ return 0, ctx
71
+
72
+
73
+ def user_prompt_submit(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
74
+ """The first message of a session, when it states a task, becomes the session's intent in the person's words.
75
+
76
+ Only the first: a session has one intent unless a person restates it with `gitvow intent`. The prompt itself is
77
+ never stored; the first line, redacted and cut to MAX_TEXT characters, is. Off with policy `intent.from_prompt`.
78
+ """
79
+ cwd = h.get("cwd") or os.getcwd()
80
+ if not toplevel(cwd):
81
+ return 0, ""
82
+ pol = _policy_or_empty(cwd, home)
83
+ if not intent_mod.settings(pol)["from_prompt"]:
84
+ return 0, ""
85
+ sid = h.get("session_id") or load_state(cwd).get("session_id")
86
+ if intent_mod.current(cwd, sid):
87
+ return 0, ""
88
+ text = intent_mod.from_prompt(h.get("prompt"))
89
+ if not text:
90
+ return 0, ""
91
+ rules, _ = _rules_or_none(cwd, home)
92
+ if rules is None:
93
+ return 0, "" # no redaction, nothing written: the person's words may hold a secret
94
+ who, _, _ = dec.identity(cwd)
95
+ intent_mod.record(cwd, text, who, "prompt", rules, sid)
96
+ return 0, ""
65
97
 
66
98
 
67
99
  def _rules_context(cwd: str, home: str | None) -> str:
@@ -188,6 +220,7 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
188
220
  if _duplicate(h, cwd, "pre"):
189
221
  return 0, ""
190
222
  _note_touched(session_cwd, cwd)
223
+ intent_mod.propagate(session_cwd, cwd)
191
224
  try:
192
225
  pol = load_policy(cwd, home)
193
226
  except PolicyError as e:
@@ -259,10 +292,18 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
259
292
  st["pending_commit"] = time.time()
260
293
  save_state(cwd, st)
261
294
  proposed = dec.mark_proposals(cwd, pol)
295
+ covered = intent_mod.mark_coverage(cwd)
262
296
  log_event(
263
297
  cwd,
264
298
  "card",
265
- {"findings": len(pending), "proposed": proposed, "mode": mode, "session_id": h.get("session_id")},
299
+ {
300
+ "findings": len(pending),
301
+ "proposed": proposed,
302
+ "intent": intent_mod.current(cwd) is not None,
303
+ "intent_covered": covered,
304
+ "mode": mode,
305
+ "session_id": h.get("session_id"),
306
+ },
266
307
  )
267
308
  return 2, dec.card(cwd, pol=pol, mode=mode)
268
309
  st["session_id"] = h.get("session_id") or st.get("session_id")
@@ -489,6 +530,7 @@ def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
489
530
  "subagents": summ["subagents"],
490
531
  "snapshot": st.get("last_snapshot"),
491
532
  "decisions": decisions,
533
+ "intent": st.get("intent"),
492
534
  "edits_outside_repository": st.get("edits_elsewhere") or {},
493
535
  "transcript": "kept local; see ledger",
494
536
  "redaction": "secrets/PII patterns, high-entropy tokens and custom rules replaced at write time",
@@ -528,6 +570,7 @@ def stop(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
528
570
  "tool_calls": summ["tool_calls"],
529
571
  "commits_during_session": commits,
530
572
  "repos_touched": st.get("repos_touched") or [],
573
+ "intent": st.get("intent"),
531
574
  "last_stated_plan": summ["last_assistant_text"],
532
575
  "usage": estimate(summ["usage"], _policy_or_empty(cwd, home)),
533
576
  "subagents": summ["subagents"],
@@ -540,6 +583,7 @@ def stop(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
540
583
 
541
584
  HANDLERS = {
542
585
  "SessionStart": session_start,
586
+ "UserPromptSubmit": user_prompt_submit,
543
587
  "PreToolUse": pre_tool_use,
544
588
  "PostToolUse": post_tool_use,
545
589
  "Stop": stop,
@@ -23,6 +23,20 @@ if [ -f "$STATE" ]; then
23
23
  SID=$(python3 -c "import json,time;s=json.load(open('$STATE'));p=s.get('pending_commit') or 0;w=s.get('pending_ttl') or 300;print(s.get('session_id') or '' if time.time()-p<w else '')" 2>/dev/null)
24
24
  STEP=$(python3 -c "import json;print(json.load(open('$STATE')).get('steps') or 0)" 2>/dev/null)
25
25
  if [ -n "$SID" ] && ! grep -q "^Gitvow-Session:" "$1"; then printf "\\nGitvow-Session: %s\\nGitvow-Step: %s\\n" "$SID" "$STEP" >> "$1"; fi
26
+ # The session's stated intent (gitvow intent, or the first message of the session) rides on every commit the
27
+ # agent makes, so a reviewer reads what the change was for next to what it did. Agent commits only: SID is set
28
+ # only inside the window after the gate saw the agent's git commit.
29
+ if [ -n "$SID" ] && ! grep -q "^Gitvow-Intent:" "$1"; then
30
+ python3 - "$STATE" "$1" <<'PY' 2>/dev/null
31
+ import json, sys
32
+ i = json.load(open(sys.argv[1])).get("intent") or {}
33
+ t = " ".join(str(i.get("text") or "").split())
34
+ if t:
35
+ line = "Gitvow-Intent: " + t + " by " + str(i.get("by") or "unknown") + (" source=prompt" if i.get("source") == "prompt" else "")
36
+ with open(sys.argv[2], "a") as fh:
37
+ fh.write(line + "\\n")
38
+ PY
39
+ fi
26
40
  # Decisions recorded with `gitvow decide` become Gitvow-Accepted/Declined/Referred trailers; findings nobody
27
41
  # decided become Gitvow-Open, on agent and human commits alike. A referral is its own trailer because "the
28
42
  # question reached the wrong person" is not an answer and must never be read as one.
@@ -162,6 +176,7 @@ def _hook_entries(cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
162
176
 
163
177
  return {
164
178
  "SessionStart": [entry("SessionStart", None)],
179
+ "UserPromptSubmit": [entry("UserPromptSubmit", None)],
165
180
  "PreToolUse": [entry("PreToolUse", "Bash|Edit|Write|MultiEdit|NotebookEdit|mcp__.*")],
166
181
  "PostToolUse": [entry("PostToolUse", "Bash|Edit|Write|MultiEdit|NotebookEdit")],
167
182
  "Stop": [entry("Stop", None)],
@@ -0,0 +1,222 @@
1
+ """Intent: what a task is for, in the person's words, stated once at task start and carried on every commit.
2
+
3
+ The only record of what a change was meant to achieve used to be a commit message written after the fact, and
4
+ every judgment downstream (the card, the reviewer, the outcome) was made without it. The intent is one line,
5
+ kept in the session state (`.git/gitvow-session.json`, never in the tree), written by the prepare-commit-msg
6
+ hook as `Gitvow-Intent: <text> by <who>` on each commit the agent makes in the session, and copied into the
7
+ session note. It comes from one of two places:
8
+
9
+ - `gitvow intent "<text>"`: a person states it (source `stated`).
10
+ - the first message of a Claude Code session, when the policy allows it (source `prompt`, and the trailer says
11
+ so): the person's own words, first line only, redacted, at most MAX_TEXT characters. Slash commands and
12
+ one-word nudges are not an intent and are skipped.
13
+
14
+ Coverage is the experiment this utility exists for: whether asking once at task start removes more consequential
15
+ questions than gating each action does. A finding is *within* the stated intent when the words of its subject
16
+ appear in the intent. That is a term overlap, nothing cleverer, and it never answers a finding: it is recorded on
17
+ the finding, shown on the card, and counted by the digest so the ordering of the toolkit can be decided on data.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ import time
24
+ from typing import Any
25
+
26
+ from .redact import redact
27
+ from .state import load_state, log_event, save_state
28
+
29
+ TRAILER = "Gitvow-Intent"
30
+ MAX_TEXT = 200
31
+ MIN_WORDS = 3
32
+ INTENT_RE = re.compile(rf"^{TRAILER}:\s*(?P<text>.+?)\s+by\s+(?P<by>\S+)(?:\s+source=(?P<source>\S+))?\s*$", re.M)
33
+ # Blocks a harness adds around or inside the person's message: pasted text, editor selections, system reminders.
34
+ # None of them are the person's words about the task.
35
+ _PASTED_RE = re.compile(r"<(pasted_content|system-reminder|ide_selection|ide_opened_file)[^>]*>.*?</\1[^>]*>", re.S)
36
+ _TAG_RE = re.compile(r"<[^>\n]{1,80}>")
37
+ _CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
38
+ _SPLIT_RE = re.compile(r"[^A-Za-z0-9]+")
39
+ # Words that name the shape of a finding rather than its subject. "edit", "route", "in" are in every finding;
40
+ # matching them would make every intent cover everything.
41
+ _STOP_WORDS = (
42
+ "a an and are as at be by for from i in into is it its of on or that the this to we with you your "
43
+ "add change edit edits fix make please update use want need should could would will can "
44
+ "file files path route command commit push pushing branch protected production cluster mutation "
45
+ "src lib app main index test tests yaml yml json py js ts go md txt"
46
+ )
47
+ STOP = frozenset(_STOP_WORDS.split())
48
+
49
+
50
+ def settings(pol: dict[str, Any] | None) -> dict[str, Any]:
51
+ cfg = (pol or {}).get("intent") or {}
52
+ return {
53
+ "from_prompt": bool(cfg.get("from_prompt", True)),
54
+ "ask_when_missing": bool(cfg.get("ask_when_missing", True)),
55
+ }
56
+
57
+
58
+ def from_prompt(prompt: str | None) -> str | None:
59
+ """The intent a first message states, or None when the message is not one (a slash command, a nudge)."""
60
+ if not prompt or not isinstance(prompt, str):
61
+ return None
62
+ text = _PASTED_RE.sub(" ", prompt)
63
+ text = _TAG_RE.sub(" ", text)
64
+ first = next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
65
+ if not first or first.startswith("/") or first.startswith("!"):
66
+ return None
67
+ first = " ".join(first.split())
68
+ if len(first.split()) < MIN_WORDS:
69
+ return None
70
+ return first[:MAX_TEXT]
71
+
72
+
73
+ def current(cwd: str, session_id: str | None = None) -> dict[str, Any] | None:
74
+ """The intent recorded for this repository's session, or None. With `session_id`, only if it is that session's."""
75
+ st = load_state(cwd)
76
+ i = st.get("intent")
77
+ if not i or not i.get("text"):
78
+ return None
79
+ if session_id and i.get("session_id") and i["session_id"] != session_id:
80
+ return None
81
+ return i
82
+
83
+
84
+ def record(
85
+ cwd: str,
86
+ text: str,
87
+ by: str,
88
+ source: str,
89
+ rules: list[tuple[str, str]] | None = None,
90
+ session_id: str | None = None,
91
+ ) -> dict[str, Any]:
92
+ """Set the session's intent. Redacted, one line, at most MAX_TEXT characters. Replaces any earlier one."""
93
+ clean = " ".join((redact(text, rules) if rules is not None else text).split())[:MAX_TEXT]
94
+ if not clean:
95
+ raise ValueError("an intent needs some words")
96
+ st = load_state(cwd)
97
+ i = {
98
+ "text": clean,
99
+ "by": by,
100
+ "source": source,
101
+ "set_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
102
+ "session_id": session_id or st.get("session_id"),
103
+ "step": st.get("steps", 0),
104
+ }
105
+ st["intent"] = i
106
+ save_state(cwd, st)
107
+ log_event(cwd, "intent", {"source": source, "by": by, "session_id": i["session_id"], "chars": len(clean)})
108
+ return i
109
+
110
+
111
+ def clear(cwd: str) -> bool:
112
+ st = load_state(cwd)
113
+ had = bool(st.pop("intent", None))
114
+ if had:
115
+ save_state(cwd, st)
116
+ log_event(cwd, "intent_cleared", {"session_id": st.get("session_id")})
117
+ return had
118
+
119
+
120
+ def propagate(session_cwd: str, target_cwd: str) -> None:
121
+ """The session's intent follows the session into another repository it reaches (0.25 routing).
122
+
123
+ The intent lives in the state of the repository the session started in; a commit made through `git -C` in a
124
+ second repository reads that repository's state. Copy the intent across, once, when the target has none or has
125
+ one from a different session.
126
+ """
127
+ if session_cwd == target_cwd:
128
+ return
129
+ src = current(session_cwd)
130
+ if not src:
131
+ return
132
+ st = load_state(target_cwd)
133
+ have = st.get("intent") or {}
134
+ if have.get("text") and have.get("session_id") == src.get("session_id"):
135
+ return
136
+ st["intent"] = dict(src)
137
+ save_state(target_cwd, st)
138
+
139
+
140
+ def trailer_line(i: dict[str, Any]) -> str:
141
+ line = f"{TRAILER}: {' '.join(str(i.get('text') or '').split())} by {i.get('by') or 'unknown'}"
142
+ if i.get("source") == "prompt":
143
+ line += " source=prompt"
144
+ return line
145
+
146
+
147
+ def parse_trailer(body: str) -> dict[str, Any] | None:
148
+ m = INTENT_RE.search(body or "")
149
+ if not m:
150
+ return None
151
+ return {"text": m.group("text"), "by": m.group("by"), "source": m.group("source") or "stated"}
152
+
153
+
154
+ def terms(text: str) -> set[str]:
155
+ """Words worth matching: split on punctuation and camelCase, lowercased, three letters or more, not STOP."""
156
+ out: set[str] = set()
157
+ for w in _SPLIT_RE.split(_CAMEL_RE.sub(" ", text or "")):
158
+ w = w.lower()
159
+ if len(w) >= 3 and w not in STOP and not w.isdigit():
160
+ out.add(w)
161
+ return out
162
+
163
+
164
+ def covers(intent_text: str, finding: dict[str, Any]) -> bool:
165
+ """Whether the finding's subject shares a word with the intent. A term overlap, never an answer."""
166
+ want = terms(intent_text)
167
+ if not want:
168
+ return False
169
+ subject = " ".join(str(finding.get(k) or "") for k in ("subject", "path", "finding"))
170
+ return bool(want & terms(subject))
171
+
172
+
173
+ def mark_coverage(cwd: str) -> int:
174
+ """Record on each open finding whether the stated intent covers it. Returns how many it covers."""
175
+ st = load_state(cwd)
176
+ i = st.get("intent") or {}
177
+ n = 0
178
+ changed = False
179
+ for f in st.get("findings") or []:
180
+ c = bool(i.get("text")) and covers(i["text"], f)
181
+ if f.get("intent_covered") != c:
182
+ f["intent_covered"] = c
183
+ changed = True
184
+ n += 1 if c and not f.get("decision") else 0
185
+ if changed:
186
+ save_state(cwd, st)
187
+ return n
188
+
189
+
190
+ def card_header(i: dict[str, Any] | None) -> list[str]:
191
+ if not i or not i.get("text"):
192
+ return []
193
+ how = "from the first message of the session" if i.get("source") == "prompt" else f"stated by {i.get('by')}"
194
+ return [
195
+ f'Stated intent: "{i["text"]}" ({how}).',
196
+ "A finding marked within the intent shares its words; that is context for the answer, not the answer.",
197
+ "",
198
+ ]
199
+
200
+
201
+ def context(cwd: str, pol: dict[str, Any] | None, session_id: str | None = None) -> str:
202
+ """One paragraph for the agent at session start: the intent if there is one, else how to get one."""
203
+ cfg = settings(pol)
204
+ i = current(cwd, session_id)
205
+ if i:
206
+ return (
207
+ f'\ngitvow intent for this session: "{i["text"]}" ({i.get("by")}, {i.get("source")}). '
208
+ "It rides on every commit as Gitvow-Intent. If the task changes, record the new intent with "
209
+ 'gitvow intent "<the person\'s words>".\n'
210
+ )
211
+ if not cfg["ask_when_missing"]:
212
+ return ""
213
+ first = (
214
+ "gitvow records the first line of the person's first message as the intent of this session, in their words, "
215
+ "and it rides on every commit as Gitvow-Intent. "
216
+ if cfg["from_prompt"]
217
+ else ""
218
+ )
219
+ return (
220
+ f"\n{first}If that message does not say what the task is for, ask once, in one line, before the first edit, "
221
+ 'and record their answer verbatim with: gitvow intent "<their words>". Never invent one.\n'
222
+ )