Skip to content

grip_hook.agent

grip_hook.agent

Drive the quiz from a coding agent instead of a terminal.

Inside Claude Code, Codex or Gemini CLI there is no tty for grip to talk to, so the agent relays the quiz: grip ask writes the questions as JSON (the rubrics stay on disk in .git/grip/pending.json), the agent shows them to the developer, and grip grade scores the answers and remembers a pass exactly like the hook does. grip check reports whether the current diff already passed, and grip agent-hook turns that into a Claude Code PreToolUse decision that blocks git push until it has.

PendingQuiz

Bases: BaseModel

A question set waiting for answers, with everything needed to grade it later.

Source code in src/grip_hook/agent.py
class PendingQuiz(BaseModel):
    """A question set waiting for answers, with everything needed to grade it later."""

    version: int = 1
    created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
    stage: Stage
    provider: str
    model: str
    description: str
    stat: str
    patch: str
    files: list[str]
    truncated: bool
    question_set: QuestionSet

    @property
    def diff(self) -> Diff:
        """Rebuild the diff the questions were written about."""
        return Diff(self.description, self.stat, self.patch, tuple(self.files), self.truncated)

diff property

diff: Diff

Rebuild the diff the questions were written about.

PendingStore

<git dir>/grip/pending.json: the quiz in flight, if any.

Source code in src/grip_hook/agent.py
class PendingStore:
    """``<git dir>/grip/pending.json``: the quiz in flight, if any."""

    def __init__(self, git_dir: Path) -> None:
        self.path = git_dir / "grip" / "pending.json"

    def load(self) -> PendingQuiz | None:
        """The pending quiz, or ``None`` when there is none or it is unreadable."""
        try:
            return PendingQuiz.model_validate_json(self.path.read_text("utf-8"))
        except (OSError, ValidationError):
            return None

    def save(self, pending: PendingQuiz) -> Path:
        """Persist ``pending`` and return its path."""
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.path.write_text(pending.model_dump_json(indent=2), "utf-8")
        return self.path

    def clear(self) -> None:
        """Forget the pending quiz."""
        with contextlib.suppress(FileNotFoundError):
            self.path.unlink()

load

load() -> PendingQuiz | None

The pending quiz, or None when there is none or it is unreadable.

Source code in src/grip_hook/agent.py
def load(self) -> PendingQuiz | None:
    """The pending quiz, or ``None`` when there is none or it is unreadable."""
    try:
        return PendingQuiz.model_validate_json(self.path.read_text("utf-8"))
    except (OSError, ValidationError):
        return None

save

save(pending: PendingQuiz) -> Path

Persist pending and return its path.

Source code in src/grip_hook/agent.py
def save(self, pending: PendingQuiz) -> Path:
    """Persist ``pending`` and return its path."""
    self.path.parent.mkdir(parents=True, exist_ok=True)
    self.path.write_text(pending.model_dump_json(indent=2), "utf-8")
    return self.path

clear

clear() -> None

Forget the pending quiz.

Source code in src/grip_hook/agent.py
def clear(self) -> None:
    """Forget the pending quiz."""
    with contextlib.suppress(FileNotFoundError):
        self.path.unlink()

ask

ask(diff: Diff, cfg: Config, provider: Provider, stage: Stage) -> PendingQuiz

Generate the question set for diff without asking anything.

Source code in src/grip_hook/agent.py
def ask(diff: Diff, cfg: Config, provider: Provider, stage: Stage) -> PendingQuiz:
    """Generate the question set for ``diff`` without asking anything."""
    question_set = provider.generate_questions(diff, cfg.difficulty)
    if len(question_set.questions) != QUESTION_COUNT:
        raise ProviderError(
            f"provider returned {len(question_set.questions)} questions, expected {QUESTION_COUNT}"
        )
    return PendingQuiz(
        stage=stage,
        provider=provider.name,
        model=provider.model,
        description=diff.description,
        stat=diff.stat,
        patch=diff.patch,
        files=list(diff.files),
        truncated=diff.truncated,
        question_set=question_set,
    )

questions_payload

questions_payload(pending: PendingQuiz, cfg: Config) -> dict[str, Any]

What the agent may see: the questions, never the rubrics.

Source code in src/grip_hook/agent.py
def questions_payload(pending: PendingQuiz, cfg: Config) -> dict[str, Any]:
    """What the agent may see: the questions, never the rubrics."""
    return {
        "status": "questions",
        "summary": pending.question_set.summary,
        "passing_score": cfg.passing_score,
        "max_score": MAX_SCORE,
        "diff": {
            "description": pending.description,
            "files": pending.files,
            "truncated": pending.truncated,
        },
        "questions": [
            {"index": i + 1, "focus": q.focus, "question": q.question}
            for i, q in enumerate(pending.question_set.questions)
        ],
        "next": "Show these to the developer, collect their answers, then run `grip grade`.",
    }

parse_answers

parse_answers(text: str) -> list[str]

Accept ["a", "b", ...] or {"answers": [...]}; missing answers count as blank.

Source code in src/grip_hook/agent.py
def parse_answers(text: str) -> list[str]:
    """Accept ``["a", "b", ...]`` or ``{"answers": [...]}``; missing answers count as blank."""
    try:
        data = json.loads(text)
    except json.JSONDecodeError as exc:
        raise GripError(f"answers must be JSON: {exc}") from exc
    if isinstance(data, dict):
        data = data.get("answers")
    if not isinstance(data, list) or not all(isinstance(a, str) for a in data):
        raise GripError('answers must be a JSON list of strings or {"answers": [...]}')
    if len(data) > QUESTION_COUNT:
        raise GripError(f"got {len(data)} answers for {QUESTION_COUNT} questions")
    return [a.strip() for a in data] + [""] * (QUESTION_COUNT - len(data))

grade

grade(pending: PendingQuiz, answers: list[str], cfg: Config, provider: Provider) -> Report

Grade answers against the pending questions and build the report.

Source code in src/grip_hook/agent.py
def grade(pending: PendingQuiz, answers: list[str], cfg: Config, provider: Provider) -> Report:
    """Grade ``answers`` against the pending questions and build the report."""
    answer_models = [Answer(question_index=i, text=a) for i, a in enumerate(answers)]
    diff = pending.diff
    sheet = provider.grade(diff, pending.question_set.questions, answer_models, cfg.difficulty)
    if len(sheet.grades) != QUESTION_COUNT:
        raise ProviderError(
            f"provider returned {len(sheet.grades)} grades, expected {QUESTION_COUNT}"
        )
    sheet.grades.sort(key=lambda g: g.question_index)
    total = sheet.total
    return Report(
        stage=pending.stage,
        provider=provider.name,
        model=provider.model,
        diff_digest=diff.digest,
        passing_score=cfg.passing_score,
        score=total,
        passed=total >= cfg.passing_score,
        summary=pending.question_set.summary,
        questions=pending.question_set.questions,
        answers=answer_models,
        grades=sheet.grades,
        verdict=sheet.verdict,
    )

report_payload

report_payload(report: Report, report_path: Path) -> dict[str, Any]

The graded result, for the agent to relay.

Source code in src/grip_hook/agent.py
def report_payload(report: Report, report_path: Path) -> dict[str, Any]:
    """The graded result, for the agent to relay."""
    return {
        "status": "pass" if report.passed else "fail",
        "score": report.score,
        "passing_score": report.passing_score,
        "max_score": MAX_SCORE,
        "grades": [
            {
                "index": g.question_index + 1,
                "focus": report.questions[g.question_index].focus,
                "score": g.score,
                "max_score": POINTS_PER_QUESTION,
                "feedback": g.feedback,
            }
            for g in report.grades
        ],
        "verdict": report.verdict,
        "report": str(report_path),
    }

gated_mode

gated_mode(command: str, gate: Gate) -> str | None

Which diff a shell command must have passed: unpushed, staged or none.

Source code in src/grip_hook/agent.py
def gated_mode(command: str, gate: Gate) -> str | None:
    """Which diff a shell ``command`` must have passed: ``unpushed``, ``staged`` or none."""
    if gate in {"push", "both"} and _PUSH_RE.search(command):
        return "unpushed"
    if gate in {"commit", "both"} and _COMMIT_RE.search(command):
        return "staged"
    return None

hook_request

hook_request(text: str, gate: Gate) -> tuple[str, Path] | None

(mode, cwd) when a Claude Code PreToolUse payload needs a check, else None.

Source code in src/grip_hook/agent.py
def hook_request(text: str, gate: Gate) -> tuple[str, Path] | None:
    """``(mode, cwd)`` when a Claude Code ``PreToolUse`` payload needs a check, else ``None``."""
    try:
        payload = json.loads(text or "{}")
    except json.JSONDecodeError:
        return None
    if not isinstance(payload, dict) or payload.get("tool_name") != "Bash":
        return None
    command = (payload.get("tool_input") or {}).get("command")
    if not isinstance(command, str):
        return None
    mode = gated_mode(command, gate)
    if mode is None:
        return None
    return mode, Path(payload.get("cwd") or Path.cwd())

claude_code_decision

claude_code_decision(reason: str) -> dict[str, Any]

The JSON a Claude Code PreToolUse hook prints to deny the tool call.

Source code in src/grip_hook/agent.py
def claude_code_decision(reason: str) -> dict[str, Any]:
    """The JSON a Claude Code ``PreToolUse`` hook prints to deny the tool call."""
    return {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": reason,
        }
    }