Agent Skill
2/7/2026

hook-architecture

This skill should be used when the user asks about "how hooks work", "UserPromptSubmit", "hook-driven testing", "test wrapper", "prompt interception", "hook JSON format", "state management", or needs to understand the architecture of hook-driven automated testing.

Z
zircote
0GitHub Stars
1Views
npx skills add zircote/auto-harness

SKILL.md

Namehook-architecture
DescriptionThis skill should be used when the user asks about "how hooks work", "UserPromptSubmit", "hook-driven testing", "test wrapper", "prompt interception", "hook JSON format", "state management", or needs to understand the architecture of hook-driven automated testing.

allowed-tools:

  • Bash
  • Glob
  • Grep
  • Read
  • Write description: This skill should be used when the user asks about "how hooks work", "UserPromptSubmit", "hook-driven testing", "test wrapper", "prompt interception", "hook JSON format", "state management", or needs to understand the architecture of hook-driven automated testing. name: hook-architecture version: 1.0.0

<!-- BEGIN MNEMONIC PROTOCOL -->

Memory

Search first: /mnemonic:search {relevant_keywords} Capture after: /mnemonic:capture {namespace} "{title}"

Run /mnemonic:list --namespaces to see available namespaces from loaded ontologies.

<!-- END MNEMONIC PROTOCOL -->

Hook-Driven Test Architecture

Understand the hook system that powers automated testing in Claude Code.

Mnemonic Integration

Before explaining hook architecture, check mnemonic for prior learnings:

# Search for hook-related memories
rg -i "hook|UserPromptSubmit|test-wrapper|prompt-interception" ~/.claude/mnemonic/ ./.claude/mnemonic/ --glob "*.memory.md" -l | head -5

Apply recalled context:

  • Prior hook implementations that worked well
  • Common pitfalls with JSON escaping or state management
  • User's familiarity level with hook concepts

Architecture Overview

The hook-driven test framework transforms Claude Code's conversational interface into an automated test harness by intercepting user prompts and replacing them with test instructions.

┌─────────────────────────────────────────────────────┐
│                 Claude Code Session                  │
├─────────────────────────────────────────────────────┤
│  User: "next"                                       │
│       │                                             │
│       ▼                                             │
│  ┌─────────────────────────────────────────────┐   │
│  │         UserPromptSubmit Hook                │   │
│  │         (test-wrapper.sh)                    │   │
│  │                                              │   │
│  │  1. Check test mode active                   │   │
│  │  2. Intercept command                        │   │
│  │  3. Call runner.sh                           │   │
│  │  4. Return {"replace": "new prompt"}         │   │
│  └─────────────────────────────────────────────┘   │
│       │                                             │
│       ▼                                             │
│  Claude sees: "Execute test: Call tool X..."        │
│       │                                             │
│       ▼                                             │
│  Claude executes the MCP tool call                  │
└─────────────────────────────────────────────────────┘

Core Components

1. UserPromptSubmit Hook

The hook intercepts every user prompt and can:

  • Pass through: Return unchanged for normal operation
  • Replace: Substitute with test instruction
  • Block: Prevent prompt from reaching Claude

Hook registration in hooks/hooks.json:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PLUGIN_ROOT}/hooks/test-wrapper.sh user-prompt-submit"
          }
        ]
      }
    ]
  }
}

2. Test Wrapper Script

The wrapper (hooks/test-wrapper.sh) handles prompt interception:

#!/usr/bin/env bash

handle_user_prompt_submit() {
  local input=$(cat)
  local prompt=$(echo "$input" | python3 -c "
import json, sys
print(json.load(sys.stdin).get('prompt', ''))
")

  # Check if test mode active
  if is_test_mode; then
    case "$prompt" in
      next|n)
        output=$("$RUNNER" next)
        json_replace "Execute the following test:\n\n$output"
        ;;
      validate*)
        response="${prompt#validate }"
        output=$("$RUNNER" validate "$response")
        json_replace "$output"
        ;;
      # ... other commands
    esac
  else
    # Pass through to normal processing
    echo "$input"
  fi
}

3. Runner Script

The runner (tests/functional/runner.sh) orchestrates test execution:

Key functions:

  • cmd_init - Initialize test state
  • cmd_next - Return next test action
  • cmd_validate - Check response against expectations
  • cmd_status - Report progress
  • cmd_report - Generate results

4. State Management

Persistent state in .claude/test-state.json:

{
  "mode": "running",
  "total_tests": 53,
  "current_index": 5,
  "current_test": {
    "id": "test_id",
    "action": "...",
    "expect": [...]
  },
  "results": [
    {"id": "test1", "status": "pass"},
    {"id": "test2", "status": "fail", "failures": ["..."]}
  ],
  "saved_vars": {
    "memory_id": "abc123"
  }
}

Hook Input/Output Format

Input (JSON via stdin)

{
  "session_id": "abc123",
  "prompt": "user's typed command",
  "cwd": "/project/path"
}

Output (JSON to stdout)

Replace prompt:

{
  "replace": "New prompt text for Claude"
}

Pass through:

{}

Block prompt:

{
  "continue": false,
  "systemMessage": "Explanation"
}

Critical Implementation Details

JSON Escaping

Hook output must be valid JSON. Use Python for reliable escaping:

json_replace() {
  local content="$1"
  python3 -c "
import json, sys
content = sys.stdin.read()
print(json.dumps({'replace': content}))
" <<< "$content"
}

Common pitfall: Bash string escaping breaks on newlines and special characters.

State Persistence

Each hook invocation is stateless. State must persist to disk:

update_state() {
  local field="$1"
  local value="$2"
  python3 -c "
import json
with open('$STATE_FILE', 'r+') as f:
    data = json.load(f)
    data['$field'] = $value
    f.seek(0)
    json.dump(data, f, indent=2)
    f.truncate()
"
}

Test Mode Detection

Check state file to determine if tests are running:

is_test_mode() {
  [[ -f "$STATE_FILE" ]] || return 1
  local mode=$(python3 -c "
import json
with open('$STATE_FILE') as f:
    print(json.load(f).get('mode', ''))
")
  [[ "$mode" == "running" ]]
}

Test Execution Flow

1. User: /run-tests
   └─> Initializes state, sets mode=running

2. User: "next"
   └─> Hook intercepts
   └─> Runner returns test action
   └─> Hook replaces prompt with action
   └─> Claude executes test

3. User: "validate <response>"
   └─> Hook intercepts
   └─> Runner validates against expectations
   └─> Records PASS/FAIL
   └─> Advances to next test

4. Repeat steps 2-3 until all tests complete

5. User: "report"
   └─> Runner generates summary

Extension Points

Adding New Commands

Extend the wrapper to handle new commands:

case "$prompt" in
  retry)
    # Re-run current test
    output=$("$RUNNER" retry)
    json_replace "$output"
    ;;
esac

Custom Validation

Add validation types in runner:

if 'json_path' in exp:
    # JSONPath validation
    import jsonpath_ng
    matches = jsonpath_ng.parse(exp['json_path']).find(response)
    if not matches:
        failures.append(f"JSONPath not found: {exp['json_path']}")

Pre/Post Test Hooks

Add lifecycle hooks in runner:

run_pre_test_hook() {
  if [[ -x "$HOOKS_DIR/pre-test.sh" ]]; then
    "$HOOKS_DIR/pre-test.sh" "$current_test_id"
  fi
}

Additional Resources

Reference Files

  • references/state-management.md - Test state file schema and management
Skills Info
Original Name:hook-architectureAuthor:zircote