Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:02:16 +08:00
commit 6ae6ce0730
49 changed files with 6362 additions and 0 deletions

View 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
View File

@@ -0,0 +1,3 @@
# turboshovel
Generic hook framework for quality enforcement and context injection

112
commands/test.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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.

View 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?

View 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.

View 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

View 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"]
}
}
}

View 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"]
}
}
}

View 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"]
}
}
}

View 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
View 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"]
}
}
}

View 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: '^_' }]
}
};

View File

@@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "none",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}

View 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');
});
});

View 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();
});
});
});

View 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();
});
});
});

View 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');
});
});

View 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'));
});
});

View 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);
});
});

View 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);
});
});

View 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');
}
});
});
});

View 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');
});
});

View 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);
});
});
});

View 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');
});
});

View 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']
};

View 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"
}
}

View 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
View 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();

View 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;
}

View 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;
}

View 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
};
}

View 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 };
}

View 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';

View 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;
}

View 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';

View 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,
};

View 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: {}
};
}
}

View 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'>;

View 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;
}
}

View File

@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*", "__tests__/**/*"],
"exclude": ["node_modules", "dist", "__tests__/**/*.d.ts"]
}

View 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
View 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": []
}
}