grip-hook 0.2.1__py3-none-any.whl

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.
grip_hook/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """grip: keep a grip on your code.
2
+
3
+ A git hook that quizzes you about your own diff before you commit or push it.
4
+ """
5
+
6
+ __version__ = "0.2.1"
7
+
8
+ QUESTION_COUNT = 5
9
+ """Number of questions asked in every quiz. Fixed by design."""
10
+
11
+ POINTS_PER_QUESTION = 20
12
+ """Maximum points per question. ``QUESTION_COUNT * POINTS_PER_QUESTION == 100``."""
13
+
14
+ MAX_SCORE = QUESTION_COUNT * POINTS_PER_QUESTION
15
+
16
+ __all__ = ["MAX_SCORE", "POINTS_PER_QUESTION", "QUESTION_COUNT", "__version__"]
grip_hook/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m grip_hook``."""
2
+
3
+ from grip_hook.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
grip_hook/agent.py ADDED
@@ -0,0 +1,235 @@
1
+ """Drive the quiz from a coding agent instead of a terminal.
2
+
3
+ Inside Claude Code, Codex or Gemini CLI there is no tty for grip to talk to, so the
4
+ agent relays the quiz: ``grip ask`` writes the questions as JSON (the rubrics stay on
5
+ disk in ``.git/grip/pending.json``), the agent shows them to the developer, and
6
+ ``grip grade`` scores the answers and remembers a pass exactly like the hook does.
7
+ ``grip check`` reports whether the current diff already passed, and ``grip agent-hook``
8
+ turns that into a Claude Code ``PreToolUse`` decision that blocks ``git push`` until it has.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import json
15
+ import re
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import Any, Literal
19
+
20
+ from pydantic import BaseModel, Field, ValidationError
21
+
22
+ from grip_hook import MAX_SCORE, POINTS_PER_QUESTION, QUESTION_COUNT
23
+ from grip_hook.config import Config
24
+ from grip_hook.errors import GripError, ProviderError
25
+ from grip_hook.git import Diff
26
+ from grip_hook.models import Answer, QuestionSet, Report, Stage
27
+ from grip_hook.providers.base import Provider
28
+
29
+ Gate = Literal["push", "commit", "both"]
30
+
31
+ # ``git [-C dir] [-c k=v] [--flag] push``: the subcommand after any global options.
32
+ _PUSH_RE = re.compile(r"(?:^|[\s;&|(])git\s+(?:-[Cc]\s+\S+\s+|-\S+\s+)*push\b")
33
+ _COMMIT_RE = re.compile(r"(?:^|[\s;&|(])git\s+(?:-[Cc]\s+\S+\s+|-\S+\s+)*commit\b")
34
+
35
+
36
+ class PendingQuiz(BaseModel):
37
+ """A question set waiting for answers, with everything needed to grade it later."""
38
+
39
+ version: int = 1
40
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
41
+ stage: Stage
42
+ provider: str
43
+ model: str
44
+ description: str
45
+ stat: str
46
+ patch: str
47
+ files: list[str]
48
+ truncated: bool
49
+ question_set: QuestionSet
50
+
51
+ @property
52
+ def diff(self) -> Diff:
53
+ """Rebuild the diff the questions were written about."""
54
+ return Diff(self.description, self.stat, self.patch, tuple(self.files), self.truncated)
55
+
56
+
57
+ class PendingStore:
58
+ """``<git dir>/grip/pending.json``: the quiz in flight, if any."""
59
+
60
+ def __init__(self, git_dir: Path) -> None:
61
+ self.path = git_dir / "grip" / "pending.json"
62
+
63
+ def load(self) -> PendingQuiz | None:
64
+ """The pending quiz, or ``None`` when there is none or it is unreadable."""
65
+ try:
66
+ return PendingQuiz.model_validate_json(self.path.read_text("utf-8"))
67
+ except (OSError, ValidationError):
68
+ return None
69
+
70
+ def save(self, pending: PendingQuiz) -> Path:
71
+ """Persist ``pending`` and return its path."""
72
+ self.path.parent.mkdir(parents=True, exist_ok=True)
73
+ self.path.write_text(pending.model_dump_json(indent=2), "utf-8")
74
+ return self.path
75
+
76
+ def clear(self) -> None:
77
+ """Forget the pending quiz."""
78
+ with contextlib.suppress(FileNotFoundError):
79
+ self.path.unlink()
80
+
81
+
82
+ def ask(diff: Diff, cfg: Config, provider: Provider, stage: Stage) -> PendingQuiz:
83
+ """Generate the question set for ``diff`` without asking anything."""
84
+ question_set = provider.generate_questions(diff, cfg.difficulty)
85
+ if len(question_set.questions) != QUESTION_COUNT:
86
+ raise ProviderError(
87
+ f"provider returned {len(question_set.questions)} questions, expected {QUESTION_COUNT}"
88
+ )
89
+ return PendingQuiz(
90
+ stage=stage,
91
+ provider=provider.name,
92
+ model=provider.model,
93
+ description=diff.description,
94
+ stat=diff.stat,
95
+ patch=diff.patch,
96
+ files=list(diff.files),
97
+ truncated=diff.truncated,
98
+ question_set=question_set,
99
+ )
100
+
101
+
102
+ def questions_payload(pending: PendingQuiz, cfg: Config) -> dict[str, Any]:
103
+ """What the agent may see: the questions, never the rubrics."""
104
+ return {
105
+ "status": "questions",
106
+ "summary": pending.question_set.summary,
107
+ "passing_score": cfg.passing_score,
108
+ "max_score": MAX_SCORE,
109
+ "diff": {
110
+ "description": pending.description,
111
+ "files": pending.files,
112
+ "truncated": pending.truncated,
113
+ },
114
+ "questions": [
115
+ {"index": i + 1, "focus": q.focus, "question": q.question}
116
+ for i, q in enumerate(pending.question_set.questions)
117
+ ],
118
+ "next": "Show these to the developer, collect their answers, then run `grip grade`.",
119
+ }
120
+
121
+
122
+ def parse_answers(text: str) -> list[str]:
123
+ """Accept ``["a", "b", ...]`` or ``{"answers": [...]}``; missing answers count as blank."""
124
+ try:
125
+ data = json.loads(text)
126
+ except json.JSONDecodeError as exc:
127
+ raise GripError(f"answers must be JSON: {exc}") from exc
128
+ if isinstance(data, dict):
129
+ data = data.get("answers")
130
+ if not isinstance(data, list) or not all(isinstance(a, str) for a in data):
131
+ raise GripError('answers must be a JSON list of strings or {"answers": [...]}')
132
+ if len(data) > QUESTION_COUNT:
133
+ raise GripError(f"got {len(data)} answers for {QUESTION_COUNT} questions")
134
+ return [a.strip() for a in data] + [""] * (QUESTION_COUNT - len(data))
135
+
136
+
137
+ def grade(pending: PendingQuiz, answers: list[str], cfg: Config, provider: Provider) -> Report:
138
+ """Grade ``answers`` against the pending questions and build the report."""
139
+ answer_models = [Answer(question_index=i, text=a) for i, a in enumerate(answers)]
140
+ diff = pending.diff
141
+ sheet = provider.grade(diff, pending.question_set.questions, answer_models, cfg.difficulty)
142
+ if len(sheet.grades) != QUESTION_COUNT:
143
+ raise ProviderError(
144
+ f"provider returned {len(sheet.grades)} grades, expected {QUESTION_COUNT}"
145
+ )
146
+ sheet.grades.sort(key=lambda g: g.question_index)
147
+ total = sheet.total
148
+ return Report(
149
+ stage=pending.stage,
150
+ provider=provider.name,
151
+ model=provider.model,
152
+ diff_digest=diff.digest,
153
+ passing_score=cfg.passing_score,
154
+ score=total,
155
+ passed=total >= cfg.passing_score,
156
+ summary=pending.question_set.summary,
157
+ questions=pending.question_set.questions,
158
+ answers=answer_models,
159
+ grades=sheet.grades,
160
+ verdict=sheet.verdict,
161
+ )
162
+
163
+
164
+ def report_payload(report: Report, report_path: Path) -> dict[str, Any]:
165
+ """The graded result, for the agent to relay."""
166
+ return {
167
+ "status": "pass" if report.passed else "fail",
168
+ "score": report.score,
169
+ "passing_score": report.passing_score,
170
+ "max_score": MAX_SCORE,
171
+ "grades": [
172
+ {
173
+ "index": g.question_index + 1,
174
+ "focus": report.questions[g.question_index].focus,
175
+ "score": g.score,
176
+ "max_score": POINTS_PER_QUESTION,
177
+ "feedback": g.feedback,
178
+ }
179
+ for g in report.grades
180
+ ],
181
+ "verdict": report.verdict,
182
+ "report": str(report_path),
183
+ }
184
+
185
+
186
+ def gated_mode(command: str, gate: Gate) -> str | None:
187
+ """Which diff a shell ``command`` must have passed: ``unpushed``, ``staged`` or none."""
188
+ if gate in {"push", "both"} and _PUSH_RE.search(command):
189
+ return "unpushed"
190
+ if gate in {"commit", "both"} and _COMMIT_RE.search(command):
191
+ return "staged"
192
+ return None
193
+
194
+
195
+ def hook_request(text: str, gate: Gate) -> tuple[str, Path] | None:
196
+ """``(mode, cwd)`` when a Claude Code ``PreToolUse`` payload needs a check, else ``None``."""
197
+ try:
198
+ payload = json.loads(text or "{}")
199
+ except json.JSONDecodeError:
200
+ return None
201
+ if not isinstance(payload, dict) or payload.get("tool_name") != "Bash":
202
+ return None
203
+ command = (payload.get("tool_input") or {}).get("command")
204
+ if not isinstance(command, str):
205
+ return None
206
+ mode = gated_mode(command, gate)
207
+ if mode is None:
208
+ return None
209
+ return mode, Path(payload.get("cwd") or Path.cwd())
210
+
211
+
212
+ def claude_code_decision(reason: str) -> dict[str, Any]:
213
+ """The JSON a Claude Code ``PreToolUse`` hook prints to deny the tool call."""
214
+ return {
215
+ "hookSpecificOutput": {
216
+ "hookEventName": "PreToolUse",
217
+ "permissionDecision": "deny",
218
+ "permissionDecisionReason": reason,
219
+ }
220
+ }
221
+
222
+
223
+ __all__ = [
224
+ "Gate",
225
+ "PendingQuiz",
226
+ "PendingStore",
227
+ "ask",
228
+ "claude_code_decision",
229
+ "gated_mode",
230
+ "grade",
231
+ "hook_request",
232
+ "parse_answers",
233
+ "questions_payload",
234
+ "report_payload",
235
+ ]