Hooks
What workout-gate runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add BotchetDig/workout-gate > /plugin install workout-gate@workout-gate
Ships with workout-gate. Installing the plugin gets these hooks.
What fires, and when
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
"${CLAUDE_PLUGIN_ROOT}/hooks/gate.sh"
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
"${CLAUDE_PLUGIN_ROOT}/hooks/session_start.sh"
Where it lives
- hooks/gate.pyGitHub
Read the script
#!/usr/bin/env python3 """UserPromptSubmit hook: the gate itself. Shared by all supported surfaces — Claude Code CLI + desktop and Codex CLI + desktop — all running this one file against the one runtime in ~/.workout-gate/. Codex's UserPromptSubmit payload carries the same prompt/session_id (plus turn_id), so no per-tool shim is needed here. Exit 0 = prompt goes through. Exit 2 = prompt blocked (stderr shown to user). Escape hatches (non-negotiable): - gate-management prompts are whitelisted so you can always turn it off: `/workout ...` (Claude slash command) AND the bare-word `workout ...` / `wg ...` forms (Codex may swallow a leading `/` as a UI command before the hook sees it) - WORKOUT_GATE_OFF=1 env var bypasses everything - config enabled=false bypasses - a challenge already running in another tool/session -> fail open (no second webcam window); the debt stays pending and gates again later - any unexpected error -> FAIL OPEN: prompt goes through, error logged to ~/.workout-gate/gate.log. A broken webcam must never lock you out. """ import hashlib import json import os import sys import time import traceback from pathlib import Path PROJECT_DIR = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_DIR)) from workout_gate import store, trigger # noqa: E402 def log(msg: str) -> None: try: with (store.data_dir() / "gate.log").open("a") as f: f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n") except OSError: pass def _is_escape(prompt: str) -> bool: """Gate-management prompts are never blocked, so you can always turn it off. `/workout` is the Claude slash command; the bare `workout`/`wg` forms are the fallback for Codex, where a leading `/` may be captured as a UI command before it ever reaches this hook.""" p = prompt.strip().lower() return (p.startswith("/workout") or p in ("workout", "wg") or p.startswith("workout ") or p.startswith("wg ")) def _source(payload: dict) -> str: """Which tool is making the user pay right now — drives the speaker name on the challenge window (same voice, just the tag). Priority: an explicit WORKOUT_GATE_SOURCE (set by the Codex project/global hook configs we write); then the plugin path (Codex exposes a bare PLUGIN_ROOT, Claude only ever sets CLAUDE_PLUGIN_ROOT); then a payload hint (non-Claude model); else Claude.""" env = os.environ.get("WORKOUT_GATE_SOURCE") if env: return env.lower() if os.environ.get("PLUGIN_ROOT"): return "codex" model = (payload.get("model") or "").lower() if model and not model.startswith("claude"): return "codex" return "claude" def duplicate_invocation(payload: dict, window_s: float = 5.0) -> bool: """True if another gate hook already handled this very prompt (the gate can be wired as plugin AND project/global hook at once - count it once). Keyed on session+turn+prompt: Codex provides turn_id, which pins the dedup to a single turn even if the same prompt text recurs.""" raw = (f"{payload.get('session_id', '')}:{payload.get('turn_id', '')}:" f"{payload.get('prompt', '')}") key = hashlib.md5(raw.encode()).hexdigest() path = store.data_dir() / "last-gate" now = time.time() # check-and-set under the shared lock, or plugin + global firing the same # prompt in parallel can both read a stale file and both pass. with store.locked(): try: prev_key, prev_ts = path.read_text().split(" ") if prev_key == key and now - float(prev_ts) < window_s: return True except (OSError, ValueError): pass path.write_text(f"{key} {now}") return False def main() -> int: if os.environ.get("WORKOUT_GATE_OFF") == "1": return 0 try: payload = json.load(sys.stdin) except json.JSONDecodeError: payload = {} if _is_escape(payload.get("prompt") or ""): return 0 config = store.load_config() if not config["enabled"]: return 0 if duplicate_invocation(payload): return 0 # Bump the prompt counter and decide under a cross-process lock so two tools # firing at once can't lose an increment or both trigger. def _bump(state): state["prompt_count"] += 1 return trigger.challenge_due(config, state) due = store.mutate_state(_bump) if not due: return 0 # Single-flight across tools: atomically claim the one challenge slot. If a # challenge is already active (another Claude/Codex session) this returns # False -> fail open (no second webcam window). The debt stays pending and # gates again once the active challenge is settled. if not store.try_claim_challenge(): log("challenge already active in another session — letting prompt through") return 0 try: # tag the challenge window with the tool that triggered this prompt os.environ["WORKOUT_GATE_SOURCE"] = _source(payload) from workout_gate import challenge # Persist the debt BEFORE opening the window: closing everything mid- # challenge keeps it owed for the next session. state = store.load_state() if state["debt_reps"] <= 0 and not state.get("debt_offers"): challenge.new_debt() owed = challenge.pending_summary(store.load_state()) log(f"challenge triggered: {owed} owed") # Under a desktop app the hook has no terminal of its own, so route the # webcam window through Terminal.app (camera permission, visible window). paid = (challenge.settle_external() if challenge.should_externalize() else challenge.settle_debt()) finally: store.clear_challenge_pid() # release the claim (webcam process clears its own) if paid: print(f"[workout-gate] The user just did {owed} to send this prompt.") return 0 # NON-BLO - hooks/gate.shRunsGitHub
Read the script
#!/bin/sh # Plugin-friendly gate entry, shared by Claude Code AND Codex (both expose # CLAUDE_PLUGIN_ROOT, and this script resolves from its own path anyway). # Prefer the vendored shared runtime (~/.workout-gate/app) so every tool runs # ONE version against the shared state; else the code dir this script lives in # (plugin cache, git clone, dev checkout). Fail open: a missing runtime must # never block a prompt. ROOT="$(cd "$(dirname "$0")/.." && pwd)" RT="${WORKOUT_GATE_DIR:-$HOME/.workout-gate}" [ -f "$RT/app/hooks/gate.py" ] && ROOT="$RT/app" PY="$RT/venv/bin/python" [ -x "$PY" ] || PY="$ROOT/.venv/bin/python" [ -x "$PY" ] || exit 0 # not bootstrapped yet; SessionStart handles onboarding exec "$PY" "$ROOT/hooks/gate.py" - hooks/session_start.shRunsGitHub
Read the script
#!/bin/sh # SessionStart hook: plugins can't run install scripts, so first-run setup # happens here - pop the bootstrap (deps + wizard) in a Terminal window, once. # stdout becomes session context, so Claude can tell the user what's going on. ROOT="$(cd "$(dirname "$0")/.." && pwd)" HOME_DIR="$HOME/.workout-gate" mkdir -p "$HOME_DIR" # keep the launcher pointed at the current code (plugin cache moves on update) echo "$ROOT" > "$HOME_DIR/app-path" PY="$HOME_DIR/venv/bin/python" [ -x "$PY" ] || PY="$ROOT/.venv/bin/python" MODEL="$HOME_DIR/models/pose_landmarker_full.task" # "Ready" means the runtime can actually run a challenge: venv python + deps # importable + pose model on disk. The old check was just `[ -x "$PY" ]`, but # bootstrap.sh creates the venv BEFORE installing deps and the model, so a first # run that died half-way (network blip, closed window) left a python binary with # no cv2/mediapipe/model - and this hook then treated it as set up forever, the # gate silently fail-opening on every challenge. Verify for real: the cheap # `ready` marker (written by bootstrap on success) is the fast path; an install # from before the marker existed is blessed once via a real import check. ready=0 if [ -x "$PY" ] && [ -f "$MODEL" ]; then if [ -f "$HOME_DIR/ready" ]; then ready=1 elif "$PY" -c "import cv2, mediapipe" >/dev/null 2>&1; then : > "$HOME_DIR/ready" ready=1 fi fi if [ "$ready" = 1 ]; then # set up: refresh launcher + vendor newest code, stay silent ( cd "$ROOT" && "$PY" -c "from workout_gate import installer; installer._install_launcher(); installer.sync_app()" ) >/dev/null 2>&1 || true exit 0 fi # Not ready: a first run, or a previous bootstrap that never finished. (Re)open # it - but only one at a time, so several sessions starting at once don't run # concurrent pip installs into the same venv. `mkdir` is the atomic claim; a # stale lock (>15 min = the old attempt died) is cleared first. bootstrap.sh # removes the lock and writes `ready` on success. LOCK="$HOME_DIR/bootstrapping" [ -n "$(find "$LOCK" -prune -mmin +15 2>/dev/null)" ] && rmdir "$LOCK" 2>/dev/null if ! mkdir "$LOCK" 2>/dev/null; then echo "Workout Gate is still finishing its first-run setup in another Terminal window (installing dependencies + pose model). Tell the user to let it complete; prompts aren't gated until it's done." exit 0 fi if command -v osascript >/dev/null 2>&1 && \ osascript -e 'tell application "Terminal"' -e 'activate' \ -e "do script \"exec '$ROOT/bootstrap.sh'\"" -e 'end tell' >/dev/null 2>&1; then echo "Workout Gate plugin: first-run setup just opened in a separate Terminal window (installs dependencies, then a 30-second wizard with a 2-pushup camera test). Let the user know and invite them to complete it there. Prompts are not gated until setup is done." else rmdir "$LOCK" 2>/dev/null # nothing will finish the bootstrap, so don't hold the lock echo "Workout Gate plugin is installed but not set up yet. Tell the user to run this once in a terminal: $ROOT/bootstrap.sh" fi exit 0
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Your AI works hard, so should you. A Claude Code and Codex hook that blocks your prompt until you work out — push-ups or squats, counted live via webcam. When a challenge fires you pick your pain (say 6 push-ups or 9 squats).

