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 OperatorBash Equivalent
regex_matchecho "$val" | grep -qE "$pattern"
containsecho "$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[].matcher values 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:

  1. Appends to .forge/audit/overrides.log (AUDIT.md Section 2)
  2. Updates overrides[] in .forge/runtime/state.json (RUNTIME.md Section 2)
  3. Increments override counters for registry metrics

7. Runtime Dependencies

DependencyRequiredPlatformBehavior if absent
jqyesallHook warns to stderr, exits 0 — all behaviors degrade to silent
bash 4+yesallUse POSIX-compatible constructs; macOS ships bash 3.2 — avoid declare -A
mkdiryesPOSIXAlways available
dateyesPOSIXAlways available
md5sum / md5noLinux / macOSSession 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.yaml and each behaviors/<id>/behavior.yaml against 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).
  • --force bypasses mtime check and regenerates all hooks.
  • _forge_runtime.sh is 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)