14 KiB
Hook System Architecture
The Turboshovel hook system is a self-referential TypeScript application that uses its own configuration format to define default behaviors. The plugin is built on itself.
Core Concept
┌─────────────────────────────────────────────────────────────────────────┐
│ Claude Code Hook Event │
│ (PostToolUse, SubagentStop, UserPromptSubmit, etc.) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ hooks.json Registration │
│ Routes ALL hook events to TypeScript CLI │
│ node ${CLAUDE_PLUGIN_ROOT}/hooks/hooks-app/dist/cli.js │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ TypeScript CLI │
│ plugin/hooks/hooks-app/src/cli.ts │
└─────────────────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────────┐
│ Context Injection │ │ Config Loading │
│ (PRIMARY - always runs) │ │ (loads + merges gates.json) │
│ │ │ │
│ 1. Project .claude/context/ │ │ 1. Plugin gates.json (defaults) │
│ 2. Plugin context/ (fallback)│ │ 2. Project gates.json (override) │
└───────────────────────────────┘ └───────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ Gate Execution │
│ │
│ Shell command gates (command:) │
│ TypeScript gates (no command:) │
└───────────────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ Action Handling │
│ │
│ CONTINUE → proceed │
│ BLOCK → prevent agent action │
│ STOP → halt Claude entirely │
│ {gate} → chain to another gate │
└───────────────────────────────────┘
Self-Referential Design
The hook system uses its own gates.json to configure default behaviors:
plugin/hooks/gates.json ← Plugin defaults (TypeScript gates)
↓ merged with
.claude/gates.json ← Project overrides (user configuration)
↓
Merged Configuration ← Project takes precedence
Plugin gates.json
{
"gates": {
"commands": {
"description": "Context-aware command injection",
"on_pass": "CONTINUE",
"on_fail": "CONTINUE"
},
"plugin-path": { ... }
},
"hooks": {
"UserPromptSubmit": {
"gates": ["commands"]
}
}
}
TypeScript gates have no command field - they're implemented in src/gates/.
Shell command gates have a command field - they execute shell commands.
Directory Structure
plugin/hooks/
├── hooks.json # Hook registration (routes to CLI)
├── gates.json # Plugin default gates configuration
├── ARCHITECTURE.md # This file
├── CONVENTIONS.md # Context file naming conventions
├── README.md # Quick start guide
├── SETUP.md # Detailed setup instructions
├── TYPESCRIPT.md # TypeScript gate development
│
├── context/ # Plugin-level context files (NEW)
│ └── session-start.md # Injects on SessionStart
│
├── hooks-app/ # TypeScript application
│ ├── src/
│ │ ├── cli.ts # Entry point
│ │ ├── dispatcher.ts # Main dispatch logic
│ │ ├── context.ts # Context file discovery/injection
│ │ ├── config.ts # Config loading/merging
│ │ ├── gate-loader.ts # Gate execution
│ │ ├── action-handler.ts # Action processing
│ │ ├── session.ts # Session state management
│ │ ├── logger.ts # Debug logging
│ │ ├── types.ts # TypeScript interfaces
│ │ ├── utils.ts # Utility functions
│ │ └── gates/ # Built-in TypeScript gates
│ │ ├── index.ts # Gate registry
│ │ └── plugin-path.ts
│ └── dist/ # Compiled JavaScript
│
└── examples/
├── context/ # Example context files
├── strict.json # Example: strict mode
├── permissive.json # Example: warn only
└── pipeline.json # Example: gate chaining
Execution Flow
1. Hook Event Received
Claude Code fires a hook event (e.g., UserPromptSubmit). The hooks.json routes it to the TypeScript CLI:
{
"hooks": {
"UserPromptSubmit": [{
"matcher": ".*",
"hooks": [{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hooks-app/dist/cli.js"
}]
}]
}
}
2. Context Injection (Primary Behavior)
Always runs first. Discovers and injects markdown content from:
-
Project context (highest priority):
.claude/context/{name}-{stage}.md.claude/context/slash-command/{name}-{stage}.md.claude/context/skill/{name}-{stage}.md
-
Plugin context (fallback):
${CLAUDE_PLUGIN_ROOT}/context/{name}-{stage}.md- (same variations as project)
3. Config Loading and Merging
Loads both configs and merges them:
// Load plugin defaults first
const pluginConfig = await loadConfigFile(`${CLAUDE_PLUGIN_ROOT}/hooks/gates.json`);
// Load project overrides
const projectConfig = await loadConfigFile('.claude/gates.json');
// Merge: project overrides plugin
const mergedConfig = {
hooks: { ...pluginConfig.hooks, ...projectConfig.hooks },
gates: { ...pluginConfig.gates, ...projectConfig.gates }
};
4. Gate Execution
For each gate in the hook's gates array:
TypeScript Gate (no command field):
// Gate name maps to module: "commands" → gates/commands.ts
const gates = await import('./gates');
const result = await gates.commands.execute(input);
Shell Command Gate (has command field):
const { stdout, stderr } = await exec(gateConfig.command, { cwd });
5. Action Handling
Based on gate result and on_pass/on_fail configuration:
- CONTINUE: Proceed to next gate or complete
- BLOCK: Return block decision to Claude Code
- STOP: Return stop signal to halt Claude
- {gate_name}: Chain to another gate
Supported Hook Events
All 12 Claude Code hook types are supported:
| Event | Context Pattern | Description |
|---|---|---|
SessionStart |
session-start.md |
Beginning of Claude session |
SessionEnd |
session-end.md |
End of Claude session |
UserPromptSubmit |
prompt-submit.md |
User submits prompt |
SlashCommandStart |
{command}-start.md |
Command begins |
SlashCommandEnd |
{command}-end.md |
Command completes |
SkillStart |
{skill}-start.md |
Skill loads |
SkillEnd |
{skill}-end.md |
Skill completes |
SubagentStop |
{agent}-end.md |
Agent completes |
PreToolUse |
{tool}-pre.md |
Before tool executes |
PostToolUse |
{tool}-post.md |
After tool executes |
Stop |
agent-stop.md |
Agent stops |
Notification |
notification-receive.md |
Notification received |
TypeScript Gates
Gates without a command field are TypeScript gates. They're implemented in src/gates/ and export an execute function:
// src/gates/commands.ts
import { HookInput, GateResult } from '../types';
export async function execute(input: HookInput): Promise<GateResult> {
// Gate logic here
return {
additionalContext: '...' // Inject content
// or
decision: 'block',
reason: '...' // Block execution
// or
continue: false,
message: '...' // Stop Claude
};
}
Register in src/gates/index.ts:
export * as pluginPath from './plugin-path';
Gate name maps to export: "commands" → gates.commands.execute()
Configuration Merging
Project configuration overrides plugin configuration at the key level:
// Plugin gates.json (defaults)
{
"hooks": {
"UserPromptSubmit": { "gates": ["commands"] },
"PostToolUse": { "gates": ["check"] }
},
"gates": {
"commands": { "on_pass": "CONTINUE" },
"check": { "command": "echo placeholder" }
}
}
// Project .claude/gates.json (overrides)
{
"hooks": {
"PostToolUse": { "gates": ["lint", "test"] } // Replaces plugin's PostToolUse
},
"gates": {
"check": { "command": "npm run lint" }, // Replaces plugin's check
"lint": { "command": "eslint ." }, // New gate
"test": { "command": "npm test" } // New gate
}
}
// Merged result
{
"hooks": {
"UserPromptSubmit": { "gates": ["commands"] }, // From plugin
"PostToolUse": { "gates": ["lint", "test"] } // From project (replaced)
},
"gates": {
"commands": { "on_pass": "CONTINUE" }, // From plugin
"check": { "command": "npm run lint" }, // From project (replaced)
"lint": { "command": "eslint ." }, // From project (new)
"test": { "command": "npm test" } // From project (new)
}
}
Session State
The hook system maintains session state for cross-hook coordination:
interface SessionState {
session_id: string; // Unique session ID
started_at: string; // ISO timestamp
active_command: string | null; // Current slash command
active_skill: string | null; // Current skill
edited_files: string[]; // Files modified this session
file_extensions: string[]; // Extensions edited
metadata: Record<string, any>; // Custom data
}
State persists in $TMPDIR/turboshovel/session-{cwd-hash}.json.
Logging
All hook invocations are logged to $TMPDIR/turboshovel/hooks-YYYY-MM-DD.log:
# View logs
tail -f $(node plugin/hooks/hooks-app/dist/cli.js log-path)
# Or use mise task
mise run logs
Log entries include:
- Hook event type
- Config file paths loaded
- Gate execution results
- Action handling decisions
- Timing information
Benefits of Self-Referential Design
- Dogfooding: Plugin uses its own infrastructure
- Consistent Patterns: Same config format for plugin and projects
- Testable: TypeScript gates are unit-testable
- Debuggable: Standard TypeScript tooling works
- Extensible: Add new TypeScript gates, projects can override
- Type-Safe: Full TypeScript type checking