1. Script code
Here’s the full review-implementation-stop.py:
#!/usr/bin/env python3
"""
Cursor stop hook: when AI made edits, inject follow-up to review before stopping.
Original: github.com/anthnykr/agent-setups | kr0der.com/newsletter/...
"""
import json
import sys
import time
from pathlib import Path
# Debug logging (writes to file, not stdout)
_DEBUG_LOG = Path.home() / ".cursor" / "hooks" / "state" / "debug-26892d.log"
def _workspace_log_path(payload: dict | None) -> Path | None:
if not payload:
return None
roots = payload.get("workspace_roots")
if isinstance(roots, list) and roots and isinstance(roots[0], str) and roots[0]:
r = roots[0].replace("\\", "/").strip()
if len(r) > 2 and r[0] == "/" and r[2] == ":":
r = r[1:]
return Path(r) / "debug-26892d.log"
return None
def _log(msg: str, data: dict, hyp: str, payload: dict | None = None) -> None:
line = json.dumps({"sessionId":"26892d","message":msg,"data":data,"hypothesisId":hyp,"timestamp":time.time()*1000}) + "\n"
for p in (_workspace_log_path(payload), _DEBUG_LOG):
if p:
try:
with open(p, "a", encoding="utf-8") as f:
f.write(line)
except OSError:
pass
from review_implementation_helpers import (
consume_edit_marker,
extract_session_id,
extract_turn_id,
noop,
session_has_edits_from_transcript,
)
REVIEW_PROMPT = (
"Review your implementation before stopping. Check whether there is a better or simpler approach, "
"whether any redundant code remains, whether duplicate logic was introduced, and whether any dead or unused "
"code was left behind. If you find issues, fix them now; if not, briefly confirm the implementation is clean."
)
def main() -> int:
raw = sys.stdin.read().strip()
if not raw:
return noop()
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return noop()
if not isinstance(payload, dict):
return noop()
status = payload.get("status")
loop_count_raw = payload.get("loop_count", 0)
try:
loop_count = int(loop_count_raw)
except (TypeError, ValueError):
loop_count = 1
if status != "completed" or loop_count != 0:
return noop()
session_id = extract_session_id(payload=payload)
turn_id = extract_turn_id(payload=payload)
if not session_id:
return noop()
consumed = consume_edit_marker(session_id=session_id, turn_id=turn_id, payload=payload)
if consumed:
out = json.dumps({"followup_message": REVIEW_PROMPT}) + "\n"
sys.stdout.write(out)
sys.stdout.flush()
return 0
# Fallback: stop runs before afterFileEdit, detect edits from transcript
transcript_path = payload.get("transcript_path")
workspace_roots = payload.get("workspace_roots")
if isinstance(transcript_path, str) and isinstance(workspace_roots, list) and workspace_roots:
has_edits = session_has_edits_from_transcript(transcript_path, workspace_roots)
if has_edits:
out = json.dumps({"followup_message": REVIEW_PROMPT}) + "\n"
sys.stdout.write(out)
sys.stdout.flush()
return 0
return noop()
if __name__ == "__main__":
raise SystemExit(main())
2. Hooks output log
[2026-03-18T05:29:10.081Z] Found 1 hook(s) to execute for step: afterFileEdit
[2026-03-18T05:29:10.081Z] Executing hook 1/1 from user config
[2026-03-18T05:29:10.081Z] Running script in directory: c:\Users\kyzer\.cursor
[2026-03-18T05:29:10.084Z] Running hook 1 with timeout 60000ms
═══════════════════════════════════════════════════════════════════════════════════════
afterFileEdit
═══════════════════════════════════════════════════════════════════════════════════════
Command: python C:/Users/kyzer/.cursor/hooks/review-implementation-mark-edit.py (589ms) exit code: 0
INPUT:
{
"conversation_id": "2019b19c-bce0-4d60-9ffa-9fc017ff7526",
"generation_id": "97a55a6d-23bb-4649-8c43-56693323fd33",
"model": "composer-1.5",
"file_path": "c:\\Users\\kyzer\\OneDrive\\Documents\\CS_USM\\pixel2024\\frontend\\src\\components\\samples\\UseEffectSample.tsx",
"edits": [
{
"old_string": "",
"new_string": "import React, { useEffect, useState } from \"react\";\n\n/**\n * Sample component demonstrating common useEffect patterns:\n * 1. Run on mount (fetch/init)\n * 2. Run with cleanup (subscriptions, intervals)\n * 3. Run when dependencies change\n */\nconst UseEffectSample: React.FC = () => {\n const [count, setCount] = useState(0);\n const [mountedAt, setMountedAt] = useState<string | null>(null);\n const [windowWidth, setWindowWidth] = useState<number | null>(null);\n\n // 1. Run once on mount (empty dependency array)\n useEffect(() => {\n setMountedAt(new Date().toISOString());\n }, []);\n\n // 2. Run with cleanup - subscribe to window resize, clean up on unmount\n useEffect(() => {\n const handleResize = () => setWindowWidth(window.innerWidth);\n setWindowWidth(window.innerWidth);\n window.addEventListener(\"resize\", handleResize);\n\n return () => {\n window.removeEventListener(\"resize\", handleResize);\n };\n }, []);\n\n // 3. Run when `count` changes\n useEffect(() => {\n document.title = `Count: ${count}`;\n\n return () => {\n document.title = \"Pixel 2024\";\n };\n }, [count]);\n\n return (\n <div className=\"rounded-lg border border-grey-3 p-4 space-y-4 max-w-md\">\n <h3 className=\"font-bold text-lg text-primary-6\">useEffect Sample</h3>\n\n <div className=\"space-y-2 text-sm\">\n <p>\n <span className=\"font-medium\">Mounted at:</span>{\" \"}\n {mountedAt ?? \"—\"}\n </p>\n <p>\n <span className=\"font-medium\">Window width:</span>{\" \"}\n {windowWidth ?? \"—\"}px\n </p>\n </div>\n\n <div className=\"flex items-center gap-3\">\n <button\n type=\"button\"\n className=\"daisy-btn daisy-btn-primary daisy-btn-sm\"\n onClick={() => setCount((c) => c + 1)}\n >\n Increment ({count})\n </button>\n <button\n type=\"button\"\n className=\"daisy-btn daisy-btn-ghost daisy-btn-sm\"\n onClick={() => setCount(0)}\n >\n Reset\n </button>\n </div>\n\n <p className=\"text-xs text-grey-4\">\n Check the browser tab title — it updates when count changes.\n </p>\n </div>\n );\n};\n\nexport default UseEffectSample;\n"
}
],
"hook_event_name": "afterFileEdit",
"cursor_version": "2.7.0-pre.78.patch.0",
"workspace_roots": [
"/c:/Users/kyzer/OneDrive/Documents/CS_USM/pixel2024"
],
"user_email": "[email protected]",
"transcript_path": "c:\\Users\\kyzer\\.cursor\\projects\\c-Users-kyzer-OneDrive-Documents-CS-USM-pixel2024\\agent-transcripts\\2019b19c-bce0-4d60-9ffa-9fc017ff7526\\2019b19c-bce0-4d60-9ffa-9fc017ff7526.jsonl"
}
OUTPUT:
{}
═══════════════════════════════════════════════════════════════════════════════════════
[2026-03-18T05:29:10.671Z] Hook 1 executed successfully and returned valid response
[2026-03-18T05:29:10.672Z] Merged 1 valid response(s) for step afterFileEdit
[2026-03-18T05:29:14.857Z] Hook step requested: stop
[2026-03-18T05:29:14.872Z] Found 1 hook(s) to execute for step: stop
[2026-03-18T05:29:14.872Z] Executing hook 1/1 from user config
[2026-03-18T05:29:14.877Z] Running script in directory: c:\Users\kyzer\.cursor
[2026-03-18T05:29:14.882Z] Running hook 1 with timeout 60000ms
═══════════════════════════════════════════════════════════════════════════════════════
stop
═══════════════════════════════════════════════════════════════════════════════════════
Command: python C:/Users/kyzer/.cursor/hooks/review-implementation-stop.py (586ms) exit code: 0
INPUT:
{
"conversation_id": "2019b19c-bce0-4d60-9ffa-9fc017ff7526",
"generation_id": "97a55a6d-23bb-4649-8c43-56693323fd33",
"model": "composer-1.5",
"status": "completed",
"loop_count": 0,
"input_tokens": 191551,
"output_tokens": 1789,
"cache_read_tokens": 176032,
"cache_write_tokens": 0,
"hook_event_name": "stop",
"cursor_version": "2.7.0-pre.78.patch.0",
"workspace_roots": [
"/c:/Users/kyzer/OneDrive/Documents/CS_USM/pixel2024"
],
"user_email": "[email protected]",
"transcript_path": "c:\\Users\\kyzer\\.cursor\\projects\\c-Users-kyzer-OneDrive-Documents-CS-USM-pixel2024\\agent-transcripts\\2019b19c-bce0-4d60-9ffa-9fc017ff7526\\2019b19c-bce0-4d60-9ffa-9fc017ff7526.jsonl"
}
OUTPUT:
{}
═══════════════════════════════════════════════════════════════════════════════════════
[2026-03-18T05:29:15.464Z] Hook 1 executed successfully and returned valid response
[2026-03-18T05:29:15.466Z] Merged 1 valid response(s) for step stop
3. Manual test results
Minimal payload (as suggested):
echo '{"hook_event_name":"stop","status":"completed","loop_count":0}' | python -u C:/Users/kyzer/.cursor/hooks/review-implementation-stop.py
Result: {} — expected, because the script needs transcript_path and workspace_roots to detect edits. Without them it returns noop() → {}.
Full payload (with transcript_path and workspace_roots):
$payload = '{"hook_event_name":"stop","status":"completed","loop_count":0,"transcript_path":"c:\\Users\\kyzer\\.cursor\\projects\\c-Users-kyzer-OneDrive-Documents-CS-USM-pixel2024\\agent-transcripts\\2019b19c-bce0-4d60-9ffa-9fc017ff7526\\2019b19c-bce0-4d60-9ffa-9fc017ff7526.jsonl","workspace_roots":["/c:/Users/kyzer/OneDrive/Documents/CS_USM/pixel2024"]}'
$payload | python -u C:/Users/kyzer/.cursor/hooks/review-implementation-stop.py
Result: full JSON with followup_message printed to the terminal.
So when the script receives the full payload (as Cursor does), it outputs valid JSON with followup_message. The problem appears to be that Cursor’s hook launcher on Windows does not capture that stdout correctly.