hook-architecture
npx machina-cli add skill zircote/auto-harness/hook-architecture --openclawMemory
Search first: /mnemonic:search {relevant_keywords}
Capture after: /mnemonic:capture {namespace} "{title}"
Run /mnemonic:list --namespaces to see available namespaces from loaded ontologies.
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 statecmd_next- Return next test actioncmd_validate- Check response against expectationscmd_status- Report progresscmd_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
Source
git clone https://github.com/zircote/auto-harness/blob/main/skills/hook-architecture/SKILL.mdView on GitHub Overview
This skill explains how hook-driven automated testing powers Claude Code by intercepting prompts and transforming conversations into test instructions. It covers the hook lifecycle, mnemonic context, and the role of the test wrapper in orchestrating test execution.
How This Skill Works
The UserPromptSubmit hook intercepts every user prompt and can pass through, replace, or block it. Hook registration is defined in hooks/hooks.json, enabling specific interception behavior. The Test Wrapper Script (hooks/test-wrapper.sh) handles the interception logic and delegates test actions to a runner, returning modified prompts to Claude.
When to Use It
- Explaining the end-to-end flow of hook-driven prompt interception.
- Debugging test mode behavior with UserPromptSubmit.
- Designing and registering a new hook in hooks.json.
- Verifying how prompts are replaced or blocked during tests.
- Documenting how the wrapper script and runner cooperate.
Quick Start
- Step 1: Review the Architecture Overview and identify the hook you’ll modify.
- Step 2: Register the hook in hooks/hooks.json with a suitable matcher.
- Step 3: Run in test mode and observe hooks/test-wrapper.sh intercept prompts and invoke the runner.
Best Practices
- Keep hook matchers specific to avoid unintended replacements.
- Document which prompts are replaced vs passed through.
- Test in development before enabling in production; clearly toggle test mode.
- Ensure proper JSON escaping in intercepted prompts.
- Log hook actions for easier troubleshooting and audits.
Example Use Cases
- UserPromptSubmit intercepts a 'next' command and routes to the test-runner.
- The wrapper outputs 'Execute the following test:' along with runner results.
- Blocking a sensitive prompt in production to prevent unsafe actions.
- Hook JSON configuration in hooks/hooks.json demonstrating the matcher setup.
- Mnemonic integration to reuse prior hook learnings for consistency.