gitvow 0.29.2__tar.gz → 0.31.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 (89) hide show
  1. {gitvow-0.29.2/src/gitvow.egg-info → gitvow-0.31.0}/PKG-INFO +5 -4
  2. {gitvow-0.29.2 → gitvow-0.31.0}/README.md +4 -3
  3. {gitvow-0.29.2 → gitvow-0.31.0}/pyproject.toml +1 -1
  4. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/adapters.py +3 -0
  5. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/cli.py +88 -3
  6. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/decisions.py +6 -0
  7. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/default_policy.json +9 -0
  8. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/digest.py +46 -1
  9. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/hooks/__init__.py +47 -3
  10. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/install.py +15 -0
  11. gitvow-0.31.0/src/gitvow/intent.py +222 -0
  12. gitvow-0.31.0/src/gitvow/outcomes.py +411 -0
  13. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/policy.py +12 -0
  14. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/recall.py +17 -1
  15. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/report.py +26 -1
  16. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/serve.py +80 -1
  17. {gitvow-0.29.2 → gitvow-0.31.0/src/gitvow.egg-info}/PKG-INFO +5 -4
  18. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow.egg-info/SOURCES.txt +4 -0
  19. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_cost.py +1 -1
  20. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_decisions.py +1 -1
  21. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_hooks.py +3 -2
  22. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_install_cli.py +1 -1
  23. gitvow-0.31.0/tests/test_intent.py +253 -0
  24. gitvow-0.31.0/tests/test_outcomes.py +227 -0
  25. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_pack.py +1 -1
  26. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_snapshots.py +1 -1
  27. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_tiers.py +1 -1
  28. {gitvow-0.29.2 → gitvow-0.31.0}/LICENSE +0 -0
  29. {gitvow-0.29.2 → gitvow-0.31.0}/setup.cfg +0 -0
  30. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/__init__.py +0 -0
  31. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/__main__.py +0 -0
  32. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/carry.py +0 -0
  33. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/claims.py +0 -0
  34. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/collect.py +0 -0
  35. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/export.py +0 -0
  36. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/notes.py +0 -0
  37. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/pack.py +0 -0
  38. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/paths.py +0 -0
  39. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/pricing.py +0 -0
  40. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/proposals.py +0 -0
  41. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/providers.py +0 -0
  42. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/redact.py +0 -0
  43. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/rules.py +0 -0
  44. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/safewrite.py +0 -0
  45. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/scan.py +0 -0
  46. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/selftest.py +0 -0
  47. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/snapshots.py +0 -0
  48. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/state.py +0 -0
  49. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/status.py +0 -0
  50. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/sync.py +0 -0
  51. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/targets.py +0 -0
  52. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow/transcript.py +0 -0
  53. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  54. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  55. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow.egg-info/requires.txt +0 -0
  56. {gitvow-0.29.2 → gitvow-0.31.0}/src/gitvow.egg-info/top_level.txt +0 -0
  57. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_adapters.py +0 -0
  58. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_answered_confirm_commit.py +0 -0
  59. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_carry.py +0 -0
  60. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_claims.py +0 -0
  61. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_collect.py +0 -0
  62. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_command_position.py +0 -0
  63. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_commit_window.py +0 -0
  64. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_digest.py +0 -0
  65. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_duplicate_hooks.py +0 -0
  66. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_e2e_globalopt.py +0 -0
  67. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_export.py +0 -0
  68. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_external_adapter.py +0 -0
  69. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_git_rules.py +0 -0
  70. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_hardening.py +0 -0
  71. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_native_question.py +0 -0
  72. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_packaging.py +0 -0
  73. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_policy.py +0 -0
  74. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_policy_refresh.py +0 -0
  75. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_proposals.py +0 -0
  76. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_providers.py +0 -0
  77. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_recall.py +0 -0
  78. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_redact.py +0 -0
  79. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_report.py +0 -0
  80. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_rules.py +0 -0
  81. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_scan.py +0 -0
  82. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_serve.py +0 -0
  83. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_status.py +0 -0
  84. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_status_environment.py +0 -0
  85. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_symlinked_paths.py +0 -0
  86. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_sync.py +0 -0
  87. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_sync_http.py +0 -0
  88. {gitvow-0.29.2 → gitvow-0.31.0}/tests/test_targets.py +0 -0
  89. {gitvow-0.29.2 → gitvow-0.31.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.31.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) · [Did the decision hold?](docs/guides/outcomes.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) · [Did the decision hold?](docs/guides/outcomes.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.31.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()
@@ -700,12 +737,20 @@ def cmd_show(a: argparse.Namespace) -> int:
700
737
  # A rule decision has no session, so its note lives on one ref of its own; try it too rather than
701
738
  # printing "(no session note)" on a commit that does carry the evidence for the rule it created.
702
739
  refs = ([notes_ref(m.group(1))] if m else []) + [RULES_NOTES_REF, CLAIMS_NOTES_REF, LEGACY_NOTES_REF]
740
+ found = False
703
741
  for ref in refs:
704
742
  rc, note, _ = git(["notes", f"--ref={ref}", "show", a.commit], os.getcwd())
705
743
  if rc == 0:
706
744
  print(note)
707
- return 0
708
- print("(no session note)")
745
+ found = True
746
+ break
747
+ if not found:
748
+ print("(no session note)")
749
+ from .outcomes import OUTCOMES_REF
750
+
751
+ rc, note, _ = git(["notes", f"--ref={OUTCOMES_REF}", "show", a.commit], os.getcwd())
752
+ if rc == 0:
753
+ print(note) # the grade, appended beside the decision on its own ref (0.31)
709
754
  return 0
710
755
 
711
756
 
@@ -814,6 +859,27 @@ def cmd_handoff(a: argparse.Namespace) -> int:
814
859
  return 0
815
860
 
816
861
 
862
+ def cmd_outcomes(a: argparse.Namespace) -> int:
863
+ """Grade every decision-bearing commit by what happened to it: merged, closed, landed direct, reverted."""
864
+ from . import outcomes as oc
865
+
866
+ cwd = os.getcwd()
867
+ if not toplevel(cwd):
868
+ print("not inside a git repository", file=sys.stderr)
869
+ return 2
870
+ try:
871
+ pol = load_policy(cwd)
872
+ except PolicyError as e:
873
+ print(f"policy error: {e}", file=sys.stderr)
874
+ return 2
875
+ out = oc.run(cwd, pol, since=a.since, dry_run=a.dry_run, scm=not a.no_scm, regrade=a.regrade)
876
+ if a.json:
877
+ print(json.dumps(out, indent=1))
878
+ else:
879
+ print(oc.render(out), end="")
880
+ return 0
881
+
882
+
817
883
  def cmd_digest(a: argparse.Namespace) -> int:
818
884
  d = dg.build(os.getcwd(), a.since)
819
885
  print(json.dumps(d, indent=1) if a.json else dg.render(d), end="\n" if a.json else "")
@@ -1065,6 +1131,15 @@ def main(argv: list[str] | None = None) -> int:
1065
1131
  s.add_argument("--by")
1066
1132
  s.add_argument("--to", help="with refer: who the question should go to")
1067
1133
  s.set_defaults(f=cmd_revisit)
1134
+ s = sub.add_parser(
1135
+ "intent",
1136
+ help='what this task is for, in the person\'s words: gitvow intent "let ops export orders"; alone, prints it',
1137
+ )
1138
+ s.add_argument("text", nargs="*", help="the intent; omit to print the one recorded")
1139
+ s.add_argument("--clear", action="store_true", help="drop the recorded intent")
1140
+ s.add_argument("--by", help="whose words these are, when not the committer")
1141
+ s.add_argument("--json", action="store_true")
1142
+ s.set_defaults(f=cmd_intent)
1068
1143
  s = sub.add_parser("decide", help="record a person's answer: gitvow decide 1 accept --scope staging")
1069
1144
  s.add_argument("finding", help="finding number from `gitvow decisions`, or 'all'")
1070
1145
  s.add_argument("answer", choices=["accept", "decline", "refer"], help="refer: not this person's call to make")
@@ -1104,6 +1179,16 @@ def main(argv: list[str] | None = None) -> int:
1104
1179
  s = sub.add_parser("handoff", help="markdown summary for the next agent")
1105
1180
  s.add_argument("--session")
1106
1181
  s.set_defaults(f=cmd_handoff)
1182
+ s = sub.add_parser(
1183
+ "outcomes",
1184
+ help="did the decisions hold? grade each decision-bearing commit by its pull request verdict, landing and reverts",
1185
+ )
1186
+ s.add_argument("--since", default="90d", help="window of commits to grade (default 90d)")
1187
+ s.add_argument("--dry-run", action="store_true", help="grade and print; write no notes")
1188
+ s.add_argument("--no-scm", action="store_true", help="do not ask the forge; verdicts from git alone")
1189
+ s.add_argument("--regrade", action="store_true", help="grade again even where the grade was final")
1190
+ s.add_argument("--json", action="store_true")
1191
+ s.set_defaults(f=cmd_outcomes)
1107
1192
  s = sub.add_parser("digest", help="period summary: agent vs human commits, sessions, attribution, gate activity")
1108
1193
  s.add_argument("--since", default="7d")
1109
1194
  s.add_argument("--json", action="store_true")
@@ -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,15 @@
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
+ },
206
+ "outcomes": {
207
+ "revert_window_days": 1,
208
+ "_doc": "revert_window_days: a decision-bearing commit reverted within this many days of landing is graded as a decision that did not hold; a later revert is recorded and does not change the grade. The public census found agent work that gets reverted is reverted within about a day. gitvow outcomes writes the grades to refs/notes/gitvow/outcomes."
209
+ },
201
210
  "llm_classifier": {
202
211
  "enabled": false,
203
212
  "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":
@@ -112,6 +122,9 @@ def build(cwd: str, since: str = "7d") -> dict[str, Any]:
112
122
  gate[kind] += 1
113
123
  reasons[e.get("reason") or "?"] += 1
114
124
  decided = _decisions_in_period(top, since_day)
125
+ from .outcomes import summary as outcome_summary
126
+
127
+ outcomes = outcome_summary(top, since_day)
115
128
  debt = dec.open_debt(top)
116
129
  referred = dec.referrals(top)
117
130
  from .policy import PolicyError, load_policy
@@ -185,6 +198,20 @@ def build(cwd: str, since: str = "7d") -> dict[str, Any]:
185
198
  "pre_answered": now["pre_answered"],
186
199
  "answers_matching_proposal": decided["matched_proposal"],
187
200
  },
201
+ # The experiment behind intent capture: does one line at task start make card questions unnecessary?
202
+ # "within" is a word overlap between the intent and the finding's subject, counted at card time; the
203
+ # accepted/declined split says how such findings were answered when they were. Nothing here is per person.
204
+ # Did the decisions hold? Read from the notes `gitvow outcomes` wrote; the digest never asks the forge.
205
+ "outcomes": outcomes,
206
+ "intent": {
207
+ "sessions_with_intent": sum(1 for s in sess_list if s.get("intent")),
208
+ "sessions": len(sess_list),
209
+ "cards_with_intent": now["cards_with_intent"],
210
+ "findings_on_those_cards": now["findings_with_intent"],
211
+ "within_intent": now["intent_covered"],
212
+ "within_intent_accepted": covered_answers["accepted"],
213
+ "within_intent_declined": covered_answers["declined"],
214
+ },
188
215
  }
189
216
 
190
217
 
@@ -281,6 +308,22 @@ def render(d: dict[str, Any]) -> str:
281
308
  f"{pb['pre_answered']} question{'s' if pb['pre_answered'] != 1 else ''} pre-answered by the record · "
282
309
  f"{pb['answers_matching_proposal']} answer{'s' if pb['answers_matching_proposal'] != 1 else ''} matched the proposal"
283
310
  )
311
+ from .outcomes import summary_line
312
+
313
+ oc_line = summary_line(d.get("outcomes") or {})
314
+ if oc_line:
315
+ out.append(oc_line)
316
+ it = d.get("intent") or {}
317
+ if it.get("sessions"):
318
+ line = f"Intent: {it['sessions_with_intent']} of {it['sessions']} session{'s' if it['sessions'] != 1 else ''} stated one"
319
+ if it.get("cards_with_intent"):
320
+ line += (
321
+ f" · {it['within_intent']} of {it['findings_on_those_cards']} card finding{'s' if it['findings_on_those_cards'] != 1 else ''} "
322
+ f"fell within it"
323
+ )
324
+ if it.get("within_intent_accepted") or it.get("within_intent_declined"):
325
+ line += f" ({it['within_intent_accepted']} accepted, {it['within_intent_declined']} declined)"
326
+ out.append(line)
284
327
  if ds.get("debt_items"):
285
328
  out += ["", "### Decision debt"]
286
329
  for o in ds["debt_items"]:
@@ -298,6 +341,8 @@ def render(d: dict[str, Any]) -> str:
298
341
  for s in d["sessions"]:
299
342
  sh = "n/a " if s["share"] is None else f"{s['share']:.2f}"
300
343
  plan = f' "{s["plan"]}"' if s["plan"] else " (no plan recorded)"
344
+ if s.get("intent"):
345
+ plan = f' intent "{s["intent"]}"' + plan
301
346
  after = (
302
347
  f" · {s['human_after']} human edit{'s' if s['human_after'] != 1 else ''} after"
303
348
  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)],