Behavior Compiler
April 13, 2026 · View on GitHub
Specifies how behaviors/ YAML files are compiled into executable Claude Code hook scripts.
Input: behaviors/index.yaml + behaviors/<id>/behavior.yaml files.
Output: .claude/hooks/behaviors/ scripts + settings.json hook registrations.
References: SPEC.md (evaluation algorithm, output protocol), SCHEMA.md (field reference, DSL), RUNTIME.md (state.json, locking).
1. Overview
The compiler is a one-shot transform: read declarative YAML, emit executable bash.
It does not interpret behavior logic at compile time — it generates bash that interprets it at runtime.
The shared runtime library (_forge_runtime.sh) implements the evaluation algorithm from SPEC.md Section 2.
Generated hooks are always exit 0; enforcement is via JSON stdout per SPEC.md Section 5.
2. Compilation Pipeline
1. Read behaviors/index.yaml
→ validate schema_version == "1"
→ collect ordered list of {id, enabled}
2. For each enabled behavior (in declaration order):
→ parse behaviors/<id>/behavior.yaml
→ validate against SCHEMA.md Section 6 (all rules)
→ collect validation errors; abort if any
3. Group behaviors by trigger event type
→ one group per event: PreToolUse, PostToolUse, UserPromptSubmit, Stop
→ behaviors with multiple triggers appear in multiple groups
4. For each event group:
→ generate ONE hook script at .claude/hooks/behaviors/<Event>.sh
→ script sources _forge_runtime.sh and calls evaluate_behavior() per behavior
5. Generate PermissionDenied override audit hook
→ .claude/hooks/behaviors/PermissionDenied.sh
→ writes override record to .forge/audit/overrides.log + state.json
→ see SPEC.md Section 6 for override protocol
6. Write .claude/hooks/behaviors/_forge_runtime.sh
→ shared library implementing SPEC.md Section 2 algorithm
7. Update .claude/settings.json
→ append behavior hook entries AFTER existing hooks for each event
→ register PermissionDenied hook for override audit trail
→ idempotent: re-running produces identical output (see Section 7)
8. chmod +x all generated scripts
9. Report:
→ behaviors compiled (count)
→ hooks generated (list)
→ validation errors (if --validate or on failure)
If any behavior fails validation, no files are written. All-or-nothing.
3. Generated Hook Structure
Every generated event hook follows this template:
#!/usr/bin/env bash
# AUTO-GENERATED by dotforge behavior compiler v1. DO NOT EDIT.
# Source: behaviors/index.yaml
# Generated: 2026-04-13T15:00:00Z
# Event: PreToolUse
# Behaviors: search-first, no-destructive-git
set -euo pipefail
# Source shared runtime
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/_forge_runtime.sh"
# Read hook input
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // empty')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // empty')
# Fallback session ID if not provided (see RUNTIME.md Section 3)
if [ -z "$SESSION_ID" ]; then
SESSION_ID=$(echo "${PWD}:${PPID}:$(date +%Y%m%d)" | md5sum 2>/dev/null | cut -c1-8 \
|| echo "${PWD}:${PPID}:$(date +%Y%m%d)" | md5 -q 2>/dev/null | cut -c1-8 \
|| echo "fallback")
fi
# Initialize state (lock + read + TTL purge per RUNTIME.md Section 10)
forge_init_state "$SESSION_ID"
# --- Behavior: search-first ---
# Source: behaviors/search-first/behavior.yaml
evaluate_behavior "search-first" "$TOOL_NAME" "$TOOL_INPUT" "$SESSION_ID" "$HOOK_EVENT"
# --- Behavior: no-destructive-git ---
# Source: behaviors/no-destructive-git/behavior.yaml
evaluate_behavior "no-destructive-git" "$TOOL_NAME" "$TOOL_INPUT" "$SESSION_ID" "$HOOK_EVENT"
# Merge outputs, write state, release lock, emit JSON to stdout
forge_emit_output
exit 0
One evaluate_behavior call is emitted per behavior, in index.yaml declaration order.
The hook always exits 0 (SPEC.md Section 5).
4. Shared Runtime Library (_forge_runtime.sh)
Function signatures and responsibilities. Implementation detail, not pseudocode.
# Acquire lock, read state.json (or initialize if missing/corrupt),
# purge expired sessions (RUNTIME.md Section 6), get/create session entry.
forge_init_state(session_id)
# Write mutated state.json to disk, release lock.
# On write failure: log to stderr, continue (RUNTIME.md Section 9).
forge_finalize_state()
# Core evaluation per SPEC.md Section 2:
# 1. Check applies_to.tools — skip if tool not in list (empty = all)
# 2. Check trigger.event matches HOOK_EVENT
# 3. Evaluate trigger conditions via forge_check_condition()
# 4. If triggered: increment counter, resolve level (SPEC.md 2.1), apply monotonic (SPEC.md 3.2)
# 5. Render output for the effective level
# 6. Queue output via forge_queue_output()
# 7. If level is soft_block or hard_block: set FORGE_BLOCK_HIT=1 (cuts chain)
evaluate_behavior(behavior_id, tool_name, tool_input, session_id, hook_event)
# Dispatch to operator-specific bash check.
# Returns 0 (condition met) or 1 (condition not met).
forge_check_condition(field, operator, value, tool_input)
# Append {behavior_id, level, message} to in-memory output queue.
forge_queue_output(behavior_id, level, message)
# Implements SPEC.md Section 2.3 merge_outputs:
# - If FORGE_BLOCK_HIT: emit last block output only
# - Else: concatenate all systemMessages with "\n\n"
# Calls forge_finalize_state() before emitting.
forge_emit_output()
# mkdir-based, 2s timeout. On timeout: set FORGE_LOCK_FAILED=1.
# See RUNTIME.md Section 7.
forge_acquire_lock()
forge_release_lock()
# Write message to stderr. Never blocks tool call.
forge_log_warning(message)
# Truncate string to max_len chars for audit summaries.
forge_truncate(string, max_len)
_forge_runtime.sh sources behaviors/<id>/behavior.yaml data embedded at compile time as
bash variables — behaviors are not re-read at runtime. The compiler inlines the YAML fields
(trigger conditions, escalation thresholds, rendering templates) as bash arrays and strings.
5. Trigger-to-Bash Compilation
How each DSL operator (SCHEMA.md Section 3) maps to bash inside evaluate_behavior:
| DSL Operator | Bash Equivalent |
|---|---|
regex_match | echo "$val" | grep -qE "$pattern" |
contains | echo "$val" | grep -qF "$pattern" |
not_contains | ! echo "$val" | grep -qF "$pattern" |
equals | [[ "$val" == "$pattern" ]] |
starts_with | [[ "$val" == "$pattern"* ]] |
ends_with | [[ "$val" == *"$pattern" ]] |
gt | [[ "$val" -gt "$pattern" ]] |
lt | [[ "$val" -lt "$pattern" ]] |
gte | [[ "$val" -ge "$pattern" ]] |
lte | [[ "$val" -le "$pattern" ]] |
exists | [[ -n "$val" ]] |
not_exists | [[ -z "$val" ]] |
Logic composition:
logic: all→ conditions joined with&&(default)logic: any→ conditions joined with||
Field extraction from tool_input JSON:
extract_field() {
local field="\$1" tool_input="\$2"
echo "$tool_input" | jq -r ".$field // empty" 2>/dev/null
}
session_state.counter is read from the in-memory state loaded by forge_init_state,
not from the JSON tool_input payload.
6. Settings.json Mutation
The compiler appends behavior hook registrations to settings.json.
Existing hooks are preserved as-is. Behavior hooks are added AFTER (SPEC.md Section 8.2).
Before (existing hooks only):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": ".claude/hooks/block-destructive.sh"}
]
}
]
}
}
After (behavior hooks appended):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": ".claude/hooks/block-destructive.sh"}
]
},
{
"matcher": "Bash|Write|Edit|Grep|Glob|Read",
"hooks": [
{"type": "command", "command": ".claude/hooks/behaviors/PreToolUse.sh"}
]
}
]
}
}
Mutation rules:
- Existing entries: never modified, never removed.
- Behavior entry matcher: union of all
triggers[].matchervalues for behaviors in that event group. - One behavior entry per event type — all behaviors for that event share one hook command.
- Idempotent: the compiler detects an existing behavior entry by its command path and overwrites only that entry. All other entries are untouched.
- If no behaviors declare a trigger for a given event, no entry is added for that event.
6.1 PermissionDenied Override Audit Hook
The compiler generates a PermissionDenied.sh hook to record override audit trails (SPEC.md Section 6).
This hook fires when Claude Code's auto-mode classifier denies a tool call. It checks if the denial was from a behavior's soft_block and records the override.
#!/usr/bin/env bash
# AUTO-GENERATED by dotforge behavior compiler v1. DO NOT EDIT.
# Purpose: record behavior overrides in triple-write audit trail
# Event: PermissionDenied
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/_forge_runtime.sh"
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
REASON=$(echo "$INPUT" | jq -r '.reason // empty')
# Check if this denial came from a behavior hook
# Behavior-generated denials include behavior_id in the systemMessage
BEHAVIOR_ID=$(echo "$REASON" | grep -oP '^\[([a-z0-9-]+)\]' | tr -d '[]' || true)
if [ -z "$BEHAVIOR_ID" ]; then
exit 0 # Not a behavior-generated denial
fi
# Record override in audit trail
forge_record_override "$SESSION_ID" "$BEHAVIOR_ID" "$TOOL_NAME" "$INPUT"
exit 0
Settings.json registration:
{
"PermissionDenied": [
{
"hooks": [
{"type": "command", "command": ".claude/hooks/behaviors/PermissionDenied.sh"}
]
}
]
}
The forge_record_override function (in _forge_runtime.sh) writes to all three audit locations:
- Appends to
.forge/audit/overrides.log(AUDIT.md Section 2) - Updates
overrides[]in.forge/runtime/state.json(RUNTIME.md Section 2) - Increments override counters for registry metrics
7. Runtime Dependencies
| Dependency | Required | Platform | Behavior if absent |
|---|---|---|---|
| jq | yes | all | Hook warns to stderr, exits 0 — all behaviors degrade to silent |
| bash 4+ | yes | all | Use POSIX-compatible constructs; macOS ships bash 3.2 — avoid declare -A |
| mkdir | yes | POSIX | Always available |
| date | yes | POSIX | Always available |
| md5sum / md5 | no | Linux / macOS | Session ID fallback only; cksum used if both absent |
A SessionStart hook must verify jq availability:
command -v jq >/dev/null 2>&1 \
|| echo "[forge] WARNING: jq not found — behavior enforcement disabled" >&2
8. Hook File Naming Convention
.claude/hooks/behaviors/
├── _forge_runtime.sh # shared library — sourced, not executed directly
├── PreToolUse.sh # all PreToolUse behaviors
├── PostToolUse.sh # all PostToolUse behaviors (if any)
├── UserPromptSubmit.sh # all UserPromptSubmit behaviors (if any)
├── Stop.sh # all Stop behaviors (if any)
└── PermissionDenied.sh # override audit trail writer
Header in every generated hook (mandatory — used by /forge compile --validate to detect generated files):
# AUTO-GENERATED by dotforge behavior compiler v1. DO NOT EDIT.
# Source: behaviors/index.yaml
# Generated: <ISO 8601 timestamp>
# Event: <event name>
# Behaviors: <comma-separated id list>
9. Incremental Compilation
- Compare mtime of
behaviors/index.yamland eachbehaviors/<id>/behavior.yamlagainst the generated hook's mtime. - Skip regeneration for an event group if all source files are older than the generated hook.
- If any behavior file in an event group changed, regenerate the entire hook for that event (behaviors share one file — partial regeneration is not possible).
--forcebypasses mtime check and regenerates all hooks._forge_runtime.shis always regenerated (it is versioned with the compiler, not with behaviors).
10. Test Pattern
Manual testing without Claude Code:
# Test search-first nudge (first Write violation)
echo '{"tool_name":"Write","tool_input":{"file_path":"/src/new.ts","content":"export function"},"session_id":"test-abc","hook_event_name":"PreToolUse"}' \
| bash .claude/hooks/behaviors/PreToolUse.sh
# Expected — nudge (counter=1):
# {"systemMessage":"Search Before Writing: Consider searching first (violation 1/5)"}
# Expected — soft_block (counter=5):
# {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"},"systemMessage":"..."}
# Exit code must always be 0:
echo $?
# Test no-destructive-git hard_block
echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main --force"},"session_id":"test-xyz","hook_event_name":"PreToolUse"}' \
| bash .claude/hooks/behaviors/PreToolUse.sh
# Expected: {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","override_allowed":false},"systemMessage":"..."}
11. Complete Example: search-first Compiled Hook (Pseudocode Structure)
The compiler generates a concrete bash function for each behavior. For search-first from SCHEMA.md Section 7:
# Compiler inlines these from behavior.yaml at compile time:
BEHAVIOR_ID="search-first"
BEHAVIOR_NAME="Search Before Writing"
APPLIES_TO_TOOLS=() # empty = all tools
TRIGGER_EVENT="PreToolUse"
TRIGGER_MATCHER="Write|Edit"
TRIGGER_CONDITIONS_FIELD=("file_path") # from conditions[]
TRIGGER_CONDITIONS_OP=("regex_match")
TRIGGER_CONDITIONS_VAL=('\.(py|ts|js|tsx|jsx|swift|go|rs|java|kt|rb|php|cs)$')
TRIGGER_LOGIC="all"
# Enforcement thresholds (sorted by after DESC for resolve_level):
ESCALATION_AFTER=(5 3 1)
ESCALATION_LEVEL=("soft_block" "warning" "nudge")
DEFAULT_LEVEL="silent"
# Rendering templates (variables substituted at output time, not compile time):
NUDGE_TEMPLATE="{behavior_name}: Consider searching first (violation {counter}/{threshold})"
WARNING_TEMPLATE="**[{behavior_id}]** You have written code {counter} times without searching first.\nExpected: use Grep/Glob to find existing patterns before implementing.\nAction: search for related code, then proceed.\nNext violation ({threshold}) triggers a block."
BLOCK_REASON="Must search the codebase before writing new code."
OVERRIDE_PROMPT="Run Grep or Glob first, then retry the write operation."
# Runtime flow in evaluate_behavior "search-first" ...:
# 1. Check APPLIES_TO_TOOLS — skip if tool not in list (empty = pass)
# 2. Check TRIGGER_MATCHER: if TOOL_NAME not in "Write|Edit" → return (no violation)
# 3. Evaluate conditions:
# - extract file_path from TOOL_INPUT via jq
# - apply regex_match against pattern
# - TRIGGER_LOGIC=all: all must pass
# 4. Trigger matched: increment counter in state (read from forge_init_state)
# 5. resolve_level: walk ESCALATION_AFTER DESC, first counter >= after wins
# - counter=1 → nudge; counter=3 → warning; counter=5 → soft_block
# 6. Apply monotonic: effective_level = max(previous_effective_level, calculated_level)
# 7. Substitute template variables: {counter}, {threshold}, {behavior_name}, {behavior_id}, etc.
# 8. forge_queue_output "search-first" "$effective_level" "$rendered_message"
# 9. If soft_block: set FORGE_BLOCK_HIT=1 (cuts chain per SPEC.md Section 4.2)
Template variable {threshold} is resolved at output time: walk escalation thresholds for the first after > current_counter; if none, emit "max".
12. Compiler Invocation
# Explicit compilation
/forge compile # compile all behaviors, skip unchanged
/forge compile --force # force full recompile of all hooks
/forge compile --dry-run # print what would be generated, write nothing
/forge compile --validate # validate all behavior.yaml files, no generation
# Automatic (as part of sync)
/forge sync # runs compile step when behaviors/ directory exists
Exit codes for /forge compile:
0— success (all hooks generated or up-to-date)1— validation errors (no files written; errors listed to stdout)