Initial commit
This commit is contained in:
15
.claude-plugin/plugin.json
Normal file
15
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "turboshovel",
|
||||
"description": "Generic hook framework for quality enforcement and context injection",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "Toby Hede",
|
||||
"email": "toby@cipherstash.com"
|
||||
},
|
||||
"commands": [
|
||||
"./commands"
|
||||
],
|
||||
"hooks": [
|
||||
"./hooks"
|
||||
]
|
||||
}
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# turboshovel
|
||||
|
||||
Generic hook framework for quality enforcement and context injection
|
||||
112
commands/test.md
Normal file
112
commands/test.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Turboshovel Plugin Test
|
||||
|
||||
Interactive test command for verifying plugin integration and gate configuration.
|
||||
|
||||
## Instructions
|
||||
|
||||
Run these diagnostic steps to verify the Turboshovel plugin is working correctly:
|
||||
|
||||
### 1. Check Plugin Status
|
||||
|
||||
First, verify the plugin loaded without errors:
|
||||
- Run `/plugin` and check for any turboshovel errors in "Installation Errors"
|
||||
- If errors exist, check `~/.claude/debug/latest` for details
|
||||
|
||||
### 2. Check Logging
|
||||
|
||||
Logging is **enabled by default**. View recent log entries:
|
||||
|
||||
```bash
|
||||
# Show log file path
|
||||
mise run logs:path
|
||||
|
||||
# Tail logs in a separate terminal
|
||||
mise run logs
|
||||
# or: mise run logs:pretty
|
||||
```
|
||||
|
||||
To disable logging: `TURBOSHOVEL_LOG=0`
|
||||
|
||||
### 3. Test Hook Invocation
|
||||
|
||||
To verify hooks are firing, perform these actions and watch the logs:
|
||||
|
||||
**Test PostToolUse hook:**
|
||||
- Edit any file (triggers PostToolUse)
|
||||
- Check logs for `HOOK_INVOKED` with `hook_event_name: "PostToolUse"`
|
||||
|
||||
**Test UserPromptSubmit hook:**
|
||||
- Submit any prompt (this one counts!)
|
||||
- Check logs for `hook_event_name: "UserPromptSubmit"`
|
||||
|
||||
### 4. Check Gates Configuration
|
||||
|
||||
Verify gates.json exists and is valid:
|
||||
|
||||
```bash
|
||||
# Check project gates
|
||||
cat .claude/gates.json 2>/dev/null || echo "No project gates.json"
|
||||
|
||||
# Check plugin default gates
|
||||
cat plugin/hooks/gates.json 2>/dev/null || echo "No plugin gates.json"
|
||||
```
|
||||
|
||||
### 5. Test a Gate Manually
|
||||
|
||||
Test the CLI directly with simulated hook input:
|
||||
|
||||
```bash
|
||||
# Test SessionStart (should return context injection)
|
||||
echo '{"hook_event_name":"SessionStart","cwd":"'$(pwd)'"}' | \
|
||||
node plugin/hooks/hooks-app/dist/cli.js
|
||||
|
||||
# Test PostToolUse with Edit tool
|
||||
echo '{"hook_event_name":"PostToolUse","tool_name":"Edit","cwd":"'$(pwd)'"}' | \
|
||||
node plugin/hooks/hooks-app/dist/cli.js
|
||||
|
||||
# Test UserPromptSubmit with keyword matching
|
||||
echo '{"hook_event_name":"UserPromptSubmit","user_message":"please run lint","cwd":"'$(pwd)'"}' | \
|
||||
node plugin/hooks/hooks-app/dist/cli.js
|
||||
```
|
||||
|
||||
### 6. Session State
|
||||
|
||||
Check current session state:
|
||||
|
||||
```bash
|
||||
node plugin/hooks/hooks-app/dist/cli.js session get edited_files .
|
||||
node plugin/hooks/hooks-app/dist/cli.js session get file_extensions .
|
||||
```
|
||||
|
||||
Clear session state:
|
||||
```bash
|
||||
node plugin/hooks/hooks-app/dist/cli.js session clear .
|
||||
```
|
||||
|
||||
## Expected Results
|
||||
|
||||
When working correctly, you should see:
|
||||
|
||||
1. **SessionStart**: Returns `additionalContext` with Turboshovel welcome message
|
||||
2. **PostToolUse (Edit)**: May trigger gates based on your `gates.json` configuration
|
||||
3. **UserPromptSubmit**: May inject context or trigger keyword-matched gates
|
||||
4. **Logs**: Show `HOOK_INVOKED` entries for each hook event
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Plugin errors on load | Invalid hooks.json | Check for unsupported hook types |
|
||||
| Gates not firing | No gates.json | Create `.claude/gates.json` |
|
||||
| Wrong cwd in logs | Plugin path issue | Check `CLAUDE_PLUGIN_ROOT` resolution |
|
||||
| Too many logs | Logging enabled by default | Set `TURBOSHOVEL_LOG=0` |
|
||||
|
||||
## Arguments
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
If arguments provided, interpret as specific test to run:
|
||||
- `hooks` - Focus on hook invocation testing
|
||||
- `gates` - Focus on gate configuration testing
|
||||
- `logs` - Focus on logging setup
|
||||
- `session` - Focus on session state testing
|
||||
342
hooks/ARCHITECTURE.md
Normal file
342
hooks/ARCHITECTURE.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# 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
|
||||
|
||||
```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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```typescript
|
||||
// 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):
|
||||
```typescript
|
||||
// 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):
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
// 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`:
|
||||
```typescript
|
||||
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:**
|
||||
|
||||
```json
|
||||
// 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:
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
354
hooks/CONVENTIONS.md
Normal file
354
hooks/CONVENTIONS.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# Hook System Conventions
|
||||
|
||||
Convention-based patterns for zero-config hook customization.
|
||||
|
||||
## Overview
|
||||
|
||||
Conventions allow project-specific hook behavior without editing `gates.json`. Place files following naming patterns and they auto-execute at the right time.
|
||||
|
||||
## Convention Types
|
||||
|
||||
### 1. Context Injection
|
||||
|
||||
**Purpose:** Auto-inject content into conversation at hook events.
|
||||
|
||||
**Patterns:**
|
||||
|
||||
**Basic Pattern:** `.claude/context/{name}-{stage}.md`
|
||||
- Commands: `.claude/context/commit-start.md`
|
||||
- Skills: `.claude/context/test-driven-development-start.md`
|
||||
- Agents: `.claude/context/commit-agent-end.md`
|
||||
|
||||
**Agent-Command Scoping:** `.claude/context/{agent}-{command}-{stage}.md`
|
||||
- Specific agent + command: `.claude/context/commit-agent-commit-start.md`
|
||||
- Agent with different command: `.claude/context/rust-agent-execute-end.md`
|
||||
- Plan review agent: `.claude/context/plan-review-agent-verify-start.md`
|
||||
|
||||
**Supported hooks:**
|
||||
- `SessionStart` - At beginning of Claude Code session
|
||||
- `SessionEnd` - At end of Claude Code session
|
||||
- `SlashCommandStart` - Before command executes
|
||||
- `SlashCommandEnd` - After command completes
|
||||
- `SkillStart` - When skill loads
|
||||
- `SkillEnd` - When skill completes
|
||||
- `SubagentStop` - After agent completes (supports agent-command scoping)
|
||||
- `UserPromptSubmit` - Before user prompt is processed
|
||||
- `PreToolUse` - Before a tool is used
|
||||
- `PostToolUse` - After a tool is used
|
||||
- `Stop` - When agent stops
|
||||
- `Notification` - When notification is received
|
||||
|
||||
**All Claude Code hook types are supported.** Plugin provides default context for `SessionStart` via `${CLAUDE_PLUGIN_ROOT}/context/session-start.md`.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Generic command context - any invocation
|
||||
.claude/context/commit-start.md
|
||||
|
||||
# Generic agent context - any command using this agent
|
||||
.claude/context/commit-agent-end.md
|
||||
|
||||
# Agent-command specific - commit-agent invoked by /commit
|
||||
.claude/context/commit-agent-commit-start.md
|
||||
|
||||
# Agent-command specific - rust-agent invoked by /execute
|
||||
.claude/context/rust-agent-execute-end.md
|
||||
|
||||
# Planning template for /plan command
|
||||
.claude/context/plan-start.md
|
||||
|
||||
# TDD standards when skill loads
|
||||
.claude/context/test-driven-development-start.md
|
||||
```
|
||||
|
||||
### 2. Directory Organization
|
||||
|
||||
**Small projects (<5 files):**
|
||||
```
|
||||
.claude/context/{name}-{stage}.md
|
||||
```
|
||||
|
||||
**Medium projects (5-20 files):**
|
||||
```
|
||||
.claude/context/slash-command/{name}-{stage}.md
|
||||
.claude/context/skill/{name}-{stage}.md
|
||||
```
|
||||
|
||||
**Large projects (>20 files):**
|
||||
```
|
||||
.claude/context/slash-command/{name}/{stage}.md
|
||||
.claude/context/skill/{name}/{stage}.md
|
||||
```
|
||||
|
||||
All structures supported - use what fits your project size.
|
||||
|
||||
## Discovery Order
|
||||
|
||||
Dispatcher searches paths in priority order. **Project-level context takes precedence over plugin-level context.**
|
||||
|
||||
**For SubagentStop (agent completion):**
|
||||
|
||||
Project paths (checked first):
|
||||
1. `.claude/context/{agent}-{command}-end.md` (agent + command/skill)
|
||||
2. `.claude/context/{agent}-end.md` (agent only)
|
||||
|
||||
Plugin paths (fallback):
|
||||
3. `${CLAUDE_PLUGIN_ROOT}/context/{agent}-{command}-end.md`
|
||||
4. `${CLAUDE_PLUGIN_ROOT}/context/{agent}-end.md`
|
||||
|
||||
Standard discovery (backward compat):
|
||||
5. Command/skill-specific paths
|
||||
|
||||
**For Commands and Skills:**
|
||||
|
||||
Project paths (checked first):
|
||||
1. `.claude/context/{name}-{stage}.md`
|
||||
2. `.claude/context/slash-command/{name}-{stage}.md`
|
||||
3. `.claude/context/slash-command/{name}/{stage}.md`
|
||||
4. `.claude/context/skill/{name}-{stage}.md`
|
||||
5. `.claude/context/skill/{name}/{stage}.md`
|
||||
|
||||
Plugin paths (fallback):
|
||||
6. `${CLAUDE_PLUGIN_ROOT}/context/{name}-{stage}.md`
|
||||
7. `${CLAUDE_PLUGIN_ROOT}/context/slash-command/{name}-{stage}.md`
|
||||
8. `${CLAUDE_PLUGIN_ROOT}/context/slash-command/{name}/{stage}.md`
|
||||
9. `${CLAUDE_PLUGIN_ROOT}/context/skill/{name}-{stage}.md`
|
||||
10. `${CLAUDE_PLUGIN_ROOT}/context/skill/{name}/{stage}.md`
|
||||
|
||||
First match wins.
|
||||
|
||||
**Priority Example (SubagentStop):**
|
||||
```
|
||||
Agent: rust-agent
|
||||
Active command: /execute
|
||||
|
||||
Search order:
|
||||
1. rust-agent-execute-end.md (most specific)
|
||||
2. rust-agent-end.md (agent-specific)
|
||||
3. execute-end.md (command-specific, backward compat)
|
||||
```
|
||||
|
||||
## Naming Rules
|
||||
|
||||
### Command Names
|
||||
- Remove leading slash and namespace: `/turboshovel:code-review` → `code-review`
|
||||
- Use exact command name: `/turboshovel:plan` → `plan`
|
||||
- Lowercase with hyphens
|
||||
|
||||
### Skill Names
|
||||
- Remove namespace prefix: `turboshovel:executing-plans` → `executing-plans`
|
||||
- Use exact skill name (may include hyphens)
|
||||
- Example: `test-driven-development`
|
||||
- Example: `conducting-code-review`
|
||||
|
||||
### Agent Names
|
||||
- Remove namespace prefix: `turboshovel:rust-agent` → `rust-agent`
|
||||
- Use exact agent name (may include hyphens)
|
||||
- Example: `commit-agent`
|
||||
- Example: `code-review-agent`
|
||||
- Example: `review-collation-agent`
|
||||
|
||||
### Stage Names
|
||||
- `start` - Before execution
|
||||
- `end` - After completion
|
||||
- Lowercase only
|
||||
|
||||
## Content Format
|
||||
|
||||
Context files are markdown with any structure:
|
||||
|
||||
```markdown
|
||||
## Project Requirements
|
||||
|
||||
List your requirements here.
|
||||
|
||||
### Security
|
||||
- Requirement 1
|
||||
- Requirement 2
|
||||
|
||||
### Performance
|
||||
- Benchmark targets
|
||||
- Optimization goals
|
||||
```
|
||||
|
||||
Content appears as `additionalContext` in conversation.
|
||||
|
||||
## Execution Model
|
||||
|
||||
### Injection Timing
|
||||
|
||||
**Before explicit gates:**
|
||||
```
|
||||
1. Convention file exists? → Auto-inject
|
||||
2. Run explicit gates (from gates.json)
|
||||
3. Continue or block based on results
|
||||
```
|
||||
|
||||
**Example flow for /code-review:**
|
||||
```
|
||||
1. SlashCommandStart fires
|
||||
2. Check for .claude/context/code-review-start.md
|
||||
3. If exists → inject content
|
||||
4. Run configured gates (e.g., verify-structure)
|
||||
5. Continue if all pass
|
||||
```
|
||||
|
||||
### Combining Conventions and Gates
|
||||
|
||||
**Zero-config approach:**
|
||||
```bash
|
||||
# Just create file - auto-injects!
|
||||
echo "## Requirements..." > .claude/context/code-review-start.md
|
||||
```
|
||||
|
||||
**Mixed approach:**
|
||||
```bash
|
||||
# Convention file for injection
|
||||
.claude/context/code-review-start.md
|
||||
|
||||
# Plus explicit gates for verification
|
||||
{
|
||||
"hooks": {
|
||||
"SlashCommandEnd": {
|
||||
"enabled_commands": ["/code-review"],
|
||||
"gates": ["verify-structure", "test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Execution: Inject context → Run verify-structure → Run test
|
||||
|
||||
## Control and Disabling
|
||||
|
||||
### Disable Convention
|
||||
|
||||
**Method 1: Rename file**
|
||||
```bash
|
||||
mv .claude/context/code-review-start.md \
|
||||
.claude/context/code-review-start.md.disabled
|
||||
```
|
||||
|
||||
**Method 2: Move to non-discovery path**
|
||||
```bash
|
||||
mkdir -p .claude/disabled
|
||||
mv .claude/context/code-review-start.md .claude/disabled/
|
||||
```
|
||||
|
||||
**Method 3: Delete file**
|
||||
```bash
|
||||
rm .claude/context/code-review-start.md
|
||||
```
|
||||
|
||||
No config changes needed - control via file presence.
|
||||
|
||||
### Enable Convention
|
||||
|
||||
Move/rename file back to discovery path:
|
||||
```bash
|
||||
mv .claude/context/code-review-start.md.disabled \
|
||||
.claude/context/code-review-start.md
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Review Requirements
|
||||
|
||||
**File:** `.claude/context/code-review-start.md`
|
||||
|
||||
**Triggered by:** `/turboshovel:code-review` command
|
||||
|
||||
**Content example:**
|
||||
```markdown
|
||||
## Security Requirements
|
||||
- Authentication required
|
||||
- Input validation
|
||||
- No secrets in logs
|
||||
|
||||
## Performance Requirements
|
||||
- No N+1 queries
|
||||
- Response time < 200ms
|
||||
```
|
||||
|
||||
### Pattern: Planning Template
|
||||
|
||||
**File:** `.claude/context/plan-start.md`
|
||||
|
||||
**Triggered by:** `/turboshovel:plan` command
|
||||
|
||||
**Content example:**
|
||||
```markdown
|
||||
## Plan Structure
|
||||
|
||||
Must include:
|
||||
1. Architecture impact
|
||||
2. Testing strategy
|
||||
3. Deployment plan
|
||||
4. Success criteria
|
||||
```
|
||||
|
||||
### Pattern: Skill Standards
|
||||
|
||||
**File:** `.claude/context/test-driven-development-start.md`
|
||||
|
||||
**Triggered by:** TDD skill loading
|
||||
|
||||
**Content example:**
|
||||
```markdown
|
||||
## Project TDD Standards
|
||||
|
||||
Framework: Vitest
|
||||
Location: src/**/__tests__/*.test.ts
|
||||
Coverage: 80% minimum
|
||||
```
|
||||
|
||||
## Migration from Custom Scripts
|
||||
|
||||
**Before (custom script):**
|
||||
```bash
|
||||
# .claude/gates/inject-requirements.sh
|
||||
#!/bin/bash
|
||||
cat .claude/requirements.md | jq -Rs '{additionalContext: .}'
|
||||
```
|
||||
|
||||
**After (convention):**
|
||||
```bash
|
||||
# Just rename/move the file!
|
||||
mv .claude/requirements.md .claude/context/code-review-start.md
|
||||
```
|
||||
|
||||
Zero scripting needed.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **File Organization:** Start flat, grow hierarchically as needed
|
||||
2. **Naming:** Use exact command/skill names (lowercase-only stage names)
|
||||
3. **Content:** Keep focused - one concern per file
|
||||
4. **Discovery:** Let multiple paths support project evolution
|
||||
5. **Control:** Rename/move files rather than editing gates.json
|
||||
|
||||
## Debugging
|
||||
|
||||
**Check if file discovered:**
|
||||
```bash
|
||||
export TURBOSHOVEL_HOOK_DEBUG=true
|
||||
tail -f $TMPDIR/turboshovel-hooks-$(date +%Y%m%d).log
|
||||
```
|
||||
|
||||
Look for: `"dispatcher: Context file: /path/to/file.md"`
|
||||
|
||||
**Common issues:**
|
||||
- Wrong file name (check exact command/skill name)
|
||||
- Wrong stage name (must be `start` or `end`, lowercase)
|
||||
- File not in discovery path (check supported structures)
|
||||
- Permissions (file must be readable)
|
||||
|
||||
## Examples Directory
|
||||
|
||||
See `plugin/hooks/examples/context/` for working examples:
|
||||
- Code review requirements
|
||||
- Planning templates
|
||||
- TDD standards
|
||||
|
||||
Copy and customize for your project.
|
||||
246
hooks/INTEGRATION_TESTS.md
Normal file
246
hooks/INTEGRATION_TESTS.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Quality Hooks Integration Tests
|
||||
|
||||
Manual integration tests to verify quality hooks work with real agents.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Quality hooks installed and registered
|
||||
- `gates.json` configured with test commands
|
||||
- Claude Code with plugin loaded
|
||||
|
||||
## Test 1: PostToolUse Hook Trigger
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Ensure gates.json has Edit tool enabled
|
||||
jq '.hooks.PostToolUse.enabled_tools' plugin/hooks/gates.json
|
||||
# Should include "Edit"
|
||||
```
|
||||
|
||||
**Test:**
|
||||
1. Create a test file: `echo "# Test" > /tmp/test-hooks.md`
|
||||
2. Use Edit tool to modify file
|
||||
3. Observe PostToolUse hook execution
|
||||
|
||||
**Expected:**
|
||||
- Hook runs after Edit completes
|
||||
- Check gate executes
|
||||
- If gate passes: No output (CONTINUE)
|
||||
- If gate fails: BLOCK decision with error output
|
||||
|
||||
## Test 2: SubagentStop Hook Trigger
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Ensure gates.json has rust-agent enabled
|
||||
jq '.hooks.SubagentStop.enabled_agents' plugin/hooks/gates.json
|
||||
# Should include "rust-agent"
|
||||
```
|
||||
|
||||
**Test:**
|
||||
1. Dispatch rust-agent with simple task
|
||||
2. Agent completes work
|
||||
3. Observe SubagentStop hook execution
|
||||
|
||||
**Expected:**
|
||||
- Hook runs when agent completes
|
||||
- Both check and test gates execute
|
||||
- Gates run in sequence
|
||||
- Results appear in agent's context
|
||||
|
||||
## Test 3: Gate Chaining
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Configure gate chaining
|
||||
cat > plugin/hooks/gates.json <<'EOF'
|
||||
{
|
||||
"gates": {
|
||||
"first": {
|
||||
"command": "echo 'First gate'",
|
||||
"on_pass": "second"
|
||||
},
|
||||
"second": {
|
||||
"command": "echo 'Second gate'",
|
||||
"on_pass": "CONTINUE"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": {
|
||||
"enabled_tools": ["Edit"],
|
||||
"gates": ["first"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Test:**
|
||||
1. Edit a file with Edit tool
|
||||
2. Observe hook execution
|
||||
|
||||
**Expected:**
|
||||
- First gate executes
|
||||
- On pass, second gate executes (chaining)
|
||||
- Both gates must pass for CONTINUE
|
||||
|
||||
## Test 4: BLOCK Action
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Configure gate to fail and block
|
||||
cat > plugin/hooks/gates.json <<'EOF'
|
||||
{
|
||||
"gates": {
|
||||
"block-test": {
|
||||
"command": "exit 1",
|
||||
"on_fail": "BLOCK"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": {
|
||||
"enabled_tools": ["Edit"],
|
||||
"gates": ["block-test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Test:**
|
||||
1. Edit a file with Edit tool
|
||||
2. Observe BLOCK behavior
|
||||
|
||||
**Expected:**
|
||||
- Gate fails
|
||||
- Hook outputs: `{"decision": "block", "reason": "..."}`
|
||||
- Agent cannot proceed
|
||||
- Error message includes gate output
|
||||
|
||||
## Test 5: CONTINUE on Failure (Warn Only)
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Configure gate to fail but continue
|
||||
cat > plugin/hooks/gates.json <<'EOF'
|
||||
{
|
||||
"gates": {
|
||||
"warn-test": {
|
||||
"command": "exit 1",
|
||||
"on_fail": "CONTINUE"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": {
|
||||
"enabled_tools": ["Edit"],
|
||||
"gates": ["warn-test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Test:**
|
||||
1. Edit a file with Edit tool
|
||||
2. Observe warning behavior
|
||||
|
||||
**Expected:**
|
||||
- Gate fails
|
||||
- Hook outputs: `{"additionalContext": "⚠️ Gate 'warn-test' failed..."}`
|
||||
- Execution continues despite failure
|
||||
- Warning appears in context
|
||||
|
||||
## Test 6: Missing Gate Error
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Configure gates.json with reference to non-existent gate
|
||||
cat > plugin/hooks/gates.json <<'EOF'
|
||||
{
|
||||
"gates": {},
|
||||
"hooks": {
|
||||
"PostToolUse": {
|
||||
"enabled_tools": ["Edit"],
|
||||
"gates": ["nonexistent"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Test:**
|
||||
1. Edit a file with Edit tool
|
||||
2. Observe error handling
|
||||
|
||||
**Expected:**
|
||||
- Hook outputs: `{"continue": false, "message": "Gate 'nonexistent' referenced but not defined..."}`
|
||||
- Claude stops entirely (STOP action)
|
||||
- Clear error message
|
||||
|
||||
## Test 7: Tool Filtering
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Configure PostToolUse for Edit only (not Read)
|
||||
jq '.hooks.PostToolUse.enabled_tools = ["Edit"]' plugin/hooks/gates.json > /tmp/gates.json
|
||||
mv /tmp/gates.json plugin/hooks/gates.json
|
||||
```
|
||||
|
||||
**Test:**
|
||||
1. Use Read tool
|
||||
2. Use Edit tool
|
||||
|
||||
**Expected:**
|
||||
- Read tool: No hook execution (not in enabled_tools)
|
||||
- Edit tool: Hook executes normally
|
||||
|
||||
## Test 8: Agent Filtering
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Configure SubagentStop for rust-agent only
|
||||
jq '.hooks.SubagentStop.enabled_agents = ["rust-agent"]' plugin/hooks/gates.json > /tmp/gates.json
|
||||
mv /tmp/gates.json plugin/hooks/gates.json
|
||||
```
|
||||
|
||||
**Test:**
|
||||
1. Dispatch rust-agent
|
||||
2. Dispatch code-review-agent
|
||||
|
||||
**Expected:**
|
||||
- rust-agent: Hook executes when agent completes
|
||||
- code-review-agent: No hook execution (not in enabled_agents)
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After running all tests:
|
||||
|
||||
- [ ] PostToolUse hook triggers on enabled tools
|
||||
- [ ] PostToolUse hook ignores non-enabled tools
|
||||
- [ ] SubagentStop hook triggers on enabled agents
|
||||
- [ ] SubagentStop hook ignores non-enabled agents
|
||||
- [ ] Gate chaining works correctly
|
||||
- [ ] BLOCK action prevents agent continuation
|
||||
- [ ] CONTINUE action proceeds with/without warning
|
||||
- [ ] STOP action halts Claude
|
||||
- [ ] Missing gate produces STOP with error
|
||||
- [ ] Error messages are clear and actionable
|
||||
- [ ] Hook output is valid JSON
|
||||
- [ ] Hooks handle missing config gracefully
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Hook doesn't run:**
|
||||
- Check `hooks.json` registered correctly
|
||||
- Verify `CLAUDE_PLUGIN_ROOT` is set
|
||||
- Check tool/agent is in enabled list
|
||||
|
||||
**Gate command fails:**
|
||||
- Verify command exists: `which <command>`
|
||||
- Test command manually: `<command>`
|
||||
- Check gate configuration in `gates.json`
|
||||
|
||||
**JSON parse errors:**
|
||||
- Validate `gates.json`: `jq . plugin/hooks/gates.json`
|
||||
- Check hook script syntax: `bash -n plugin/hooks/*.sh`
|
||||
- Review error messages for formatting issues
|
||||
328
hooks/README.md
Normal file
328
hooks/README.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Quality Hooks
|
||||
|
||||
Automated quality enforcement and context injection via Claude Code's hook system. A **self-referential TypeScript application** that uses its own configuration format.
|
||||
|
||||
> **💡 CONTEXT INJECTION IS AUTOMATIC**
|
||||
>
|
||||
> Just create `.claude/context/{name}-{stage}.md` files - they auto-inject at the right time.
|
||||
> **No configuration files needed.** No gates.json. No setup.
|
||||
>
|
||||
> The `gates.json` file is ONLY for optional quality enforcement (lint, test, build checks).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Zero-Config Context Injection (Recommended)
|
||||
|
||||
**Just create context files - they auto-inject automatically:**
|
||||
|
||||
```bash
|
||||
# Create context directory
|
||||
mkdir -p .claude/context
|
||||
|
||||
# Add context for /code-review command
|
||||
cat > .claude/context/code-review-start.md << 'EOF'
|
||||
## Security Requirements
|
||||
- Authentication on all endpoints
|
||||
- Input validation for user data
|
||||
- No secrets in logs
|
||||
- HTTPS only
|
||||
EOF
|
||||
|
||||
# That's it! When /code-review runs, requirements auto-inject!
|
||||
```
|
||||
|
||||
**Works with ANY command, skill, or agent.** Follow the naming pattern: `.claude/context/{name}-{stage}.md`
|
||||
|
||||
### Advanced: Quality Gates (Optional)
|
||||
|
||||
**Need to enforce quality checks?** Add `gates.json` configuration:
|
||||
|
||||
```bash
|
||||
mkdir -p .claude
|
||||
cat > .claude/gates.json << 'EOF'
|
||||
{
|
||||
"gates": {
|
||||
"check": {"command": "npm run lint", "on_fail": "BLOCK"},
|
||||
"test": {"command": "npm test", "on_fail": "BLOCK"}
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": {
|
||||
"enabled_tools": ["Edit", "Write"],
|
||||
"gates": ["check"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
See **[SETUP.md](./SETUP.md)** for detailed gate configuration.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Hook Event → Context Injection (AUTOMATIC) → [OPTIONAL: gates.json Gates] → Action
|
||||
↓ ↓
|
||||
.claude/context/ Quality checks
|
||||
plugin/context/ Custom commands
|
||||
(zero config!) (requires gates.json)
|
||||
```
|
||||
|
||||
1. **Context Injection** (AUTOMATIC): Always runs first, discovers `.claude/context/{name}-{stage}.md` files
|
||||
2. **Gate Execution** (OPTIONAL): If `gates.json` configured, runs quality checks/custom commands
|
||||
3. **Action Handling**: CONTINUE, BLOCK, STOP, or chain to another gate
|
||||
|
||||
**Context injection works standalone - gates.json is only for optional quality enforcement.**
|
||||
|
||||
See **[ARCHITECTURE.md](./ARCHITECTURE.md)** for detailed system design.
|
||||
|
||||
## Supported Hook Events
|
||||
|
||||
All 12 Claude Code hook types are supported:
|
||||
|
||||
| Event | Context Pattern | Default Behavior |
|
||||
|-------|----------------|------------------|
|
||||
| `SessionStart` | `session-start.md` | Plugin injects agent selection guide |
|
||||
| `SessionEnd` | `session-end.md` | - |
|
||||
| `UserPromptSubmit` | `prompt-submit.md` | Keyword-triggered gates (check, test, build) |
|
||||
| `SlashCommandStart` | `{command}-start.md` | - |
|
||||
| `SlashCommandEnd` | `{command}-end.md` | - |
|
||||
| `SkillStart` | `{skill}-start.md` | - |
|
||||
| `SkillEnd` | `{skill}-end.md` | - |
|
||||
| `SubagentStop` | `{agent}-end.md` | - |
|
||||
| `PreToolUse` | `{tool}-pre.md` | - |
|
||||
| `PostToolUse` | `{tool}-post.md` | - |
|
||||
| `Stop` | `agent-stop.md` | - |
|
||||
| `Notification` | `notification-receive.md` | - |
|
||||
|
||||
## Context Injection
|
||||
|
||||
**Zero-config content injection** via file naming convention.
|
||||
|
||||
### Naming Convention
|
||||
|
||||
```
|
||||
Pattern: .claude/context/{name}-{stage}.md
|
||||
|
||||
Examples:
|
||||
/code-review starts → .claude/context/code-review-start.md
|
||||
/plan starts → .claude/context/plan-start.md
|
||||
TDD skill loads → .claude/context/test-driven-development-start.md
|
||||
SessionStart fires → .claude/context/session-start.md
|
||||
```
|
||||
|
||||
### Priority Order
|
||||
|
||||
1. **Project context** (`.claude/context/`) - highest priority
|
||||
2. **Plugin context** (`${CLAUDE_PLUGIN_ROOT}/context/`) - fallback defaults
|
||||
|
||||
Projects can override any plugin-provided context by creating their own file.
|
||||
|
||||
### Complete Zero-Config Example
|
||||
|
||||
**Step 1: Create context file**
|
||||
|
||||
```bash
|
||||
mkdir -p .claude/context
|
||||
cat > .claude/context/code-review-start.md << 'EOF'
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Authentication on all endpoints
|
||||
- [ ] Input validation for user data
|
||||
- [ ] No secrets in logs
|
||||
- [ ] HTTPS only
|
||||
- [ ] Rate limiting configured
|
||||
EOF
|
||||
```
|
||||
|
||||
**Step 2: Run the command**
|
||||
|
||||
```bash
|
||||
/code-review src/api/users.ts
|
||||
```
|
||||
|
||||
**Step 3: Context auto-injects**
|
||||
|
||||
The security checklist appears in the conversation automatically. **No configuration files needed!**
|
||||
|
||||
**This works with ANY slash command, skill, or agent.** Just follow the naming pattern: `.claude/context/{name}-{stage}.md`
|
||||
|
||||
### Hook-to-File Mapping
|
||||
|
||||
| Hook Type | File Pattern | Example |
|
||||
|-----------|--------------|---------|
|
||||
| `SessionStart` | `session-start.md` | Session begins |
|
||||
| `UserPromptSubmit` | `prompt-submit.md` | User sends message |
|
||||
| `SlashCommandStart` | `{command}-start.md` | `/code-review-start.md` |
|
||||
| `SkillStart` | `{skill}-start.md` | `test-driven-development-start.md` |
|
||||
| `SubagentStop` | `{agent}-end.md` | `rust-agent-end.md` |
|
||||
| `PreToolUse` | `{tool}-pre.md` | `Edit-pre.md` |
|
||||
|
||||
See **[CONVENTIONS.md](./CONVENTIONS.md)** for full documentation.
|
||||
|
||||
## Gate Configuration (Optional)
|
||||
|
||||
**Most users only need context files.** Gates are for optional quality enforcement and custom commands.
|
||||
|
||||
Gates are defined in `gates.json` and can be:
|
||||
|
||||
## Plugin Gate References
|
||||
|
||||
Reference gates defined in other plugins:
|
||||
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"plan-compliance": {
|
||||
"plugin": "cipherpowers",
|
||||
"gate": "plan-compliance"
|
||||
},
|
||||
"check": {
|
||||
"command": "npm run lint"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"SubagentStop": {
|
||||
"gates": ["plan-compliance", "check"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `plugin` field uses sibling convention - assumes plugins are installed in the same directory (e.g., `~/.claude/plugins/`). The gate's command runs in the plugin's directory context.
|
||||
|
||||
### Shell Command Gates
|
||||
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"check": {
|
||||
"command": "npm run lint",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript Gates
|
||||
|
||||
Gates without `command` field are TypeScript modules in `src/gates/`:
|
||||
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"plugin-path": {
|
||||
"description": "Verify plugin path resolution in subagents",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "CONTINUE"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See **[TYPESCRIPT.md](./TYPESCRIPT.md)** for creating TypeScript gates.
|
||||
|
||||
### Keyword-Triggered Gates
|
||||
|
||||
Gates can define `keywords` to only run when the user message contains matching terms:
|
||||
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"test": {
|
||||
"description": "Run project test suite",
|
||||
"keywords": ["test", "testing", "spec", "verify"],
|
||||
"command": "npm test",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": {
|
||||
"gates": ["test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Gates with `keywords` only run if any keyword is found in the user message
|
||||
- Gates without `keywords` always run (backwards compatible)
|
||||
- Keyword matching is case-insensitive
|
||||
|
||||
### Agent Filtering for SubagentStop
|
||||
|
||||
**Important:** Without `enabled_agents`, SubagentStop triggers for ALL agents - including verification-only agents that don't modify code.
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SubagentStop": {
|
||||
"enabled_agents": ["rust-agent", "code-agent", "commit-agent"],
|
||||
"gates": ["check", "test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- Verification agents (technical-writer in VERIFICATION mode, research-agent) only read files
|
||||
- Running `check` and `test` gates after read-only verification is unnecessary
|
||||
- Gate failures for verification agents confuse the workflow (false positives)
|
||||
|
||||
**Recommended pattern:** Only include agents that modify code:
|
||||
- `rust-agent`, `code-agent` - write/edit code
|
||||
- `commit-agent` - makes git commits
|
||||
- Exclude: `technical-writer` (verification mode), `research-agent`, `plan-review-agent`
|
||||
|
||||
**Note:** `enabled_tools` works the same way for PostToolUse hooks.
|
||||
|
||||
## Configuration Merging
|
||||
|
||||
The system merges plugin and project configurations:
|
||||
|
||||
```
|
||||
plugin/hooks/gates.json (defaults)
|
||||
↓ merged with
|
||||
.claude/gates.json (project overrides)
|
||||
↓
|
||||
Merged Configuration (project takes precedence)
|
||||
```
|
||||
|
||||
**Plugin provides defaults. Projects override what they need.**
|
||||
|
||||
## Debugging
|
||||
|
||||
Logs are written to `$TMPDIR/turboshovel/hooks-YYYY-MM-DD.log`:
|
||||
|
||||
```bash
|
||||
# View logs in real-time
|
||||
tail -f $(node ${CLAUDE_PLUGIN_ROOT}/hooks/hooks-app/dist/cli.js log-path)
|
||||
|
||||
# Or find the log file
|
||||
ls $TMPDIR/turboshovel/hooks-*.log
|
||||
```
|
||||
|
||||
**What gets logged:**
|
||||
- Hook event received
|
||||
- Config files loaded
|
||||
- Context files discovered
|
||||
- Gates executed
|
||||
- Actions taken
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[ARCHITECTURE.md](./ARCHITECTURE.md)** - System design and data flow
|
||||
- **[CONVENTIONS.md](./CONVENTIONS.md)** - Context file naming conventions
|
||||
- **[SETUP.md](./SETUP.md)** - Detailed configuration guide
|
||||
- **[TYPESCRIPT.md](./TYPESCRIPT.md)** - Creating TypeScript gates
|
||||
- **[INTEGRATION_TESTS.md](./INTEGRATION_TESTS.md)** - Testing procedures
|
||||
|
||||
## Examples
|
||||
|
||||
See `examples/` for ready-to-use configurations:
|
||||
|
||||
- `strict.json` - Block on all failures
|
||||
- `permissive.json` - Warn only
|
||||
- `pipeline.json` - Gate chaining
|
||||
- `context/` - Example context files
|
||||
470
hooks/SETUP.md
Normal file
470
hooks/SETUP.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# Quality Hooks Setup
|
||||
|
||||
## Simple Setup (Context Files Only)
|
||||
|
||||
**For most projects, context files are all you need.**
|
||||
|
||||
Context injection is AUTOMATIC - no configuration files required. Just create `.claude/context/` directory and add markdown files following the naming pattern.
|
||||
|
||||
### Quick Setup
|
||||
|
||||
```bash
|
||||
# 1. Create context directory
|
||||
mkdir -p .claude/context
|
||||
|
||||
# 2. Add context files for your commands/skills
|
||||
# For /code-review command
|
||||
cat > .claude/context/code-review-start.md << 'EOF'
|
||||
## Security Requirements
|
||||
- Authentication on all endpoints
|
||||
- Input validation
|
||||
- No secrets in logs
|
||||
EOF
|
||||
|
||||
# For test-driven-development skill
|
||||
cat > .claude/context/test-driven-development-start.md << 'EOF'
|
||||
## TDD Standards
|
||||
- Write failing test first
|
||||
- Implement minimal code to pass
|
||||
- Refactor with tests passing
|
||||
EOF
|
||||
|
||||
# For session start
|
||||
cat > .claude/context/session-start.md << 'EOF'
|
||||
## Project Context
|
||||
- TypeScript project using Vitest
|
||||
- Follow functional programming style
|
||||
- Use strict type checking
|
||||
EOF
|
||||
```
|
||||
|
||||
### That's It!
|
||||
|
||||
Context files auto-inject when commands/skills run. **No gates.json needed.**
|
||||
|
||||
**Need quality gates or custom commands?** Continue to "Advanced Setup" below.
|
||||
|
||||
---
|
||||
|
||||
## Advanced Setup (gates.json Configuration)
|
||||
|
||||
**Only needed for quality enforcement (lint, test, build checks) or custom commands.**
|
||||
|
||||
Quality hooks support optional **project-level** `gates.json` configuration for running quality checks.
|
||||
|
||||
### gates.json Search Priority
|
||||
|
||||
The hooks search for `gates.json` in this order:
|
||||
|
||||
1. **`.claude/gates.json`** - Project-specific configuration (recommended)
|
||||
2. **`gates.json`** - Project root configuration
|
||||
3. **`${CLAUDE_PLUGIN_ROOT}hooks/gates.json`** - Plugin default (fallback)
|
||||
|
||||
### Quick gates.json Setup
|
||||
|
||||
### Option 1: Recommended (.claude/gates.json)
|
||||
|
||||
```bash
|
||||
# Create .claude directory
|
||||
mkdir -p .claude
|
||||
|
||||
# Copy example configuration
|
||||
cp ${CLAUDE_PLUGIN_ROOT}hooks/examples/strict.json .claude/gates.json
|
||||
|
||||
# Customize for your project
|
||||
vim .claude/gates.json
|
||||
```
|
||||
|
||||
### Option 2: Project Root (gates.json)
|
||||
|
||||
```bash
|
||||
# Copy example configuration
|
||||
cp ${CLAUDE_PLUGIN_ROOT}hooks/examples/strict.json gates.json
|
||||
|
||||
# Customize for your project
|
||||
vim gates.json
|
||||
```
|
||||
|
||||
## Customizing Gates
|
||||
|
||||
Edit your project's `gates.json` to match your build tooling:
|
||||
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"check": {
|
||||
"description": "Run quality checks",
|
||||
"command": "npm run lint", // ← Change to your command
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
},
|
||||
"test": {
|
||||
"description": "Run tests",
|
||||
"command": "npm test", // ← Change to your command
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": {
|
||||
"enabled_tools": ["Edit", "Write"],
|
||||
"gates": ["check"]
|
||||
},
|
||||
"SubagentStop": {
|
||||
"enabled_agents": ["rust-agent"],
|
||||
"gates": ["check", "test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Command Patterns
|
||||
|
||||
**Node.js/TypeScript:**
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"check": {"command": "npm run lint"},
|
||||
"test": {"command": "npm test"},
|
||||
"build": {"command": "npm run build"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rust:**
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"check": {"command": "cargo clippy"},
|
||||
"test": {"command": "cargo test"},
|
||||
"build": {"command": "cargo build"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Python:**
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"check": {"command": "ruff check ."},
|
||||
"test": {"command": "pytest"},
|
||||
"build": {"command": "python -m build"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**mise tasks:**
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"check": {"command": "mise run check"},
|
||||
"test": {"command": "mise run test"},
|
||||
"build": {"command": "mise run build"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Make:**
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"check": {"command": "make lint"},
|
||||
"test": {"command": "make test"},
|
||||
"build": {"command": "make build"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example Configurations
|
||||
|
||||
The plugin provides three example configurations:
|
||||
|
||||
### Strict Mode (Block on Failures)
|
||||
```bash
|
||||
cp ${CLAUDE_PLUGIN_ROOT}hooks/examples/strict.json .claude/gates.json
|
||||
```
|
||||
|
||||
Best for: Production code, established projects
|
||||
|
||||
### Permissive Mode (Warn Only)
|
||||
```bash
|
||||
cp ${CLAUDE_PLUGIN_ROOT}hooks/examples/permissive.json .claude/gates.json
|
||||
```
|
||||
|
||||
Best for: Prototyping, learning, experimental work
|
||||
|
||||
### Pipeline Mode (Chained Gates)
|
||||
```bash
|
||||
cp ${CLAUDE_PLUGIN_ROOT}hooks/examples/pipeline.json .claude/gates.json
|
||||
```
|
||||
|
||||
Best for: Complex workflows, auto-formatting before checks
|
||||
|
||||
## Enabling/Disabling Hooks
|
||||
|
||||
### Disable Quality Hooks Entirely
|
||||
|
||||
Remove or rename your project's `gates.json`:
|
||||
|
||||
```bash
|
||||
mv .claude/gates.json .claude/gates.json.disabled
|
||||
```
|
||||
|
||||
### Disable Specific Hooks
|
||||
|
||||
Edit `gates.json` to remove hooks:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": {
|
||||
"enabled_tools": [], // ← Empty = disabled
|
||||
"gates": []
|
||||
},
|
||||
"SubagentStop": {
|
||||
"enabled_agents": ["rust-agent"], // ← Keep enabled
|
||||
"gates": ["check", "test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Disable Specific Tools/Agents
|
||||
|
||||
Remove from enabled lists:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": {
|
||||
"enabled_tools": ["Edit"], // ← Removed "Write"
|
||||
"gates": ["check"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Your Configuration
|
||||
|
||||
```bash
|
||||
# Test gate execution manually
|
||||
source ${CLAUDE_PLUGIN_ROOT}hooks/shared-functions.sh
|
||||
run_gate "check" ".claude/gates.json"
|
||||
|
||||
# Verify JSON is valid
|
||||
jq . .claude/gates.json
|
||||
|
||||
# Test with mock hook input
|
||||
export CLAUDE_PLUGIN_ROOT=/path/to/plugin
|
||||
echo '{"tool_name": "Edit", "cwd": "'$(pwd)'"}' | ${CLAUDE_PLUGIN_ROOT}hooks/post-tool-use.sh
|
||||
```
|
||||
|
||||
## Version Control
|
||||
|
||||
### Recommended: Commit gates.json
|
||||
|
||||
```bash
|
||||
git add .claude/gates.json
|
||||
git commit -m "chore: configure quality gates"
|
||||
```
|
||||
|
||||
This ensures all team members use the same quality standards.
|
||||
|
||||
### Optional: Per-Developer Override
|
||||
|
||||
Developers can override with local configuration:
|
||||
|
||||
```bash
|
||||
# Team config
|
||||
.claude/gates.json ← committed
|
||||
|
||||
# Personal override (gitignored)
|
||||
gates.json ← takes priority, not committed
|
||||
```
|
||||
|
||||
Add to `.gitignore`:
|
||||
```
|
||||
/gates.json
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hooks Not Running
|
||||
|
||||
1. Check configuration exists:
|
||||
```bash
|
||||
ls -la .claude/gates.json
|
||||
```
|
||||
|
||||
2. Verify plugin root is set:
|
||||
```bash
|
||||
echo $CLAUDE_PLUGIN_ROOT
|
||||
```
|
||||
|
||||
3. Check tool/agent is enabled:
|
||||
```bash
|
||||
jq '.hooks.PostToolUse.enabled_tools' .claude/gates.json
|
||||
```
|
||||
|
||||
### Gate Fails for Verification-Only Agents
|
||||
|
||||
**Symptom:** SubagentStop gates fail for agents that only read files (technical-writer in verification mode, research-agent).
|
||||
|
||||
**Cause:** Missing `enabled_agents` filter - gates run for ALL agents.
|
||||
|
||||
**Solution:** Add `enabled_agents` to only include code-modifying agents:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"SubagentStop": {
|
||||
"enabled_agents": ["rust-agent", "code-agent", "commit-agent"],
|
||||
"gates": ["check", "test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Verification agents don't modify code, so check/test gates are unnecessary and produce false positive failures.
|
||||
|
||||
### Commands Failing
|
||||
|
||||
1. Test command manually:
|
||||
```bash
|
||||
npm run lint # or whatever your check command is
|
||||
```
|
||||
|
||||
2. Check command exists:
|
||||
```bash
|
||||
which npm
|
||||
```
|
||||
|
||||
3. Verify working directory:
|
||||
- Commands run from project root (where gates.json lives)
|
||||
- Use absolute paths if needed
|
||||
|
||||
### JSON Syntax Errors
|
||||
|
||||
```bash
|
||||
# Validate JSON
|
||||
jq . .claude/gates.json
|
||||
|
||||
# Common errors:
|
||||
# - Missing commas between items
|
||||
# - Trailing commas in arrays/objects
|
||||
# - Unescaped quotes in strings
|
||||
```
|
||||
|
||||
## Plugin Gate References
|
||||
|
||||
You can reference gates defined in other plugins to reuse quality checks across projects.
|
||||
|
||||
### Configuration
|
||||
|
||||
Use the `plugin` and `gate` fields to reference external gates:
|
||||
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"plan-compliance": {
|
||||
"plugin": "cipherpowers",
|
||||
"gate": "plan-compliance",
|
||||
"description": "Verify implementation matches plan"
|
||||
},
|
||||
"check": {
|
||||
"command": "npm run lint",
|
||||
"on_fail": "BLOCK"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"SubagentStop": {
|
||||
"gates": ["plan-compliance", "check"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How Plugin Gates Work
|
||||
|
||||
**Plugin Discovery:**
|
||||
- The `plugin` field uses **sibling convention**
|
||||
- Assumes plugins are installed in the same parent directory
|
||||
- Example: If your plugin is in `~/.claude/plugins/turboshovel/`, it looks for `~/.claude/plugins/cipherpowers/`
|
||||
|
||||
**Execution Context:**
|
||||
- Plugin gate commands run in the **plugin's directory**
|
||||
- This allows plugin gates to access their own tools and configurations
|
||||
- Your project's working directory is still available via environment variables
|
||||
|
||||
**Required Fields:**
|
||||
- `plugin`: Name of the plugin containing the gate
|
||||
- `gate`: Name of the gate defined in the plugin's `gates.json`
|
||||
|
||||
**Optional Fields:**
|
||||
- `description`: Override the plugin's gate description
|
||||
- Other gate fields (like `on_pass`, `on_fail`) use the plugin's defaults
|
||||
|
||||
### Mixing Local and Plugin Gates
|
||||
|
||||
You can combine local gates (with `command` field) and plugin gates (with `plugin` field) in the same configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"plan-compliance": {
|
||||
"plugin": "cipherpowers",
|
||||
"gate": "plan-compliance"
|
||||
},
|
||||
"code-review": {
|
||||
"plugin": "cipherpowers",
|
||||
"gate": "code-review"
|
||||
},
|
||||
"check": {
|
||||
"command": "npm run lint"
|
||||
},
|
||||
"test": {
|
||||
"command": "npm test"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"SubagentStop": {
|
||||
"gates": ["plan-compliance", "check", "test"]
|
||||
},
|
||||
"PostToolUse": {
|
||||
"enabled_tools": ["Edit", "Write"],
|
||||
"gates": ["check"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Troubleshooting Plugin Gates
|
||||
|
||||
**Plugin not found:**
|
||||
- Verify plugin is installed in sibling directory
|
||||
- Check plugin name matches directory name
|
||||
- Example: `"plugin": "cipherpowers"` requires `../cipherpowers/` directory
|
||||
|
||||
**Gate not found in plugin:**
|
||||
- Verify gate name matches plugin's `gates.json`
|
||||
- Check plugin's `gates.json` for available gates
|
||||
- Gate names are case-sensitive
|
||||
|
||||
**Plugin gate fails:**
|
||||
- Plugin gates run in plugin's directory context
|
||||
- Check plugin's own configuration and dependencies
|
||||
- Review logs for plugin-specific error messages
|
||||
|
||||
## Migration from Plugin Default
|
||||
|
||||
If you were using the plugin's default `gates.json`, migrate to project-level:
|
||||
|
||||
```bash
|
||||
# Copy current config
|
||||
cp ${CLAUDE_PLUGIN_ROOT}hooks/gates.json .claude/gates.json
|
||||
|
||||
# Customize for this project
|
||||
vim .claude/gates.json
|
||||
```
|
||||
|
||||
The plugin default now serves as a fallback template only.
|
||||
320
hooks/TYPESCRIPT.md
Normal file
320
hooks/TYPESCRIPT.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# TypeScript Gates
|
||||
|
||||
Guide to creating and working with TypeScript gates in the Turboshovel hook system.
|
||||
|
||||
## Overview
|
||||
|
||||
TypeScript gates are gates defined **without a `command` field** in `gates.json`. They're implemented as TypeScript modules in `hooks-app/src/gates/`.
|
||||
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"commands": {
|
||||
"description": "Context-aware command injection",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "CONTINUE"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When this gate runs, the system loads `src/gates/commands.ts` and calls its `execute()` function.
|
||||
|
||||
## Built-in Gates
|
||||
|
||||
The plugin includes these TypeScript gates:
|
||||
|
||||
| Gate | Purpose | Default Hook |
|
||||
|------|---------|--------------|
|
||||
| `plugin-path` | Verify plugin path resolution in subagents | (manual) |
|
||||
|
||||
## Creating a TypeScript Gate
|
||||
|
||||
### 1. Create the Gate Module
|
||||
|
||||
Create `hooks-app/src/gates/my-gate.ts`:
|
||||
|
||||
```typescript
|
||||
import { HookInput, GateResult } from '../types';
|
||||
|
||||
/**
|
||||
* My custom gate
|
||||
*
|
||||
* Describe what this gate does and when it should be used.
|
||||
*/
|
||||
export async function execute(input: HookInput): Promise<GateResult> {
|
||||
// Access hook input data
|
||||
const { cwd, hook_event_name, tool_name, user_message } = input;
|
||||
|
||||
// Your gate logic here
|
||||
const shouldPass = true;
|
||||
|
||||
if (shouldPass) {
|
||||
return {
|
||||
additionalContext: 'Gate passed - injecting this context'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
decision: 'block',
|
||||
reason: 'Gate failed because...'
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Register in Index
|
||||
|
||||
Add to `hooks-app/src/gates/index.ts`:
|
||||
|
||||
```typescript
|
||||
export * as pluginPath from './plugin-path';
|
||||
export * as myGate from './my-gate'; // Add this line
|
||||
```
|
||||
|
||||
**Note:** Gate name in `gates.json` uses kebab-case (`my-gate`), which maps to camelCase export (`myGate`).
|
||||
|
||||
### 3. Add to gates.json
|
||||
|
||||
Add to `plugin/hooks/gates.json` (for plugin default) or project `.claude/gates.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"gates": {
|
||||
"my-gate": {
|
||||
"description": "My custom gate",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": {
|
||||
"gates": ["my-gate"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Build
|
||||
|
||||
```bash
|
||||
cd plugin/hooks/hooks-app
|
||||
npm run build
|
||||
```
|
||||
|
||||
## HookInput Interface
|
||||
|
||||
```typescript
|
||||
interface HookInput {
|
||||
hook_event_name: string; // "PostToolUse", "UserPromptSubmit", etc.
|
||||
cwd: string; // Current working directory
|
||||
|
||||
// PostToolUse
|
||||
tool_name?: string; // "Edit", "Write", etc.
|
||||
file_path?: string; // File being edited
|
||||
|
||||
// SubagentStop
|
||||
agent_name?: string; // "rust-agent", "code-review-agent", etc.
|
||||
subagent_name?: string; // Alternative agent name field
|
||||
output?: string; // Agent output
|
||||
|
||||
// UserPromptSubmit
|
||||
user_message?: string; // User's prompt text
|
||||
|
||||
// SlashCommand/Skill
|
||||
command?: string; // "/code-review", etc.
|
||||
skill?: string; // "executing-plans", etc.
|
||||
}
|
||||
```
|
||||
|
||||
## GateResult Interface
|
||||
|
||||
```typescript
|
||||
interface GateResult {
|
||||
// Success - add context and continue
|
||||
additionalContext?: string;
|
||||
|
||||
// Block agent from proceeding
|
||||
decision?: 'block';
|
||||
reason?: string;
|
||||
|
||||
// Stop Claude entirely
|
||||
continue?: false;
|
||||
message?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Return Values
|
||||
|
||||
**Pass with context injection:**
|
||||
```typescript
|
||||
return {
|
||||
additionalContext: 'This content is injected into the conversation'
|
||||
};
|
||||
```
|
||||
|
||||
**Pass silently:**
|
||||
```typescript
|
||||
return {};
|
||||
```
|
||||
|
||||
**Block execution:**
|
||||
```typescript
|
||||
return {
|
||||
decision: 'block',
|
||||
reason: 'Cannot proceed because...'
|
||||
};
|
||||
```
|
||||
|
||||
**Stop Claude entirely:**
|
||||
```typescript
|
||||
return {
|
||||
continue: false,
|
||||
message: 'Stopping because...'
|
||||
};
|
||||
```
|
||||
|
||||
## Accessing Session State
|
||||
|
||||
Gates can read/write session state for cross-hook coordination:
|
||||
|
||||
```typescript
|
||||
import { HookInput, GateResult } from '../types';
|
||||
import { Session } from '../session';
|
||||
|
||||
export async function execute(input: HookInput): Promise<GateResult> {
|
||||
const session = new Session(input.cwd);
|
||||
|
||||
// Read state
|
||||
const activeCommand = await session.get('active_command');
|
||||
const editedFiles = await session.get('edited_files');
|
||||
|
||||
// Write state
|
||||
await session.set('active_command', '/my-command');
|
||||
await session.append('edited_files', '/path/to/file.ts');
|
||||
|
||||
// Check array membership
|
||||
const hasRustFiles = await session.contains('file_extensions', 'rs');
|
||||
|
||||
return {};
|
||||
}
|
||||
```
|
||||
|
||||
## Using the Logger
|
||||
|
||||
```typescript
|
||||
import { logger } from '../logger';
|
||||
|
||||
export async function execute(input: HookInput): Promise<GateResult> {
|
||||
await logger.debug('Gate starting', { input });
|
||||
await logger.info('Processing', { file: input.file_path });
|
||||
await logger.warn('Potential issue', { reason: '...' });
|
||||
await logger.error('Gate failed', { error: '...' });
|
||||
|
||||
return {};
|
||||
}
|
||||
```
|
||||
|
||||
Logs go to `$TMPDIR/turboshovel/hooks-YYYY-MM-DD.log`.
|
||||
|
||||
## Example: Commands Gate
|
||||
|
||||
The built-in `commands` gate shows a complete implementation:
|
||||
|
||||
```typescript
|
||||
// src/gates/commands.ts
|
||||
import { HookInput, GateResult } from '../types';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
interface ClaudeMdFrontmatter {
|
||||
commands?: Record<string, string>;
|
||||
}
|
||||
|
||||
async function parseClaudeMd(cwd: string): Promise<Record<string, string>> {
|
||||
const claudeMdPath = path.join(cwd, 'CLAUDE.md');
|
||||
try {
|
||||
const content = await fs.readFile(claudeMdPath, 'utf-8');
|
||||
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!frontmatterMatch) return {};
|
||||
const frontmatter = yaml.load(frontmatterMatch[1]) as ClaudeMdFrontmatter;
|
||||
return frontmatter.commands || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function detectNeededCommands(userMessage: string): string[] {
|
||||
const needed: string[] = [];
|
||||
const lower = userMessage.toLowerCase();
|
||||
if (lower.includes('run project test command')) needed.push('test');
|
||||
if (lower.includes('run project check command')) needed.push('check');
|
||||
if (lower.includes('run project build command')) needed.push('build');
|
||||
return [...new Set(needed)];
|
||||
}
|
||||
|
||||
export async function execute(input: HookInput): Promise<GateResult> {
|
||||
const commands = await parseClaudeMd(input.cwd);
|
||||
const needed = detectNeededCommands(input.user_message || '');
|
||||
|
||||
if (needed.length === 0) return {};
|
||||
|
||||
const lines = ['<project_commands>'];
|
||||
for (const cmd of needed) {
|
||||
if (commands[cmd]) {
|
||||
lines.push(` <${cmd}>${commands[cmd]}</${cmd}>`);
|
||||
}
|
||||
}
|
||||
lines.push('</project_commands>');
|
||||
|
||||
return { additionalContext: lines.join('\n') };
|
||||
}
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd plugin/hooks/hooks-app
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Test Manually
|
||||
|
||||
```bash
|
||||
# Test a hook event
|
||||
echo '{"hook_event_name": "UserPromptSubmit", "cwd": "/path/to/project", "user_message": "Run project test command"}' | \
|
||||
CLAUDE_PLUGIN_ROOT=/path/to/plugin \
|
||||
node dist/cli.js
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Watch Mode
|
||||
|
||||
```bash
|
||||
npm run build -- --watch
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| gates.json | TypeScript Export | File |
|
||||
|------------|-------------------|------|
|
||||
| `plugin-path` | `pluginPath` | `plugin-path.ts` |
|
||||
| `my-custom-gate` | `myCustomGate` | `my-custom-gate.ts` |
|
||||
|
||||
The gate loader converts kebab-case to camelCase automatically.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Single responsibility**: Each gate does one thing well
|
||||
2. **Fast execution**: Gates run synchronously in hook flow
|
||||
3. **Graceful failures**: Return empty result `{}` on non-critical errors
|
||||
4. **Logging**: Use logger for debugging, not console
|
||||
5. **Type safety**: Leverage TypeScript interfaces
|
||||
6. **Documentation**: Add JSDoc comments explaining gate purpose
|
||||
36
hooks/examples/context/code-review-start.md
Normal file
36
hooks/examples/context/code-review-start.md
Normal file
@@ -0,0 +1,36 @@
|
||||
## Project-Specific Code Review Requirements
|
||||
|
||||
This file demonstrates convention-based context injection.
|
||||
|
||||
**Location:** `.claude/context/code-review-start.md`
|
||||
|
||||
**Triggered by:** Running a code review command (SlashCommandStart hook)
|
||||
|
||||
**Purpose:** Inject project-specific review requirements automatically.
|
||||
|
||||
---
|
||||
|
||||
### Additional Security Checks
|
||||
|
||||
For this project, code reviews MUST verify:
|
||||
|
||||
1. **Authentication:** All API endpoints require valid JWT
|
||||
2. **Input Validation:** All user inputs use allowlist validation
|
||||
3. **Rate Limiting:** Public endpoints have rate limits configured
|
||||
4. **Logging:** No PII in application logs
|
||||
|
||||
### Performance Requirements
|
||||
|
||||
- Database queries: No N+1 patterns
|
||||
- API response time: < 200ms for p95
|
||||
- Memory usage: No leaks detected in tests
|
||||
|
||||
### Documentation
|
||||
|
||||
- Public APIs have JSDoc/TSDoc comments
|
||||
- Complex algorithms have inline explanations
|
||||
- Breaking changes noted in CHANGELOG.md
|
||||
|
||||
---
|
||||
|
||||
**To use:** Copy to `.claude/context/code-review-start.md` in your project.
|
||||
32
hooks/examples/context/plan-start.md
Normal file
32
hooks/examples/context/plan-start.md
Normal file
@@ -0,0 +1,32 @@
|
||||
## Project Planning Template
|
||||
|
||||
**Location:** `.claude/context/plan-start.md`
|
||||
|
||||
**Triggered by:** Running a planning command (SlashCommandStart hook)
|
||||
|
||||
Your implementation plan must include:
|
||||
|
||||
### Architecture Impact
|
||||
- Which services/modules are affected?
|
||||
- Any new dependencies introduced?
|
||||
- Database schema changes required?
|
||||
|
||||
### API Surface
|
||||
- New endpoints or breaking changes?
|
||||
- Version bump needed?
|
||||
- Backward compatibility strategy?
|
||||
|
||||
### Testing Strategy
|
||||
- Unit test coverage target (80%+)
|
||||
- Integration tests for new flows
|
||||
- E2E tests for user-facing features
|
||||
|
||||
### Deployment Considerations
|
||||
- Feature flags required?
|
||||
- Migration scripts needed?
|
||||
- Rollback strategy?
|
||||
|
||||
### Success Criteria
|
||||
- What does "done" look like?
|
||||
- How to verify it works?
|
||||
- What metrics to monitor?
|
||||
41
hooks/examples/context/session-start.md
Normal file
41
hooks/examples/context/session-start.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Session Start Context
|
||||
|
||||
This file provides environment context at the beginning of each Claude Code session.
|
||||
|
||||
## Plugin Environment
|
||||
|
||||
**CLAUDE_PLUGIN_ROOT:** `${pwd}`
|
||||
|
||||
This variable points to the root directory of the CipherPowers plugin installation.
|
||||
|
||||
## Path Reference Convention
|
||||
|
||||
When referencing plugin files in agents, commands, or skills, always use:
|
||||
|
||||
```markdown
|
||||
@${CLAUDE_PLUGIN_ROOT}skills/skill-name/SKILL.md
|
||||
@${CLAUDE_PLUGIN_ROOT}standards/standard-name.md
|
||||
@${CLAUDE_PLUGIN_ROOT}principles/principle-name.md
|
||||
@${CLAUDE_PLUGIN_ROOT}templates/template-name.md
|
||||
```
|
||||
|
||||
**Do NOT use relative paths without the variable:**
|
||||
```markdown
|
||||
@skills/... ❌ Does not work in subagent contexts
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Copy this file to your project's `.claude/context/` directory to inject plugin environment information at session start:
|
||||
|
||||
```bash
|
||||
mkdir -p .claude/context
|
||||
cp ${CLAUDE_PLUGIN_ROOT}hooks/examples/context/session-start.md \
|
||||
.claude/context/session-start.md
|
||||
```
|
||||
|
||||
**Note:** SessionStart is not currently a supported hook in Claude Code. This file serves as a template for injecting environment context via other hooks (e.g., UserPromptSubmit, SlashCommandStart).
|
||||
|
||||
## Alternative: User Prompt Hook
|
||||
|
||||
If SessionStart hook becomes available, this context will auto-inject. Until then, consider using UserPromptSubmit hook or command-specific context injection.
|
||||
40
hooks/examples/context/test-driven-development-start.md
Normal file
40
hooks/examples/context/test-driven-development-start.md
Normal file
@@ -0,0 +1,40 @@
|
||||
## Project TDD Standards
|
||||
|
||||
**Location:** `.claude/context/test-driven-development-start.md`
|
||||
|
||||
**Triggered by:** When `test-driven-development` skill loads (SkillStart hook)
|
||||
|
||||
This project uses:
|
||||
|
||||
- **Test framework:** Vitest
|
||||
- **Test location:** `src/**/__tests__/*.test.ts`
|
||||
- **Coverage requirement:** 80% line coverage minimum
|
||||
- **Property testing:** Use fast-check for algorithms
|
||||
|
||||
### File Structure
|
||||
```
|
||||
src/
|
||||
components/
|
||||
Button/
|
||||
Button.tsx
|
||||
__tests__/
|
||||
Button.test.tsx
|
||||
```
|
||||
|
||||
### Naming Convention
|
||||
- Use `describe/it` blocks (not `test()`)
|
||||
- Test names: "should [behavior] when [condition]"
|
||||
- File naming: `{Component}.test.ts`
|
||||
|
||||
### Mocking Strategy
|
||||
- Mock external services (APIs, databases)
|
||||
- Do NOT mock internal modules (test real behavior)
|
||||
- Use MSW for HTTP mocking
|
||||
|
||||
### RED-GREEN-REFACTOR
|
||||
1. Write failing test first
|
||||
2. Run test (verify it fails for right reason)
|
||||
3. Write minimal code to pass
|
||||
4. Run test (verify it passes)
|
||||
5. Refactor (if needed)
|
||||
6. Commit
|
||||
26
hooks/examples/convention-based.json
Normal file
26
hooks/examples/convention-based.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"description": "Demonstrates convention-based context injection with explicit gates",
|
||||
"comment": "Combines zero-config conventions with explicit verification gates",
|
||||
|
||||
"gates": {
|
||||
"test": {
|
||||
"description": "Run project test suite",
|
||||
"comment": "Examples: 'npm test' | 'cargo test' | 'mise run test'",
|
||||
"command": "npm test",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
}
|
||||
},
|
||||
|
||||
"hooks": {
|
||||
"SlashCommandEnd": {
|
||||
"comment": "Convention file .claude/context/code-review-end.md auto-injects if exists",
|
||||
"enabled_commands": ["/code-review"],
|
||||
"gates": ["test"]
|
||||
},
|
||||
"SkillStart": {
|
||||
"comment": "Convention file .claude/context/test-driven-development-start.md auto-injects",
|
||||
"enabled_skills": ["test-driven-development"]
|
||||
}
|
||||
}
|
||||
}
|
||||
31
hooks/examples/permissive.json
Normal file
31
hooks/examples/permissive.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"description": "Permissive mode - warn only, never block. Supports convention-based context injection.",
|
||||
"comment": "Context files in .claude/context/ auto-inject without configuration. See CONVENTIONS.md",
|
||||
|
||||
"gates": {
|
||||
"check": {
|
||||
"description": "Quality checks (warn only)",
|
||||
"comment": "Examples: 'npm run lint' | 'cargo clippy' | 'mise run check'",
|
||||
"command": "mise run check",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "CONTINUE"
|
||||
},
|
||||
"test": {
|
||||
"description": "Tests (warn only)",
|
||||
"comment": "Examples: 'npm test' | 'cargo test' | 'mise run test'",
|
||||
"command": "mise run test",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "CONTINUE"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": {
|
||||
"enabled_tools": ["Edit", "Write"],
|
||||
"gates": ["check"]
|
||||
},
|
||||
"SubagentStop": {
|
||||
"enabled_agents": [],
|
||||
"gates": ["check", "test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
38
hooks/examples/pipeline.json
Normal file
38
hooks/examples/pipeline.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"gates": {
|
||||
"format": {
|
||||
"description": "Auto-format code",
|
||||
"comment": "Examples: 'npm run format' | 'cargo fmt' | 'mise run format'",
|
||||
"command": "mise run format",
|
||||
"on_pass": "check",
|
||||
"on_fail": "STOP"
|
||||
},
|
||||
"check": {
|
||||
"description": "Quality checks",
|
||||
"comment": "Examples: 'npm run lint' | 'cargo clippy' | 'mise run check'",
|
||||
"command": "mise run check",
|
||||
"on_pass": "test",
|
||||
"on_fail": "BLOCK"
|
||||
},
|
||||
"test": {
|
||||
"description": "Run tests",
|
||||
"comment": "Examples: 'npm test' | 'cargo test' | 'mise run test'",
|
||||
"command": "mise run test",
|
||||
"on_pass": "build",
|
||||
"on_fail": "BLOCK"
|
||||
},
|
||||
"build": {
|
||||
"description": "Build project",
|
||||
"comment": "Examples: 'npm run build' | 'cargo build' | 'mise run build'",
|
||||
"command": "mise run build",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"SubagentStop": {
|
||||
"enabled_agents": [],
|
||||
"gates": ["format"]
|
||||
}
|
||||
}
|
||||
}
|
||||
38
hooks/examples/strict.json
Normal file
38
hooks/examples/strict.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"description": "Strict enforcement - block on all failures. Supports convention-based context injection.",
|
||||
"comment": "Context files in .claude/context/ auto-inject without configuration. See CONVENTIONS.md",
|
||||
|
||||
"gates": {
|
||||
"check": {
|
||||
"description": "Quality checks must pass",
|
||||
"comment": "Examples: 'npm run lint' | 'cargo clippy' | 'mise run check'",
|
||||
"command": "mise run check",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
},
|
||||
"test": {
|
||||
"description": "All tests must pass",
|
||||
"comment": "Examples: 'npm test' | 'cargo test' | 'mise run test'",
|
||||
"command": "mise run test",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
},
|
||||
"build": {
|
||||
"description": "Build must succeed",
|
||||
"comment": "Examples: 'npm run build' | 'cargo build' | 'mise run build'",
|
||||
"command": "mise run build",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": {
|
||||
"enabled_tools": ["Edit", "Write", "mcp__serena__replace_symbol_body"],
|
||||
"gates": ["check"]
|
||||
},
|
||||
"SubagentStop": {
|
||||
"enabled_agents": [],
|
||||
"gates": ["check", "test", "build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
43
hooks/gates.json
Normal file
43
hooks/gates.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"gates": {
|
||||
"plugin-path": {
|
||||
"description": "Verify plugin path resolution in subagents",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "CONTINUE"
|
||||
},
|
||||
"check": {
|
||||
"description": "Run project quality checks (formatting, linting, types)",
|
||||
"keywords": ["lint", "check", "format", "quality", "clippy", "typecheck"],
|
||||
"command": "echo '[PLACEHOLDER] Quality checks passed. Configure with actual project check command (e.g., npm run lint, cargo clippy)'",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
},
|
||||
"test": {
|
||||
"description": "Run project test suite",
|
||||
"keywords": ["test", "testing", "spec", "verify"],
|
||||
"command": "echo '[PLACEHOLDER] Tests passed. Configure with actual project test command (e.g., npm test, cargo test)'",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "BLOCK"
|
||||
},
|
||||
"build": {
|
||||
"description": "Run project build",
|
||||
"keywords": ["build", "compile", "package"],
|
||||
"command": "echo '[PLACEHOLDER] Build passed. Configure with actual project build command (e.g., npm run build, cargo build)'",
|
||||
"on_pass": "CONTINUE",
|
||||
"on_fail": "CONTINUE"
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": {
|
||||
"gates": ["check", "test", "build"]
|
||||
},
|
||||
"PostToolUse": {
|
||||
"enabled_tools": ["Edit", "Write"],
|
||||
"gates": ["check"]
|
||||
},
|
||||
"SubagentStop": {
|
||||
"enabled_agents": [],
|
||||
"gates": ["check", "test"]
|
||||
}
|
||||
}
|
||||
}
|
||||
17
hooks/hooks-app/.eslintrc.js
Normal file
17
hooks/hooks-app/.eslintrc.js
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
project: './tsconfig.eslint.json'
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended'
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
|
||||
}
|
||||
};
|
||||
7
hooks/hooks-app/.prettierrc
Normal file
7
hooks/hooks-app/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
57
hooks/hooks-app/__tests__/action-handler.test.ts
Normal file
57
hooks/hooks-app/__tests__/action-handler.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// plugin/hooks/hooks-app/__tests__/action-handler.test.ts
|
||||
import { handleAction } from '../src/action-handler';
|
||||
import { GateResult, GatesConfig } from '../src/types';
|
||||
|
||||
const mockConfig: GatesConfig = {
|
||||
hooks: {},
|
||||
gates: {
|
||||
'next-gate': { command: 'echo "next"', on_pass: 'CONTINUE' }
|
||||
}
|
||||
};
|
||||
|
||||
const mockInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test'
|
||||
};
|
||||
|
||||
describe('Action Handler', () => {
|
||||
test('CONTINUE returns continue=true', async () => {
|
||||
const result: GateResult = {};
|
||||
const action = await handleAction('CONTINUE', result, mockConfig, mockInput);
|
||||
|
||||
expect(action.continue).toBe(true);
|
||||
expect(action.context).toBeUndefined();
|
||||
});
|
||||
|
||||
test('CONTINUE with context returns context', async () => {
|
||||
const result: GateResult = { additionalContext: 'test context' };
|
||||
const action = await handleAction('CONTINUE', result, mockConfig, mockInput);
|
||||
|
||||
expect(action.continue).toBe(true);
|
||||
expect(action.context).toBe('test context');
|
||||
});
|
||||
|
||||
test('BLOCK returns continue=false', async () => {
|
||||
const result: GateResult = { decision: 'block', reason: 'test reason' };
|
||||
const action = await handleAction('BLOCK', result, mockConfig, mockInput);
|
||||
|
||||
expect(action.continue).toBe(false);
|
||||
expect(action.blockReason).toBe('test reason');
|
||||
});
|
||||
|
||||
test('BLOCK with no reason uses default', async () => {
|
||||
const result: GateResult = {};
|
||||
const action = await handleAction('BLOCK', result, mockConfig, mockInput);
|
||||
|
||||
expect(action.continue).toBe(false);
|
||||
expect(action.blockReason).toBe('Gate failed');
|
||||
});
|
||||
|
||||
test('STOP returns continue=false with stop message', async () => {
|
||||
const result: GateResult = { message: 'stop message' };
|
||||
const action = await handleAction('STOP', result, mockConfig, mockInput);
|
||||
|
||||
expect(action.continue).toBe(false);
|
||||
expect(action.stopMessage).toBe('stop message');
|
||||
});
|
||||
});
|
||||
33
hooks/hooks-app/__tests__/builtin-gates.test.ts
Normal file
33
hooks/hooks-app/__tests__/builtin-gates.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// plugin/hooks/hooks-app/__tests__/builtin-gates.test.ts
|
||||
import { executeBuiltinGate } from '../src/gate-loader';
|
||||
import { HookInput } from '../src/types';
|
||||
import * as path from 'path';
|
||||
|
||||
// Set CLAUDE_PLUGIN_ROOT for tests to point to plugin directory
|
||||
process.env.CLAUDE_PLUGIN_ROOT = path.resolve(__dirname, '../../..');
|
||||
|
||||
describe('Built-in Gates', () => {
|
||||
describe('plugin-path', () => {
|
||||
test('logs plugin path when available', async () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SessionStart',
|
||||
cwd: '/test'
|
||||
};
|
||||
|
||||
const result = await executeBuiltinGate('plugin-path', input);
|
||||
// plugin-path gate should always continue
|
||||
expect(result.decision).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles SubagentStop hook', async () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: '/test',
|
||||
agent_name: 'test-agent'
|
||||
};
|
||||
|
||||
const result = await executeBuiltinGate('plugin-path', input);
|
||||
expect(result.decision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
226
hooks/hooks-app/__tests__/cli.integration.test.ts
Normal file
226
hooks/hooks-app/__tests__/cli.integration.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
// __tests__/cli.integration.test.ts
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('CLI Integration', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temp directory for each test
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cli-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Session Management Mode', () => {
|
||||
const runCLI = (
|
||||
args: string[]
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('node', ['dist/cli.js', ...args], {
|
||||
cwd: path.resolve(__dirname, '..')
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
};
|
||||
|
||||
test('should set and get active_command', async () => {
|
||||
// Set
|
||||
const setResult = await runCLI(['session', 'set', 'active_command', '/execute', testDir]);
|
||||
expect(setResult.exitCode).toBe(0);
|
||||
|
||||
// Get
|
||||
const getResult = await runCLI(['session', 'get', 'active_command', testDir]);
|
||||
expect(getResult.exitCode).toBe(0);
|
||||
expect(getResult.stdout.trim()).toBe('/execute');
|
||||
});
|
||||
|
||||
test('should set and get active_skill', async () => {
|
||||
// Set
|
||||
const setResult = await runCLI(['session', 'set', 'active_skill', 'brainstorming', testDir]);
|
||||
expect(setResult.exitCode).toBe(0);
|
||||
|
||||
// Get
|
||||
const getResult = await runCLI(['session', 'get', 'active_skill', testDir]);
|
||||
expect(getResult.exitCode).toBe(0);
|
||||
expect(getResult.stdout.trim()).toBe('brainstorming');
|
||||
});
|
||||
|
||||
test('should append to edited_files', async () => {
|
||||
// Append
|
||||
const append1 = await runCLI(['session', 'append', 'edited_files', 'file1.ts', testDir]);
|
||||
expect(append1.exitCode).toBe(0);
|
||||
|
||||
const append2 = await runCLI(['session', 'append', 'edited_files', 'file2.ts', testDir]);
|
||||
expect(append2.exitCode).toBe(0);
|
||||
|
||||
// Check contains
|
||||
const contains1 = await runCLI(['session', 'contains', 'edited_files', 'file1.ts', testDir]);
|
||||
expect(contains1.exitCode).toBe(0);
|
||||
|
||||
const contains2 = await runCLI(['session', 'contains', 'edited_files', 'file2.ts', testDir]);
|
||||
expect(contains2.exitCode).toBe(0);
|
||||
|
||||
const notContains = await runCLI([
|
||||
'session',
|
||||
'contains',
|
||||
'edited_files',
|
||||
'file3.ts',
|
||||
testDir
|
||||
]);
|
||||
expect(notContains.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test('should append to file_extensions', async () => {
|
||||
// Append
|
||||
const append1 = await runCLI(['session', 'append', 'file_extensions', 'ts', testDir]);
|
||||
expect(append1.exitCode).toBe(0);
|
||||
|
||||
const append2 = await runCLI(['session', 'append', 'file_extensions', 'js', testDir]);
|
||||
expect(append2.exitCode).toBe(0);
|
||||
|
||||
// Check contains
|
||||
const contains1 = await runCLI(['session', 'contains', 'file_extensions', 'ts', testDir]);
|
||||
expect(contains1.exitCode).toBe(0);
|
||||
|
||||
const notContains = await runCLI(['session', 'contains', 'file_extensions', 'py', testDir]);
|
||||
expect(notContains.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test('should clear session', async () => {
|
||||
// Set some data
|
||||
await runCLI(['session', 'set', 'active_command', '/execute', testDir]);
|
||||
await runCLI(['session', 'append', 'edited_files', 'file1.ts', testDir]);
|
||||
|
||||
// Clear
|
||||
const clearResult = await runCLI(['session', 'clear', testDir]);
|
||||
expect(clearResult.exitCode).toBe(0);
|
||||
|
||||
// Verify cleared
|
||||
const getResult = await runCLI(['session', 'get', 'active_command', testDir]);
|
||||
expect(getResult.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
test('should reject invalid session keys', async () => {
|
||||
const result = await runCLI(['session', 'get', 'invalid_key', testDir]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Invalid session key: invalid_key');
|
||||
});
|
||||
|
||||
test('should reject invalid array keys for append', async () => {
|
||||
const result = await runCLI(['session', 'append', 'session_id', 'value', testDir]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Invalid array key');
|
||||
});
|
||||
|
||||
test('should reject setting non-settable keys', async () => {
|
||||
const result = await runCLI(['session', 'set', 'session_id', 'value', testDir]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Cannot set session_id');
|
||||
});
|
||||
|
||||
test('should show usage for missing arguments', async () => {
|
||||
const result = await runCLI(['session']);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Usage:');
|
||||
});
|
||||
|
||||
test('should show error for unknown session command', async () => {
|
||||
const result = await runCLI(['session', 'unknown', testDir]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Unknown session command');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Dispatch Mode', () => {
|
||||
test('should handle hook dispatch with valid JSON input', (done) => {
|
||||
const proc = spawn('node', ['dist/cli.js'], {
|
||||
cwd: path.resolve(__dirname, '..')
|
||||
});
|
||||
|
||||
const input = JSON.stringify({
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: testDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: {}
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
expect(code).toBe(0);
|
||||
// Should produce empty output or valid JSON
|
||||
if (stdout.trim()) {
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
proc.stdin.write(input);
|
||||
proc.stdin.end();
|
||||
});
|
||||
|
||||
test('should handle graceful exit on missing required fields', (done) => {
|
||||
const proc = spawn('node', ['dist/cli.js'], {
|
||||
cwd: path.resolve(__dirname, '..')
|
||||
});
|
||||
|
||||
const input = JSON.stringify({
|
||||
// Missing hook_event_name and cwd
|
||||
tool_name: 'Edit'
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
expect(code).toBe(0); // Graceful exit
|
||||
done();
|
||||
});
|
||||
|
||||
proc.stdin.write(input);
|
||||
proc.stdin.end();
|
||||
});
|
||||
|
||||
test('should handle invalid JSON input', (done) => {
|
||||
const proc = spawn('node', ['dist/cli.js'], {
|
||||
cwd: path.resolve(__dirname, '..')
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Invalid JSON input');
|
||||
done();
|
||||
});
|
||||
|
||||
proc.stdin.write('not valid json');
|
||||
proc.stdin.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
250
hooks/hooks-app/__tests__/config.test.ts
Normal file
250
hooks/hooks-app/__tests__/config.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
// plugin/hooks/hooks-app/__tests__/config.test.ts
|
||||
import { loadConfig, resolvePluginPath } from '../src/config';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('Config Loading', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('returns plugin defaults when no project config exists', async () => {
|
||||
// Config loader now returns plugin defaults when no project config exists
|
||||
// This provides fallback behavior without requiring every project to have gates.json
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config).not.toBeNull();
|
||||
// Verify it's actually plugin defaults by checking for expected structure
|
||||
expect(config?.hooks).toBeDefined();
|
||||
expect(config?.gates).toBeDefined();
|
||||
});
|
||||
|
||||
test('loads .claude/gates.json with highest priority', async () => {
|
||||
const claudeDir = path.join(testDir, '.claude');
|
||||
await fs.mkdir(claudeDir);
|
||||
|
||||
const config1 = { hooks: {}, gates: { test: { command: 'claude-config' } } };
|
||||
const config2 = { hooks: {}, gates: { test: { command: 'root-config' } } };
|
||||
|
||||
await fs.writeFile(path.join(claudeDir, 'gates.json'), JSON.stringify(config1));
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(config2));
|
||||
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config?.gates.test.command).toBe('claude-config');
|
||||
});
|
||||
|
||||
test('loads gates.json from root when .claude does not exist', async () => {
|
||||
const config1 = { hooks: {}, gates: { test: { command: 'root-config' } } };
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(config1));
|
||||
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config?.gates.test.command).toBe('root-config');
|
||||
});
|
||||
|
||||
test('parses valid JSON config', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
PostToolUse: {
|
||||
enabled_tools: ['Edit', 'Write'],
|
||||
gates: ['format', 'test']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
format: { command: 'npm run format', on_pass: 'CONTINUE' },
|
||||
test: { command: 'npm test', on_pass: 'CONTINUE' }
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config?.hooks.PostToolUse.enabled_tools).toEqual(['Edit', 'Write']);
|
||||
expect(config?.gates.format.command).toBe('npm run format');
|
||||
});
|
||||
|
||||
test('rejects unknown hook event', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
UnknownEvent: { gates: [] }
|
||||
},
|
||||
gates: {}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
await expect(loadConfig(testDir)).rejects.toThrow('Unknown hook event');
|
||||
});
|
||||
|
||||
test('rejects undefined gate reference', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
PostToolUse: { gates: ['nonexistent'] }
|
||||
},
|
||||
gates: {}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
await expect(loadConfig(testDir)).rejects.toThrow('references undefined gate');
|
||||
});
|
||||
|
||||
test('rejects invalid action', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
PostToolUse: { gates: ['test'] }
|
||||
},
|
||||
gates: {
|
||||
test: { command: 'echo test', on_pass: 'INVALID' }
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
'is not CONTINUE/BLOCK/STOP or valid gate name'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Path Resolution', () => {
|
||||
test('resolves sibling plugin using CLAUDE_PLUGIN_ROOT', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
|
||||
|
||||
try {
|
||||
const result = resolvePluginPath('cipherpowers');
|
||||
expect(result).toBe('/home/user/.claude/plugins/cipherpowers');
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('throws when CLAUDE_PLUGIN_ROOT not set', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
delete process.env.CLAUDE_PLUGIN_ROOT;
|
||||
|
||||
try {
|
||||
expect(() => resolvePluginPath('cipherpowers')).toThrow(
|
||||
'Cannot resolve plugin path: CLAUDE_PLUGIN_ROOT not set'
|
||||
);
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects plugin names with path separators', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
|
||||
|
||||
try {
|
||||
expect(() => resolvePluginPath('../etc')).toThrow(
|
||||
"Invalid plugin name: '../etc' (must not contain path separators)"
|
||||
);
|
||||
expect(() => resolvePluginPath('foo/bar')).toThrow(
|
||||
"Invalid plugin name: 'foo/bar' (must not contain path separators)"
|
||||
);
|
||||
expect(() => resolvePluginPath('foo\\bar')).toThrow(
|
||||
"Invalid plugin name: 'foo\\bar' (must not contain path separators)"
|
||||
);
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects plugin names with parent directory references', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
|
||||
|
||||
try {
|
||||
expect(() => resolvePluginPath('..')).toThrow(
|
||||
"Invalid plugin name: '..' (must not contain path separators)"
|
||||
);
|
||||
expect(() => resolvePluginPath('..foo')).toThrow(
|
||||
"Invalid plugin name: '..foo' (must not contain path separators)"
|
||||
);
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gate Config Validation', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('rejects gate with plugin but no gate name', async () => {
|
||||
const configObj = {
|
||||
hooks: { PostToolUse: { gates: ['test'] } },
|
||||
gates: {
|
||||
test: { plugin: 'cipherpowers' } // Missing gate field
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
"Gate 'test' has 'plugin' but missing 'gate' field"
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects gate with gate name but no plugin', async () => {
|
||||
const configObj = {
|
||||
hooks: { PostToolUse: { gates: ['test'] } },
|
||||
gates: {
|
||||
test: { gate: 'plan-compliance' } // Missing plugin field
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
"Gate 'test' has 'gate' but missing 'plugin' field"
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects gate with both command and plugin', async () => {
|
||||
const configObj = {
|
||||
hooks: { PostToolUse: { gates: ['test'] } },
|
||||
gates: {
|
||||
test: {
|
||||
plugin: 'cipherpowers',
|
||||
gate: 'plan-compliance',
|
||||
command: 'npm run lint' // Conflicting
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
"Gate 'test' cannot have both 'command' and 'plugin/gate'"
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts valid plugin gate reference', async () => {
|
||||
const configObj = {
|
||||
hooks: {},
|
||||
gates: {
|
||||
test: { plugin: 'cipherpowers', gate: 'plan-compliance' }
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
// Should not throw validation error for structure
|
||||
// (May fail later when trying to resolve plugin, which is acceptable)
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.gates.test.plugin).toBe('cipherpowers');
|
||||
expect(config?.gates.test.gate).toBe('plan-compliance');
|
||||
});
|
||||
});
|
||||
69
hooks/hooks-app/__tests__/context.test.ts
Normal file
69
hooks/hooks-app/__tests__/context.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// plugin/hooks/hooks-app/__tests__/context.test.ts
|
||||
import { discoverContextFile } from '../src/context';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('Context Injection', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('returns null when no context file exists', async () => {
|
||||
const result = await discoverContextFile(testDir, 'test-command', 'start');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('discovers flat context file', async () => {
|
||||
const contextDir = path.join(testDir, '.claude', 'context');
|
||||
await fs.mkdir(contextDir, { recursive: true });
|
||||
await fs.writeFile(path.join(contextDir, 'test-command-start.md'), 'content');
|
||||
|
||||
const result = await discoverContextFile(testDir, 'test-command', 'start');
|
||||
expect(result).toBe(path.join(contextDir, 'test-command-start.md'));
|
||||
});
|
||||
|
||||
test('discovers slash-command subdirectory', async () => {
|
||||
const contextDir = path.join(testDir, '.claude', 'context', 'slash-command');
|
||||
await fs.mkdir(contextDir, { recursive: true });
|
||||
await fs.writeFile(path.join(contextDir, 'test-command-start.md'), 'content');
|
||||
|
||||
const result = await discoverContextFile(testDir, 'test-command', 'start');
|
||||
expect(result).toBe(path.join(contextDir, 'test-command-start.md'));
|
||||
});
|
||||
|
||||
test('discovers nested slash-command directory', async () => {
|
||||
const contextDir = path.join(testDir, '.claude', 'context', 'slash-command', 'test-command');
|
||||
await fs.mkdir(contextDir, { recursive: true });
|
||||
await fs.writeFile(path.join(contextDir, 'start.md'), 'content');
|
||||
|
||||
const result = await discoverContextFile(testDir, 'test-command', 'start');
|
||||
expect(result).toBe(path.join(contextDir, 'start.md'));
|
||||
});
|
||||
|
||||
test('discovers skill context', async () => {
|
||||
const contextDir = path.join(testDir, '.claude', 'context', 'skill');
|
||||
await fs.mkdir(contextDir, { recursive: true });
|
||||
await fs.writeFile(path.join(contextDir, 'test-skill-start.md'), 'content');
|
||||
|
||||
const result = await discoverContextFile(testDir, 'test-skill', 'start');
|
||||
expect(result).toBe(path.join(contextDir, 'test-skill-start.md'));
|
||||
});
|
||||
|
||||
test('follows priority order - flat wins', async () => {
|
||||
const contextBase = path.join(testDir, '.claude', 'context');
|
||||
await fs.mkdir(path.join(contextBase, 'slash-command'), { recursive: true });
|
||||
|
||||
await fs.writeFile(path.join(contextBase, 'test-command-start.md'), 'flat');
|
||||
await fs.writeFile(path.join(contextBase, 'slash-command', 'test-command-start.md'), 'subdir');
|
||||
|
||||
const result = await discoverContextFile(testDir, 'test-command', 'start');
|
||||
expect(result).toBe(path.join(contextBase, 'test-command-start.md'));
|
||||
});
|
||||
});
|
||||
263
hooks/hooks-app/__tests__/dispatcher.test.ts
Normal file
263
hooks/hooks-app/__tests__/dispatcher.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// plugin/hooks/hooks-app/__tests__/dispatcher.test.ts
|
||||
import { shouldProcessHook, dispatch, gateMatchesKeywords } from '../src/dispatcher';
|
||||
import { HookInput, HookConfig, GateConfig } from '../src/types';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('Dispatcher - Event Filtering', () => {
|
||||
test('PostToolUse with enabled tool returns true', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test',
|
||||
tool_name: 'Edit'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
enabled_tools: ['Edit', 'Write']
|
||||
};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
||||
});
|
||||
|
||||
test('PostToolUse with disabled tool returns false', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test',
|
||||
tool_name: 'Read'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
enabled_tools: ['Edit', 'Write']
|
||||
};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(false);
|
||||
});
|
||||
|
||||
test('SubagentStop with enabled agent returns true', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: '/test',
|
||||
agent_name: 'test-namespace:test-agent'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
enabled_agents: ['test-namespace:test-agent']
|
||||
};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
||||
});
|
||||
|
||||
test('SubagentStop with disabled agent returns false', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: '/test',
|
||||
agent_name: 'other-agent'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
enabled_agents: ['test-namespace:test-agent']
|
||||
};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(false);
|
||||
});
|
||||
|
||||
test('SubagentStop checks subagent_name if agent_name missing', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: '/test',
|
||||
subagent_name: 'test-namespace:test-agent'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
enabled_agents: ['test-namespace:test-agent']
|
||||
};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
||||
});
|
||||
|
||||
test('UserPromptSubmit always returns true', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
cwd: '/test'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
||||
});
|
||||
|
||||
test('No filtering config returns true', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test',
|
||||
tool_name: 'Edit'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dispatcher - Gate Chaining', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temporary directory for test config
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gates-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('gate chaining works - gate-a chains to gate-b on pass', async () => {
|
||||
// Create gates.json with chaining config
|
||||
const gatesConfig = {
|
||||
hooks: {
|
||||
PostToolUse: {
|
||||
gates: ['gate-a']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
'gate-a': {
|
||||
command: 'echo "gate-a passed"',
|
||||
on_pass: 'gate-b' // Chain to gate-b on pass
|
||||
},
|
||||
'gate-b': {
|
||||
command: 'echo "gate-b passed"',
|
||||
on_pass: 'CONTINUE'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(gatesConfig, null, 2));
|
||||
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: testDir,
|
||||
tool_name: 'Edit'
|
||||
};
|
||||
|
||||
const result = await dispatch(input);
|
||||
|
||||
// Should contain output from both gates
|
||||
expect(result.context).toContain('gate-a passed');
|
||||
expect(result.context).toContain('gate-b passed');
|
||||
expect(result.blockReason).toBeUndefined();
|
||||
});
|
||||
|
||||
test('circular chain prevention - exceeds max gate depth', async () => {
|
||||
// Create gates.json with circular chain
|
||||
const gatesConfig = {
|
||||
hooks: {
|
||||
PostToolUse: {
|
||||
gates: ['gate-a']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
'gate-a': {
|
||||
command: 'echo "gate-a"',
|
||||
on_pass: 'gate-b'
|
||||
},
|
||||
'gate-b': {
|
||||
command: 'echo "gate-b"',
|
||||
on_pass: 'gate-a' // Circular chain back to gate-a
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(gatesConfig, null, 2));
|
||||
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: testDir,
|
||||
tool_name: 'Edit'
|
||||
};
|
||||
|
||||
const result = await dispatch(input);
|
||||
|
||||
// Should hit circuit breaker
|
||||
expect(result.blockReason).toContain('Exceeded max gate chain depth');
|
||||
expect(result.blockReason).toContain('circular');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyword Matching', () => {
|
||||
test('no keywords - gate always runs', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test'
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, '')).toBe(true);
|
||||
});
|
||||
|
||||
test('empty keywords array - gate always runs', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: []
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test('no user message with keywords - gate does not run', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: ['test', 'testing']
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(false);
|
||||
expect(gateMatchesKeywords(gateConfig, '')).toBe(false);
|
||||
});
|
||||
|
||||
test('keyword match - case insensitive', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: ['test']
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, 'run the TEST')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'RUN THE Test')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'test this')).toBe(true);
|
||||
});
|
||||
|
||||
test('multiple keywords - any matches', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: ['test', 'testing', 'spec', 'verify']
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, 'run the tests')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'verify this works')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'check the spec')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'we are testing')).toBe(true);
|
||||
});
|
||||
|
||||
test('no keyword match - gate does not run', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: ['test', 'testing']
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(false);
|
||||
expect(gateMatchesKeywords(gateConfig, 'run the linter')).toBe(false);
|
||||
});
|
||||
|
||||
test('substring matching - partial word matches', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: ['test']
|
||||
};
|
||||
|
||||
// Intentional substring matching (not word-boundary)
|
||||
expect(gateMatchesKeywords(gateConfig, 'latest version')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'contest results')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'testing')).toBe(true);
|
||||
});
|
||||
});
|
||||
178
hooks/hooks-app/__tests__/gate-loader.test.ts
Normal file
178
hooks/hooks-app/__tests__/gate-loader.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// plugin/hooks/hooks-app/__tests__/gate-loader.test.ts
|
||||
import { executeShellCommand, executeGate, loadPluginGate } from '../src/gate-loader';
|
||||
import { GateConfig, HookInput } from '../src/types';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('Gate Loader - Shell Commands', () => {
|
||||
test('executes shell command and returns exit code', async () => {
|
||||
const result = await executeShellCommand('echo "test"', process.cwd());
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain('test');
|
||||
});
|
||||
|
||||
test('captures non-zero exit code', async () => {
|
||||
const result = await executeShellCommand('exit 1', process.cwd());
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test('captures stdout', async () => {
|
||||
const result = await executeShellCommand('echo "hello world"', process.cwd());
|
||||
expect(result.output).toContain('hello world');
|
||||
});
|
||||
|
||||
test('captures stderr', async () => {
|
||||
const result = await executeShellCommand('echo "error" >&2', process.cwd());
|
||||
expect(result.output).toContain('error');
|
||||
});
|
||||
|
||||
test('executes in specified directory', async () => {
|
||||
const tmpDir = os.tmpdir();
|
||||
const result = await executeShellCommand('pwd', tmpDir);
|
||||
// macOS may prepend /private to paths
|
||||
expect(result.output.trim()).toMatch(new RegExp(tmpDir.replace('/var/', '(/private)?/var/')));
|
||||
});
|
||||
|
||||
test('timeout returns exit code 124 and timeout message', async () => {
|
||||
const result = await executeShellCommand('sleep 1', process.cwd(), 100);
|
||||
expect(result.exitCode).toBe(124);
|
||||
expect(result.output).toContain('timed out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gate Loader - executeGate', () => {
|
||||
const mockInput: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: process.cwd()
|
||||
};
|
||||
|
||||
test('shell command gate with exit 0 returns passed=true', async () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'echo "success"'
|
||||
};
|
||||
|
||||
const result = await executeGate('test-gate', gateConfig, mockInput);
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.result.additionalContext).toContain('success');
|
||||
});
|
||||
|
||||
test('shell command gate with exit 1 returns passed=false', async () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'exit 1'
|
||||
};
|
||||
|
||||
const result = await executeGate('test-gate', gateConfig, mockInput);
|
||||
|
||||
expect(result.passed).toBe(false);
|
||||
});
|
||||
|
||||
test('built-in gate throws error when gate not found', async () => {
|
||||
const gateConfig: GateConfig = {
|
||||
// No command = built-in gate
|
||||
};
|
||||
|
||||
await expect(executeGate('nonexistent-gate', gateConfig, mockInput)).rejects.toThrow(
|
||||
'Failed to load built-in gate nonexistent-gate'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Gate Loading', () => {
|
||||
let mockPluginDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock plugin directory structure
|
||||
mockPluginDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-plugins-'));
|
||||
const cipherpowersDir = path.join(mockPluginDir, 'cipherpowers', 'hooks');
|
||||
await fs.mkdir(cipherpowersDir, { recursive: true });
|
||||
|
||||
// Create mock gates.json for cipherpowers
|
||||
const gatesConfig = {
|
||||
hooks: {},
|
||||
gates: {
|
||||
'plan-compliance': {
|
||||
command: 'node dist/gates/plan-compliance.js',
|
||||
on_fail: 'BLOCK'
|
||||
}
|
||||
}
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(cipherpowersDir, 'gates.json'),
|
||||
JSON.stringify(gatesConfig)
|
||||
);
|
||||
|
||||
// Set CLAUDE_PLUGIN_ROOT to point to turboshovel sibling
|
||||
originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = path.join(mockPluginDir, 'turboshovel');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
await fs.rm(mockPluginDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('loads gate config from plugin', async () => {
|
||||
const result = await loadPluginGate('cipherpowers', 'plan-compliance');
|
||||
|
||||
expect(result.gateConfig.command).toBe('node dist/gates/plan-compliance.js');
|
||||
expect(result.gateConfig.on_fail).toBe('BLOCK');
|
||||
expect(result.pluginRoot).toBe(path.join(mockPluginDir, 'cipherpowers'));
|
||||
});
|
||||
|
||||
test('throws when plugin gates.json not found', async () => {
|
||||
await expect(loadPluginGate('nonexistent', 'some-gate')).rejects.toThrow(
|
||||
"Cannot find gates.json for plugin 'nonexistent'"
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when gate not found in plugin', async () => {
|
||||
await expect(loadPluginGate('cipherpowers', 'nonexistent-gate')).rejects.toThrow(
|
||||
"Gate 'nonexistent-gate' not found in plugin 'cipherpowers'"
|
||||
);
|
||||
});
|
||||
|
||||
test('validates loaded plugin config structure', async () => {
|
||||
// Create plugin with malformed gates.json
|
||||
const malformedDir = path.join(mockPluginDir, 'malformed', 'hooks');
|
||||
await fs.mkdir(malformedDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(malformedDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'bad-gate': {
|
||||
// Missing required fields (no command, plugin, or gate)
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// This should succeed loading but the gate config is invalid
|
||||
// Validation happens when the gate is used, not when loading
|
||||
const result = await loadPluginGate('malformed', 'bad-gate');
|
||||
expect(result.gateConfig).toBeDefined();
|
||||
});
|
||||
|
||||
test('executeGate handles plugin gate reference', async () => {
|
||||
const gateConfig: GateConfig = {
|
||||
plugin: 'cipherpowers',
|
||||
gate: 'plan-compliance'
|
||||
};
|
||||
|
||||
const mockInput: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: '/some/project'
|
||||
};
|
||||
|
||||
// The command from cipherpowers will be executed in cipherpowers plugin dir
|
||||
// For this test, the mock plugin has 'node dist/gates/plan-compliance.js'
|
||||
// which won't exist, so it will fail - but we can verify the flow
|
||||
const result = await executeGate('my-gate', gateConfig, mockInput);
|
||||
|
||||
// Command execution will fail (file doesn't exist) but flow is correct
|
||||
expect(result.passed).toBe(false);
|
||||
});
|
||||
});
|
||||
164
hooks/hooks-app/__tests__/integration.test.ts
Normal file
164
hooks/hooks-app/__tests__/integration.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { join, dirname } from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
let testDir: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(tmpdir(), `integration-test-${Date.now()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
cliPath = join(__dirname, '../dist/cli.js');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Session Management', () => {
|
||||
test('set and get command', async () => {
|
||||
await execAsync(`node ${cliPath} session set active_command /execute ${testDir}`);
|
||||
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
|
||||
expect(stdout.trim()).toBe('/execute');
|
||||
});
|
||||
|
||||
test('append and check contains', async () => {
|
||||
await execAsync(`node ${cliPath} session append file_extensions ts ${testDir}`);
|
||||
|
||||
const result = await execAsync(
|
||||
`node ${cliPath} session contains file_extensions ts ${testDir}`
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('clear removes state', async () => {
|
||||
await execAsync(`node ${cliPath} session set active_command /plan ${testDir}`);
|
||||
await execAsync(`node ${cliPath} session clear ${testDir}`);
|
||||
|
||||
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
|
||||
expect(stdout.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Dispatch with Session Tracking', () => {
|
||||
test('PostToolUse updates session', async () => {
|
||||
const hookInput = JSON.stringify({
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Edit',
|
||||
file_path: 'main.ts',
|
||||
cwd: testDir
|
||||
});
|
||||
|
||||
await execAsync(`echo '${hookInput}' | node ${cliPath}`);
|
||||
|
||||
const { stdout: files } = await execAsync(
|
||||
`node ${cliPath} session get edited_files ${testDir}`
|
||||
);
|
||||
expect(files).toContain('main.ts');
|
||||
|
||||
const containsTs = await execAsync(
|
||||
`node ${cliPath} session contains file_extensions ts ${testDir}`
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(containsTs).toBe(true);
|
||||
});
|
||||
|
||||
test('SlashCommandStart/End updates session', async () => {
|
||||
// Start command
|
||||
const startInput = JSON.stringify({
|
||||
hook_event_name: 'SlashCommandStart',
|
||||
command: '/execute',
|
||||
cwd: testDir
|
||||
});
|
||||
await execAsync(`echo '${startInput}' | node ${cliPath}`);
|
||||
|
||||
const { stdout: activeCmd } = await execAsync(
|
||||
`node ${cliPath} session get active_command ${testDir}`
|
||||
);
|
||||
expect(activeCmd.trim()).toBe('/execute');
|
||||
|
||||
// End command
|
||||
const endInput = JSON.stringify({
|
||||
hook_event_name: 'SlashCommandEnd',
|
||||
command: '/execute',
|
||||
cwd: testDir
|
||||
});
|
||||
await execAsync(`echo '${endInput}' | node ${cliPath}`);
|
||||
|
||||
const { stdout: cleared } = await execAsync(
|
||||
`node ${cliPath} session get active_command ${testDir}`
|
||||
);
|
||||
expect(cleared.trim()).toBe('');
|
||||
});
|
||||
|
||||
test('SkillStart/End updates session', async () => {
|
||||
// Start skill
|
||||
const startInput = JSON.stringify({
|
||||
hook_event_name: 'SkillStart',
|
||||
skill: 'executing-plans',
|
||||
cwd: testDir
|
||||
});
|
||||
await execAsync(`echo '${startInput}' | node ${cliPath}`);
|
||||
|
||||
const { stdout: activeSkill } = await execAsync(
|
||||
`node ${cliPath} session get active_skill ${testDir}`
|
||||
);
|
||||
expect(activeSkill.trim()).toBe('executing-plans');
|
||||
|
||||
// End skill
|
||||
const endInput = JSON.stringify({
|
||||
hook_event_name: 'SkillEnd',
|
||||
skill: 'executing-plans',
|
||||
cwd: testDir
|
||||
});
|
||||
await execAsync(`echo '${endInput}' | node ${cliPath}`);
|
||||
|
||||
const { stdout: cleared } = await execAsync(
|
||||
`node ${cliPath} session get active_skill ${testDir}`
|
||||
);
|
||||
expect(cleared.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('handles corrupted state file gracefully', async () => {
|
||||
const stateFile = join(testDir, '.claude', 'session', 'state.json');
|
||||
await fs.mkdir(dirname(stateFile), { recursive: true });
|
||||
await fs.writeFile(stateFile, '{invalid json', 'utf-8');
|
||||
|
||||
// Should reinitialize and work
|
||||
await execAsync(`node ${cliPath} session set active_command /plan ${testDir}`);
|
||||
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
|
||||
expect(stdout.trim()).toBe('/plan');
|
||||
});
|
||||
|
||||
test('rejects invalid session keys', async () => {
|
||||
try {
|
||||
await execAsync(`node ${cliPath} session get invalid_key ${testDir}`);
|
||||
fail('Should have thrown error');
|
||||
} catch (error) {
|
||||
const err = error as { stderr?: string };
|
||||
expect(err.stderr).toContain('Invalid session key');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects invalid array keys for append', async () => {
|
||||
try {
|
||||
await execAsync(`node ${cliPath} session append invalid_key value ${testDir}`);
|
||||
fail('Should have thrown error');
|
||||
} catch (error) {
|
||||
const err = error as { stderr?: string };
|
||||
expect(err.stderr).toContain('Invalid array key');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
239
hooks/hooks-app/__tests__/plugin-gates.integration.test.ts
Normal file
239
hooks/hooks-app/__tests__/plugin-gates.integration.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
// plugin/hooks/hooks-app/__tests__/plugin-gates.integration.test.ts
|
||||
import { dispatch } from '../src/dispatcher';
|
||||
import { HookInput } from '../src/types';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('Plugin Gate Composition Integration', () => {
|
||||
let mockPluginsDir: string;
|
||||
let projectDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock plugins directory with two plugins
|
||||
mockPluginsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-plugins-'));
|
||||
|
||||
// Create mock cipherpowers plugin
|
||||
const cipherpowersHooksDir = path.join(mockPluginsDir, 'cipherpowers', 'hooks');
|
||||
await fs.mkdir(cipherpowersHooksDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(cipherpowersHooksDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'plan-compliance': {
|
||||
command: 'echo "plan-compliance check passed"',
|
||||
on_fail: 'BLOCK'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Create mock turboshovel plugin (current plugin)
|
||||
const turboshovelHooksDir = path.join(mockPluginsDir, 'turboshovel', 'hooks');
|
||||
await fs.mkdir(turboshovelHooksDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(turboshovelHooksDir, 'gates.json'),
|
||||
JSON.stringify({ hooks: {}, gates: {} })
|
||||
);
|
||||
|
||||
// Create test project directory
|
||||
projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-project-'));
|
||||
const claudeDir = path.join(projectDir, '.claude');
|
||||
await fs.mkdir(claudeDir);
|
||||
|
||||
// Project config references cipherpowers gate
|
||||
await fs.writeFile(
|
||||
path.join(claudeDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
SubagentStop: {
|
||||
gates: ['plan-compliance', 'check']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
'plan-compliance': {
|
||||
plugin: 'cipherpowers',
|
||||
gate: 'plan-compliance'
|
||||
},
|
||||
'check': {
|
||||
command: 'echo "project check passed"'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Set CLAUDE_PLUGIN_ROOT
|
||||
originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = path.join(mockPluginsDir, 'turboshovel');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
await fs.rm(mockPluginsDir, { recursive: true, force: true });
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('executes plugin gate followed by project gate', async () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: projectDir,
|
||||
agent_name: 'test-agent'
|
||||
};
|
||||
|
||||
const result = await dispatch(input);
|
||||
|
||||
// Both gates should pass (no blockReason or stopMessage)
|
||||
expect(result.blockReason).toBeUndefined();
|
||||
expect(result.stopMessage).toBeUndefined();
|
||||
|
||||
// Should have output from both gates
|
||||
expect(result.context).toContain('plan-compliance check passed');
|
||||
expect(result.context).toContain('project check passed');
|
||||
});
|
||||
|
||||
test('plugin gate BLOCK stops execution', async () => {
|
||||
// Update cipherpowers gate to fail
|
||||
const cipherpowersHooksDir = path.join(mockPluginsDir, 'cipherpowers', 'hooks');
|
||||
await fs.writeFile(
|
||||
path.join(cipherpowersHooksDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'plan-compliance': {
|
||||
command: 'exit 1',
|
||||
on_fail: 'BLOCK'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: projectDir,
|
||||
agent_name: 'test-agent'
|
||||
};
|
||||
|
||||
const result = await dispatch(input);
|
||||
|
||||
// Should be blocked (blockReason will be set)
|
||||
expect(result.blockReason).toBeDefined();
|
||||
});
|
||||
|
||||
test('prevents circular gate references', async () => {
|
||||
// Create circular reference: pluginA -> pluginB -> pluginA
|
||||
const pluginADir = path.join(mockPluginsDir, 'pluginA', 'hooks');
|
||||
const pluginBDir = path.join(mockPluginsDir, 'pluginB', 'hooks');
|
||||
await fs.mkdir(pluginADir, { recursive: true });
|
||||
await fs.mkdir(pluginBDir, { recursive: true });
|
||||
|
||||
// PluginA has gate that references pluginB
|
||||
await fs.writeFile(
|
||||
path.join(pluginADir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'gateA': {
|
||||
plugin: 'pluginB',
|
||||
gate: 'gateB'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// PluginB has gate that references pluginA (circular)
|
||||
await fs.writeFile(
|
||||
path.join(pluginBDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'gateB': {
|
||||
plugin: 'pluginA',
|
||||
gate: 'gateA'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Project config references pluginA gate
|
||||
const claudeDir = path.join(projectDir, '.claude');
|
||||
await fs.writeFile(
|
||||
path.join(claudeDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
SubagentStop: {
|
||||
gates: ['test-circular']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
'test-circular': {
|
||||
plugin: 'pluginA',
|
||||
gate: 'gateA'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: projectDir,
|
||||
agent_name: 'test-agent'
|
||||
};
|
||||
|
||||
// Should error or handle gracefully (not infinite loop)
|
||||
// Implementation decision: error on circular reference
|
||||
await expect(dispatch(input)).rejects.toThrow(/circular|depth|recursion/i);
|
||||
});
|
||||
|
||||
test('handles plugin self-reference', async () => {
|
||||
// Plugin references its own gate
|
||||
const selfRefDir = path.join(mockPluginsDir, 'selfref', 'hooks');
|
||||
await fs.mkdir(selfRefDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(selfRefDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'gate1': {
|
||||
command: 'echo "gate1"'
|
||||
},
|
||||
'gate2': {
|
||||
plugin: 'selfref',
|
||||
gate: 'gate1'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Project references the self-referencing gate
|
||||
const claudeDir = path.join(projectDir, '.claude');
|
||||
await fs.writeFile(
|
||||
path.join(claudeDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
SubagentStop: {
|
||||
gates: ['test-self']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
'test-self': {
|
||||
plugin: 'selfref',
|
||||
gate: 'gate2'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: projectDir,
|
||||
agent_name: 'test-agent'
|
||||
};
|
||||
|
||||
// Should work - self-reference to a different gate is valid
|
||||
const result = await dispatch(input);
|
||||
expect(result.blockReason).toBeUndefined();
|
||||
expect(result.context).toContain('gate1');
|
||||
});
|
||||
});
|
||||
198
hooks/hooks-app/__tests__/session.test.ts
Normal file
198
hooks/hooks-app/__tests__/session.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Session } from '../src/session';
|
||||
import { promises as fs } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
describe('Session', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(tmpdir(), `session-test-${Date.now()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
test('sets state file path', () => {
|
||||
const session = new Session(testDir);
|
||||
expect(session['stateFile']).toBe(join(testDir, '.claude', 'session', 'state.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('get/set', () => {
|
||||
test('set and get scalar value', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.set('active_command', '/execute');
|
||||
|
||||
const value = await session.get('active_command');
|
||||
expect(value).toBe('/execute');
|
||||
});
|
||||
|
||||
test('get returns null for unset values', async () => {
|
||||
const session = new Session(testDir);
|
||||
const value = await session.get('active_skill');
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
test('set multiple values independently', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.set('active_command', '/execute');
|
||||
await session.set('active_skill', 'executing-plans');
|
||||
|
||||
expect(await session.get('active_command')).toBe('/execute');
|
||||
expect(await session.get('active_skill')).toBe('executing-plans');
|
||||
});
|
||||
});
|
||||
|
||||
describe('append/contains', () => {
|
||||
test('append adds value to array', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.append('edited_files', 'main.ts');
|
||||
await session.append('edited_files', 'lib.ts');
|
||||
|
||||
const files = await session.get('edited_files');
|
||||
expect(files).toEqual(['main.ts', 'lib.ts']);
|
||||
});
|
||||
|
||||
test('append deduplicates values', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.append('edited_files', 'main.ts');
|
||||
await session.append('edited_files', 'lib.ts');
|
||||
await session.append('edited_files', 'main.ts'); // Duplicate
|
||||
|
||||
const files = await session.get('edited_files');
|
||||
expect(files).toEqual(['main.ts', 'lib.ts']);
|
||||
});
|
||||
|
||||
test('contains returns true for existing value', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.append('file_extensions', 'ts');
|
||||
await session.append('file_extensions', 'js');
|
||||
|
||||
expect(await session.contains('file_extensions', 'ts')).toBe(true);
|
||||
expect(await session.contains('file_extensions', 'js')).toBe(true);
|
||||
});
|
||||
|
||||
test('contains returns false for missing value', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.append('file_extensions', 'ts');
|
||||
|
||||
expect(await session.contains('file_extensions', 'rs')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
test('removes state file', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.set('active_command', '/execute');
|
||||
|
||||
const stateFile = join(testDir, '.claude', 'session', 'state.json');
|
||||
const exists = await fs
|
||||
.access(stateFile)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
await session.clear();
|
||||
|
||||
const existsAfter = await fs
|
||||
.access(stateFile)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(existsAfter).toBe(false);
|
||||
});
|
||||
|
||||
test('is safe when file does not exist', async () => {
|
||||
const session = new Session(testDir);
|
||||
await expect(session.clear()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
test('state persists across Session instances', async () => {
|
||||
const session1 = new Session(testDir);
|
||||
await session1.set('active_command', '/plan');
|
||||
await session1.append('edited_files', 'main.ts');
|
||||
|
||||
const session2 = new Session(testDir);
|
||||
expect(await session2.get('active_command')).toBe('/plan');
|
||||
expect(await session2.get('edited_files')).toEqual(['main.ts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('atomic writes', () => {
|
||||
test('uses atomic rename', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.set('active_command', '/execute');
|
||||
|
||||
const stateFile = join(testDir, '.claude', 'session', 'state.json');
|
||||
const tempFile = stateFile + '.tmp';
|
||||
|
||||
// Temp file should not exist after save completes
|
||||
const tempExists = await fs
|
||||
.access(tempFile)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(tempExists).toBe(false);
|
||||
|
||||
// State file should exist
|
||||
const stateExists = await fs
|
||||
.access(stateFile)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(stateExists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error scenarios', () => {
|
||||
test('handles corrupted JSON gracefully', async () => {
|
||||
const session = new Session(testDir);
|
||||
const stateFile = join(testDir, '.claude', 'session', 'state.json');
|
||||
|
||||
// Create directory and write corrupted JSON
|
||||
await fs.mkdir(dirname(stateFile), { recursive: true });
|
||||
await fs.writeFile(stateFile, '{invalid json', 'utf-8');
|
||||
|
||||
// Should reinitialize state on corruption
|
||||
const value = await session.get('active_command');
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
test('handles cross-process persistence', async () => {
|
||||
// Simulate separate process invocations
|
||||
const session1 = new Session(testDir);
|
||||
await session1.set('active_command', '/execute');
|
||||
await session1.append('edited_files', 'main.ts');
|
||||
|
||||
// Create new session instance (simulates new process)
|
||||
const session2 = new Session(testDir);
|
||||
expect(await session2.get('active_command')).toBe('/execute');
|
||||
expect(await session2.get('edited_files')).toEqual(['main.ts']);
|
||||
});
|
||||
|
||||
test('handles concurrent writes via atomic rename', async () => {
|
||||
const session = new Session(testDir);
|
||||
|
||||
// Rapid concurrent writes (atomic rename prevents corruption)
|
||||
// Note: Some writes may fail due to temp file conflicts, but state file
|
||||
// should never be corrupted (that's what atomic rename protects against)
|
||||
const results = await Promise.allSettled([
|
||||
session.append('edited_files', 'file1.ts'),
|
||||
session.append('edited_files', 'file2.ts'),
|
||||
session.append('edited_files', 'file3.ts')
|
||||
]);
|
||||
|
||||
// At least one operation should succeed
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
||||
expect(successCount).toBeGreaterThan(0);
|
||||
|
||||
// State file should be valid (not corrupted)
|
||||
const files = await session.get('edited_files');
|
||||
expect(Array.isArray(files)).toBe(true);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
hooks/hooks-app/__tests__/types.test.ts
Normal file
63
hooks/hooks-app/__tests__/types.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// plugin/hooks/hooks-app/__tests__/types.test.ts
|
||||
import { HookInput, GateResult, GateConfig } from '../src/types';
|
||||
|
||||
describe('Types', () => {
|
||||
test('HookInput has required fields', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test/path'
|
||||
};
|
||||
expect(input.hook_event_name).toBe('PostToolUse');
|
||||
expect(input.cwd).toBe('/test/path');
|
||||
});
|
||||
|
||||
test('HookInput accepts optional PostToolUse fields', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test/path',
|
||||
tool_name: 'Edit',
|
||||
file_path: '/test/file.ts'
|
||||
};
|
||||
expect(input.tool_name).toBe('Edit');
|
||||
expect(input.file_path).toBe('/test/file.ts');
|
||||
});
|
||||
|
||||
test('GateResult can be empty object', () => {
|
||||
const result: GateResult = {};
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test('GateResult can have additionalContext', () => {
|
||||
const result: GateResult = {
|
||||
additionalContext: 'Test context'
|
||||
};
|
||||
expect(result.additionalContext).toBe('Test context');
|
||||
});
|
||||
|
||||
test('GateResult can have block decision', () => {
|
||||
const result: GateResult = {
|
||||
decision: 'block',
|
||||
reason: 'Test reason'
|
||||
};
|
||||
expect(result.decision).toBe('block');
|
||||
expect(result.reason).toBe('Test reason');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GateConfig Type', () => {
|
||||
test('accepts plugin gate reference', () => {
|
||||
const config: GateConfig = {
|
||||
plugin: 'cipherpowers',
|
||||
gate: 'plan-compliance'
|
||||
};
|
||||
expect(config.plugin).toBe('cipherpowers');
|
||||
expect(config.gate).toBe('plan-compliance');
|
||||
});
|
||||
|
||||
test('accepts local command gate', () => {
|
||||
const config: GateConfig = {
|
||||
command: 'npm run lint'
|
||||
};
|
||||
expect(config.command).toBe('npm run lint');
|
||||
});
|
||||
});
|
||||
8
hooks/hooks-app/jest.config.js
Normal file
8
hooks/hooks-app/jest.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/__tests__'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
collectCoverageFrom: ['src/**/*.ts'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json']
|
||||
};
|
||||
31
hooks/hooks-app/package.json
Normal file
31
hooks/hooks-app/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@turboshovel/hooks-app",
|
||||
"version": "1.0.0",
|
||||
"description": "TypeScript hooks dispatcher for Turboshovel",
|
||||
"main": "dist/cli.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/**/*.ts __tests__/**/*.ts",
|
||||
"lint:fix": "eslint src/**/*.ts __tests__/**/*.ts --fix",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.ts\" \"__tests__/**/*.ts\"",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.1"
|
||||
}
|
||||
}
|
||||
45
hooks/hooks-app/src/action-handler.ts
Normal file
45
hooks/hooks-app/src/action-handler.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// plugin/hooks/hooks-app/src/action-handler.ts
|
||||
import { GateResult, GatesConfig, HookInput } from './types';
|
||||
|
||||
export interface ActionResult {
|
||||
continue: boolean;
|
||||
context?: string;
|
||||
blockReason?: string;
|
||||
stopMessage?: string;
|
||||
chainedGate?: string;
|
||||
}
|
||||
|
||||
export async function handleAction(
|
||||
action: string,
|
||||
gateResult: GateResult,
|
||||
_config: GatesConfig,
|
||||
_input: HookInput
|
||||
): Promise<ActionResult> {
|
||||
switch (action) {
|
||||
case 'CONTINUE':
|
||||
return {
|
||||
continue: true,
|
||||
context: gateResult.additionalContext
|
||||
};
|
||||
|
||||
case 'BLOCK':
|
||||
return {
|
||||
continue: false,
|
||||
blockReason: gateResult.reason || 'Gate failed'
|
||||
};
|
||||
|
||||
case 'STOP':
|
||||
return {
|
||||
continue: false,
|
||||
stopMessage: gateResult.message || 'Gate stopped execution'
|
||||
};
|
||||
|
||||
default:
|
||||
// Gate chaining - action is another gate name
|
||||
return {
|
||||
continue: true,
|
||||
context: gateResult.additionalContext,
|
||||
chainedGate: action
|
||||
};
|
||||
}
|
||||
}
|
||||
268
hooks/hooks-app/src/cli.ts
Normal file
268
hooks/hooks-app/src/cli.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// plugin/hooks/hooks-app/src/cli.ts
|
||||
import { HookInput, SessionState, SessionStateArrayKey } from './types';
|
||||
import { dispatch } from './dispatcher';
|
||||
import { Session } from './session';
|
||||
import { logger } from './logger';
|
||||
|
||||
interface OutputMessage {
|
||||
additionalContext?: string;
|
||||
decision?: string;
|
||||
reason?: string;
|
||||
continue?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Check if first arg is "session" - session management mode
|
||||
if (args.length > 0 && args[0] === 'session') {
|
||||
await handleSessionCommand(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if first arg is "log-path" - return log file path for mise tasks
|
||||
if (args.length > 0 && args[0] === 'log-path') {
|
||||
console.log(logger.getLogFilePath());
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if first arg is "log-dir" - return log directory for mise tasks
|
||||
if (args.length > 0 && args[0] === 'log-dir') {
|
||||
console.log(logger.getLogDir());
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, hook dispatch mode (existing behavior)
|
||||
await handleHookDispatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for SessionState keys
|
||||
*/
|
||||
function isSessionStateKey(key: string): key is keyof SessionState {
|
||||
const validKeys = [
|
||||
'session_id',
|
||||
'started_at',
|
||||
'active_command',
|
||||
'active_skill',
|
||||
'edited_files',
|
||||
'file_extensions',
|
||||
'metadata'
|
||||
] as const;
|
||||
return (validKeys as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for array keys
|
||||
*/
|
||||
function isArrayKey(key: string): key is SessionStateArrayKey {
|
||||
return key === 'edited_files' || key === 'file_extensions';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session management commands with proper type safety
|
||||
*/
|
||||
async function handleSessionCommand(args: string[]): Promise<void> {
|
||||
if (args.length < 1) {
|
||||
console.error('Usage: hooks-app session [get|set|append|contains|clear] ...');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [command, ...params] = args;
|
||||
const cwd = params[params.length - 1] || '.';
|
||||
const session = new Session(cwd);
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'get': {
|
||||
if (params.length < 2) {
|
||||
console.error('Usage: hooks-app session get <key> [cwd]');
|
||||
process.exit(1);
|
||||
}
|
||||
const [key] = params;
|
||||
if (!isSessionStateKey(key)) {
|
||||
console.error(`Invalid session key: ${key}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const value = await session.get(key);
|
||||
console.log(value ?? '');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'set': {
|
||||
if (params.length < 3) {
|
||||
console.error('Usage: hooks-app session set <key> <value> [cwd]');
|
||||
process.exit(1);
|
||||
}
|
||||
const [key, value] = params;
|
||||
if (!isSessionStateKey(key)) {
|
||||
console.error(`Invalid session key: ${key}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Type-safe set with runtime validation
|
||||
if (key === 'active_command' || key === 'active_skill') {
|
||||
await session.set(key, value === 'null' ? null : value);
|
||||
} else if (key === 'metadata') {
|
||||
await session.set(key, JSON.parse(value));
|
||||
} else {
|
||||
console.error(`Cannot set ${key} via CLI (use get, append, or contains)`);
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'append': {
|
||||
if (params.length < 3) {
|
||||
console.error('Usage: hooks-app session append <key> <value> [cwd]');
|
||||
process.exit(1);
|
||||
}
|
||||
const [key, value] = params;
|
||||
if (!isArrayKey(key)) {
|
||||
console.error(`Invalid array key: ${key} (must be edited_files or file_extensions)`);
|
||||
process.exit(1);
|
||||
}
|
||||
await session.append(key, value);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'contains': {
|
||||
if (params.length < 3) {
|
||||
console.error('Usage: hooks-app session contains <key> <value> [cwd]');
|
||||
process.exit(1);
|
||||
}
|
||||
const [key, value] = params;
|
||||
if (!isArrayKey(key)) {
|
||||
console.error(`Invalid array key: ${key} (must be edited_files or file_extensions)`);
|
||||
process.exit(1);
|
||||
}
|
||||
const result = await session.contains(key, value);
|
||||
process.exit(result ? 0 : 1);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'clear': {
|
||||
await session.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`Unknown session command: ${command}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await logger.error('Session command failed', { command, error: errorMessage });
|
||||
console.error(`Session error: ${errorMessage}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hook dispatch (existing behavior)
|
||||
*/
|
||||
async function handleHookDispatch(): Promise<void> {
|
||||
try {
|
||||
// Read stdin
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const inputStr = Buffer.concat(chunks).toString('utf-8');
|
||||
|
||||
// ALWAYS log hook invocation (unconditional - for debugging)
|
||||
await logger.always('HOOK_INVOKED', {
|
||||
input_length: inputStr.length,
|
||||
input_preview: inputStr.substring(0, 500)
|
||||
});
|
||||
|
||||
// Log raw input at CLI entry point
|
||||
await logger.debug('CLI received hook input', {
|
||||
input_length: inputStr.length,
|
||||
input_preview: inputStr.substring(0, 200)
|
||||
});
|
||||
|
||||
// Parse input
|
||||
let input: HookInput;
|
||||
try {
|
||||
input = JSON.parse(inputStr);
|
||||
} catch (error) {
|
||||
await logger.error('CLI failed to parse JSON input', {
|
||||
input_preview: inputStr.substring(0, 200),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
continue: false,
|
||||
message: 'Invalid JSON input'
|
||||
})
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Log parsed hook event
|
||||
await logger.info('CLI dispatching hook', {
|
||||
event: input.hook_event_name,
|
||||
cwd: input.cwd,
|
||||
tool: input.tool_name,
|
||||
agent: input.agent_name,
|
||||
command: input.command,
|
||||
skill: input.skill
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!input.hook_event_name || !input.cwd) {
|
||||
await logger.warn('CLI missing required fields, exiting', {
|
||||
has_event: !!input.hook_event_name,
|
||||
has_cwd: !!input.cwd
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch
|
||||
const result = await dispatch(input);
|
||||
|
||||
// Build output
|
||||
const output: OutputMessage = {};
|
||||
|
||||
if (result.context) {
|
||||
output.additionalContext = result.context;
|
||||
}
|
||||
|
||||
if (result.blockReason) {
|
||||
output.decision = 'block';
|
||||
output.reason = result.blockReason;
|
||||
}
|
||||
|
||||
if (result.stopMessage) {
|
||||
output.continue = false;
|
||||
output.message = result.stopMessage;
|
||||
}
|
||||
|
||||
// Log result
|
||||
await logger.info('CLI hook completed', {
|
||||
event: input.hook_event_name,
|
||||
has_context: !!result.context,
|
||||
has_block: !!result.blockReason,
|
||||
has_stop: !!result.stopMessage,
|
||||
output_keys: Object.keys(output)
|
||||
});
|
||||
|
||||
// Write output
|
||||
if (Object.keys(output).length > 0) {
|
||||
console.log(JSON.stringify(output));
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await logger.error('Hook dispatch failed', { error: errorMessage });
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
continue: false,
|
||||
message: `Unexpected error: ${error}`
|
||||
})
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
219
hooks/hooks-app/src/config.ts
Normal file
219
hooks/hooks-app/src/config.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// plugin/hooks/hooks-app/src/config.ts
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { GatesConfig, HookConfig, GateConfig } from './types';
|
||||
import { fileExists } from './utils';
|
||||
import { logger } from './logger';
|
||||
|
||||
const KNOWN_HOOK_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'SubagentStop',
|
||||
'UserPromptSubmit',
|
||||
'SlashCommandStart',
|
||||
'SlashCommandEnd',
|
||||
'SkillStart',
|
||||
'SkillEnd',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'Stop',
|
||||
'Notification'
|
||||
];
|
||||
|
||||
const KNOWN_ACTIONS = ['CONTINUE', 'BLOCK', 'STOP'];
|
||||
|
||||
function validateGateConfig(gateName: string, gateConfig: GateConfig): void {
|
||||
const hasPlugin = gateConfig.plugin !== undefined;
|
||||
const hasGate = gateConfig.gate !== undefined;
|
||||
const hasCommand = gateConfig.command !== undefined;
|
||||
|
||||
// plugin requires gate
|
||||
if (hasPlugin && !hasGate) {
|
||||
throw new Error(`Gate '${gateName}' has 'plugin' but missing 'gate' field`);
|
||||
}
|
||||
|
||||
// gate requires plugin
|
||||
if (hasGate && !hasPlugin) {
|
||||
throw new Error(`Gate '${gateName}' has 'gate' but missing 'plugin' field`);
|
||||
}
|
||||
|
||||
// command is mutually exclusive with plugin/gate
|
||||
if (hasCommand && (hasPlugin || hasGate)) {
|
||||
throw new Error(`Gate '${gateName}' cannot have both 'command' and 'plugin/gate'`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate config invariants to catch configuration errors early.
|
||||
* Throws descriptive errors when invariants are violated.
|
||||
*/
|
||||
export function validateConfig(config: GatesConfig): void {
|
||||
// Invariant: Hook event names must be known types
|
||||
for (const hookName of Object.keys(config.hooks)) {
|
||||
if (!KNOWN_HOOK_EVENTS.includes(hookName)) {
|
||||
throw new Error(
|
||||
`Unknown hook event: ${hookName}. Must be one of: ${KNOWN_HOOK_EVENTS.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant: Gates referenced in hooks must exist in gates config
|
||||
for (const [hookName, hookConfig] of Object.entries(config.hooks)) {
|
||||
if (hookConfig.gates) {
|
||||
for (const gateName of hookConfig.gates) {
|
||||
if (!config.gates[gateName]) {
|
||||
throw new Error(`Hook '${hookName}' references undefined gate '${gateName}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant: Gate actions must be CONTINUE/BLOCK/STOP or reference existing gates
|
||||
for (const [gateName, gateConfig] of Object.entries(config.gates)) {
|
||||
// Validate gate structure first
|
||||
validateGateConfig(gateName, gateConfig);
|
||||
|
||||
for (const action of [gateConfig.on_pass, gateConfig.on_fail]) {
|
||||
if (action && !KNOWN_ACTIONS.includes(action) && !config.gates[action]) {
|
||||
throw new Error(
|
||||
`Gate '${gateName}' action '${action}' is not CONTINUE/BLOCK/STOP or valid gate name`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve plugin path using sibling convention.
|
||||
* Assumes plugins are installed as siblings under the same parent directory.
|
||||
*
|
||||
* SECURITY: Plugin names are validated to prevent path traversal attacks.
|
||||
* This does NOT mean untrusted plugins are safe - plugins are trusted by virtue
|
||||
* of being explicitly installed by the user. This validation only prevents
|
||||
* accidental or malicious config entries from accessing arbitrary paths.
|
||||
*
|
||||
* @param pluginName - Name of the plugin to resolve
|
||||
* @returns Absolute path to the plugin root
|
||||
* @throws Error if CLAUDE_PLUGIN_ROOT is not set or plugin name is invalid
|
||||
*/
|
||||
export function resolvePluginPath(pluginName: string): string {
|
||||
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
if (!pluginRoot) {
|
||||
throw new Error('Cannot resolve plugin path: CLAUDE_PLUGIN_ROOT not set');
|
||||
}
|
||||
|
||||
// Security: Reject plugin names with path separators or parent references
|
||||
// Prevents path traversal attacks like "../../../etc" or "foo/bar"
|
||||
if (pluginName.includes('/') || pluginName.includes('\\') || pluginName.includes('..')) {
|
||||
throw new Error(
|
||||
`Invalid plugin name: '${pluginName}' (must not contain path separators)`
|
||||
);
|
||||
}
|
||||
|
||||
// Sibling convention: plugins are in same parent directory
|
||||
// e.g., ~/.claude/plugins/turboshovel -> ~/.claude/plugins/cipherpowers
|
||||
return path.resolve(pluginRoot, '..', pluginName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plugin root directory from CLAUDE_PLUGIN_ROOT env var.
|
||||
* Falls back to computing relative to this file's location.
|
||||
*/
|
||||
function getPluginRoot(): string | null {
|
||||
const envRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
if (envRoot) {
|
||||
return envRoot;
|
||||
}
|
||||
|
||||
// Fallback: compute from this file's location
|
||||
// This file is at: plugin/hooks/hooks-app/src/config.ts (dev)
|
||||
// Or at: plugin/hooks/hooks-app/dist/config.js (built)
|
||||
// Plugin root is: plugin/
|
||||
try {
|
||||
return path.resolve(__dirname, '..', '..', '..');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single config file
|
||||
*/
|
||||
export async function loadConfigFile(configPath: string): Promise<GatesConfig | null> {
|
||||
if (await fileExists(configPath)) {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two configs. Project config takes precedence over plugin config.
|
||||
* - hooks: project hooks override plugin hooks for same event
|
||||
* - gates: project gates override plugin gates for same name
|
||||
*/
|
||||
function mergeConfigs(pluginConfig: GatesConfig, projectConfig: GatesConfig): GatesConfig {
|
||||
return {
|
||||
hooks: {
|
||||
...pluginConfig.hooks,
|
||||
...projectConfig.hooks
|
||||
},
|
||||
gates: {
|
||||
...pluginConfig.gates,
|
||||
...projectConfig.gates
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and merge project and plugin configs.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Project: .claude/gates.json (highest)
|
||||
* 2. Project: gates.json
|
||||
* 3. Plugin: ${CLAUDE_PLUGIN_ROOT}/hooks/gates.json (fallback/defaults)
|
||||
*
|
||||
* Configs are MERGED - project overrides plugin for same keys.
|
||||
*/
|
||||
export async function loadConfig(cwd: string): Promise<GatesConfig | null> {
|
||||
const pluginRoot = getPluginRoot();
|
||||
|
||||
// Load plugin config first (defaults)
|
||||
let mergedConfig: GatesConfig | null = null;
|
||||
|
||||
if (pluginRoot) {
|
||||
const pluginConfigPath = path.join(pluginRoot, 'hooks', 'gates.json');
|
||||
const pluginConfig = await loadConfigFile(pluginConfigPath);
|
||||
if (pluginConfig) {
|
||||
await logger.debug('Loaded plugin gates.json', { path: pluginConfigPath });
|
||||
mergedConfig = pluginConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// Load project config (overrides)
|
||||
const projectPaths = [
|
||||
path.join(cwd, '.claude', 'gates.json'),
|
||||
path.join(cwd, 'gates.json')
|
||||
];
|
||||
|
||||
for (const configPath of projectPaths) {
|
||||
const projectConfig = await loadConfigFile(configPath);
|
||||
if (projectConfig) {
|
||||
await logger.debug('Loaded project gates.json', { path: configPath });
|
||||
if (mergedConfig) {
|
||||
mergedConfig = mergeConfigs(mergedConfig, projectConfig);
|
||||
await logger.debug('Merged project config with plugin config');
|
||||
} else {
|
||||
mergedConfig = projectConfig;
|
||||
}
|
||||
break; // Only load first project config found
|
||||
}
|
||||
}
|
||||
|
||||
// Validate merged config
|
||||
if (mergedConfig) {
|
||||
validateConfig(mergedConfig);
|
||||
}
|
||||
|
||||
return mergedConfig;
|
||||
}
|
||||
280
hooks/hooks-app/src/context.ts
Normal file
280
hooks/hooks-app/src/context.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
// plugin/hooks/hooks-app/src/context.ts
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { HookInput } from './types';
|
||||
import { fileExists } from './utils';
|
||||
import { Session } from './session';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Get the plugin root directory from CLAUDE_PLUGIN_ROOT env var.
|
||||
* Falls back to computing relative to this file's location.
|
||||
*/
|
||||
function getPluginRoot(): string | null {
|
||||
// First check env var (set by Claude Code when plugin is loaded)
|
||||
const envRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
if (envRoot) {
|
||||
return envRoot;
|
||||
}
|
||||
|
||||
// Fallback: compute from this file's location
|
||||
// This file is at: plugin/hooks/hooks-app/src/context.ts (dev)
|
||||
// Or at: plugin/hooks/hooks-app/dist/context.js (built)
|
||||
// Plugin root is: plugin/
|
||||
try {
|
||||
// Go up from src/ or dist/ -> hooks-app/ -> hooks/ -> plugin/
|
||||
return path.resolve(__dirname, '..', '..', '..');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context file paths for a given base directory.
|
||||
* Returns array of paths following priority order:
|
||||
* flat > slash-command subdir > slash-command nested > skill subdir > skill nested
|
||||
*/
|
||||
function buildContextPaths(baseDir: string, contextDir: string, name: string, stage: string): string[] {
|
||||
return [
|
||||
path.join(baseDir, contextDir, `${name}-${stage}.md`),
|
||||
path.join(baseDir, contextDir, 'slash-command', `${name}-${stage}.md`),
|
||||
path.join(baseDir, contextDir, 'slash-command', name, `${stage}.md`),
|
||||
path.join(baseDir, contextDir, 'skill', `${name}-${stage}.md`),
|
||||
path.join(baseDir, contextDir, 'skill', name, `${stage}.md`)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover context file following priority order.
|
||||
*
|
||||
* Priority (project takes precedence over plugin):
|
||||
* 1. Project: .claude/context/{name}-{stage}.md (and variations)
|
||||
* 2. Plugin: ${CLAUDE_PLUGIN_ROOT}/context/{name}-{stage}.md (and variations)
|
||||
*/
|
||||
export async function discoverContextFile(
|
||||
cwd: string,
|
||||
name: string,
|
||||
stage: string
|
||||
): Promise<string | null> {
|
||||
// Project-level context (highest priority)
|
||||
const projectPaths = buildContextPaths(cwd, '.claude/context', name, stage);
|
||||
|
||||
for (const filePath of projectPaths) {
|
||||
if (await fileExists(filePath)) {
|
||||
await logger.debug('Found project context file', { path: filePath, name, stage });
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin-level context (fallback)
|
||||
const pluginRoot = getPluginRoot();
|
||||
if (pluginRoot) {
|
||||
const pluginPaths = buildContextPaths(pluginRoot, 'context', name, stage);
|
||||
|
||||
for (const filePath of pluginPaths) {
|
||||
if (await fileExists(filePath)) {
|
||||
await logger.debug('Found plugin context file', { path: filePath, name, stage });
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover agent-command scoped context file.
|
||||
* Pattern: {agent}-{command}-{stage}.md
|
||||
*
|
||||
* Priority:
|
||||
* 1. Project: {agent}-{command}-{stage}.md (most specific)
|
||||
* 2. Project: {agent}-{stage}.md (agent-specific)
|
||||
* 3. Plugin: {agent}-{command}-{stage}.md
|
||||
* 4. Plugin: {agent}-{stage}.md
|
||||
* 5. Standard discovery (backward compat, checks both project and plugin)
|
||||
*/
|
||||
async function discoverAgentCommandContext(
|
||||
cwd: string,
|
||||
agent: string,
|
||||
commandOrSkill: string | null,
|
||||
stage: string
|
||||
): Promise<string | null> {
|
||||
// Strip namespace prefix from agent name (namespace:agent-name → agent-name)
|
||||
const agentName = agent.replace(/^[^:]+:/, '');
|
||||
const contextName = commandOrSkill?.replace(/^\//, '').replace(/^[^:]+:/, '');
|
||||
|
||||
// Project-level paths (highest priority)
|
||||
const projectPaths: string[] = [];
|
||||
if (contextName) {
|
||||
projectPaths.push(path.join(cwd, '.claude', 'context', `${agentName}-${contextName}-${stage}.md`));
|
||||
}
|
||||
projectPaths.push(path.join(cwd, '.claude', 'context', `${agentName}-${stage}.md`));
|
||||
|
||||
for (const filePath of projectPaths) {
|
||||
if (await fileExists(filePath)) {
|
||||
await logger.debug('Found project agent context file', { path: filePath, agent: agentName, stage });
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin-level paths (fallback)
|
||||
const pluginRoot = getPluginRoot();
|
||||
if (pluginRoot) {
|
||||
const pluginPaths: string[] = [];
|
||||
if (contextName) {
|
||||
pluginPaths.push(path.join(pluginRoot, 'context', `${agentName}-${contextName}-${stage}.md`));
|
||||
}
|
||||
pluginPaths.push(path.join(pluginRoot, 'context', `${agentName}-${stage}.md`));
|
||||
|
||||
for (const filePath of pluginPaths) {
|
||||
if (await fileExists(filePath)) {
|
||||
await logger.debug('Found plugin agent context file', { path: filePath, agent: agentName, stage });
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compat: try standard discovery with command/skill name
|
||||
// (discoverContextFile already checks both project and plugin)
|
||||
if (contextName) {
|
||||
const standardPath = await discoverContextFile(cwd, contextName, stage);
|
||||
if (standardPath) {
|
||||
return standardPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract name and stage from hook event.
|
||||
* Returns { name, stage } for context file discovery.
|
||||
*
|
||||
* Mapping:
|
||||
* - SlashCommandStart → { name: command, stage: 'start' }
|
||||
* - SlashCommandEnd → { name: command, stage: 'end' }
|
||||
* - SkillStart → { name: skill, stage: 'start' }
|
||||
* - SkillEnd → { name: skill, stage: 'end' }
|
||||
* - PreToolUse → { name: tool_name, stage: 'pre' }
|
||||
* - PostToolUse → { name: tool_name, stage: 'post' }
|
||||
* - SubagentStop → { name: agent_name, stage: 'end' } (special handling)
|
||||
* - UserPromptSubmit → { name: 'prompt', stage: 'submit' }
|
||||
* - Stop → { name: 'agent', stage: 'stop' }
|
||||
* - SessionStart → { name: 'session', stage: 'start' }
|
||||
* - SessionEnd → { name: 'session', stage: 'end' }
|
||||
* - Notification → { name: 'notification', stage: 'receive' }
|
||||
*/
|
||||
function extractNameAndStage(
|
||||
hookEvent: string,
|
||||
input: HookInput
|
||||
): { name: string; stage: string } | null {
|
||||
switch (hookEvent) {
|
||||
case 'SlashCommandStart':
|
||||
return input.command
|
||||
? { name: input.command.replace(/^\//, '').replace(/^[^:]+:/, ''), stage: 'start' }
|
||||
: null;
|
||||
|
||||
case 'SlashCommandEnd':
|
||||
return input.command
|
||||
? { name: input.command.replace(/^\//, '').replace(/^[^:]+:/, ''), stage: 'end' }
|
||||
: null;
|
||||
|
||||
case 'SkillStart':
|
||||
return input.skill ? { name: input.skill.replace(/^[^:]+:/, ''), stage: 'start' } : null;
|
||||
|
||||
case 'SkillEnd':
|
||||
return input.skill ? { name: input.skill.replace(/^[^:]+:/, ''), stage: 'end' } : null;
|
||||
|
||||
case 'PreToolUse':
|
||||
return input.tool_name ? { name: input.tool_name.toLowerCase(), stage: 'pre' } : null;
|
||||
|
||||
case 'PostToolUse':
|
||||
return input.tool_name ? { name: input.tool_name.toLowerCase(), stage: 'post' } : null;
|
||||
|
||||
case 'SubagentStop':
|
||||
// SubagentStop has special handling - uses agent-command scoping
|
||||
return null;
|
||||
|
||||
case 'UserPromptSubmit':
|
||||
return { name: 'prompt', stage: 'submit' };
|
||||
|
||||
case 'Stop':
|
||||
return { name: 'agent', stage: 'stop' };
|
||||
|
||||
case 'SessionStart':
|
||||
return { name: 'session', stage: 'start' };
|
||||
|
||||
case 'SessionEnd':
|
||||
return { name: 'session', stage: 'end' };
|
||||
|
||||
case 'Notification':
|
||||
return { name: 'notification', stage: 'receive' };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject context from .claude/context/ files based on hook event.
|
||||
* This is the PRIMARY built-in gate - automatic context injection.
|
||||
*
|
||||
* Convention:
|
||||
* - .claude/context/{name}-{stage}.md
|
||||
* - e.g., .claude/context/code-review-start.md
|
||||
* - e.g., .claude/context/prompt-submit.md
|
||||
*/
|
||||
export async function injectContext(hookEvent: string, input: HookInput): Promise<string | null> {
|
||||
await logger.debug('Context injection starting', { event: hookEvent, cwd: input.cwd });
|
||||
|
||||
// Handle SubagentStop with agent-command scoping (special case)
|
||||
if (hookEvent === 'SubagentStop' && input.agent_name) {
|
||||
const session = new Session(input.cwd);
|
||||
const activeCommand = await session.get('active_command');
|
||||
const activeSkill = await session.get('active_skill');
|
||||
const commandOrSkill = activeCommand || activeSkill;
|
||||
|
||||
const contextFile = await discoverAgentCommandContext(
|
||||
input.cwd,
|
||||
input.agent_name,
|
||||
commandOrSkill,
|
||||
'end'
|
||||
);
|
||||
|
||||
if (contextFile) {
|
||||
const content = await fs.readFile(contextFile, 'utf-8');
|
||||
await logger.info('Injecting agent context', {
|
||||
event: hookEvent,
|
||||
agent: input.agent_name,
|
||||
file: contextFile
|
||||
});
|
||||
return content;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Standard context discovery for all other hooks
|
||||
const extracted = extractNameAndStage(hookEvent, input);
|
||||
if (!extracted) {
|
||||
await logger.debug('No name/stage extracted', { event: hookEvent });
|
||||
return null;
|
||||
}
|
||||
|
||||
const { name, stage } = extracted;
|
||||
const contextFile = await discoverContextFile(input.cwd, name, stage);
|
||||
|
||||
if (contextFile) {
|
||||
const content = await fs.readFile(contextFile, 'utf-8');
|
||||
await logger.info('Injecting context', {
|
||||
event: hookEvent,
|
||||
name,
|
||||
stage,
|
||||
file: contextFile
|
||||
});
|
||||
return content;
|
||||
}
|
||||
|
||||
await logger.debug('No context file found', { event: hookEvent, name, stage });
|
||||
return null;
|
||||
}
|
||||
260
hooks/hooks-app/src/dispatcher.ts
Normal file
260
hooks/hooks-app/src/dispatcher.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
// plugin/hooks/hooks-app/src/dispatcher.ts
|
||||
import { HookInput, HookConfig, GateConfig } from './types';
|
||||
import { loadConfig } from './config';
|
||||
import { injectContext } from './context';
|
||||
import { executeGate } from './gate-loader';
|
||||
import { handleAction } from './action-handler';
|
||||
import { Session } from './session';
|
||||
import { logger } from './logger';
|
||||
|
||||
export function shouldProcessHook(input: HookInput, hookConfig: HookConfig): boolean {
|
||||
const hookEvent = input.hook_event_name;
|
||||
|
||||
// PostToolUse filtering
|
||||
if (hookEvent === 'PostToolUse') {
|
||||
if (hookConfig.enabled_tools && hookConfig.enabled_tools.length > 0) {
|
||||
return hookConfig.enabled_tools.includes(input.tool_name || '');
|
||||
}
|
||||
}
|
||||
|
||||
// SubagentStop filtering
|
||||
if (hookEvent === 'SubagentStop') {
|
||||
if (hookConfig.enabled_agents && hookConfig.enabled_agents.length > 0) {
|
||||
const agentName = input.agent_name || input.subagent_name || '';
|
||||
return hookConfig.enabled_agents.includes(agentName);
|
||||
}
|
||||
}
|
||||
|
||||
// No filtering or other events
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface DispatchResult {
|
||||
context?: string;
|
||||
blockReason?: string;
|
||||
stopMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ERROR HANDLING: Circular gate chain prevention (max 10 gates per dispatch).
|
||||
* Prevents infinite loops from misconfigured gate chains.
|
||||
*/
|
||||
const MAX_GATES_PER_DISPATCH = 10;
|
||||
|
||||
// Built-in gates removed - context injection is the primary behavior
|
||||
// Context injection happens via injectContext() which discovers .claude/context/ files
|
||||
|
||||
/**
|
||||
* Check if gate should run based on keyword matching (UserPromptSubmit only).
|
||||
* Gates without keywords always run (backwards compatible).
|
||||
*
|
||||
* Note: Uses substring matching, not word-boundary matching. This means "test"
|
||||
* will match "latest" or "contest". This is intentional for flexibility - users
|
||||
* can say "let's test this" or "testing the feature" and both will match.
|
||||
* If word-boundary matching is needed in the future, consider using regex like:
|
||||
* /\b${keyword}\b/i.test(message)
|
||||
*/
|
||||
export function gateMatchesKeywords(gateConfig: GateConfig, userMessage: string | undefined): boolean {
|
||||
// No keywords = always run (backwards compatible)
|
||||
if (!gateConfig.keywords || gateConfig.keywords.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No user message = skip keyword gates
|
||||
if (!userMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerMessage = userMessage.toLowerCase();
|
||||
return gateConfig.keywords.some(keyword =>
|
||||
lowerMessage.includes(keyword.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
async function updateSessionState(input: HookInput): Promise<void> {
|
||||
const session = new Session(input.cwd);
|
||||
const event = input.hook_event_name;
|
||||
|
||||
try {
|
||||
switch (event) {
|
||||
case 'SlashCommandStart':
|
||||
if (input.command) {
|
||||
await session.set('active_command', input.command);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SlashCommandEnd':
|
||||
await session.set('active_command', null);
|
||||
break;
|
||||
|
||||
case 'SkillStart':
|
||||
if (input.skill) {
|
||||
await session.set('active_skill', input.skill);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SkillEnd':
|
||||
await session.set('active_skill', null);
|
||||
break;
|
||||
|
||||
// Note: SubagentStart/SubagentStop NOT tracked - Claude Code does not
|
||||
// provide unique agent identifiers, making reliable agent tracking impossible
|
||||
// when multiple agents of the same type run in parallel.
|
||||
|
||||
case 'PostToolUse':
|
||||
if (input.file_path) {
|
||||
await session.append('edited_files', input.file_path);
|
||||
|
||||
// Extract and track file extension
|
||||
// Edge case: ext !== input.file_path prevents tracking entire filename
|
||||
// as extension when file has no dot (e.g., "README")
|
||||
const ext = input.file_path.split('.').pop();
|
||||
if (ext && ext !== input.file_path) {
|
||||
await session.append('file_extensions', ext);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Session state is best-effort, don't fail the hook if it errors
|
||||
// Structured error logging for debugging
|
||||
const errorData = {
|
||||
error_type: error instanceof Error ? error.constructor.name : 'UnknownError',
|
||||
error_message: error instanceof Error ? error.message : String(error),
|
||||
hook_event: event,
|
||||
cwd: input.cwd,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
console.error(`[Session Error] ${JSON.stringify(errorData)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispatch(input: HookInput): Promise<DispatchResult> {
|
||||
const hookEvent = input.hook_event_name;
|
||||
const cwd = input.cwd;
|
||||
const startTime = Date.now();
|
||||
|
||||
await logger.event('debug', hookEvent, {
|
||||
tool: input.tool_name,
|
||||
agent: input.agent_name || input.subagent_name,
|
||||
file: input.file_path,
|
||||
cwd,
|
||||
});
|
||||
|
||||
// Update session state (best-effort)
|
||||
await updateSessionState(input);
|
||||
|
||||
// 1. ALWAYS run context injection FIRST (primary behavior)
|
||||
// This discovers .claude/context/{name}-{stage}.md files
|
||||
const contextContent = await injectContext(hookEvent, input);
|
||||
let accumulatedContext = contextContent || '';
|
||||
|
||||
// 2. Load config for additional gates (optional)
|
||||
const config = await loadConfig(cwd);
|
||||
if (!config) {
|
||||
await logger.debug('No gates.json config found', { cwd });
|
||||
// Return context injection result even without gates.json
|
||||
return accumulatedContext ? { context: accumulatedContext } : {};
|
||||
}
|
||||
|
||||
// 3. Check if hook event has additional gates configured
|
||||
const hookConfig = config.hooks[hookEvent];
|
||||
if (!hookConfig) {
|
||||
await logger.debug('Hook event not configured in gates.json', { event: hookEvent });
|
||||
// Return context injection result even if hook not in gates.json
|
||||
return accumulatedContext ? { context: accumulatedContext } : {};
|
||||
}
|
||||
|
||||
// 4. Filter by enabled lists
|
||||
if (!shouldProcessHook(input, hookConfig)) {
|
||||
await logger.debug('Hook filtered out by enabled list', {
|
||||
event: hookEvent,
|
||||
tool: input.tool_name,
|
||||
agent: input.agent_name,
|
||||
});
|
||||
// Still return context injection result
|
||||
return accumulatedContext ? { context: accumulatedContext } : {};
|
||||
}
|
||||
|
||||
// 5. Run additional gates in sequence (from gates.json)
|
||||
const gates = hookConfig.gates || [];
|
||||
let gatesExecuted = 0;
|
||||
|
||||
for (let i = 0; i < gates.length; i++) {
|
||||
const gateName = gates[i];
|
||||
|
||||
// Circuit breaker: prevent infinite chains
|
||||
if (gatesExecuted >= MAX_GATES_PER_DISPATCH) {
|
||||
return {
|
||||
blockReason: `Exceeded max gate chain depth (${MAX_GATES_PER_DISPATCH}). Check for circular references.`
|
||||
};
|
||||
}
|
||||
|
||||
const gateConfig = config.gates[gateName];
|
||||
if (!gateConfig) {
|
||||
// Graceful degradation: skip undefined gates with warning
|
||||
accumulatedContext += `\nWarning: Gate '${gateName}' not defined, skipping`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keyword filtering for UserPromptSubmit
|
||||
if (hookEvent === 'UserPromptSubmit' && !gateMatchesKeywords(gateConfig, input.user_message)) {
|
||||
await logger.debug('Gate skipped - no keyword match', { gate: gateName });
|
||||
continue;
|
||||
}
|
||||
|
||||
gatesExecuted++;
|
||||
|
||||
// Execute gate
|
||||
const gateStartTime = Date.now();
|
||||
const { passed, result } = await executeGate(gateName, gateConfig, input, []);
|
||||
const gateDuration = Date.now() - gateStartTime;
|
||||
|
||||
await logger.event('info', hookEvent, {
|
||||
gate: gateName,
|
||||
passed,
|
||||
duration_ms: gateDuration,
|
||||
tool: input.tool_name,
|
||||
});
|
||||
|
||||
// Determine action
|
||||
const action = passed ? gateConfig.on_pass || 'CONTINUE' : gateConfig.on_fail || 'BLOCK';
|
||||
|
||||
// Handle action
|
||||
const actionResult = await handleAction(action, result, config, input);
|
||||
|
||||
if (actionResult.context) {
|
||||
accumulatedContext += '\n' + actionResult.context;
|
||||
}
|
||||
|
||||
if (!actionResult.continue) {
|
||||
await logger.event('warn', hookEvent, {
|
||||
gate: gateName,
|
||||
action,
|
||||
blocked: !!actionResult.blockReason,
|
||||
stopped: !!actionResult.stopMessage,
|
||||
duration_ms: Date.now() - startTime,
|
||||
});
|
||||
return {
|
||||
context: accumulatedContext,
|
||||
blockReason: actionResult.blockReason,
|
||||
stopMessage: actionResult.stopMessage
|
||||
};
|
||||
}
|
||||
|
||||
// Gate chaining
|
||||
if (actionResult.chainedGate) {
|
||||
gates.push(actionResult.chainedGate);
|
||||
}
|
||||
}
|
||||
|
||||
await logger.event('debug', hookEvent, {
|
||||
status: 'completed',
|
||||
gates_executed: gatesExecuted,
|
||||
duration_ms: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return {
|
||||
context: accumulatedContext
|
||||
};
|
||||
}
|
||||
210
hooks/hooks-app/src/gate-loader.ts
Normal file
210
hooks/hooks-app/src/gate-loader.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
// plugin/hooks/hooks-app/src/gate-loader.ts
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as path from 'path';
|
||||
import { HookInput, GateResult, GateConfig, GatesConfig } from './types';
|
||||
import { resolvePluginPath, loadConfigFile } from './config';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface ShellResult {
|
||||
exitCode: number;
|
||||
output: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute shell command from gate configuration with timeout.
|
||||
*
|
||||
* SECURITY MODEL: gates.json is trusted configuration (project-controlled, not user input).
|
||||
* Commands are executed without sanitization because:
|
||||
* 1. gates.json is committed to repository or managed by project admins
|
||||
* 2. Users cannot inject commands without write access to gates.json
|
||||
* 3. If gates.json is compromised, the project is already compromised
|
||||
*
|
||||
* This is equivalent to package.json scripts or Makefile targets - trusted project configuration.
|
||||
*
|
||||
* ERROR HANDLING: Commands timeout after 30 seconds to prevent hung gates.
|
||||
*/
|
||||
export async function executeShellCommand(
|
||||
command: string,
|
||||
cwd: string,
|
||||
timeoutMs: number = 30000
|
||||
): Promise<ShellResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, { cwd, timeout: timeoutMs });
|
||||
return {
|
||||
exitCode: 0,
|
||||
output: stdout + stderr
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
if (err.killed && err.signal === 'SIGTERM') {
|
||||
return {
|
||||
exitCode: 124, // Standard timeout exit code
|
||||
output: `Command timed out after ${timeoutMs}ms`
|
||||
};
|
||||
}
|
||||
return {
|
||||
exitCode: err.code || 1,
|
||||
output: (err.stdout || '') + (err.stderr || '')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and execute a built-in TypeScript gate
|
||||
*
|
||||
* Built-in gates are TypeScript modules in src/gates/ that export an execute function.
|
||||
* Gate names use kebab-case and are mapped to camelCase module names:
|
||||
* - "plugin-path" → pluginPath
|
||||
* - "custom-gate" → customGate
|
||||
*/
|
||||
export async function executeBuiltinGate(gateName: string, input: HookInput): Promise<GateResult> {
|
||||
try {
|
||||
// Convert kebab-case to camelCase for module lookup
|
||||
// "plugin-path" -> "pluginPath"
|
||||
const moduleName = gateName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
|
||||
// Import the gate module dynamically
|
||||
const gates = await import('./gates');
|
||||
const gateModule = (gates as any)[moduleName];
|
||||
|
||||
if (!gateModule || typeof gateModule.execute !== 'function') {
|
||||
throw new Error(`Gate module '${moduleName}' not found or missing execute function`);
|
||||
}
|
||||
|
||||
return await gateModule.execute(input);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load built-in gate ${gateName}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Track plugin gate call stack to detect circular references
|
||||
const MAX_PLUGIN_DEPTH = 10;
|
||||
|
||||
export async function executeGate(
|
||||
gateName: string,
|
||||
gateConfig: GateConfig,
|
||||
input: HookInput,
|
||||
pluginStack: string[] = []
|
||||
): Promise<{ passed: boolean; result: GateResult }> {
|
||||
// Handle plugin gate reference
|
||||
if (gateConfig.plugin && gateConfig.gate) {
|
||||
// Circular reference detection
|
||||
const gateRef = `${gateConfig.plugin}:${gateConfig.gate}`;
|
||||
if (pluginStack.includes(gateRef)) {
|
||||
throw new Error(
|
||||
`Circular gate reference detected: ${pluginStack.join(' -> ')} -> ${gateRef}`
|
||||
);
|
||||
}
|
||||
|
||||
// Depth limit to prevent infinite recursion
|
||||
if (pluginStack.length >= MAX_PLUGIN_DEPTH) {
|
||||
throw new Error(
|
||||
`Maximum plugin gate depth (${MAX_PLUGIN_DEPTH}) exceeded: ${pluginStack.join(' -> ')} -> ${gateRef}`
|
||||
);
|
||||
}
|
||||
|
||||
const { gateConfig: pluginGateConfig, pluginRoot } = await loadPluginGate(
|
||||
gateConfig.plugin,
|
||||
gateConfig.gate
|
||||
);
|
||||
|
||||
// Recursively execute the plugin's gate with updated stack
|
||||
const newStack = [...pluginStack, gateRef];
|
||||
|
||||
// Execute the plugin's gate command in the plugin's directory
|
||||
if (pluginGateConfig.command) {
|
||||
const shellResult = await executeShellCommand(pluginGateConfig.command, pluginRoot);
|
||||
const passed = shellResult.exitCode === 0;
|
||||
|
||||
return {
|
||||
passed,
|
||||
result: {
|
||||
additionalContext: shellResult.output
|
||||
}
|
||||
};
|
||||
} else if (pluginGateConfig.plugin && pluginGateConfig.gate) {
|
||||
// Plugin gate references another plugin gate - recurse
|
||||
return executeGate(gateRef, pluginGateConfig, input, newStack);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Plugin gate '${gateConfig.plugin}:${gateConfig.gate}' has no command`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (gateConfig.command) {
|
||||
// Shell command gate (existing behavior)
|
||||
const shellResult = await executeShellCommand(gateConfig.command, input.cwd);
|
||||
const passed = shellResult.exitCode === 0;
|
||||
|
||||
return {
|
||||
passed,
|
||||
result: {
|
||||
additionalContext: shellResult.output
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Built-in TypeScript gate
|
||||
const result = await executeBuiltinGate(gateName, input);
|
||||
const passed = !result.decision && result.continue !== false;
|
||||
|
||||
return {
|
||||
passed,
|
||||
result
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginGateResult {
|
||||
gateConfig: GateConfig;
|
||||
pluginRoot: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a gate definition from another plugin.
|
||||
*
|
||||
* SECURITY: Plugins are trusted by virtue of being explicitly installed by the user.
|
||||
* This function loads plugin configuration and does NOT validate command safety.
|
||||
* The trust boundary is at plugin installation, not at gate reference.
|
||||
*
|
||||
* However, we do validate that the loaded config has the expected structure to
|
||||
* prevent runtime errors from malformed plugin configurations.
|
||||
*
|
||||
* @param pluginName - Name of the plugin (e.g., 'cipherpowers')
|
||||
* @param gateName - Name of the gate within the plugin
|
||||
* @returns The gate config and the plugin root path for execution context
|
||||
*/
|
||||
export async function loadPluginGate(
|
||||
pluginName: string,
|
||||
gateName: string
|
||||
): Promise<PluginGateResult> {
|
||||
const pluginRoot = resolvePluginPath(pluginName);
|
||||
const gatesPath = path.join(pluginRoot, 'hooks', 'gates.json');
|
||||
|
||||
const pluginConfig = await loadConfigFile(gatesPath);
|
||||
if (!pluginConfig) {
|
||||
throw new Error(`Cannot find gates.json for plugin '${pluginName}' at ${gatesPath}`);
|
||||
}
|
||||
|
||||
// Validate plugin config has gates object
|
||||
if (!pluginConfig.gates || typeof pluginConfig.gates !== 'object') {
|
||||
throw new Error(
|
||||
`Invalid gates.json structure in plugin '${pluginName}': missing or invalid 'gates' object`
|
||||
);
|
||||
}
|
||||
|
||||
const gateConfig = pluginConfig.gates[gateName];
|
||||
if (!gateConfig) {
|
||||
throw new Error(`Gate '${gateName}' not found in plugin '${pluginName}'`);
|
||||
}
|
||||
|
||||
return { gateConfig, pluginRoot };
|
||||
}
|
||||
8
hooks/hooks-app/src/gates/index.ts
Normal file
8
hooks/hooks-app/src/gates/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// plugin/hooks/hooks-app/src/gates/index.ts
|
||||
/**
|
||||
* Built-in gates registry
|
||||
*
|
||||
* All TypeScript gates are exported here for easy discovery and import.
|
||||
*/
|
||||
|
||||
export * as pluginPath from './plugin-path';
|
||||
54
hooks/hooks-app/src/gates/plugin-path.ts
Normal file
54
hooks/hooks-app/src/gates/plugin-path.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// plugin/hooks/hooks-app/src/gates/plugin-path.ts
|
||||
import { HookInput, GateResult } from '../types';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Plugin Path Injection Gate
|
||||
*
|
||||
* Injects CLAUDE_PLUGIN_ROOT as context for agents to resolve file references.
|
||||
* This gate provides the absolute path to the plugin root directory, enabling
|
||||
* agents to properly resolve @${CLAUDE_PLUGIN_ROOT}/... file references.
|
||||
*
|
||||
* Typical usage: SubagentStop hook to inject path context when agents complete.
|
||||
*/
|
||||
|
||||
export async function execute(_input: HookInput): Promise<GateResult> {
|
||||
// Determine plugin root:
|
||||
// 1. Use CLAUDE_PLUGIN_ROOT if set (standard Claude Code environment)
|
||||
// 2. Otherwise compute from this script's location
|
||||
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || computePluginRoot();
|
||||
|
||||
const contextMessage = `## Plugin Path Context
|
||||
|
||||
For this session:
|
||||
\`\`\`
|
||||
CLAUDE_PLUGIN_ROOT=${pluginRoot}
|
||||
\`\`\`
|
||||
|
||||
When you see file references like \`@\${CLAUDE_PLUGIN_ROOT}skills/...\`, resolve them using the path above.`;
|
||||
|
||||
return {
|
||||
additionalContext: contextMessage
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute plugin root from this file's location
|
||||
* This file is at: plugin/hooks/hooks-app/src/gates/plugin-path.ts
|
||||
* Plugin root is: plugin/
|
||||
*
|
||||
* We go up 4 levels: gates/ -> src/ -> hooks-app/ -> hooks/ -> plugin/
|
||||
*/
|
||||
function computePluginRoot(): string {
|
||||
// In CommonJS, use __dirname
|
||||
// __dirname is at: plugin/hooks/hooks-app/dist/gates/
|
||||
// (after compilation from src/ to dist/)
|
||||
|
||||
// Go up 4 directories from dist/gates/
|
||||
let pluginRoot = path.dirname(__dirname); // dist/
|
||||
pluginRoot = path.dirname(pluginRoot); // hooks-app/
|
||||
pluginRoot = path.dirname(pluginRoot); // hooks/
|
||||
pluginRoot = path.dirname(pluginRoot); // plugin/
|
||||
|
||||
return pluginRoot;
|
||||
}
|
||||
25
hooks/hooks-app/src/index.ts
Normal file
25
hooks/hooks-app/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// plugin/hooks/hooks-app/src/index.ts
|
||||
|
||||
// Existing exports
|
||||
export { dispatch } from './dispatcher';
|
||||
export { executeGate } from './gate-loader';
|
||||
export { handleAction } from './action-handler';
|
||||
export { loadConfig } from './config';
|
||||
export { injectContext } from './context';
|
||||
|
||||
export type {
|
||||
HookInput,
|
||||
GateResult,
|
||||
GateExecute,
|
||||
GateConfig,
|
||||
HookConfig,
|
||||
GatesConfig
|
||||
} from './types';
|
||||
|
||||
// New session exports
|
||||
export { Session } from './session';
|
||||
export type { SessionState, SessionStateArrayKey, SessionStateScalarKey } from './types';
|
||||
|
||||
// Logging exports
|
||||
export { logger } from './logger';
|
||||
export type { LogLevel } from './logger';
|
||||
180
hooks/hooks-app/src/logger.ts
Normal file
180
hooks/hooks-app/src/logger.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// plugin/hooks/hooks-app/src/logger.ts
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LogEntry {
|
||||
ts: string;
|
||||
level: LogLevel;
|
||||
event?: string;
|
||||
message?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the log directory path.
|
||||
* Uses ${TMPDIR}/turboshovel/ for isolation.
|
||||
*/
|
||||
function getLogDir(): string {
|
||||
return path.join(tmpdir(), 'turboshovel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the log file path for today.
|
||||
* Format: hooks-YYYY-MM-DD.log
|
||||
*/
|
||||
function getLogFilePath(): string {
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
return path.join(getLogDir(), `hooks-${date}.log`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if logging is enabled via environment variable.
|
||||
* Logging is ENABLED by default (env vars don't pass through from Claude CLI).
|
||||
* Set TURBOSHOVEL_LOG=0 to disable.
|
||||
*/
|
||||
function isLoggingEnabled(): boolean {
|
||||
return process.env.TURBOSHOVEL_LOG !== '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum log level from environment.
|
||||
* TURBOSHOVEL_LOG_LEVEL=debug|info|warn|error (default: info)
|
||||
*/
|
||||
function getMinLogLevel(): LogLevel {
|
||||
const level = process.env.TURBOSHOVEL_LOG_LEVEL as LogLevel;
|
||||
if (level && LOG_LEVELS[level] !== undefined) {
|
||||
return level;
|
||||
}
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a log level should be written based on minimum level.
|
||||
*/
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[getMinLogLevel()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the log directory exists.
|
||||
*/
|
||||
async function ensureLogDir(): Promise<void> {
|
||||
const dir = getLogDir();
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a log entry to the log file.
|
||||
* Each entry is a JSON line for easy parsing with jq.
|
||||
*/
|
||||
async function writeLog(entry: LogEntry): Promise<void> {
|
||||
if (!isLoggingEnabled()) return;
|
||||
if (!shouldLog(entry.level)) return;
|
||||
|
||||
try {
|
||||
await ensureLogDir();
|
||||
const line = JSON.stringify(entry) + '\n';
|
||||
await fs.appendFile(getLogFilePath(), line, 'utf-8');
|
||||
} catch {
|
||||
// Silently fail - logging should never break the hook
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a log entry unconditionally (bypasses TURBOSHOVEL_LOG check).
|
||||
* Used for startup/diagnostic logging to verify hooks are being invoked.
|
||||
*/
|
||||
async function writeLogAlways(entry: LogEntry): Promise<void> {
|
||||
try {
|
||||
await ensureLogDir();
|
||||
const line = JSON.stringify(entry) + '\n';
|
||||
await fs.appendFile(getLogFilePath(), line, 'utf-8');
|
||||
} catch {
|
||||
// Silently fail - logging should never break the hook
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a log entry with timestamp.
|
||||
*/
|
||||
function createEntry(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
): LogEntry {
|
||||
return {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for hooks-app.
|
||||
*
|
||||
* Enable logging: TURBOSHOVEL_LOG=1
|
||||
* Set level: TURBOSHOVEL_LOG_LEVEL=debug|info|warn|error
|
||||
*
|
||||
* Logs are written to: ${TMPDIR}/turboshovel/hooks-YYYY-MM-DD.log
|
||||
* Format: JSON lines (one JSON object per line)
|
||||
*
|
||||
* Example:
|
||||
* {"ts":"2025-11-25T10:30:00.000Z","level":"info","event":"PostToolUse","tool":"Edit"}
|
||||
*/
|
||||
export const logger = {
|
||||
debug: (message: string, data?: Record<string, unknown>) =>
|
||||
writeLog(createEntry('debug', message, data)),
|
||||
|
||||
info: (message: string, data?: Record<string, unknown>) =>
|
||||
writeLog(createEntry('info', message, data)),
|
||||
|
||||
warn: (message: string, data?: Record<string, unknown>) =>
|
||||
writeLog(createEntry('warn', message, data)),
|
||||
|
||||
error: (message: string, data?: Record<string, unknown>) =>
|
||||
writeLog(createEntry('error', message, data)),
|
||||
|
||||
/**
|
||||
* Log unconditionally (bypasses TURBOSHOVEL_LOG check).
|
||||
* Used for startup/diagnostic logging to verify hooks are invoked.
|
||||
*/
|
||||
always: (message: string, data?: Record<string, unknown>) =>
|
||||
writeLogAlways(createEntry('info', message, data)),
|
||||
|
||||
/**
|
||||
* Log a hook event with structured data.
|
||||
* Convenience method for common hook logging pattern.
|
||||
*/
|
||||
event: (
|
||||
level: LogLevel,
|
||||
event: string,
|
||||
data?: Record<string, unknown>
|
||||
) =>
|
||||
writeLog({
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
event,
|
||||
...data,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the current log file path (for mise tasks).
|
||||
*/
|
||||
getLogFilePath,
|
||||
|
||||
/**
|
||||
* Get the log directory path (for mise tasks).
|
||||
*/
|
||||
getLogDir,
|
||||
};
|
||||
131
hooks/hooks-app/src/session.ts
Normal file
131
hooks/hooks-app/src/session.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { SessionState, SessionStateArrayKey } from './types';
|
||||
|
||||
/**
|
||||
* Manages session state with atomic file updates.
|
||||
*
|
||||
* State is stored in .claude/session/state.json relative to the project directory.
|
||||
*/
|
||||
export class Session {
|
||||
private stateFile: string;
|
||||
|
||||
constructor(cwd: string = '.') {
|
||||
this.stateFile = join(cwd, '.claude', 'session', 'state.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session state value
|
||||
*/
|
||||
async get<K extends keyof SessionState>(key: K): Promise<SessionState[K]> {
|
||||
const state = await this.load();
|
||||
return state[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a session state value
|
||||
*/
|
||||
async set<K extends keyof SessionState>(key: K, value: SessionState[K]): Promise<void> {
|
||||
const state = await this.load();
|
||||
state[key] = value;
|
||||
await this.save(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append value to array field (deduplicated)
|
||||
*/
|
||||
async append(key: SessionStateArrayKey, value: string): Promise<void> {
|
||||
const state = await this.load();
|
||||
const array = state[key];
|
||||
|
||||
if (!array.includes(value)) {
|
||||
array.push(value);
|
||||
await this.save(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if array contains value
|
||||
*/
|
||||
async contains(key: SessionStateArrayKey, value: string): Promise<boolean> {
|
||||
const state = await this.load();
|
||||
return state[key].includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session state (remove file)
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.stateFile);
|
||||
} catch (error) {
|
||||
// File doesn't exist, that's fine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load state from file or initialize new state
|
||||
*/
|
||||
private async load(): Promise<SessionState> {
|
||||
try {
|
||||
const content = await fs.readFile(this.stateFile, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
// File doesn't exist or is corrupt, initialize new state
|
||||
return this.initState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save state to file atomically (write to temp, then rename)
|
||||
*
|
||||
* Performance note: File I/O adds small overhead (~1-5ms) per operation.
|
||||
* Atomic writes prevent corruption but require temp file creation.
|
||||
*
|
||||
* Concurrency note: Atomic rename prevents file corruption (invalid JSON,
|
||||
* partial writes) but does NOT prevent logical race conditions where
|
||||
* concurrent operations overwrite each other's changes. This is acceptable
|
||||
* because hooks run sequentially in practice. If true concurrent access is
|
||||
* needed, add file locking or retry logic.
|
||||
*/
|
||||
private async save(state: SessionState): Promise<void> {
|
||||
await fs.mkdir(dirname(this.stateFile), { recursive: true });
|
||||
const temp = this.stateFile + '.tmp';
|
||||
|
||||
try {
|
||||
// Write to temp file
|
||||
await fs.writeFile(temp, JSON.stringify(state, null, 2), 'utf-8');
|
||||
|
||||
// Atomic rename (prevents corruption from concurrent writes)
|
||||
await fs.rename(temp, this.stateFile);
|
||||
} catch (error) {
|
||||
// Clean up temp file on error
|
||||
try {
|
||||
await fs.unlink(temp);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize new session state
|
||||
*
|
||||
* Session ID format: ISO timestamp with punctuation replaced (e.g., "2025-11-23T14-30-45")
|
||||
* Unique per millisecond. Collisions possible if multiple sessions start in same millisecond,
|
||||
* but unlikely in practice due to hook serialization.
|
||||
*/
|
||||
private initState(): SessionState {
|
||||
const now = new Date();
|
||||
return {
|
||||
session_id: now.toISOString().replace(/[:.]/g, '-').substring(0, 19),
|
||||
started_at: now.toISOString(),
|
||||
active_command: null,
|
||||
active_skill: null,
|
||||
edited_files: [],
|
||||
file_extensions: [],
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
102
hooks/hooks-app/src/types.ts
Normal file
102
hooks/hooks-app/src/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// plugin/hooks/hooks-app/src/types.ts
|
||||
|
||||
export interface HookInput {
|
||||
hook_event_name: string;
|
||||
cwd: string;
|
||||
|
||||
// PostToolUse
|
||||
tool_name?: string;
|
||||
file_path?: string;
|
||||
|
||||
// SubagentStop
|
||||
agent_name?: string;
|
||||
subagent_name?: string;
|
||||
output?: string;
|
||||
|
||||
// UserPromptSubmit
|
||||
user_message?: string;
|
||||
|
||||
// SlashCommand/Skill
|
||||
command?: string;
|
||||
skill?: string;
|
||||
}
|
||||
|
||||
export interface GateResult {
|
||||
// Success - add context and continue
|
||||
additionalContext?: string;
|
||||
|
||||
// Block agent from proceeding
|
||||
decision?: 'block';
|
||||
reason?: string;
|
||||
|
||||
// Stop Claude entirely
|
||||
continue?: false;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type GateExecute = (input: HookInput) => Promise<GateResult>;
|
||||
|
||||
export interface GateConfig {
|
||||
/** Reference gate from another plugin (requires gate field) */
|
||||
plugin?: string;
|
||||
|
||||
/** Gate name within the plugin's hooks/gates.json (requires plugin field) */
|
||||
gate?: string;
|
||||
|
||||
/** Local shell command (mutually exclusive with plugin/gate) */
|
||||
command?: string;
|
||||
|
||||
/**
|
||||
* Keywords that trigger this gate (UserPromptSubmit hook only).
|
||||
* When specified, the gate only runs if the user message contains one of these keywords.
|
||||
* For all other hooks (PostToolUse, SubagentStop, etc.), this field is ignored.
|
||||
* Gates without keywords always run (backwards compatible).
|
||||
*/
|
||||
keywords?: string[];
|
||||
on_pass?: string;
|
||||
on_fail?: string;
|
||||
}
|
||||
|
||||
export interface HookConfig {
|
||||
enabled_tools?: string[];
|
||||
enabled_agents?: string[];
|
||||
gates?: string[];
|
||||
}
|
||||
|
||||
export interface GatesConfig {
|
||||
hooks: Record<string, HookConfig>;
|
||||
gates: Record<string, GateConfig>;
|
||||
}
|
||||
|
||||
// Session state interface
|
||||
export interface SessionState {
|
||||
/** Unique session identifier (timestamp-based) */
|
||||
session_id: string;
|
||||
|
||||
/** ISO 8601 timestamp when session started */
|
||||
started_at: string;
|
||||
|
||||
/** Currently active slash command (e.g., "/execute") */
|
||||
active_command: string | null;
|
||||
|
||||
/** Currently active skill (e.g., "executing-plans") */
|
||||
active_skill: string | null;
|
||||
|
||||
/** Files edited during this session */
|
||||
edited_files: string[];
|
||||
|
||||
/** File extensions edited during this session (deduplicated) */
|
||||
file_extensions: string[];
|
||||
|
||||
/** Custom metadata for specific workflows */
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
// Note: active_agent NOT included - Claude Code does not provide unique
|
||||
// agent identifiers. Use metadata field if you need custom agent tracking.
|
||||
|
||||
/** Array field keys in SessionState (for type-safe operations) */
|
||||
export type SessionStateArrayKey = 'edited_files' | 'file_extensions';
|
||||
|
||||
/** Scalar field keys in SessionState */
|
||||
export type SessionStateScalarKey = Exclude<keyof SessionState, SessionStateArrayKey | 'metadata'>;
|
||||
15
hooks/hooks-app/src/utils.ts
Normal file
15
hooks/hooks-app/src/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// plugin/hooks/hooks-app/src/utils.ts
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Check if a file exists at the given path.
|
||||
* Used by config and context modules to probe file system.
|
||||
*/
|
||||
export async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
5
hooks/hooks-app/tsconfig.eslint.json
Normal file
5
hooks/hooks-app/tsconfig.eslint.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*", "__tests__/**/*"],
|
||||
"exclude": ["node_modules", "dist", "__tests__/**/*.d.ts"]
|
||||
}
|
||||
17
hooks/hooks-app/tsconfig.json
Normal file
17
hooks/hooks-app/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "__tests__"]
|
||||
}
|
||||
225
plugin.lock.json
Normal file
225
plugin.lock.json
Normal file
@@ -0,0 +1,225 @@
|
||||
{
|
||||
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||
"pluginId": "gh:tobyhede/turboshovel:plugin",
|
||||
"normalized": {
|
||||
"repo": null,
|
||||
"ref": "refs/tags/v20251128.0",
|
||||
"commit": "da86aead9768e3626e87e2ed2ea022dbed20b778",
|
||||
"treeHash": "965292236ebbb4de42048121ad03582e2810e70fc8354c5fcc5c207e6c45da2a",
|
||||
"generatedAt": "2025-11-28T10:28:42.782380Z",
|
||||
"toolVersion": "publish_plugins.py@0.2.0"
|
||||
},
|
||||
"origin": {
|
||||
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||
"branch": "master",
|
||||
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "turboshovel",
|
||||
"description": "Generic hook framework for quality enforcement and context injection",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"content": {
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "c6255e9cb290bf30acd33851dd190e9ad34a60814ece3c9087b9e8546f3c8e26"
|
||||
},
|
||||
{
|
||||
"path": "hooks/ARCHITECTURE.md",
|
||||
"sha256": "e09830c7b1edd1757d2b768a41cb175e1a1a85787261f0c56b174ba5bb1a3c2f"
|
||||
},
|
||||
{
|
||||
"path": "hooks/CONVENTIONS.md",
|
||||
"sha256": "94d2830c89ebaf2ed55f8d5174261cfa4d54e17a960cc19dae9bdbb68127c808"
|
||||
},
|
||||
{
|
||||
"path": "hooks/INTEGRATION_TESTS.md",
|
||||
"sha256": "da61e645156827e97a1e96ae3d43241ce3f3e00d4cea862715ac9415e3ad7a8d"
|
||||
},
|
||||
{
|
||||
"path": "hooks/SETUP.md",
|
||||
"sha256": "24e8af232c90c40ee6fa30e3339778fb45f78da67ca36c3eb245fc4e50c70fdd"
|
||||
},
|
||||
{
|
||||
"path": "hooks/README.md",
|
||||
"sha256": "d11a632d3449e9adb527efc7e97bdf33dc388bbd88a8c78525f3ae6e69064c49"
|
||||
},
|
||||
{
|
||||
"path": "hooks/gates.json",
|
||||
"sha256": "a003ff597ac2b9dbeba6dfa961f5386d299d50109827da94fecbeba6cf9d1bef"
|
||||
},
|
||||
{
|
||||
"path": "hooks/TYPESCRIPT.md",
|
||||
"sha256": "3c2bc29054da35955d233e5a480ab80baeea4a5d8127ecf4747f4fe1f06b8f08"
|
||||
},
|
||||
{
|
||||
"path": "hooks/examples/permissive.json",
|
||||
"sha256": "e5027216d541f1c856fe74f59d4e584668a86980782c355f24971f2b353e04b2"
|
||||
},
|
||||
{
|
||||
"path": "hooks/examples/strict.json",
|
||||
"sha256": "6778e354d7852d883d54f10fc7f0c18a583f070cec8443517db0103c8199e3bf"
|
||||
},
|
||||
{
|
||||
"path": "hooks/examples/convention-based.json",
|
||||
"sha256": "7f4ee1fb67909d01460e73e82f7729cdab17fd25b8c4887d620e7a4b2f079094"
|
||||
},
|
||||
{
|
||||
"path": "hooks/examples/pipeline.json",
|
||||
"sha256": "7cd5807401adf810c07043f605cb70d61c68e65a4ab12f921dafb43b2a6aad5e"
|
||||
},
|
||||
{
|
||||
"path": "hooks/examples/context/plan-start.md",
|
||||
"sha256": "a13f38c107003fb33349ac40d0ec43807b8fc9c7f589cd3871f7f27fc9744872"
|
||||
},
|
||||
{
|
||||
"path": "hooks/examples/context/session-start.md",
|
||||
"sha256": "517256b048d89ff60c4f22c36728abfd4285f0021fb6b7d2f3a4d37fd75dba51"
|
||||
},
|
||||
{
|
||||
"path": "hooks/examples/context/test-driven-development-start.md",
|
||||
"sha256": "76edff5188c5719f158127ac22468626099338c22dbf8d2b8e6231400297194c"
|
||||
},
|
||||
{
|
||||
"path": "hooks/examples/context/code-review-start.md",
|
||||
"sha256": "2bd0cce4c7c4b9af29701fb4f8dea3e7afbb94f4319c843225f40509b93c9c71"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/jest.config.js",
|
||||
"sha256": "7788a77d96f31a8d9a1648b2349c7399455e3d279ab2cca5cfc0131793e109f5"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/tsconfig.eslint.json",
|
||||
"sha256": "6331f56f9a370236296fdae421a64ea1744d6c95428efbf1bdcbd6f438c5a5a8"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/package.json",
|
||||
"sha256": "dcdd10b3986dff8998fd23dfe5153dc6053ee26815adc253f4162a694f74a33c"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/.prettierrc",
|
||||
"sha256": "a2ec035f969e1742e6a241775ad5b63e68cf4608335f1b32bf37eb140b6eb0a8"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/.eslintrc.js",
|
||||
"sha256": "049ca579e9028c7d420a149e0677df8dd9bbf048f4eb6e475b9612ea34904644"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/tsconfig.json",
|
||||
"sha256": "5a0dd5aef5955de4523df8c7e95524ca85128f672840aae48c40f150d70f8ddb"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/__tests__/plugin-gates.integration.test.ts",
|
||||
"sha256": "27dfd1c40c18a7a2281d3a4ee4be018a1a3fb84053ff07d3e5374a4c39176866"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/__tests__/dispatcher.test.ts",
|
||||
"sha256": "0929a4439c9a5c1bf66e4df57c88d967b1d5443ab49823bc8e2b3805853e80c5"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/__tests__/builtin-gates.test.ts",
|
||||
"sha256": "8d9cf53f2e08bab86332ece9e4dee9133d7836bd6d8e1465bd845d6b72815109"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/__tests__/types.test.ts",
|
||||
"sha256": "994b2d646bd698c8016fc17305f82f9169dc143994b892e71e9821c9187792f0"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/__tests__/gate-loader.test.ts",
|
||||
"sha256": "bf3f2b4702a6b3657bb093636bd4b4c532298c6c7a9deda7bf4122cdbc70f5bf"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/__tests__/action-handler.test.ts",
|
||||
"sha256": "fb9990a77cba016f34ad5d494c5126afa22bfbca513b1abdbb69bc2b74027ecb"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/__tests__/context.test.ts",
|
||||
"sha256": "ddf4e57b3e49330882e686391bada819805db3b923548c8ddea2c6bdbae73537"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/__tests__/cli.integration.test.ts",
|
||||
"sha256": "72b04d197dc2261f07f2e47ac4921e8cd437a84f14782b096aca1e9a1975e936"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/__tests__/session.test.ts",
|
||||
"sha256": "0bfd34ce5ca28cf9a24b09e900b7c7d96b492dd1da5e5c0e2a6e8602cea33ef1"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/__tests__/config.test.ts",
|
||||
"sha256": "b53824b2f96aa3e604433cea40399773b6d4c5c7ac63c13e97f2a61b13492fae"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/__tests__/integration.test.ts",
|
||||
"sha256": "8a40d74e87195b1d31052eb14cfe01d045363feea3269ed092bef02fc155d810"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/action-handler.ts",
|
||||
"sha256": "7d949c57c1cd016c6bc8b484c6f115925485a8acf9821b7545e3fc202110e8a1"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/cli.ts",
|
||||
"sha256": "dd63c9777e895d5cae986fe10dffbb3e35c5834a12f32ac37fc1802198225b40"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/context.ts",
|
||||
"sha256": "49cae73ee3e05ef90046ebe719495bf462ff4ba2447c6d16a29b7eab8f47f771"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/utils.ts",
|
||||
"sha256": "bd9c6d2e11d5f2cf79acc72d28b17400e7b3660f5e6d59ce61f0866d18eec1f7"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/types.ts",
|
||||
"sha256": "2c41ac25ab946019625ad3106c45e34275f9aa23af04c9992a206fec1a2b1402"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/logger.ts",
|
||||
"sha256": "1e3567d9671e1bba8445b9453145634eab7574169c95a58522f1f6888add1758"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/session.ts",
|
||||
"sha256": "97be7103eb58e6bc45573c2ead0a0ce114e5e55273c57027eefdad378f7446df"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/index.ts",
|
||||
"sha256": "6a2f85a9a486ef1288f4ca6fbb99c4d02ce0bc299e9a05ac29738c2bd5f64c0e"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/config.ts",
|
||||
"sha256": "a851bfd22d9832c13a92773f9b45d93fb08a3e78692ad72139433b5cb0b8def9"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/gate-loader.ts",
|
||||
"sha256": "c36c0bcd533757db258b1f40330e474efeba6e34fc4099afea4129cd58194549"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/dispatcher.ts",
|
||||
"sha256": "2a69f44739162a864d5b72d2c6c0a73a24a4bdfbaf230f65adf628a3b7b41cde"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/gates/plugin-path.ts",
|
||||
"sha256": "2de489fea3d2bdaf1fd0fe88ca6eb1a3bdc171bda9ffaa38d806ac660f3dcf04"
|
||||
},
|
||||
{
|
||||
"path": "hooks/hooks-app/src/gates/index.ts",
|
||||
"sha256": "a8aad1ec888afbc7b040ee2fab2402a0776d793030af147aa2f7c86a81f77b1d"
|
||||
},
|
||||
{
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"sha256": "c68b234bbc4779553f31ce1a20e4d0688dab9978b26f59a2ced253d6d33b983b"
|
||||
},
|
||||
{
|
||||
"path": "commands/test.md",
|
||||
"sha256": "ec9d08efcff4977d3f4da6147b58d6356d49f680cd5c018baf2216eac8c3b701"
|
||||
}
|
||||
],
|
||||
"dirSha256": "965292236ebbb4de42048121ad03582e2810e70fc8354c5fcc5c207e6c45da2a"
|
||||
},
|
||||
"security": {
|
||||
"scannedAt": null,
|
||||
"scannerVersion": null,
|
||||
"flags": []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user