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 +16 -0
- grip_hook/__main__.py +6 -0
- grip_hook/agent.py +235 -0
- grip_hook/cli.py +1320 -0
- grip_hook/config.py +255 -0
- grip_hook/errors.py +31 -0
- grip_hook/git.py +240 -0
- grip_hook/hooks.py +140 -0
- grip_hook/integrations.py +386 -0
- grip_hook/memory.py +178 -0
- grip_hook/models.py +134 -0
- grip_hook/platform.py +357 -0
- grip_hook/prompts.py +173 -0
- grip_hook/providers/__init__.py +82 -0
- grip_hook/providers/agents.py +299 -0
- grip_hook/providers/anthropic.py +133 -0
- grip_hook/providers/base.py +45 -0
- grip_hook/providers/fake.py +255 -0
- grip_hook/providers/openai_compat.py +137 -0
- grip_hook/py.typed +0 -0
- grip_hook/quiz.py +158 -0
- grip_hook/study.py +267 -0
- grip_hook/terminal.py +137 -0
- grip_hook/whiteboard.html +429 -0
- grip_hook/whiteboard.py +476 -0
- grip_hook/whiteboard_server.py +208 -0
- grip_hook-0.2.1.dist-info/METADATA +381 -0
- grip_hook-0.2.1.dist-info/RECORD +31 -0
- grip_hook-0.2.1.dist-info/WHEEL +4 -0
- grip_hook-0.2.1.dist-info/entry_points.txt +3 -0
- grip_hook-0.2.1.dist-info/licenses/LICENSE +28 -0
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
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
|
+
]
|