Read the script
#!/bin/sh
# vibecheck — the gate.
#
# Three modes:
# (no args) PreToolUse hook. Reads the hook JSON on stdin. Exit 2 blocks the push.
# --diff Print the outgoing diff. The exam reads this.
# --pass Record a receipt for the current outgoing diff. The exam calls this after you pass.
#
# The receipt is a hash of the diff you were examined on. The gate lets a push
# through only when that hash still matches what you're about to send. Add a
# commit and the hash moves, so you sit the exam again.
set -eu
EMPTY_TREE=4b825dc642cb6eb9a060e54bf8d69288fbee4904
RECEIPT=""
# Our own absolute path — resolved before any cd, so we can hand it to the exam.
SELF=$(cd "$(dirname "$0")" && pwd)/$(basename "$0")
# Anchor to the repo root. Run from a subdirectory, a relative receipt path is
# never found (you'd pass the exam and stay blocked forever) and `git diff -- .`
# would only see the part of the push under your cwd. Returns 1 if not a repo.
# `--absolute-git-dir`, not a literal .git/, because in a worktree or submodule
# .git is a file pointing elsewhere.
anchor() {
root=$(git rev-parse --show-toplevel 2>/dev/null) || return 1
cd "$root" || return 1
gd="$(git rev-parse --absolute-git-dir)"
RECEIPT="$gd/vibecheck-receipt"
CHALLENGE="$gd/vibecheck-challenge"
}
# --- config (all optional; absent file means defaults) ------------------------
# Only the two keys that decide *whether to examine at all* are read here. The
# rest of .vibecheck.toml is read by the exam skill itself.
config() { sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//p" .vibecheck.toml 2>/dev/null | head -1; }
# Defaults to 1: if it isn't an ignored file, you get examined on it.
# Size is not a proxy for risk — `def is_admin(u): return True` is two lines. The
# ignore globs are the right filter for "don't bother me", not a line threshold.
min_lines() { l=$(config min_lines | tr -cd '0-9'); echo "${l:-1}"; }
# Ignore globs become git pathspecs, so ignored files never reach the exam.
excludes() {
raw=$(config ignore | tr -d '[]"' )
[ -n "${raw:-}" ] || raw='*.lock, dist/**, *.md, package-lock.json'
echo "$raw" | tr ',' '\n' | while read -r g; do
g=$(echo "$g" | xargs 2>/dev/null || true)
[ -n "$g" ] && printf ':(exclude,glob)%s\n' "$g"
done
}
# --- the outgoing diff --------------------------------------------------------
# What `git push` would actually send: everything on HEAD since it forked from
# the branch we're tracking. `A...HEAD` diffs from the merge-base, so a stale
# upstream doesn't drag unrelated changes into the exam.
base() {
if up=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then
git merge-base "$up" HEAD 2>/dev/null && return 0
fi
# No upstream yet — this branch has never been pushed. Fork point from the
# remote's default branch, falling back through the usual suspects. Skip the
# branch we're standing on: merge-base(main, HEAD) while *on* main is HEAD
# itself, which would diff HEAD against HEAD and wave the whole push through.
here=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
for ref in origin/HEAD origin/main origin/master main master; do
[ "$ref" = "$here" ] && continue
if git rev-parse --verify --quiet "$ref" >/dev/null 2>&1; then
git merge-base "$ref" HEAD 2>/dev/null && return 0
fi
done
echo "$EMPTY_TREE" # never pushed anywhere: all of it is new
}
outgoing() {
# shellcheck disable=SC2046
git diff "$(base)" HEAD -- . $(excludes) 2>/dev/null || true
}
hash_outgoing() {
outgoing | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d' ' -f1
}
# The receipt: line 1 is the hash of the diff you were examined on, line 2 marks an
# exam that was skipped rather than passed. It is valid only for the exact diff it
# was issued against — add a commit and the hash moves, so you sit a new exam.
#
# This is a forcing function, not a security control. The hash is derivable from the
# repo, so a model with a shell could forge the receipt and skip the exam. It doesn't,
# because in a session you are watching, you would see it happen: no questions, just a
# push. The human in the loop is the enforcement. See the README on what that means
# for unattended runs.
receipt_ok() {
[ -f "$RECEIPT" ] || return 1
[ "$(head -1 "$RECEIPT")" = "$(hash_outgoing)" ] || return 1
}
unlock() { # $1 = "SKIPPED" or empty
d=$(outgoing)
[ -n "$d" ] || { echo "vibecheck: nothing outgoing, no receipt issued." >&2; exit 1; }
printf '%s\n%s\n' "$(hash_outgoing)" "${1:-}" > "$RECEIPT"
}
# --- modes --------------------------------------------------------------------
case "${1:-}" in
--guide)
# Print the shared, tool-neutral exam guide. Ships next to this script; every
# adapter (Claude skill, OpenCode plugin, Codex hook) points the model here so
# they all get the same question-craft. No repo needed.
d=$(dirname "$SELF")
for g in "$d/EXAM-GUIDE.md" "$d/../EXAM-GUIDE.md"; do
[ -f "$g" ] && { cat "$g"; exit 0; }
done
echo "vibecheck: EXAM-GUIDE.md not found next to the gate." >&2; exit 1 ;;
--diff)
anchor || { echo "vibecheck: not a git repository." >&2; exit 1; }
outgoing; exit 0 ;;
--pass)
anchor || { echo "vibecheck: not a git repository." >&2; exit 1; }
unlock ""
echo "🧪 vibecheck passed. Push unlocked."
exit 0 ;;
--skip)
anchor || { echo "vibecheck: not a git repository." >&2; exit 1; }
unlock "SKIPPED"
echo "🧪 vibecheck SKIPPED — no exam taken. Pushing code nobody has been checked on."
exit 0 ;;
esac
# --- hook mode ----------------------------------------------------------------
payload=$(cat)
# Pull a string field out of the hook payload. jq if it's there, python3 if not,
# and failing both, sed — because a gate that quietly stops gating on a machine
# without jq installed is worse than no gate at all.
#
# The sed path: cut everything up to the key, protect escaped quotes behind a
# sentinel, cut at the closing quot