From 6ae6ce0730e6b964bcd49535590efea5f45b0940 Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 09:02:16 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 15 + README.md | 3 + commands/test.md | 112 +++++ hooks/ARCHITECTURE.md | 342 +++++++++++++ hooks/CONVENTIONS.md | 354 +++++++++++++ hooks/INTEGRATION_TESTS.md | 246 +++++++++ hooks/README.md | 328 ++++++++++++ hooks/SETUP.md | 470 ++++++++++++++++++ hooks/TYPESCRIPT.md | 320 ++++++++++++ hooks/examples/context/code-review-start.md | 36 ++ hooks/examples/context/plan-start.md | 32 ++ hooks/examples/context/session-start.md | 41 ++ .../context/test-driven-development-start.md | 40 ++ hooks/examples/convention-based.json | 26 + hooks/examples/permissive.json | 31 ++ hooks/examples/pipeline.json | 38 ++ hooks/examples/strict.json | 38 ++ hooks/gates.json | 43 ++ hooks/hooks-app/.eslintrc.js | 17 + hooks/hooks-app/.prettierrc | 7 + .../__tests__/action-handler.test.ts | 57 +++ .../hooks-app/__tests__/builtin-gates.test.ts | 33 ++ .../__tests__/cli.integration.test.ts | 226 +++++++++ hooks/hooks-app/__tests__/config.test.ts | 250 ++++++++++ hooks/hooks-app/__tests__/context.test.ts | 69 +++ hooks/hooks-app/__tests__/dispatcher.test.ts | 263 ++++++++++ hooks/hooks-app/__tests__/gate-loader.test.ts | 178 +++++++ hooks/hooks-app/__tests__/integration.test.ts | 164 ++++++ .../plugin-gates.integration.test.ts | 239 +++++++++ hooks/hooks-app/__tests__/session.test.ts | 198 ++++++++ hooks/hooks-app/__tests__/types.test.ts | 63 +++ hooks/hooks-app/jest.config.js | 8 + hooks/hooks-app/package.json | 31 ++ hooks/hooks-app/src/action-handler.ts | 45 ++ hooks/hooks-app/src/cli.ts | 268 ++++++++++ hooks/hooks-app/src/config.ts | 219 ++++++++ hooks/hooks-app/src/context.ts | 280 +++++++++++ hooks/hooks-app/src/dispatcher.ts | 260 ++++++++++ hooks/hooks-app/src/gate-loader.ts | 210 ++++++++ hooks/hooks-app/src/gates/index.ts | 8 + hooks/hooks-app/src/gates/plugin-path.ts | 54 ++ hooks/hooks-app/src/index.ts | 25 + hooks/hooks-app/src/logger.ts | 180 +++++++ hooks/hooks-app/src/session.ts | 131 +++++ hooks/hooks-app/src/types.ts | 102 ++++ hooks/hooks-app/src/utils.ts | 15 + hooks/hooks-app/tsconfig.eslint.json | 5 + hooks/hooks-app/tsconfig.json | 17 + plugin.lock.json | 225 +++++++++ 49 files changed, 6362 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 commands/test.md create mode 100644 hooks/ARCHITECTURE.md create mode 100644 hooks/CONVENTIONS.md create mode 100644 hooks/INTEGRATION_TESTS.md create mode 100644 hooks/README.md create mode 100644 hooks/SETUP.md create mode 100644 hooks/TYPESCRIPT.md create mode 100644 hooks/examples/context/code-review-start.md create mode 100644 hooks/examples/context/plan-start.md create mode 100644 hooks/examples/context/session-start.md create mode 100644 hooks/examples/context/test-driven-development-start.md create mode 100644 hooks/examples/convention-based.json create mode 100644 hooks/examples/permissive.json create mode 100644 hooks/examples/pipeline.json create mode 100644 hooks/examples/strict.json create mode 100644 hooks/gates.json create mode 100644 hooks/hooks-app/.eslintrc.js create mode 100644 hooks/hooks-app/.prettierrc create mode 100644 hooks/hooks-app/__tests__/action-handler.test.ts create mode 100644 hooks/hooks-app/__tests__/builtin-gates.test.ts create mode 100644 hooks/hooks-app/__tests__/cli.integration.test.ts create mode 100644 hooks/hooks-app/__tests__/config.test.ts create mode 100644 hooks/hooks-app/__tests__/context.test.ts create mode 100644 hooks/hooks-app/__tests__/dispatcher.test.ts create mode 100644 hooks/hooks-app/__tests__/gate-loader.test.ts create mode 100644 hooks/hooks-app/__tests__/integration.test.ts create mode 100644 hooks/hooks-app/__tests__/plugin-gates.integration.test.ts create mode 100644 hooks/hooks-app/__tests__/session.test.ts create mode 100644 hooks/hooks-app/__tests__/types.test.ts create mode 100644 hooks/hooks-app/jest.config.js create mode 100644 hooks/hooks-app/package.json create mode 100644 hooks/hooks-app/src/action-handler.ts create mode 100644 hooks/hooks-app/src/cli.ts create mode 100644 hooks/hooks-app/src/config.ts create mode 100644 hooks/hooks-app/src/context.ts create mode 100644 hooks/hooks-app/src/dispatcher.ts create mode 100644 hooks/hooks-app/src/gate-loader.ts create mode 100644 hooks/hooks-app/src/gates/index.ts create mode 100644 hooks/hooks-app/src/gates/plugin-path.ts create mode 100644 hooks/hooks-app/src/index.ts create mode 100644 hooks/hooks-app/src/logger.ts create mode 100644 hooks/hooks-app/src/session.ts create mode 100644 hooks/hooks-app/src/types.ts create mode 100644 hooks/hooks-app/src/utils.ts create mode 100644 hooks/hooks-app/tsconfig.eslint.json create mode 100644 hooks/hooks-app/tsconfig.json create mode 100644 plugin.lock.json diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..21a8b31 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4359b3e --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# turboshovel + +Generic hook framework for quality enforcement and context injection diff --git a/commands/test.md b/commands/test.md new file mode 100644 index 0000000..2b4e951 --- /dev/null +++ b/commands/test.md @@ -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 diff --git a/hooks/ARCHITECTURE.md b/hooks/ARCHITECTURE.md new file mode 100644 index 0000000..df3b468 --- /dev/null +++ b/hooks/ARCHITECTURE.md @@ -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 { + // 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; // 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 diff --git a/hooks/CONVENTIONS.md b/hooks/CONVENTIONS.md new file mode 100644 index 0000000..7b86941 --- /dev/null +++ b/hooks/CONVENTIONS.md @@ -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. diff --git a/hooks/INTEGRATION_TESTS.md b/hooks/INTEGRATION_TESTS.md new file mode 100644 index 0000000..65c98d2 --- /dev/null +++ b/hooks/INTEGRATION_TESTS.md @@ -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 ` +- Test command manually: `` +- 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 diff --git a/hooks/README.md b/hooks/README.md new file mode 100644 index 0000000..10b0e4e --- /dev/null +++ b/hooks/README.md @@ -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 diff --git a/hooks/SETUP.md b/hooks/SETUP.md new file mode 100644 index 0000000..ac5c954 --- /dev/null +++ b/hooks/SETUP.md @@ -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. diff --git a/hooks/TYPESCRIPT.md b/hooks/TYPESCRIPT.md new file mode 100644 index 0000000..075713f --- /dev/null +++ b/hooks/TYPESCRIPT.md @@ -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 { + // 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 { + 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 { + 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; +} + +async function parseClaudeMd(cwd: string): Promise> { + 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 { + const commands = await parseClaudeMd(input.cwd); + const needed = detectNeededCommands(input.user_message || ''); + + if (needed.length === 0) return {}; + + const lines = ['']; + for (const cmd of needed) { + if (commands[cmd]) { + lines.push(` <${cmd}>${commands[cmd]}`); + } + } + lines.push(''); + + 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 diff --git a/hooks/examples/context/code-review-start.md b/hooks/examples/context/code-review-start.md new file mode 100644 index 0000000..1905d2f --- /dev/null +++ b/hooks/examples/context/code-review-start.md @@ -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. diff --git a/hooks/examples/context/plan-start.md b/hooks/examples/context/plan-start.md new file mode 100644 index 0000000..46ae7b6 --- /dev/null +++ b/hooks/examples/context/plan-start.md @@ -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? diff --git a/hooks/examples/context/session-start.md b/hooks/examples/context/session-start.md new file mode 100644 index 0000000..7548620 --- /dev/null +++ b/hooks/examples/context/session-start.md @@ -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. diff --git a/hooks/examples/context/test-driven-development-start.md b/hooks/examples/context/test-driven-development-start.md new file mode 100644 index 0000000..29fb71e --- /dev/null +++ b/hooks/examples/context/test-driven-development-start.md @@ -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 diff --git a/hooks/examples/convention-based.json b/hooks/examples/convention-based.json new file mode 100644 index 0000000..604cd5e --- /dev/null +++ b/hooks/examples/convention-based.json @@ -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"] + } + } +} diff --git a/hooks/examples/permissive.json b/hooks/examples/permissive.json new file mode 100644 index 0000000..26e92e4 --- /dev/null +++ b/hooks/examples/permissive.json @@ -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"] + } + } +} diff --git a/hooks/examples/pipeline.json b/hooks/examples/pipeline.json new file mode 100644 index 0000000..e11783c --- /dev/null +++ b/hooks/examples/pipeline.json @@ -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"] + } + } +} diff --git a/hooks/examples/strict.json b/hooks/examples/strict.json new file mode 100644 index 0000000..440f162 --- /dev/null +++ b/hooks/examples/strict.json @@ -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"] + } + } +} diff --git a/hooks/gates.json b/hooks/gates.json new file mode 100644 index 0000000..046102e --- /dev/null +++ b/hooks/gates.json @@ -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"] + } + } +} diff --git a/hooks/hooks-app/.eslintrc.js b/hooks/hooks-app/.eslintrc.js new file mode 100644 index 0000000..06992e8 --- /dev/null +++ b/hooks/hooks-app/.eslintrc.js @@ -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: '^_' }] + } +}; diff --git a/hooks/hooks-app/.prettierrc b/hooks/hooks-app/.prettierrc new file mode 100644 index 0000000..8867f14 --- /dev/null +++ b/hooks/hooks-app/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "trailingComma": "none", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2 +} diff --git a/hooks/hooks-app/__tests__/action-handler.test.ts b/hooks/hooks-app/__tests__/action-handler.test.ts new file mode 100644 index 0000000..cdcf803 --- /dev/null +++ b/hooks/hooks-app/__tests__/action-handler.test.ts @@ -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'); + }); +}); diff --git a/hooks/hooks-app/__tests__/builtin-gates.test.ts b/hooks/hooks-app/__tests__/builtin-gates.test.ts new file mode 100644 index 0000000..869a894 --- /dev/null +++ b/hooks/hooks-app/__tests__/builtin-gates.test.ts @@ -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(); + }); + }); +}); diff --git a/hooks/hooks-app/__tests__/cli.integration.test.ts b/hooks/hooks-app/__tests__/cli.integration.test.ts new file mode 100644 index 0000000..e308ef9 --- /dev/null +++ b/hooks/hooks-app/__tests__/cli.integration.test.ts @@ -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(); + }); + }); +}); diff --git a/hooks/hooks-app/__tests__/config.test.ts b/hooks/hooks-app/__tests__/config.test.ts new file mode 100644 index 0000000..9a3d263 --- /dev/null +++ b/hooks/hooks-app/__tests__/config.test.ts @@ -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'); + }); +}); diff --git a/hooks/hooks-app/__tests__/context.test.ts b/hooks/hooks-app/__tests__/context.test.ts new file mode 100644 index 0000000..3feee2b --- /dev/null +++ b/hooks/hooks-app/__tests__/context.test.ts @@ -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')); + }); +}); diff --git a/hooks/hooks-app/__tests__/dispatcher.test.ts b/hooks/hooks-app/__tests__/dispatcher.test.ts new file mode 100644 index 0000000..62ff45b --- /dev/null +++ b/hooks/hooks-app/__tests__/dispatcher.test.ts @@ -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); + }); +}); diff --git a/hooks/hooks-app/__tests__/gate-loader.test.ts b/hooks/hooks-app/__tests__/gate-loader.test.ts new file mode 100644 index 0000000..24c38bd --- /dev/null +++ b/hooks/hooks-app/__tests__/gate-loader.test.ts @@ -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); + }); +}); diff --git a/hooks/hooks-app/__tests__/integration.test.ts b/hooks/hooks-app/__tests__/integration.test.ts new file mode 100644 index 0000000..35e415a --- /dev/null +++ b/hooks/hooks-app/__tests__/integration.test.ts @@ -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'); + } + }); + }); +}); diff --git a/hooks/hooks-app/__tests__/plugin-gates.integration.test.ts b/hooks/hooks-app/__tests__/plugin-gates.integration.test.ts new file mode 100644 index 0000000..d346cd8 --- /dev/null +++ b/hooks/hooks-app/__tests__/plugin-gates.integration.test.ts @@ -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'); + }); +}); diff --git a/hooks/hooks-app/__tests__/session.test.ts b/hooks/hooks-app/__tests__/session.test.ts new file mode 100644 index 0000000..da42e0d --- /dev/null +++ b/hooks/hooks-app/__tests__/session.test.ts @@ -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); + }); + }); +}); diff --git a/hooks/hooks-app/__tests__/types.test.ts b/hooks/hooks-app/__tests__/types.test.ts new file mode 100644 index 0000000..5d8d7be --- /dev/null +++ b/hooks/hooks-app/__tests__/types.test.ts @@ -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'); + }); +}); diff --git a/hooks/hooks-app/jest.config.js b/hooks/hooks-app/jest.config.js new file mode 100644 index 0000000..b7f9386 --- /dev/null +++ b/hooks/hooks-app/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/__tests__'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts'], + moduleFileExtensions: ['ts', 'js', 'json'] +}; diff --git a/hooks/hooks-app/package.json b/hooks/hooks-app/package.json new file mode 100644 index 0000000..80a76b5 --- /dev/null +++ b/hooks/hooks-app/package.json @@ -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" + } +} diff --git a/hooks/hooks-app/src/action-handler.ts b/hooks/hooks-app/src/action-handler.ts new file mode 100644 index 0000000..0fd5ded --- /dev/null +++ b/hooks/hooks-app/src/action-handler.ts @@ -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 { + 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 + }; + } +} diff --git a/hooks/hooks-app/src/cli.ts b/hooks/hooks-app/src/cli.ts new file mode 100644 index 0000000..2d2917d --- /dev/null +++ b/hooks/hooks-app/src/cli.ts @@ -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 { + 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 { + 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 [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 [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 [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 [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 { + 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(); diff --git a/hooks/hooks-app/src/config.ts b/hooks/hooks-app/src/config.ts new file mode 100644 index 0000000..2e7f016 --- /dev/null +++ b/hooks/hooks-app/src/config.ts @@ -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 { + 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 { + 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; +} diff --git a/hooks/hooks-app/src/context.ts b/hooks/hooks-app/src/context.ts new file mode 100644 index 0000000..562c5ca --- /dev/null +++ b/hooks/hooks-app/src/context.ts @@ -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 { + // 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 { + // 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 { + 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; +} diff --git a/hooks/hooks-app/src/dispatcher.ts b/hooks/hooks-app/src/dispatcher.ts new file mode 100644 index 0000000..8277b55 --- /dev/null +++ b/hooks/hooks-app/src/dispatcher.ts @@ -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 { + 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 { + 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 + }; +} diff --git a/hooks/hooks-app/src/gate-loader.ts b/hooks/hooks-app/src/gate-loader.ts new file mode 100644 index 0000000..ff55f64 --- /dev/null +++ b/hooks/hooks-app/src/gate-loader.ts @@ -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 { + 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 { + 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 { + 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 }; +} diff --git a/hooks/hooks-app/src/gates/index.ts b/hooks/hooks-app/src/gates/index.ts new file mode 100644 index 0000000..0e43fb9 --- /dev/null +++ b/hooks/hooks-app/src/gates/index.ts @@ -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'; diff --git a/hooks/hooks-app/src/gates/plugin-path.ts b/hooks/hooks-app/src/gates/plugin-path.ts new file mode 100644 index 0000000..c5ef072 --- /dev/null +++ b/hooks/hooks-app/src/gates/plugin-path.ts @@ -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 { + // 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; +} diff --git a/hooks/hooks-app/src/index.ts b/hooks/hooks-app/src/index.ts new file mode 100644 index 0000000..9009c6a --- /dev/null +++ b/hooks/hooks-app/src/index.ts @@ -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'; diff --git a/hooks/hooks-app/src/logger.ts b/hooks/hooks-app/src/logger.ts new file mode 100644 index 0000000..abdcc8a --- /dev/null +++ b/hooks/hooks-app/src/logger.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 +): 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) => + writeLog(createEntry('debug', message, data)), + + info: (message: string, data?: Record) => + writeLog(createEntry('info', message, data)), + + warn: (message: string, data?: Record) => + writeLog(createEntry('warn', message, data)), + + error: (message: string, data?: Record) => + 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) => + 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 + ) => + 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, +}; diff --git a/hooks/hooks-app/src/session.ts b/hooks/hooks-app/src/session.ts new file mode 100644 index 0000000..ee6349d --- /dev/null +++ b/hooks/hooks-app/src/session.ts @@ -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(key: K): Promise { + const state = await this.load(); + return state[key]; + } + + /** + * Set a session state value + */ + async set(key: K, value: SessionState[K]): Promise { + 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 { + 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 { + const state = await this.load(); + return state[key].includes(value); + } + + /** + * Clear session state (remove file) + */ + async clear(): Promise { + 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 { + 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 { + 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: {} + }; + } +} diff --git a/hooks/hooks-app/src/types.ts b/hooks/hooks-app/src/types.ts new file mode 100644 index 0000000..aa40205 --- /dev/null +++ b/hooks/hooks-app/src/types.ts @@ -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; + +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; + gates: Record; +} + +// 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; +} + +// 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; diff --git a/hooks/hooks-app/src/utils.ts b/hooks/hooks-app/src/utils.ts new file mode 100644 index 0000000..158aaac --- /dev/null +++ b/hooks/hooks-app/src/utils.ts @@ -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 { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} diff --git a/hooks/hooks-app/tsconfig.eslint.json b/hooks/hooks-app/tsconfig.eslint.json new file mode 100644 index 0000000..fe7f820 --- /dev/null +++ b/hooks/hooks-app/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "__tests__/**/*"], + "exclude": ["node_modules", "dist", "__tests__/**/*.d.ts"] +} diff --git a/hooks/hooks-app/tsconfig.json b/hooks/hooks-app/tsconfig.json new file mode 100644 index 0000000..4a95e0d --- /dev/null +++ b/hooks/hooks-app/tsconfig.json @@ -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__"] +} diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..b71667f --- /dev/null +++ b/plugin.lock.json @@ -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": [] + } +} \ No newline at end of file