Skip to content

Per-edit review gate: drive the review surface from an agent PreToolUse hook (structured output + proposed-change input) #883

Description

@TheOutdoorProgrammer

Use case

Plan review through Plannotator is great: line-anchored comments go straight back to the agent, which revises before moving on. I wanted the exact same loop for code changes — every Edit/Write Claude Code proposes opens in the Plannotator review surface before it touches disk, and submitted comments bounce the change back to the agent as feedback.

I have this working today as a Claude Code PreToolUse hook driving plannotator review, and it's fantastic — side-by-side diff, real syntax highlighting from the file extension, per-line comments, and the agent revises and re-proposes on deny. Filing this so you can see the pattern, and because two small upstream changes would make it robust instead of duct-taped.

How the prototype works

PreToolUse fires before the tool runs and can allow/deny with a reason, so the flow is:

  1. Hook receives the proposed Edit/Write/MultiEdit as JSON on stdin and computes the file's before/after content.
  2. Since plannotator review only reads VCS state, the hook stages the proposal in a throwaway git repo: commit the current content, write the proposed content into the worktree (git add -N for brand-new files so they show in the unstaged diff), keeping the file's real name/relative path so highlighting is correct.
  3. It launches plannotator review with that repo as cwd and maps the outcome to a hook decision:
    • Approve → permissionDecision: "allow" — the edit applies, no terminal prompt
    • Submit comments → permissionDecision: "deny" with the review feedback as the reason — the agent receives the line-anchored comments, revises, and proposes the edit again (which opens a fresh review)
    • Close the tab → empty stdout — falls through to the normal permission prompt
  4. The temp repo is deleted. A flag file checked at hook runtime makes the whole gate toggleable instantly via a slash command.

Hook registration (~/.claude/settings.json):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^(Edit|Write|MultiEdit|NotebookEdit)$",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/plannotator-review-gate.py",
            "timeout": 345600
          }
        ]
      }
    ]
  }
}

The asks

  1. Structured output for review mode. annotate has --json/--hook, but review only emits plaintext, and two of the three outcome strings are user-configurable prompts (review.approved, review.denied). The hook currently infers the decision by matching "Review session closed without feedback." (hardcoded) and "no changes requested" (the default approved prompt) — a custom prompt config or a wording change silently breaks approve detection. A --json emitting {"decision": "approved"|"dismissed"|"annotated", "feedback": "..."} like annotate's would make this contract-stable.

  2. First-class "proposed change" review. The throwaway-git-repo staging works, but it's a kludge. Something like plannotator review --proposed --path src/exporter.go --before <file> --after <file> (or a unified diff on stdin) would let hooks gate not-yet-applied changes directly, no fake repo required.

  3. (Stretch) Ship this as an opt-in hook in the Claude Code plugin itself, the same way the plugin already intercepts ExitPlanMode for plan review. "Plan review, but for every edit" feels like a natural extension of what Plannotator is for, and the prototype below shows the full shape.

Full hook script

plannotator-review-gate.py
#!/usr/bin/env python3
"""Plannotator review gate — Claude Code PreToolUse hook for Edit/Write tools.

When enabled, every file change Claude proposes (Edit, Write, MultiEdit)
opens in Plannotator's code-review surface — side-by-side diff, real syntax
highlighting from the file's extension, line-level comments — before it
touches disk. Since `plannotator review` can only diff a git workspace, the
hook stages the proposed change in a throwaway git repo containing just that
one file and points the review UI at it.

The reviewer's action maps to a PreToolUse decision:

  approve                -> permissionDecision "allow"
  submit comments        -> permissionDecision "deny" (feedback goes back to
                            Claude; it revises and proposes the edit again)
  close the tab          -> empty stdout (defer to the normal permission flow)

Modes:
  plannotator-review-gate.py                  hook mode (JSON on stdin)
  plannotator-review-gate.py on|off|toggle    flip the gate
  plannotator-review-gate.py status           print enabled/disabled

The gate is controlled by a flag file (~/.claude/plannotator-review-gate.enabled)
checked at hook runtime, so /review-gate on|off takes effect instantly.

Fails open by design: any internal error logs to stderr and exits 0 so a bug
here can never lock Claude out of editing. This is a review-UX gate, not a
security boundary.

KLUDGE: `plannotator review` has no structured output mode (unlike annotate's
--json), so decisions are inferred from its stdout text: the hardcoded
"Review session closed without feedback." marker, the default approved prompt
("no changes requested"), and anything else is reviewer feedback. A custom
`review.approved` prompt in the Plannotator config would break the approve
detection; structured output for review mode upstream would remove this.
"""

import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

FLAG_FILE = Path.home() / ".claude" / "plannotator-review-gate.enabled"

CLOSED_MARKER = "Review session closed without feedback."
APPROVED_MARKER = "no changes requested"


# plannotator_bin resolves the plannotator CLI. Hooks may run with a minimal
# PATH, so fall back to the known install location.
def plannotator_bin():
    found = shutil.which("plannotator")
    if found:
        return found
    fallback = Path.home() / ".local" / "bin" / "plannotator"
    return str(fallback) if fallback.exists() else None


# proposed_change computes the file's (before, after) contents for the tool
# call, or returns None when the gate should stay out of the way (e.g. an
# Edit whose old_string doesn't match — the tool surfaces its own error).
def proposed_change(tool_name, tool_input):
    path = tool_input.get("file_path")
    if not path:
        return None

    file_exists = os.path.isfile(path)
    before = None
    if file_exists:
        try:
            before = Path(path).read_text(errors="replace")
        except OSError:
            return None

    if tool_name == "Write":
        return path, before, tool_input.get("content", "")

    if tool_name in ("Edit", "MultiEdit"):
        if before is None:
            return None
        after = before
        for edit in tool_input.get("edits") or [tool_input]:
            old = edit.get("old_string", "")
            new = edit.get("new_string", "")
            if not old or old not in after:
                return None
            count = -1 if edit.get("replace_all") else 1
            after = after.replace(old, new, count)
        return path, before, after

    return None


def run_git(repo, *argv):
    subprocess.run(
        ["git", "-C", str(repo), *argv],
        check=True,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )


# stage_repo builds a throwaway git repo where the only unstaged change is
# the proposed edit: HEAD/index hold the current content (or an intent-to-add
# entry for a new file) and the worktree holds the proposed content. The file
# keeps its real name and repo-relative path so the review UI syntax-
# highlights it correctly and shows where it lives.
def stage_repo(repo, path, before, after, cwd):
    try:
        rel = os.path.relpath(path, cwd) if cwd else Path(path).name
        if rel.startswith(".."):
            rel = Path(path).name
    except ValueError:
        rel = Path(path).name

    target = Path(repo) / rel
    target.parent.mkdir(parents=True, exist_ok=True)

    run_git(repo, "init", "-q")
    run_git(repo, "config", "user.email", "review-gate@localhost")
    run_git(repo, "config", "user.name", "review-gate")

    if before is not None:
        target.write_text(before)
        run_git(repo, "add", rel)
        run_git(repo, "commit", "-q", "-m", "current")
        target.write_text(after)
    else:
        run_git(repo, "commit", "-q", "--allow-empty", "-m", "empty")
        target.write_text(after)
        run_git(repo, "add", "-N", rel)  # intent-to-add: new file shows in unstaged diff


# run_gate opens the staged repo in Plannotator's review UI and translates
# its plaintext outcome into a PreToolUse hook response on stdout.
def run_gate(repo):
    binary = plannotator_bin()
    if not binary:
        print("plannotator-review-gate: plannotator binary not found", file=sys.stderr)
        return

    result = subprocess.run(
        [binary, "review"],
        cwd=str(repo),
        capture_output=True,
        text=True,
    )
    out = result.stdout.strip()

    if not out or CLOSED_MARKER in out:
        return  # dismissed -> defer to normal permission flow

    if APPROVED_MARKER in out:
        respond("allow", "Approved by the user in the Plannotator review gate.")
        return

    respond(
        "deny",
        "The user reviewed this proposed change in the Plannotator review gate "
        "and left feedback before it was applied. The change has NOT been made. "
        "Address the feedback, then propose the edit again:\n\n" + out,
    )


def respond(permission_decision, reason):
    print(
        json.dumps(
            {
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": permission_decision,
                    "permissionDecisionReason": reason,
                }
            }
        )
    )


def hook_mode():
    data = json.load(sys.stdin)
    if not FLAG_FILE.exists():
        return

    tool_input = data.get("tool_input") or {}

    # Autonomous memory writes shouldn't pop browser tabs at random times.
    target = tool_input.get("file_path") or ""
    if "/.claude/projects/" in target and "/memory/" in target:
        return

    change = proposed_change(data.get("tool_name", ""), tool_input)
    if not change:
        return
    path, before, after = change
    if before == after:
        return

    repo = tempfile.mkdtemp(prefix="review-gate-")
    try:
        stage_repo(repo, path, before, after, data.get("cwd"))
        run_gate(repo)
    finally:
        shutil.rmtree(repo, ignore_errors=True)


def toggle_mode(arg):
    if arg == "on" or (arg == "toggle" and not FLAG_FILE.exists()):
        FLAG_FILE.parent.mkdir(parents=True, exist_ok=True)
        FLAG_FILE.touch()
        print("review gate: enabled — every Edit/Write now opens in Plannotator's review UI")
    elif arg in ("off", "toggle"):
        FLAG_FILE.unlink(missing_ok=True)
        print("review gate: disabled — edits follow the normal permission flow")
    elif arg == "status":
        print(f"review gate: {'enabled' if FLAG_FILE.exists() else 'disabled'}")
    else:
        print(__doc__.strip().splitlines()[0])
        print("usage: plannotator-review-gate.py [on|off|toggle|status]")
        sys.exit(64)


def main():
    if len(sys.argv) > 1:
        toggle_mode(sys.argv[1])
        return
    try:
        hook_mode()
    except Exception as exc:  # fail open: never block edits on a gate bug
        print(f"plannotator-review-gate: {exc}", file=sys.stderr)
        sys.exit(0)


if __name__ == "__main__":
    main()

Happy to test a build or PR any of this if useful. Thanks for Plannotator — the plan-review loop already changed how I work with agents, and this extends it to the last mile.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions