backnotprop / backnotprop/plannotator

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

Open
#883 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
8.7k
Forks
649
Avg merge
11h 12m
Merged PRs (30d)
109

Description

## 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`):

```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 --after ` (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

```python
#!/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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.