From 3fc9f0fa194994ad99c96ceada8f8c54e559dcc9 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Wed, 15 Apr 2026 13:31:13 +0700 Subject: [PATCH] chore(agent hooks): remind on new React effects and memos --- .claude/hooks.json | 8 + .claude/hooks/react-pattern-review.sh | 4 + .codex/hooks.json | 8 + .codex/hooks/react-pattern-review.sh | 4 + .cursor/hooks.json | 8 + .cursor/hooks/react-pattern-review.sh | 4 + AGENTS.md | 1 + docs/agent-playbooks/hooks-setup.md | 3 + scripts/agent-hooks/react-pattern-review.sh | 177 ++++++++++++++++++++ 9 files changed, 217 insertions(+) create mode 100755 .claude/hooks/react-pattern-review.sh create mode 100755 .codex/hooks/react-pattern-review.sh create mode 100755 .cursor/hooks/react-pattern-review.sh create mode 100755 scripts/agent-hooks/react-pattern-review.sh diff --git a/.claude/hooks.json b/.claude/hooks.json index f328ec43..1b96c07d 100644 --- a/.claude/hooks.json +++ b/.claude/hooks.json @@ -9,6 +9,10 @@ { "command": ".claude/hooks/yarn-install.sh", "timeout": 120 + }, + { + "command": ".claude/hooks/react-pattern-review.sh", + "timeout": 10 } ], "stop": [ @@ -16,6 +20,10 @@ "command": ".claude/hooks/sync-git-branches.sh", "timeout": 60 }, + { + "command": ".claude/hooks/react-pattern-review.sh", + "timeout": 10 + }, { "command": ".claude/hooks/verify.sh", "timeout": 60 diff --git a/.claude/hooks/react-pattern-review.sh b/.claude/hooks/react-pattern-review.sh new file mode 100755 index 00000000..6b128960 --- /dev/null +++ b/.claude/hooks/react-pattern-review.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +exec "$repo_root/scripts/agent-hooks/react-pattern-review.sh" --skill-dir .claude/skills --scope-prefix src/ "$@" diff --git a/.codex/hooks.json b/.codex/hooks.json index e779144a..483fc302 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -9,6 +9,10 @@ { "command": ".codex/hooks/yarn-install.sh", "timeout": 120 + }, + { + "command": ".codex/hooks/react-pattern-review.sh", + "timeout": 10 } ], "stop": [ @@ -16,6 +20,10 @@ "command": ".codex/hooks/sync-git-branches.sh", "timeout": 60 }, + { + "command": ".codex/hooks/react-pattern-review.sh", + "timeout": 10 + }, { "command": ".codex/hooks/verify.sh", "timeout": 60 diff --git a/.codex/hooks/react-pattern-review.sh b/.codex/hooks/react-pattern-review.sh new file mode 100755 index 00000000..159a5c1e --- /dev/null +++ b/.codex/hooks/react-pattern-review.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +exec "$repo_root/scripts/agent-hooks/react-pattern-review.sh" --skill-dir .codex/skills --scope-prefix src/ "$@" diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 98f92367..a0b1a9ff 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -9,6 +9,10 @@ { "command": ".cursor/hooks/yarn-install.sh", "timeout": 120 + }, + { + "command": ".cursor/hooks/react-pattern-review.sh", + "timeout": 10 } ], "stop": [ @@ -16,6 +20,10 @@ "command": ".cursor/hooks/sync-git-branches.sh", "timeout": 60 }, + { + "command": ".cursor/hooks/react-pattern-review.sh", + "timeout": 10 + }, { "command": ".cursor/hooks/verify.sh", "timeout": 60 diff --git a/.cursor/hooks/react-pattern-review.sh b/.cursor/hooks/react-pattern-review.sh new file mode 100755 index 00000000..9952745d --- /dev/null +++ b/.cursor/hooks/react-pattern-review.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +exec "$repo_root/scripts/agent-hooks/react-pattern-review.sh" --skill-dir .cursor/skills --scope-prefix src/ "$@" diff --git a/AGENTS.md b/AGENTS.md index e8ac67d0..f203a65f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,7 @@ src/ - When changing shared agent behavior, update the relevant files in `.codex/skills/`, `.cursor/skills/`, `.claude/skills/`, `.codex/agents/`, `.cursor/agents/`, `.claude/agents/`, `.codex/hooks/`, `.cursor/hooks/`, `.claude/hooks/`, and their `hooks.json` or config entry points as needed. - If `AGENTS.md` references a skill, agent, or hook, prefer a tracked file under `.codex/`, `.cursor/`, or `.claude/` rather than an untracked local-only instruction. - Review `.codex/config.toml`, `.cursor/hooks.json`, and `.claude/hooks.json` before changing agent orchestration or hook behavior, because they are the entry points contributors will actually load. +- When a diff adds new `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useMemo`, `useCallback`, or `memo(...)` usage under `src/`, treat the repo hook reminder as mandatory and reconsider the change with `you-might-not-need-an-effect` and `vercel-react-best-practices` before finishing. - Directory-specific auto-loaded rules live under `src/AGENTS.md` and `scripts/AGENTS.md`; read them before editing files in those trees. - For work expected to span multiple sessions, keep explicit task state in a `feature-list.json` plus `progress.md` pair using `docs/agent-playbooks/long-running-agent-workflow.md`. - If more than one human or toolchain needs the same task state, keep it in a tracked location such as `docs/agent-runs//` instead of burying it in a tool-specific hidden directory. diff --git a/docs/agent-playbooks/hooks-setup.md b/docs/agent-playbooks/hooks-setup.md index 65389915..1b4e8760 100644 --- a/docs/agent-playbooks/hooks-setup.md +++ b/docs/agent-playbooks/hooks-setup.md @@ -8,13 +8,16 @@ If your AI coding assistant supports lifecycle hooks, configure these for this r |---|---|---| | `afterFileEdit` | `scripts/agent-hooks/format.sh` | Auto-format files after AI edits | | `afterFileEdit` | `scripts/agent-hooks/yarn-install.sh` | Run `corepack yarn install` when `package.json` changes | +| `afterFileEdit` | `scripts/agent-hooks/react-pattern-review.sh` | When a diff adds `useEffect`/memo primitives in `src/`, remind the agent to reconsider with the React review skills | | `stop` | `scripts/agent-hooks/sync-git-branches.sh` | Prune stale refs and delete integrated temporary task branches | +| `stop` | `scripts/agent-hooks/react-pattern-review.sh` | Re-scan the current diff for new React effects/memos before the final verify gate | | `stop` | `scripts/agent-hooks/verify.sh` | Hard-gate build, lint, and type-check; keep `yarn audit` informational | ## Why - Consistent formatting - Lockfile stays in sync +- New `useEffect`/memo additions get an explicit second look before the agent finishes - Build/lint/type issues caught early - Security visibility via `corepack yarn npm audit` - One shared hook implementation for both Codex and Cursor diff --git a/scripts/agent-hooks/react-pattern-review.sh b/scripts/agent-hooks/react-pattern-review.sh new file mode 100755 index 00000000..7826ad94 --- /dev/null +++ b/scripts/agent-hooks/react-pattern-review.sh @@ -0,0 +1,177 @@ +#!/bin/bash + +# afterFileEdit/stop hook: remind agents to review new React effects and memoization + +set -u + +input="$(cat)" + +skill_dir="" +scope_prefixes=() + +while [ "$#" -gt 0 ]; do + case "$1" in + --skill-dir) + skill_dir="${2:-}" + shift 2 + ;; + --scope-prefix) + scope_prefixes+=("${2:-}") + shift 2 + ;; + *) + shift + ;; + esac +done + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$repo_root" || exit 0 + +extract_file_path() { + if command -v jq >/dev/null 2>&1; then + printf '%s' "$input" | jq -r '.file_path // empty' 2>/dev/null + return + fi + + echo "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:.*"\([^"]*\)"/\1/' +} + +is_source_file() { + case "$1" in + *.js|*.jsx|*.ts|*.tsx|*.mjs|*.cjs) return 0 ;; + *) return 1 ;; + esac +} + +matches_scope() { + local candidate="$1" + + if [ "${#scope_prefixes[@]}" -eq 0 ]; then + return 0 + fi + + local prefix + for prefix in "${scope_prefixes[@]}"; do + case "$candidate" in + "$prefix"*) return 0 ;; + esac + done + + return 1 +} + +parse_matches_from_diff() { + awk ' + /^\+\+\+ b\// { + file = substr($0, 7) + next + } + + /^\+[^+]/ { + line = substr($0, 2) + if (line ~ /(^|[^[:alnum:]_])(useEffect|useLayoutEffect|useInsertionEffect|useMemo|useCallback)[[:space:]]*[(<]/ || line ~ /(^|[^[:alnum:]_])React\.(useEffect|useLayoutEffect|useInsertionEffect|useMemo|useCallback|memo)[[:space:]]*[(<]/ || line ~ /(^|[^[:alnum:]_])memo[[:space:]]*[(<]/) { + print file ": " line + } + } + ' +} + +scan_untracked_file() { + local file_path="$1" + + [ -f "$file_path" ] || return 0 + + awk -v file="$file_path" ' + { + line = $0 + if (line ~ /(^|[^[:alnum:]_])(useEffect|useLayoutEffect|useInsertionEffect|useMemo|useCallback)[[:space:]]*[(<]/ || line ~ /(^|[^[:alnum:]_])React\.(useEffect|useLayoutEffect|useInsertionEffect|useMemo|useCallback|memo)[[:space:]]*[(<]/ || line ~ /(^|[^[:alnum:]_])memo[[:space:]]*[(<]/) { + print file ": " line + } + } + ' "$file_path" +} + +append_results() { + local existing="$1" + local incoming="$2" + + if [ -z "$incoming" ]; then + printf '%s' "$existing" + return + fi + + if [ -z "$existing" ]; then + printf '%s' "$incoming" + return + fi + + printf '%s\n%s' "$existing" "$incoming" +} + +results="" +file_path="$(extract_file_path)" + +if [ -n "$file_path" ]; then + if is_source_file "$file_path" && matches_scope "$file_path"; then + if git ls-files --others --exclude-standard -- "$file_path" | grep -q '.'; then + results="$(scan_untracked_file "$file_path")" + else + diff_output="$(git diff --no-ext-diff --unified=0 --no-color HEAD -- "$file_path" 2>/dev/null || true)" + results="$(printf '%s\n' "$diff_output" | parse_matches_from_diff)" + fi + fi +else + diff_output="$(git diff --no-ext-diff --unified=0 --no-color HEAD -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs' 2>/dev/null || true)" + results="$(printf '%s\n' "$diff_output" | parse_matches_from_diff)" + + while IFS= read -r untracked_file; do + [ -z "$untracked_file" ] && continue + is_source_file "$untracked_file" || continue + matches_scope "$untracked_file" || continue + file_results="$(scan_untracked_file "$untracked_file")" + results="$(append_results "$results" "$file_results")" + done < <(git ls-files --others --exclude-standard -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs') +fi + +results="$(printf '%s\n' "$results" | sed '/^$/d' | awk '!seen[$0]++')" + +if [ -z "$results" ]; then + exit 0 +fi + +effect_skill="you-might-not-need-an-effect" +if [ -n "$skill_dir" ] && [ -f "$repo_root/$skill_dir/you-might-not-need-an-effect/SKILL.md" ]; then + effect_skill="$repo_root/$skill_dir/you-might-not-need-an-effect/SKILL.md" +fi + +vercel_skill="vercel-react-best-practices" +if [ -n "$skill_dir" ] && [ -f "$repo_root/$skill_dir/vercel-react-best-practices/SKILL.md" ]; then + vercel_skill="$repo_root/$skill_dir/vercel-react-best-practices/SKILL.md" +fi + +echo "=== React Hook Review Reminder ===" +echo "New React effect or memo primitives were added in the current diff:" + +match_count=0 +while IFS= read -r match_line; do + [ -z "$match_line" ] && continue + match_count=$((match_count + 1)) + if [ "$match_count" -le 10 ]; then + echo "- $match_line" + fi +done <<< "$results" + +if [ "$match_count" -gt 10 ]; then + echo "- ... and $((match_count - 10)) more" +fi + +echo "Reconsider this change with:" +echo "- $effect_skill" +echo "- $vercel_skill" +echo "Questions to resolve before finishing:" +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?" + +exit 0