Appearance
preflight-redirect.sh
.claude/hooks/preflight-redirect.sh
PreToolUse
Route composer ci:check through the preflight skill. The make-green loop (ci:check → fix → re-run, then the mutation gate) is the noisiest, most context-burning churn in a session. The preflight skill runs it in an ISOLATED fork and hands the main thread one verdict (PASS/BLOCKED), so the run-fix-rerun never floods the conversation. To make that the default path, this hook DENIES a bare composer ci:check in the main thread and redirects to /preflight. Preflight runs ci:check itself — from inside the fork it invokes it with the sentinel TEMPO_PREFLIGHT=1 composer ci:check, which this hook recognises and lets through. That sentinel is also the deliberate escape hatch: prefix it when you genuinely want a raw, in-thread run (mirrors block-local-mutation's hatch). Registered in settings.json under PreToolUse (Bash). jq parses the stdin JSON. Tested by preflight-redirect.test.sh.
Source
bash
#!/usr/bin/env bash
#
# PreToolUse (Bash) hook — route `composer ci:check` through the preflight skill.
#
# The make-green loop (ci:check → fix → re-run, then the mutation gate) is the
# noisiest, most context-burning churn in a session. The `preflight` skill runs it
# in an ISOLATED fork and hands the main thread one verdict (PASS/BLOCKED), so the
# run-fix-rerun never floods the conversation. To make that the default path, this
# hook DENIES a bare `composer ci:check` in the main thread and redirects to
# `/preflight`.
#
# Preflight runs ci:check itself — from inside the fork it invokes it with the
# sentinel `TEMPO_PREFLIGHT=1 composer ci:check`, which this hook recognises and
# lets through. That sentinel is also the deliberate escape hatch: prefix it when
# you genuinely want a raw, in-thread run (mirrors block-local-mutation's hatch).
#
# Registered in settings.json under PreToolUse (Bash). jq parses the stdin JSON.
# Tested by preflight-redirect.test.sh.
set -euo pipefail
input="$(cat)"
tool="$(jq -r '.tool_name // empty' <<<"$input")"
cmd="$(jq -r '.tool_input.command // empty' <<<"$input")"
[ "$tool" = "Bash" ] || exit 0
# A phrase counts only at a command boundary (start, or after ; && || |), so the
# hook fires on a real invocation — not on the phrase buried in an echo/grep arg.
at_boundary() { grep -qE "(^|[;&|])[[:space:]]*$1" <<<"$cmd"; }
at_boundary 'composer[[:space:]]+(run[[:space:]]+)?ci:check' || exit 0
# Preflight's own run carries the sentinel — let it through (also the escape hatch).
grep -qE 'TEMPO_PREFLIGHT' <<<"$cmd" && exit 0
read -r -d '' message <<'EOF' || true
Blocked: run `composer ci:check` through the preflight skill, not inline.
The make-green loop (ci:check → fix → re-run, then the `mutation (diff-scoped)` gate) is the noisiest churn in a session. `preflight` runs it in an isolated fork and returns one verdict (PASS / BLOCKED), keeping the run-fix-rerun out of this thread.
→ Invoke the `preflight` skill (`/preflight`) instead of running ci:check here.
Escape hatch — if you genuinely need a raw, in-thread run (preflight uses this internally): prefix the sentinel, `TEMPO_PREFLIGHT=1 composer ci:check`.
EOF
jq -n --arg r "$message" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
exit 0