Files
gh-tobyhede-turboshovel-plugin/hooks/ARCHITECTURE.md
2025-11-30 09:02:16 +08:00

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:

  1. Project context (highest priority):

    • .claude/context/{name}-{stage}.md
    • .claude/context/slash-command/{name}-{stage}.md
    • .claude/context/skill/{name}-{stage}.md
  2. 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

  1. Dogfooding: Plugin uses its own infrastructure
  2. Consistent Patterns: Same config format for plugin and projects
  3. Testable: TypeScript gates are unit-testable
  4. Debuggable: Standard TypeScript tooling works
  5. Extensible: Add new TypeScript gates, projects can override
  6. Type-Safe: Full TypeScript type checking