fix(agent hooks): wire hooks into real harness entry points and fix payload parsing

Claude Code never reads a standalone .claude/hooks.json, so the PostToolUse and
Stop pipeline is moved into .claude/settings.json and hooks.json is removed;
.cursor/hooks.json is rewritten in Cursor's version+afterFileEdit/stop schema;
.codex/hooks.json is already Codex-valid and stays. The shared scripts now
parse both the Cursor file_path and Claude/Codex tool_input.file_path stdin
shapes and normalize absolute paths, so the format, yarn-install, and
react-pattern-review hooks stop being silent no-ops. verify.sh blocks with
exit 2 plus a stderr reason, guards stop_hook_active, and skips clean trees;
react-pattern-review surfaces its reminder via hookSpecificOutput on
PostToolUse; sync-git-branches no longer misreports open PRs as merged. The
validator now checks that the three harness-specific entry points wire the
same hook scripts instead of requiring byte-identical hooks.json copies.
This commit is contained in:
Tommaso Casaburi
2026-07-03 13:58:36 +07:00
parent 0166f2f4e8
commit 1ac3e5883b
9 changed files with 190 additions and 123 deletions
+13 -4
View File
@@ -1,14 +1,16 @@
#!/bin/bash
# afterFileEdit hook: Auto-format files after AI edits them
# Receives JSON via stdin: {"file_path": "...", "edits": [...]}
# afterFileEdit/PostToolUse hook: Auto-format files after AI edits them
# Stdin JSON differs per harness:
# Cursor afterFileEdit: {"file_path": "...", "edits": [...]}
# Claude/Codex PostToolUse: {"tool_input": {"file_path": "..."}, ...}
input=$(cat)
if ! command -v jq >/dev/null 2>&1; then
exit 0
fi
file_path=$(printf '%s' "$input" | jq -r '.file_path // empty' 2>/dev/null)
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // .file_path // empty' 2>/dev/null)
if [ -z "$file_path" ]; then
exit 0
@@ -17,8 +19,15 @@ fi
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$repo_root" || exit 0
# Claude Code and Codex deliver absolute paths; make them repo-relative and
# skip anything outside the repo.
case "$file_path" in
*.js|*.ts|*.tsx|*.mjs)
"$repo_root"/*) file_path="${file_path#"$repo_root"/}" ;;
/*) exit 0 ;;
esac
case "$file_path" in
*.js|*.jsx|*.cjs|*.mjs|*.ts|*.tsx)
dir_part="${file_path%/*}"
base_name="${file_path##*/}"
if [ "$dir_part" = "$file_path" ]; then
+41 -3
View File
@@ -28,13 +28,26 @@ done
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$repo_root" || exit 0
# Cursor afterFileEdit sends {"file_path": ...}; Claude/Codex PostToolUse send
# {"tool_input": {"file_path": ...}} with an absolute path.
extract_file_path() {
if command -v jq >/dev/null 2>&1; then
printf '%s' "$input" | jq -r '.file_path // empty' 2>/dev/null
printf '%s' "$input" | jq -r '.tool_input.file_path // .file_path // empty' 2>/dev/null
return
fi
echo "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:.*"\([^"]*\)"/\1/'
echo "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:.*"\([^"]*\)"/\1/'
}
# Make absolute paths repo-relative so scope prefixes and src/ checks match.
normalize_file_path() {
local candidate="$1"
case "$candidate" in
"$repo_root"/*) printf '%s' "${candidate#"$repo_root"/}" ;;
/*) printf '' ;;
*) printf '%s' "$candidate" ;;
esac
}
is_source_file() {
@@ -130,7 +143,13 @@ append_file_if_react_ui_source() {
results=""
react_source_files=""
file_path="$(extract_file_path)"
raw_file_path="$(extract_file_path)"
file_path="$(normalize_file_path "$raw_file_path")"
# A per-file event for a file outside the repo is not ours to review.
if [ -n "$raw_file_path" ] && [ -z "$file_path" ]; then
exit 0
fi
if [ -n "$file_path" ]; then
if is_source_file "$file_path" && matches_scope "$file_path"; then
@@ -178,6 +197,7 @@ if [ -n "$skill_dir" ] && [ -f "$repo_root/$skill_dir/vercel-react-best-practice
vercel_skill="$repo_root/$skill_dir/vercel-react-best-practices/SKILL.md"
fi
build_report() {
echo "=== React Best Practices Review Reminder ==="
if [ -n "$react_source_files" ]; then
@@ -226,5 +246,23 @@ echo "- Does the TSX avoid inline object/array prop churn and unnecessary compon
echo "- Can this be derived during render instead of synchronized with an effect?"
echo "- Can interaction logic move to an event handler or a key-based reset?"
echo "- Is the memoization actually needed, or is simpler render-time code better?"
}
report="$(build_report)"
# On Claude Code and Codex PostToolUse events, plain stdout with exit 0 is
# transcript-only and never reaches the model; hookSpecificOutput JSON does.
# Cursor's afterFileEdit/stop events send no hook_event_name, so they keep
# getting the plain-text report.
hook_event_name=""
if command -v jq >/dev/null 2>&1; then
hook_event_name="$(printf '%s' "$input" | jq -r '.hook_event_name // empty' 2>/dev/null)"
fi
if [ "$hook_event_name" = "PostToolUse" ] && command -v jq >/dev/null 2>&1; then
jq -cn --arg ctx "$report" '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}'
else
printf '%s\n' "$report"
fi
exit 0
+5 -1
View File
@@ -45,6 +45,7 @@ branch_has_live_upstream() {
merged_pr_number_for_branch() {
local branch="$1"
local pr_number=""
local merged_at=""
if ! command -v gh >/dev/null 2>&1; then
return 0
@@ -60,7 +61,10 @@ merged_pr_number_for_branch() {
esac
if [ -n "$pr_number" ]; then
gh pr view "$pr_number" --repo bitsocialnet/5chan --json mergedAt --jq 'select(.mergedAt != null) | .mergedAt' >/dev/null 2>&1 || return 0
# gh exits 0 even when the PR exists but is unmerged (the jq select just
# produces no output), so test the output instead of the exit code.
merged_at="$(gh pr view "$pr_number" --repo bitsocialnet/5chan --json mergedAt --jq 'select(.mergedAt != null) | .mergedAt' 2>/dev/null || true)"
[ -n "$merged_at" ] || return 0
echo "$pr_number"
return 0
fi
+20 -3
View File
@@ -11,10 +11,24 @@ if [ "${1:-}" = "--advisory" ]; then
shift
fi
cat > /dev/null
input="$(cat)"
# Avoid infinite stop loops: when a previous blocking verify already forced the
# agent to continue, Claude Code/Codex set stop_hook_active on the next Stop.
if command -v jq >/dev/null 2>&1; then
if [ "$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null)" = "true" ]; then
exit 0
fi
fi
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" || exit 0
# Read-only sessions change nothing; skip the expensive full verification.
if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -z "$(git status --porcelain 2>/dev/null)" ]; then
echo "Working tree clean; skipping verification."
exit 0
fi
cleanup_generated_dir() {
local path="$1"
@@ -76,8 +90,11 @@ if [ "$failures" -ne 0 ]; then
exit 0
fi
echo "Verification failed."
exit 1
# Exit 2 is the only exit code that blocks the stop and feeds the reason back
# to the agent in Claude Code and Codex; exit 1 would be a silent, non-blocking
# error. The full logs are on stdout above; keep the stderr reason short.
echo "Verification failed: build, lint, or type-check reported errors (see hook output). Fix them before finishing, or rerun with AGENT_VERIFY_MODE=advisory to intentionally stop on a broken tree." >&2
exit 2
fi
echo "Verification complete."
+18 -4
View File
@@ -1,17 +1,31 @@
#!/bin/bash
# afterFileEdit hook: Run Corepack-managed Yarn install when package.json is changed
# Receives JSON via stdin: {"file_path": "...", "edits": [...]}
# afterFileEdit/PostToolUse hook: Run Corepack-managed Yarn install when package.json is changed
# Stdin JSON differs per harness:
# Cursor afterFileEdit: {"file_path": "...", "edits": [...]}
# Claude/Codex PostToolUse: {"tool_input": {"file_path": "..."}, ...}
input=$(cat)
file_path=$(echo "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:.*"\([^"]*\)"/\1/')
if command -v jq >/dev/null 2>&1; then
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // .file_path // empty' 2>/dev/null)
else
file_path=$(echo "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:.*"\([^"]*\)"/\1/')
fi
if [ -z "$file_path" ]; then
exit 0
fi
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
# Claude Code and Codex deliver absolute paths; compare repo-relative so the
# root package.json matches in every harness.
case "$file_path" in
"$repo_root"/*) file_path="${file_path#"$repo_root"/}" ;;
/*) exit 0 ;;
esac
if [ "$file_path" = "package.json" ]; then
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$repo_root" || exit 0
echo "package.json changed - running corepack yarn install to update yarn.lock..."
corepack yarn install