Appearance
require-qa-ui.sh
.claude/hooks/require-qa-ui.sh
PreToolUse
Gate PR creation on a fresh qa-ui pass for UI diffs (#964). A PR whose diff touches resources/js or resources/css is required to have run qa-ui (or qa-everything, which includes it) against the CURRENT HEAD first. qa-ui/qa-everything write a marker file on completion (see the tail of both SKILL.md files); this hook checks that marker matches the branch + HEAD sha about to be opened as a PR. A docs/backend-only diff is never blocked — it just gets a reminder in the hook's context output. Registered in settings.json under PreToolUse for mcp__github__create_pull_request. jq parses the hook's stdin JSON.
Source
bash
#!/usr/bin/env bash
#
# PreToolUse hook — gate PR creation on a fresh qa-ui pass for UI diffs (#964).
#
# A PR whose diff touches resources/js or resources/css is required to have run
# qa-ui (or qa-everything, which includes it) against the CURRENT HEAD first.
# qa-ui/qa-everything write a marker file on completion (see the tail of both
# SKILL.md files); this hook checks that marker matches the branch + HEAD sha
# about to be opened as a PR. A docs/backend-only diff is never blocked — it just
# gets a reminder in the hook's context output.
#
# Registered in settings.json under PreToolUse for
# mcp__github__create_pull_request. jq parses the hook's stdin JSON.
set -euo pipefail
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
input="$(cat)"
tool="$(jq -r '.tool_name // empty' <<<"$input")"
[ "$tool" = "mcp__github__create_pull_request" ] || exit 0
marker=".claude/.qa-ui-pass"
skip_once=".claude/.qa-ui-skip-once"
diff_files="$(git diff origin/main...HEAD --name-only 2>/dev/null || true)"
touches_ui=0
grep -qE '^(resources/js|resources/css)/' <<<"$diff_files" && touches_ui=1
if [ "$touches_ui" = 0 ]; then
jq -n --arg ctx "Reminder: this PR's diff is docs/backend-only, so the qa-ui gate does not apply. If it does touch resources/js or resources/css indirectly, run /qa-ui or /qa-everything first." \
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $ctx}}'
exit 0
fi
# Escape hatch — consume the one-shot skip.
if [ -f "$skip_once" ]; then
rm -f "$skip_once"
jq -n --arg ctx "qa-ui gate skipped via .claude/.qa-ui-skip-once (consumed)." \
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $ctx}}'
exit 0
fi
branch="$(git branch --show-current)"
head_sha="$(git rev-parse HEAD)"
deny() {
jq -n --arg r "$1" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
exit 0
}
if [ ! -f "$marker" ]; then
deny "UI diff without a qa-ui pass — run /qa-ui (or /qa-everything) first. This PR touches resources/js or resources/css, and no ${marker} marker was found."
fi
read -r marker_branch marker_sha < "$marker" || true
if [ "$marker_branch" != "$branch" ] || [ "$marker_sha" != "$head_sha" ]; then
deny "Stale qa-ui pass — ${marker} was recorded for '${marker_branch:-?}' @ ${marker_sha:-?}, but this PR is '${branch}' @ ${head_sha}. Re-run /qa-ui (or /qa-everything) against the current HEAD first."
fi
exit 0