backnotprop / backnotprop/plannotator
Per-edit review gate: drive the review surface from an agent PreToolUse hook (structured output + proposed-change input)
- 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
Assessment
This issue has not been assessed yet.