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