Skip to content
Development
Hook

Hooks

What ultraship runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
ultraship
12345 skills13 agents16 commands3 hooks
+1
Install
> /plugin marketplace add Houseofmvps/ultraship
> /plugin install ultraship@ultraship

Ships with ultraship. Installing the plugin gets these hooks.

What fires, and when

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.

  • bash "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh"

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.

  • bash "${CLAUDE_PLUGIN_ROOT}/hooks/currency-guard.sh"

PostCompact

  • bash "${CLAUDE_PLUGIN_ROOT}/hooks/post-compact.sh"
Read hooks/hooks.json

Where it lives

  • hooks/currency-guard.shRunsGitHub
    Read the script
    #!/bin/bash
    # Ultraship Currency Guard — UserPromptSubmit hook
    #
    # Models answer version-sensitive questions from training data that may be
    # months or years stale. This hook fires on every prompt, detects when the
    # prompt is about something whose correct answer changes over time (library
    # APIs, versions, pricing, model IDs, "latest" anything), and injects a
    # deterministic directive telling Claude to verify against current sources
    # (context7 for library docs, WebSearch/WebFetch for everything else) instead
    # of relying on training data.
    #
    # This is a reminder, not a block — it exits 0 and only adds context when a
    # currency-sensitive trigger is present, so normal prompts are untouched.
    
    # Read the hook payload from stdin (JSON with a `prompt` field).
    payload=$(cat)
    
    # Extract the prompt text. Prefer python3 for robust JSON parsing; fall back to
    # a sed extraction if python3 is unavailable.
    prompt=""
    if command -v python3 >/dev/null 2>&1; then
      prompt=$(printf '%s' "$payload" | python3 -c 'import sys,json
    try:
        print(json.load(sys.stdin).get("prompt",""))
    except Exception:
        pass' 2>/dev/null)
    fi
    if [ -z "$prompt" ]; then
      # Fallback: grab the value of the first "prompt": "..." occurrence.
      prompt=$(printf '%s' "$payload" | sed -n 's/.*"prompt"[[:space:]]*:[[:space:]]*"\(.*\)/\1/p' | head -c 4000)
    fi
    
    # Lowercase for matching.
    lc=$(printf '%s' "$prompt" | tr '[:upper:]' '[:lower:]')
    
    # If the prompt is empty, do nothing.
    if [ -z "$lc" ]; then
      exit 0
    fi
    
    # Currency-sensitive triggers. Two buckets:
    #   1. Generic recency/version words.
    #   2. Library/framework/tool/SDK/API discussion, which is version-sensitive.
    # Word-boundary-ish matching via grep -E. Keep this fast and conservative —
    # false positives just add a short reminder, false negatives miss enforcement.
    generic_re='(latest|newest|current|up[ -]?to[ -]?date|recently|this year|2025|2026|deprecat|breaking change|migrat|upgrade|release notes|changelog)'
    versionish_re='\bv?[0-9]+\.[0-9x]+'
    techterm_re='(api|sdk|cli|library|framework|package|npm|pypi|crate|gem|endpoint|model id|model name|pricing|price|cost per|rate limit|docs|documentation|install|config|version)'
    # Named ecosystems that move fast (high-signal). Extend freely.
    named_re='(next\.?js|react|vue|svelte|astro|hono|express|fastify|nest|drizzle|prisma|tailwind|shadcn|vite|bun|deno|node|typescript|stripe|polar|supabase|vercel|railway|cloudflare|openai|anthropic|claude|gpt|gemini|llama|langchain|playwright|puppeteer)'
    
    inject=false
    reason=""
    
    if printf '%s' "$lc" | grep -Eq "$generic_re"; then
      inject=true
      reason="recency/version language"
    elif printf '%s' "$lc" | grep -Eq "$versionish_re"; then
      inject=true
      reason="a specific version number"
    elif printf '%s' "$lc" | grep -Eq "$named_re" && printf '%s' "$lc" | grep -Eq "$techterm_re"; then
      inject=true
      reason="a fast-moving library/tool"
    fi
    
    if [ "$inject" != true ]; then
      exit 0
    fi
    
    # Build the directive. Single line, escaped for JSON.
    msg="Ultraship Currency Guard: this prompt touches ${reason}, where training data is often stale. BEFORE answering, verify against current sources — use the context7 MCP (resolve-library-id then query-docs) for any library/framework/SDK API, and WebSearch/WebFetch for versions, pricing, model IDs, release notes, or anything time-sensitive. Do not state version-specific facts, API signatures, or prices from memory. If you cannot verify, say so explicitly rather than guessing."
    
    # Escape backslashes and double quotes for valid JSON.
    esc=$(printf '%s' "$msg" | sed 's/\\/\\\\/g; s/"/\\"/g')
    
    printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"%s"}}' "$esc"
    exit 0
    
  • hooks/guard-bash.shGitHub
    Read the script
    #!/bin/bash
    # Ultraship Guard — PreToolUse hook for Bash commands
    # Blocks destructive commands and warns before execution
    # Reads the tool input from stdin (JSON with "input" field containing the command)
    
    INPUT=$(cat)
    # Extract the command value from JSON — handle escaped quotes
    # Use grep to get the "command":"value" pair, then strip the key prefix and trailing quote+brace
    COMMAND=$(printf '%s' "$INPUT" | tr -d '\n' | sed 's/.*"command"[[:space:]]*:[[:space:]]*"//;s/"[[:space:]]*[,}].*//' | sed 's/\\"/"/g')
    
    if [ -z "$COMMAND" ]; then
      # No command found — allow
      exit 0
    fi
    
    # Destructive patterns to block
    BLOCKED=false
    REASON=""
    
    # rm -rf anywhere in the command (catches piped, chained, and direct usage)
    if echo "$COMMAND" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|--recursive)'; then
      BLOCKED=true
      REASON="Destructive recursive removal (rm -rf)"
    fi
    
    # DROP TABLE / DROP DATABASE / TRUNCATE
    if echo "$COMMAND" | grep -qiE '(DROP\s+(TABLE|DATABASE)|TRUNCATE\s+TABLE)'; then
      BLOCKED=true
      REASON="SQL destructive operation — DROP/TRUNCATE"
    fi
    
    # git push --force to main/master
    if echo "$COMMAND" | grep -qE 'git\s+push\s+(-[a-zA-Z]*f|--force).*\s+(main|master)'; then
      BLOCKED=true
      REASON="Force-push to main/master branch"
    fi
    
    # git reset --hard
    if echo "$COMMAND" | grep -qE 'git\s+reset\s+--hard'; then
      BLOCKED=true
      REASON="git reset --hard — discards all uncommitted changes"
    fi
    
    # git checkout . (discard all changes)
    if echo "$COMMAND" | grep -qE 'git\s+checkout\s+\.$'; then
      BLOCKED=true
      REASON="git checkout . — discards all working directory changes"
    fi
    
    # git clean -f
    if echo "$COMMAND" | grep -qE 'git\s+clean\s+-[a-zA-Z]*f'; then
      BLOCKED=true
      REASON="git clean -f — permanently removes untracked files"
    fi
    
    # git branch -D (force delete)
    if echo "$COMMAND" | grep -qE 'git\s+branch\s+-D\s'; then
      BLOCKED=true
      REASON="git branch -D — force-deletes branch without merge check"
    fi
    
    # kubectl delete
    if echo "$COMMAND" | grep -qE 'kubectl\s+delete'; then
      BLOCKED=true
      REASON="kubectl delete — removes Kubernetes resources"
    fi
    
    # docker system prune / docker volume rm
    if echo "$COMMAND" | grep -qE 'docker\s+(system\s+prune|volume\s+rm)'; then
      BLOCKED=true
      REASON="Docker destructive operation"
    fi
    
    # git restore . (discard all working directory changes)
    if echo "$COMMAND" | grep -qE 'git\s+restore\s+\.$'; then
      BLOCKED=true
      REASON="git restore . — discards all working directory changes"
    fi
    
    # base64 decode piped to shell (encoded destructive commands)
    if echo "$COMMAND" | grep -qE 'base64\s+(-d|--decode).*\|\s*(ba)?sh'; then
      BLOCKED=true
      REASON="base64-encoded command piped to shell — potential destructive payload"
    fi
    
    # curl/wget piped to shell (remote code execution)
    if echo "$COMMAND" | grep -qE '(curl|wget)\s.*\|\s*(ba)?sh'; then
      BLOCKED=true
      REASON="Remote script piped to shell — potential code execution"
    fi
    
    # Python/Perl destructive one-liners
    if echo "$COMMAND" | grep -qE 'python[23]?\s+-c\s.*\b(rmtree|unlink|remove)\b'; then
      BLOCKED=true
      REASON="Python destructive filesystem operation"
    fi
    if echo "$COMMAND" | grep -qE 'perl\s+-e\s.*\b(rmtree|unlink)\b'; then
      BLOCKED=true
      REASON="Perl destructive filesystem operation"
    fi
    
    # xargs with destructive git/rm commands
    if echo "$COMMAND" | grep -qE 'xargs\s.*\b(rm\s+-rf|git\s+push\s+--force)'; then
      BLOCKED=true
      REASON="xargs chaining destructive command"
    fi
    
    if [ "$BLOCKED" = true ]; then
      echo "⚠️  GUARD BLOCKED: $REASON"
      echo "Command: $COMMAND"
      echo "To proceed, explicitly confirm this action."
      exit 2
    fi
    
    exit 0
    
  • hooks/guard-edit.shGitHub
    Read the script
    #!/bin/bash
    # Ultraship Guard — PreToolUse hook for Edit/Write commands
    # Blocks edits outside the frozen directory (if set)
    # Reads tool input from stdin
    
    FREEZE_FILE="${PWD}/.ultraship/guard-freeze.txt"
    
    # If no freeze file, allow all edits
    if [ ! -f "$FREEZE_FILE" ]; then
      exit 0
    fi
    
    FREEZE_DIR=$(cat "$FREEZE_FILE" 2>/dev/null | head -1 | tr -d '[:space:]')
    
    if [ -z "$FREEZE_DIR" ]; then
      exit 0
    fi
    
    # Extract file_path from the input JSON
    INPUT=$(cat)
    FILE_PATH=$(echo "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
    
    if [ -z "$FILE_PATH" ]; then
      exit 0
    fi
    
    # Resolve to absolute path for comparison
    RESOLVED_FREEZE=$(cd "$PWD" && realpath "$FREEZE_DIR" 2>/dev/null || echo "$PWD/$FREEZE_DIR")
    RESOLVED_FILE=$(realpath "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
    
    # Check if the file is within the frozen directory
    case "$RESOLVED_FILE" in
      "$RESOLVED_FREEZE"/*)
        # File is within the allowed directory
        exit 0
        ;;
      "$RESOLVED_FREEZE")
        # File is the directory itself (unlikely but safe)
        exit 0
        ;;
      *)
        echo "⚠️  GUARD BLOCKED: Edit outside frozen directory"
        echo "File: $FILE_PATH"
        echo "Allowed directory: $FREEZE_DIR"
        echo "To edit files outside this directory, run /unfreeze first."
        exit 2
        ;;
    esac
    
  • hooks/post-compact.shRunsGitHub
    Read the script
    #!/bin/bash
    # Ultraship PostCompact hook
    # Re-injects essential context after conversation compaction
    # This prevents ultraship state from being lost in long sessions
    
    CONTEXT=""
    
    # Check if guard is active
    FREEZE_FILE="${PWD}/.ultraship/guard-freeze.txt"
    if [ -f "$FREEZE_FILE" ]; then
      FREEZE_DIR=$(cat "$FREEZE_FILE" 2>/dev/null | head -1 | tr -d '[:space:]')
      if [ -n "$FREEZE_DIR" ]; then
        CONTEXT="Ultraship Guard is ACTIVE — edits restricted to: ${FREEZE_DIR}. Run /guard to manage."
      fi
    fi
    
    # Remind about available Ultraship commands
    CONTEXT="${CONTEXT}\\nUltraship plugin is active. Key commands: /ship (pre-deploy audit), /seo (SEO audit), /pentest (security test), /guard (safety), /sprint (workflow), /investigate (debug), /rescue (incidents), /compete (competitor analysis), /seo-strategy (elite SEO), /canary (post-deploy check), /retro (retrospective), /learn (project knowledge)."
    
    # Check CLAUDE.md freshness
    CLAUDE_MD="$PWD/CLAUDE.md"
    if [ -f "$CLAUDE_MD" ]; then
      if [ "$(uname)" = "Darwin" ]; then
        mod_epoch=$(stat -f %m "$CLAUDE_MD")
      else
        mod_epoch=$(stat -c %Y "$CLAUDE_MD")
      fi
      now_epoch=$(date +%s)
      age_days=$(( (now_epoch - mod_epoch) / 86400 ))
      if [ "$age_days" -ge 7 ]; then
        CONTEXT="${CONTEXT}\\nCLAUDE.md is ${age_days} days old — consider /revise-claude-md."
      fi
    fi
    
    # Output as hookSpecificOutput
    msg=$(printf '%s' "$CONTEXT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/	/\\t/g')
    printf '{"hookSpecificOutput":{"hookEventName":"PostCompact","additionalContext":"%s"}}' "$msg"
    
  • hooks/session-start.shRunsGitHub
    Read the script
    #!/bin/bash
    # Ultraship SessionStart hook
    # Checks CLAUDE.md existence/freshness and enforces memory-first behavior
    
    CLAUDE_MD="$PWD/CLAUDE.md"
    CONTEXT=""
    
    # Helper: safely output JSON with escaped strings
    json_context() {
      local msg="$1"
      # Escape backslashes, double quotes, and control characters for valid JSON
      msg=$(printf '%s' "$msg" | sed 's/\\/\\\\/g; s/"/\\"/g; s/	/\\t/g')
      printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}' "$msg"
    }
    
    # --- Memory check ---
    # Look for MEMORY.md in common locations
    MEMORY_LOCATIONS=(
      "$HOME/.claude/projects/$(echo "$PWD" | tr '/' '-')/memory/MEMORY.md"
      "$PWD/.claude/memory/MEMORY.md"
      "$HOME/.claude/MEMORY.md"
    )
    
    MEMORY_FOUND=false
    for loc in "${MEMORY_LOCATIONS[@]}"; do
      if [ -f "$loc" ]; then
        MEMORY_FOUND=true
        break
      fi
    done
    
    if [ "$MEMORY_FOUND" = true ]; then
      CONTEXT="IMPORTANT: Read MEMORY.md and relevant memory files BEFORE performing any task. This ensures persistent context across sessions. Never skip this step."
    else
      CONTEXT="No memory files found. Consider setting up auto-memory (MEMORY.md) for persistent context across sessions."
    fi
    
    # --- CLAUDE.md check ---
    if [ ! -f "$CLAUDE_MD" ]; then
      CONTEXT="${CONTEXT}\\nNo CLAUDE.md found in this project directory (${PWD}). Offer to create one based on the project structure — check package.json, directory layout, and any existing README for context."
      json_context "$CONTEXT"
      exit 0
    fi
    
    if [ "$(uname)" = "Darwin" ]; then
      mod_epoch=$(stat -f %m "$CLAUDE_MD")
    else
      mod_epoch=$(stat -c %Y "$CLAUDE_MD")
    fi
    now_epoch=$(date +%s)
    age_days=$(( (now_epoch - mod_epoch) / 86400 ))
    
    if [ "$age_days" -ge 7 ]; then
      CONTEXT="${CONTEXT}\\nCLAUDE.md in this project is ${age_days} days old. Consider running /revise-claude-md to keep it current."
    fi
    
    json_context "$CONTEXT"
    

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.

Ships withultraship

"ULTRASHIP" Claude Code plugin — 39 skills, 33 tools, 11 agents for ship-ready workflows: planning, review, pentesting, safety guardrails, canary monitoring, SEO/AI-readiness check, penetration testing, code review, competitive analysis, incident response. 1 dependency. 180 tests. MIT.

Get the whole plugin