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.
- {gitvow-0.29.2/src/gitvow.egg-info → gitvow-0.30.0}/PKG-INFO +5 -4
- {gitvow-0.29.2 → gitvow-0.30.0}/README.md +4 -3
- {gitvow-0.29.2 → gitvow-0.30.0}/pyproject.toml +1 -1
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/adapters.py +3 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/cli.py +47 -1
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/decisions.py +6 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/default_policy.json +5 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/digest.py +36 -1
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/hooks/__init__.py +47 -3
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/install.py +15 -0
- gitvow-0.30.0/src/gitvow/intent.py +222 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/policy.py +6 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/recall.py +17 -1
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/report.py +3 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/serve.py +49 -1
- {gitvow-0.29.2 → gitvow-0.30.0/src/gitvow.egg-info}/PKG-INFO +5 -4
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow.egg-info/SOURCES.txt +2 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_cost.py +1 -1
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_decisions.py +1 -1
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_hooks.py +3 -2
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_install_cli.py +1 -1
- gitvow-0.30.0/tests/test_intent.py +253 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_pack.py +1 -1
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_snapshots.py +1 -1
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_tiers.py +1 -1
- {gitvow-0.29.2 → gitvow-0.30.0}/LICENSE +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/setup.cfg +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/carry.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/claims.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/export.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/notes.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/pack.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/paths.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/pricing.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/proposals.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/providers.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/rules.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/safewrite.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/scan.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/selftest.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/state.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/status.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/sync.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/targets.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_adapters.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_answered_confirm_commit.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_carry.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_claims.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_collect.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_command_position.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_commit_window.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_digest.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_duplicate_hooks.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_e2e_globalopt.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_export.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_external_adapter.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_git_rules.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_hardening.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_native_question.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_packaging.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_policy.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_policy_refresh.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_proposals.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_providers.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_recall.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_redact.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_report.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_rules.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_scan.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_serve.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_status.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_status_environment.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_symlinked_paths.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_sync.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_sync_http.py +0 -0
- {gitvow-0.29.2 → gitvow-0.30.0}/tests/test_targets.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
+
)
|