Skip to content
Development
Hook

Hooks

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

From plugin
hcf
744 skills3 agents3 hooks
Install
> /plugin marketplace add markshust/hcf
> /plugin install hcf@hcf

Ships with hcf. 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.

  • Matchesstartup|resume|clear${CLAUDE_PLUGIN_ROOT}/hooks/detect-legacy-pipeline.sh

PreToolUse

  • MatchesSkill${CLAUDE_PLUGIN_ROOT}/hooks/gate-skill.sh

UserPromptExpansion

  • Matchesplan-create|plan-orchestrate${CLAUDE_PLUGIN_ROOT}/hooks/gate-command.sh
Read hooks/hooks.json

In the plugin's words

How hcf describes its own hook set.

HCF lifecycle hooks. Detects a legacy .claude/pipeline.md and blocks the planning workflow (plan-create / plan-orchestrate) until it is migrated with /hcf:project-update.

Where it lives

  • hooks/detect-legacy-pipeline.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # HCF SessionStart hook — surfaces a legacy .claude/pipeline.md and urges migration.
    # Self-silences (exit 0, no output) when there is no legacy file, so it goes quiet
    # the moment /hcf:project-update removes the file. Enforcement of the block lives in the
    # PreToolUse / UserPromptExpansion gates; this hook is the up-front heads-up.
    
    DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    . "$DIR/pipeline-status.sh"
    
    status="$(pipeline_status)"
    [ "$status" = "none" ] && exit 0
    
    if [ "$status" = "custom" ]; then
      note="A legacy .claude/pipeline.md with CUSTOM pipeline configuration was found. HCF no longer reads it (agents enroll via frontmatter), so those customizations are currently INACTIVE. HCF planning is gated until you migrate: plan-create and plan-orchestrate are BLOCKED while this file exists. Strongly urge the user to run /hcf:project-update now (it is command-only, so they type it themselves) to migrate the customizations into agent frontmatter and remove the stale file. Surface this immediately, not as a passing note. Include this link so they can read about the change: https://github.com/markshust/hcf#pipeline"
    else
      note="A legacy .claude/pipeline.md was found — a stale file from an older HCF version. HCF now enrolls agents via frontmatter and no longer reads it. HCF planning is gated until you migrate: plan-create and plan-orchestrate are BLOCKED while this file exists. Strongly urge the user to run /hcf:project-update now (it is command-only, so they type it themselves) to remove the stale file and bring the project onto the current model. Surface this immediately, not as a passing note. Include this link so they can read about the change: https://github.com/markshust/hcf#pipeline"
    fi
    
    printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"HCF migration required: %s"}}\n' "$note"
    
  • hooks/discover-hooks.shGitHub
    Read the script
    #!/usr/bin/env bash
    # HCF hook discovery — deterministic enumeration of hook-enrolled agents.
    #
    # Why this exists: HCF's discovery routine used to be prose instructions that
    # the in-session model executed by improvising a bash glob loop. Different
    # sessions wrote different scripts, and a syntax error in one of them crashed
    # mid-enumeration; the partial output was consumed as complete, every hook
    # resolved empty, and the empty-hook silence rule made the failure invisible.
    # See https://github.com/markshust/hcf/issues/4.
    #
    # This script is the single implementation of HOOKS.md's Discovery Routine.
    # Callers run it and read its output; nobody enumerates agent files by hand.
    #
    # Usage:
    #   discover-hooks.sh                     # all 8 hooks (by-hand debugging view)
    #   discover-hooks.sh --hook=<name>       # one hook; empty prints nothing
    #   discover-hooks.sh --json              # machine-readable
    #   discover-hooks.sh --fingerprint       # stable enrollment digest
    #   discover-hooks.sh --expect=<value>    # halt if enrollment drifted
    #
    # Exit codes:
    #   0  success (including a legitimately empty hook)
    #   1  runtime error (plugin agents/ dir or project root unresolvable)
    #   2  bad arguments (incl. a malformed --expect value)
    #   3  enrollment validation failure (invalid phase or mode in an agent file)
    #   4  enrollment drift (--expect mismatch)
    
    set -o pipefail
    
    PROG="discover-hooks"
    VALID_HOOKS="pre-plan post-plan pre-implementation pre-batch post-batch post-implementation pre-commit post-commit"
    VALID_MODES="single batch"
    FP_PREFIX="discover-hooks-fingerprint"
    FP_VERSION="1"
    
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    PLUGIN_AGENTS="$SCRIPT_DIR/../agents"
    RESOLVER="$SCRIPT_DIR/resolve-project-dir.sh"
    # PROJECT_DIR and LOCAL_AGENTS are resolved after argument parsing, so --help
    # stays answerable from anywhere. See the resolution block below.
    
    HOOK_FILTER=""
    WANT_JSON=""
    WANT_FINGERPRINT=""
    EXPECT=""
    HAVE_EXPECT=""
    
    warn() { printf '%s: warning: %s\n' "$PROG" "$1" >&2; }
    err()  { printf '%s: error: %s\n'   "$PROG" "$1" >&2; }
    
    usage() {
      cat <<'EOF'
    discover-hooks.sh — resolve which agents run at an HCF hook point.
    
      --hook=<name>     Restrict output to one hook. Prints nothing when the hook
                        resolves empty (callers treat exit 0 + empty stdout as
                        "empty hook"). Omit to print all 8 hooks with explicit
                        empty markers, which is the by-hand debugging view.
      --json            Emit JSON instead of the human table.
      --fingerprint     Print a stable digest of the full resolved enrollment
                        across all 8 hooks, then exit.
      --expect=<value>  Compare current enrollment against a previously captured
                        fingerprint. Exits 4 if it changed. Accepts either the
                        full versioned line or the bare 64-hex digest.
      -h, --help        Show this help.
    
    Exit codes: 0 success, 1 runtime error, 2 bad arguments,
                3 enrollment validation failure, 4 enrollment drift.
    EOF
    }
    
    is_valid_hook() {
      case " $VALID_HOOKS " in *" $1 "*) return 0 ;; *) return 1 ;; esac
    }
    
    is_valid_mode() {
      case " $VALID_MODES " in *" $1 "*) return 0 ;; *) return 1 ;; esac
    }
    
    while [ $# -gt 0 ]; do
      case "$1" in
        --hook=*)
          HOOK_FILTER="${1#*=}"
          if [ -z "$HOOK_FILTER" ]; then
            err "--hook requires a value; use --hook=<name>"
            exit 2
          fi
          if ! is_valid_hook "$HOOK_FILTER"; then
            err "unknown hook '$HOOK_FILTER'"
            err "  valid hooks: $VALID_HOOKS"
            exit 2
          fi
          ;;
        --hook)
          err "--hook must be given as --hook=<name>, not as two arguments"
          exit 2
          ;;
        --expect=*)
          EXPECT="${1#*=}"
          HAVE_EXPECT=1
          ;;
        --expect)
          err "--expect must be given as --expect=<value>, not as two arguments"
          exit 2
          ;;
        --json)        WANT_JSON=1 ;;
        --fingerprint) WANT_FINGERPRINT=1 ;;
        -h|--help)     usage; exit 0 ;;
        *)
          err "unknown argument '$1'"
          err "  run with --help for usage"
          exit 2
          ;;
      esac
      shift
    done
    
    if [ -n "$WANT_FINGERPRINT" ] && [ -n "$HAVE_EXPECT" ]; then
      err "--fingerprint and --expect are mutually exclusive"
      exit 2
    fi
    
    # --- frontmatter parsing ----------------------------------------------------
    #
    # Anchored at start-of-line inside the FIRST `---` block only, so a commented
    # `# phase:` key never enrolls an agent. This matters concretely: the bundled
    # standards-enforcer ships dormant with its phase commented out, and an
    # unanchored match would silently enable it for every HCF user.
    
    has_frontmatter() {
      # NOTE: awk's `exit` runs the END block, so the result is carried in a flag
      # rather than as an exit status from inside the body.
      LC_ALL=C awk '
        { sub(/\r$/, "") }
        NR == 1 { if ($0 != "---") { bad = 1; exit } ; next }
        /^---$/ { found = 1; exit }
        END { if (found && !bad) exit 0; exit 1 }
      ' "$1"
    }
    
    # fm_get <file> <key> — echoes the trimmed, unquoted, comment-stripped value.
    fm_get() {
      LC_ALL=C awk -v key="$2" '
        { sub(/\r$/, "") }
        NR == 1 { next }
        /^---$/ { exit }
        index($0, key ":") == 1 {
          val = substr($0, length(key) + 2)
          sub(/^[ \t]+/, "", val)
          first = substr(val, 1, 1)
          if (first == "\"" || first == "'"'"'") {
            val = substr(val, 2)
            i = index(val, first)
            if (i > 0) val = substr(val, 1, i - 1)
          } else {
            # Strip a trailing inline comment. HOOKS.md and README.md both document
            # enrollment as `phase: post-plan   # enrolls this agent...`, so this is
            # the shape a copy-pasting user actually produces.
            sub(/[ \t]*#.*$/, "", val)
          }
          sub(/[ \t]+$/, "", val)
          print val
          exit
        }
      ' "$1"
    }
    
    # --- enumeration ------------------------------------------------------------
    
    names=()
    phases=()
    orders=()
    modes=()
    origins=()
    
    VALIDATION_ERRORS=0
    
    index_of_name() {
      local target="$1" i=0 n=${#names[@]}
      while [ "$i" -l
  • hooks/gate-command.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # HCF UserPromptExpansion hook (matcher: plan-create|plan-orchestrate). Covers the
    # direct slash-command path (/plan-create, /plan-orchestrate) that PreToolUse does
    # NOT see. The matcher already scopes this to those commands, so we only need to
    # check for a legacy .claude/pipeline.md and block until it is migrated.
    
    DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    . "$DIR/pipeline-status.sh"
    
    [ "$(pipeline_status)" = "none" ] && exit 0
    
    reason="HCF: this project still has a legacy .claude/pipeline.md. HCF now enrolls agents via frontmatter and no longer reads that file. Run /hcf:project-update first to migrate it into agent frontmatter and remove the stale file, then re-run this command. Share this link so they can read about the change: https://github.com/markshust/hcf#pipeline"
    printf '{"decision":"block","reason":"%s"}\n' "$reason"
    
  • hooks/gate-skill.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # HCF PreToolUse hook (matcher: Skill). Covers the path where Claude invokes the
    # Skill tool. Denies plan-create / plan-orchestrate while a legacy
    # .claude/pipeline.md exists, forcing migration via /hcf:project-update first.
    # Everything else (including /hcf:project-update itself) is left untouched.
    
    DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    . "$DIR/pipeline-status.sh"
    
    input="$(cat)"
    
    # Only gate the HCF planning skills. project-update and all other skills pass through.
    case "$input" in
      *plan-create*|*plan-orchestrate*) ;;
      *) exit 0 ;;
    esac
    
    [ "$(pipeline_status)" = "none" ] && exit 0
    
    reason="HCF: this project still has a legacy .claude/pipeline.md. HCF now enrolls agents via frontmatter and no longer reads that file, so it must be migrated before the planning workflow can run. Tell the user to run /hcf:project-update first — it migrates any customizations into agent frontmatter and removes the stale file — then retry. Do not attempt to plan or orchestrate until the file is gone. Share this link so they can read about the change: https://github.com/markshust/hcf#pipeline"
    printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
    
  • hooks/pipeline-status.shGitHub
    Read the script
    #!/usr/bin/env bash
    # Shared helper for HCF hooks. Classifies a project's legacy .claude/pipeline.md.
    # Sourced by the other hook scripts (no exec bit needed).
    #
    # pipeline_status echoes exactly one of:
    #   none    - no .claude/pipeline.md present
    #   default - present but only enrolls the shipped default (devils-advocate, or
    #             nothing active); frontmatter already reproduces it, so nothing is lost
    #   custom  - present with custom enrollment; those customizations are INACTIVE
    #             because HCF now enrolls agents via frontmatter
    
    pipeline_status() {
      local project_dir pipeline names count
      project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
      pipeline="$project_dir/.claude/pipeline.md"
    
      [ -f "$pipeline" ] || { echo none; return; }
    
      # "Active" agents = non-commented "- name" bullets (HTML-commented lines inactive).
      names="$(grep -E '^[[:space:]]*-[[:space:]]+[^[:space:]]' "$pipeline" 2>/dev/null \
        | grep -v '<!--' \
        | sed -E 's/^[[:space:]]*-[[:space:]]+//; s/[[:space:]].*$//')"
      count="$(printf '%s\n' "$names" | grep -c '[^[:space:]]')"
    
      if [ "$count" = "0" ] || { [ "$count" = "1" ] && printf '%s' "$names" | grep -qx 'devils-advocate'; }; then
        echo default
      else
        echo custom
      fi
    }
    
  • hooks/resolve-plans-dir.shGitHub
    Read the script
    #!/usr/bin/env bash
    # HCF plans-directory resolution — where this project keeps its plan folders.
    #
    # Defaults to <project>/.claude/plans. A project may override it with an
    # optional, user-owned .claude/hcf.json:
    #
    #   { "plansDir": "docs/plans" }
    #
    # No skill creates or modifies that file. Its absence is the normal case.
    #
    # Two deliberate departures from a naive one-liner, both the same lesson this
    # repo keeps relearning (see issue #4 and the 2.1.1 fix):
    #
    #   1. No `jq`. A `jq ... || echo <default>` pipeline turns a missing jq — the
    #      norm on Debian minimal and Alpine — into a silent fallback: you set
    #      plansDir, you get .claude/plans, and nothing says why.
    #   2. A malformed plansDir is an error, not a fallback. Writing plans to a
    #      directory the project did not ask for is worse than refusing, and it
    #      matches how an invalid `phase` already aborts discovery.
    #
    # The project root comes from resolve-project-dir.sh, so the answer does not
    # depend on the working directory. Output is absolute for the same reason:
    # callers pass it to subagents whose cwd is not knowable from here.
    #
    # Usage:
    #   resolve-plans-dir.sh          # print the absolute plans directory
    #   . resolve-plans-dir.sh        # define hcf_resolve_plans_dir
    #
    # Exit codes:
    #   0  resolved; the absolute path is on stdout
    #   1  unresolvable project root, or an invalid plansDir value
    
    HCF_PLANS_DIR_DEFAULT=".claude/plans"
    
    _hcf_plans_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    if [ ! -f "$_hcf_plans_script_dir/resolve-project-dir.sh" ]; then
      printf 'resolve-plans-dir: error: resolve-project-dir.sh not found alongside this script\n' >&2
      return 1 2>/dev/null || exit 1
    fi
    # shellcheck source=hooks/resolve-project-dir.sh
    . "$_hcf_plans_script_dir/resolve-project-dir.sh"
    
    # Reads the plansDir value out of a flat JSON object without a JSON parser.
    # Deliberately narrow: one key, a string value, no escapes. Anything it cannot
    # read is reported by the caller as absent, which falls back to the default.
    #
    # Line-oriented, so it does not understand nesting: a plansDir buried inside
    # another object would be read as if top-level. hcf.json is a flat file the user
    # writes by hand and nesting this key means nothing, so that is a documented
    # limit rather than a reason to put a JSON parser in bash. Duplicate keys take
    # the last, and a padded value keeps its padding — both match what `jq` returns.
    hcf_read_plans_dir_key() {
      sed -n 's/.*"plansDir"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" 2>/dev/null | head -1
    }
    
    # Prints the absolute plans directory on stdout. Returns non-zero with a
    # message on stderr when the project root or the configured value is unusable.
    hcf_resolve_plans_dir() {
      local project status config value
    
      project="$(hcf_resolve_project_dir)"
      status=$?
      if [ "$status" -eq 2 ]; then
        printf 'resolve-plans-dir: error: CLAUDE_PROJECT_DIR is set to '\''%s'\'', which is not a directory\n' "${CLAUDE_PROJECT_DIR:-}" >&2
        return 1
      elif [ "$status" -ne 0 ]; then
        printf 'resolve-plans-dir: error: cannot determine the project root from '\''%s'\''\n' "$PWD" >&2
        printf 'resolve-plans-dir: error:   run from inside the project, or set CLAUDE_PROJECT_DIR to its path\n' >&2
        return 1
      fi
    
      config="$project/.claude/hcf.json"
      value=""
    
      if [ -f "$config" ]; then
        # Absent key and unreadable value are the same case on purpose: fall back
        # quietly. A present-but-empty value is a typo, and is reported below.
        if grep -q '"plansDir"' "$config" 2>/dev/null; then
          value="$(hcf_read_plans_dir_key "$config")"
          if [ -z "$value" ]; then
            printf 'resolve-plans-dir: error: '\''%s'\'' sets an empty plansDir\n' "$config" >&2
            printf 'resolve-plans-dir: error:   give it a relative path, or remove the key to use %s\n' "$HCF_PLANS_DIR_DEFAULT" >&2
            return 1
          fi
        fi
      fi
    
      if [ -z "$value" ]; then
        value="$HCF_PLANS_DIR_DEFAULT"
      fi
    
      # Containment: the plans directory belongs to the project. Escaping it would
      # scatter plan folders outside the repo they document.
      case "$value" in
        /*)
          printf 'resolve-plans-dir: error: plansDir '\''%s'\'' must be relative to the project root, not absolute\n' "$value" >&2
          printf 'resolve-plans-dir: error:   set it in %s\n' "$config" >&2
          return 1
          ;;
        ..|../*|*/..|*/../*)
          printf 'resolve-plans-dir: error: plansDir '\''%s'\'' must not contain a '\''..'\'' segment\n' "$value" >&2
          printf 'resolve-plans-dir: error:   set it in %s\n' "$config" >&2
          return 1
          ;;
      esac
    
      printf '%s\n' "$project/$value"
    }
    
    if [ "${BASH_SOURCE[0]}" = "$0" ]; then
      hcf_resolve_plans_dir || exit 1
    fi
    
  • hooks/resolve-project-dir.shGitHub

All 7 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

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 withhcf

Autonomous development plugin for Claude Code. Define requirements with a PM, then let parallel workers implement everything using TDD.

Get the whole plugin
Stats
74
Stars
14
Forks
Active
Maintenance
Shell
Language
MIT
License
1d ago
Last commit
6mo ago
Created

Repo: markshust/hcf