diff --git a/.speckit/features.md b/.speckit/features.md index 6dcaadb..a495abe 100644 --- a/.speckit/features.md +++ b/.speckit/features.md @@ -200,6 +200,13 @@ git push origin v1.0.0 - `logging/`: JSON Lines logging infrastructure - `cli/`: Command-line interface (init, install, debug, validate, logs, explain) +### Claude Code JSON Protocol +CCH communicates with Claude Code via stdin/stdout JSON. Key field mappings: +- **Event field**: Claude Code sends `hook_event_name` (CCH accepts both `hook_event_name` and `event_type` via serde alias) +- **Response field**: CCH serializes `continue` (not `continue_`) via `#[serde(rename = "continue")]` +- **Timestamp**: Claude Code may not send `timestamp`; CCH defaults to `Utc::now()` +- **Event types**: PreToolUse, PostToolUse, Stop, SessionStart, SessionEnd, PostToolUseFailure, SubagentStart, SubagentStop, Notification, Setup, PermissionRequest, UserPromptSubmit, PreCompact + ### Key Patterns - Async-first design for performance - Configuration-driven behavior (no hardcoded rules) @@ -232,4 +239,25 @@ git push origin v1.0.0 - YAML configuration file loading - External script execution (Python validators) - JSON Lines log file management -- Directory-based context file injection \ No newline at end of file +- Directory-based context file injection + +--- + +## cch-advanced-rules (Backlog) +**Status**: Backlog +**Priority**: P3 (Future Enhancement) +**Description**: Advanced rule features removed from skill docs during schema fix — never implemented in CCH binary +**Location**: cch_cli/ (future Rust implementation) +**Completion**: 0% - Spec only + +### SDD Artifacts +- **Spec:** `.speckit/features/cch-advanced-rules/spec.md` + +### User Stories (Backlog) +- [ ] US-ADV-01 (P2): `enabled_when` conditional matcher +- [ ] US-ADV-02 (P3): `prompt_match` regex matcher +- [ ] US-ADV-03 (P3): `require_fields` action type +- [ ] US-ADV-04 (P2): Inline content injection (`inject_inline`) +- [ ] US-ADV-05 (P2): Command-based context generation (`inject_command`) +- [ ] US-ADV-06 (P3): Inline script blocks in `run:` +- [ ] US-ADV-07 (P3): Context variables for expressions \ No newline at end of file diff --git a/.speckit/features/cch-advanced-rules/spec.md b/.speckit/features/cch-advanced-rules/spec.md new file mode 100644 index 0000000..e99d1bc --- /dev/null +++ b/.speckit/features/cch-advanced-rules/spec.md @@ -0,0 +1,146 @@ +# Feature Specification: CCH Advanced Rules + +**Feature Branch**: `feature/cch-advanced-rules` +**Created**: 2026-01-27 +**Status**: Backlog +**Input**: Features removed from mastering-hooks skill docs (never implemented in CCH binary) + +## Background + +During the skill documentation fix (January 2026), several features documented in the mastering-hooks skill were found to not exist in the CCH binary. These were fabricated by the AI that generated the original skill docs. This spec captures them as future backlog items. + +## User Scenarios & Testing + +### User Story 1 - enabled_when Conditional Matcher (Priority: P2) + +Users want rules that only activate under certain conditions (e.g., CI environment, specific branches, test files). Currently, all rules are always active when their matchers match. + +**Why this priority**: Enables environment-aware rules without duplicating configs. High value for teams with different dev/CI workflows. + +**Independent Test**: Can be tested by creating a rule with `enabled_when: "env.CI == 'true'"` and verifying it only fires when the CI env var is set. + +**Acceptance Scenarios**: + +1. **Given** a rule with `enabled_when: "env.CI == 'true'"`, **When** the rule is evaluated in a non-CI environment, **Then** the rule does not match +2. **Given** a rule with `enabled_when: "env.CI == 'true'"`, **When** the rule is evaluated with `CI=true`, **Then** the rule matches normally + +--- + +### User Story 2 - prompt_match Matcher (Priority: P3) + +Users want rules that match against user prompt text, enabling prompt-based routing (e.g., deploy requests, slash commands). + +**Why this priority**: Useful but niche. Most rules match on tool usage, not prompt text. + +**Independent Test**: Can be tested by creating a rule with `prompt_match: "(?i)deploy"` and simulating a UserPromptSubmit event. + +**Acceptance Scenarios**: + +1. **Given** a rule with `prompt_match: "(?i)deploy"`, **When** a user types "Deploy to production", **Then** the rule matches +2. **Given** a rule with `prompt_match: "^/fix"`, **When** a user types "Fix the bug", **Then** the rule does not match (no leading slash) + +--- + +### User Story 3 - require_fields Action (Priority: P3) + +Users want to validate that required fields exist in tool input before allowing execution. + +**Why this priority**: Low priority — most validation can be done via `run:` scripts. + +**Independent Test**: Can be tested by creating a rule with `require_fields: [path, content]` on the Write tool. + +**Acceptance Scenarios**: + +1. **Given** a rule requiring fields `[path, content]` on Write, **When** Write is called with both, **Then** the tool proceeds +2. **Given** a rule requiring fields `[path, content]` on Write, **When** Write is called without `content`, **Then** the tool is blocked + +--- + +### User Story 4 - Inline Content Injection (Priority: P2) + +Users want to inject short markdown content directly in the rule without creating a separate file. + +**Why this priority**: Reduces file proliferation for simple warnings or reminders. Currently `inject:` only accepts file paths. + +**Independent Test**: Can be tested by creating a rule with `inject_inline: "Warning: check before proceeding"` and verifying the content appears in context. + +**Acceptance Scenarios**: + +1. **Given** a rule with `inject_inline: "## Warning\nBe careful"`, **When** the rule matches, **Then** the inline content is injected into Claude's context + +--- + +### User Story 5 - Command-Based Context Generation (Priority: P2) + +Users want to generate context dynamically by running a shell command (e.g., `git branch --show-current`). + +**Why this priority**: Enables dynamic context without full validator scripts. Currently requires a `run:` script that outputs JSON. + +**Independent Test**: Can be tested by creating a rule with `inject_command: "git branch --show-current"` and verifying the output appears in context. + +**Acceptance Scenarios**: + +1. **Given** a rule with `inject_command: "echo '## Branch\nMain'"`, **When** the rule matches, **Then** the command output is injected as context + +--- + +### User Story 6 - Inline Script Blocks in run: (Priority: P3) + +Users want to write small validator scripts directly in hooks.yaml instead of creating separate script files. + +**Why this priority**: Convenience for simple checks. Currently `run:` only accepts file paths. + +**Independent Test**: Can be tested by creating a rule with multiline `run:` script and verifying execution. + +**Acceptance Scenarios**: + +1. **Given** a rule with `run: |` multiline script block, **When** the rule matches, **Then** the inline script executes and returns JSON + +--- + +### User Story 7 - Context Variables in Expressions (Priority: P3) + +Users want access to runtime variables (`tool.name`, `env.CI`, `session.id`) in `enabled_when` expressions. + +**Why this priority**: Required by US-ADV-01 (enabled_when). Dependency for conditional matching. + +**Independent Test**: Can be tested by creating rules referencing `tool.name`, `env.CI`, etc. + +**Acceptance Scenarios**: + +1. **Given** an expression `tool.name == 'Bash'`, **When** Bash tool is used, **Then** the expression evaluates to true +2. **Given** an expression `env.USER == 'ci-bot'`, **When** USER env var is "ci-bot", **Then** the expression evaluates to true + +--- + +### Edge Cases + +- What happens when `enabled_when` expression has a syntax error? +- How does `inject_command` handle script timeouts? +- What happens with `require_fields` on tools that have no input fields? + +## Requirements + +### Functional Requirements + +- **FR-001**: System MUST support `enabled_when` conditional expressions on rules +- **FR-002**: System MUST support `prompt_match` regex matching on user prompts +- **FR-003**: System MUST support `require_fields` action type for input validation +- **FR-004**: System MUST support inline content injection (not just file paths) +- **FR-005**: System MUST support command-based context generation +- **FR-006**: System MUST support inline script blocks in `run:` action +- **FR-007**: System MUST provide context variables for expressions + +### Key Entities + +- **Expression**: A conditional expression evaluated at runtime (used by `enabled_when`) +- **ContextVariable**: A runtime variable providing event context (tool.name, env.CI, etc.) + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: All 7 user stories have passing integration tests +- **SC-002**: Backward compatibility maintained — existing configs work without changes +- **SC-003**: Performance stays under 10ms for rule evaluation with new matchers +- **SC-004**: `cch validate` catches expression syntax errors diff --git a/.speckit/features/enhanced-logging/plan.md b/.speckit/features/enhanced-logging/plan.md index ff23489..89e67d8 100644 --- a/.speckit/features/enhanced-logging/plan.md +++ b/.speckit/features/enhanced-logging/plan.md @@ -306,7 +306,7 @@ impl EventDetails { .map(String::from); EventDetails::Grep { pattern, path } } - None if matches!(event.event_type, EventType::SessionStart | EventType::SessionEnd) => { + None if matches!(event.hook_event_name, EventType::SessionStart | EventType::SessionEnd) => { let source = tool_input .and_then(|ti| ti.get("source")) .and_then(|s| s.as_str()) @@ -380,7 +380,7 @@ pub async fn process_event(event: Event, debug_config: &DebugConfig) -> Result, @@ -138,7 +139,7 @@ pub struct MatcherResults { pub struct LogEntry { // === Existing fields (preserved) === pub timestamp: DateTime, - pub event_type: String, + pub hook_event_name: String, // Note: aliased from event_type for backward compat pub session_id: String, pub tool_name: Option, pub rules_matched: Vec, @@ -246,7 +247,7 @@ pub struct LogEntry { ```json { "timestamp": "2026-01-22T14:32:11Z", - "event_type": "PreToolUse", + "hook_event_name": "PreToolUse", "session_id": "abc123", "tool_name": "Bash", "event_details": { @@ -256,7 +257,7 @@ pub struct LogEntry { "rules_matched": ["block-force-push"], "outcome": "block", "response": { - "continue_": false, + "continue": false, "reason": "Blocked by rule 'block-force-push': Force push is not allowed" }, "timing": { @@ -270,7 +271,7 @@ pub struct LogEntry { ```json { "timestamp": "2026-01-22T14:32:11Z", - "event_type": "PreToolUse", + "hook_event_name": "PreToolUse", "session_id": "abc123", "tool_name": "Bash", "event_details": { @@ -280,7 +281,7 @@ pub struct LogEntry { "rules_matched": ["block-force-push"], "outcome": "block", "response": { - "continue_": false, + "continue": false, "reason": "Blocked by rule 'block-force-push'" }, "timing": { diff --git a/.speckit/features/enhanced-logging/tasks.md b/.speckit/features/enhanced-logging/tasks.md index cca45b3..b8e0614 100644 --- a/.speckit/features/enhanced-logging/tasks.md +++ b/.speckit/features/enhanced-logging/tasks.md @@ -20,7 +20,7 @@ - [x] Session variant with `source`, `reason`, `transcript_path`, `cwd` fields - [x] Permission variant with `permission_mode` and boxed `tool_details` - [x] Unknown variant with `tool_name` field -- [x] Add `ResponseSummary` struct with `continue_`, `reason`, `context_length` +- [x] Add `ResponseSummary` struct with `continue` (serde-renamed from `continue_`), `reason`, `context_length` - [x] Add `RuleEvaluation` struct with `rule_name`, `matched`, `matcher_results` - [x] Add `MatcherResults` struct with individual matcher result fields - [x] Add `DebugConfig` struct with `enabled` flag diff --git a/.speckit/features/integration-testing/plan.md b/.speckit/features/integration-testing/plan.md index 8950e15..96733a1 100644 --- a/.speckit/features/integration-testing/plan.md +++ b/.speckit/features/integration-testing/plan.md @@ -195,7 +195,7 @@ EOF fn test_oq_block_force_push() { let temp = setup_test_workspace("block-force-push"); let event = json!({ - "event_type": "PreToolUse", + "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "git push --force origin main"}, "session_id": "test-001" diff --git a/.speckit/features/phase2-governance/tasks.md b/.speckit/features/phase2-governance/tasks.md index 37c3cc4..a8303b3 100644 --- a/.speckit/features/phase2-governance/tasks.md +++ b/.speckit/features/phase2-governance/tasks.md @@ -229,7 +229,7 @@ pub enum Decision { **Output Format:** ``` Rule: -Event: +Event: Mode: (default: enforce) Priority: (default: 0) diff --git a/.speckit/features/rulez-ui/plan.md b/.speckit/features/rulez-ui/plan.md index 24f9c7a..f72e05b 100644 --- a/.speckit/features/rulez-ui/plan.md +++ b/.speckit/features/rulez-ui/plan.md @@ -496,13 +496,13 @@ Enable testing rules by simulating events through CCH binary. ```rust #[tauri::command] pub async fn run_debug( - event_type: String, + hook_event_name: String, tool: Option, command: Option, path: Option, ) -> Result { let mut cmd = Command::new("cch"); - cmd.arg("debug").arg(&event_type); + cmd.arg("debug").arg(&hook_event_name); if let Some(t) = tool { cmd.arg("--tool").arg(t); diff --git a/.speckit/features/rulez-ui/spec.md b/.speckit/features/rulez-ui/spec.md index 48b2ae3..a117048 100644 --- a/.speckit/features/rulez-ui/spec.md +++ b/.speckit/features/rulez-ui/spec.md @@ -154,7 +154,7 @@ rulez_ui/ | `list_config_files` | List global and project configs | `project_dir?: string` | | `read_config` | Read config file content | `path: string` | | `write_config` | Write config file content | `path: string, content: string` | -| `run_debug` | Execute CCH debug command | `event_type, tool?, command?, path?` | +| `run_debug` | Execute CCH debug command | `hook_event_name, tool?, command?, path?` | | `validate_config` | Validate config via CCH | `path: string` | --- diff --git a/Cargo.toml b/Cargo.toml index 5a89947..3c3a2ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ members = ["cch_cli"] resolver = "2" [workspace.package] -version = "1.0.0" -authors = ["Spillwave Solutions "] +version = "1.0.2" +authors = ["Rick Hightower "] license = "MIT OR Apache-2.0" edition = "2024" diff --git a/cch_cli/src/cli/debug.rs b/cch_cli/src/cli/debug.rs index 02ae509..3f0b104 100644 --- a/cch_cli/src/cli/debug.rs +++ b/cch_cli/src/cli/debug.rs @@ -146,12 +146,16 @@ fn build_event( }; Event { - event_type: event_type.as_model_event_type(), + hook_event_name: event_type.as_model_event_type(), session_id, tool_name: Some(tool_name), tool_input: Some(tool_input), timestamp: Utc::now(), user_id: None, + transcript_path: None, + cwd: None, + permission_mode: None, + tool_use_id: None, } } diff --git a/cch_cli/src/cli/install.rs b/cch_cli/src/cli/install.rs index 544388a..08fd7e1 100644 --- a/cch_cli/src/cli/install.rs +++ b/cch_cli/src/cli/install.rs @@ -17,22 +17,46 @@ struct ClaudeSettings { other: HashMap, } -/// Hooks configuration in Claude Code settings +/// Hooks configuration in Claude Code settings. +/// +/// Claude Code expects PascalCase event keys with a nested matcher/hooks structure: +/// ```json +/// { +/// "hooks": { +/// "PreToolUse": [ +/// { "matcher": "*", "hooks": [{ "type": "command", "command": "/path/to/cch", "timeout": 5 }] } +/// ] +/// } +/// } +/// ``` #[derive(Debug, Serialize, Deserialize, Clone, Default)] struct HooksConfig { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pre_tool_use: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - post_tool_use: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - session_start: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - permission_request: Vec, + #[serde(rename = "PreToolUse", default, skip_serializing_if = "Vec::is_empty")] + pre_tool_use: Vec, + #[serde(rename = "PostToolUse", default, skip_serializing_if = "Vec::is_empty")] + post_tool_use: Vec, + #[serde(rename = "Stop", default, skip_serializing_if = "Vec::is_empty")] + stop: Vec, + #[serde( + rename = "SessionStart", + default, + skip_serializing_if = "Vec::is_empty" + )] + session_start: Vec, } -/// Individual hook entry +/// A matcher entry groups a glob pattern with its hook commands #[derive(Debug, Serialize, Deserialize, Clone)] -struct HookEntry { +struct MatcherEntry { + matcher: String, + hooks: Vec, +} + +/// Individual hook command within a matcher entry +#[derive(Debug, Serialize, Deserialize, Clone)] +struct HookCommand { + #[serde(rename = "type")] + hook_type: String, command: String, #[serde(skip_serializing_if = "Option::is_none")] timeout: Option, @@ -75,17 +99,24 @@ pub async fn run(scope: Scope, binary_path: Option) -> Result<()> { // Build hook command let hook_command = format!("{}", cch_path.display()); - // Create hook entry - let hook_entry = HookEntry { - command: hook_command.clone(), - timeout: Some(10000), // 10 second timeout + // Create the matcher entry with nested hook command + let matcher_entry = MatcherEntry { + matcher: "*".to_string(), + hooks: vec![HookCommand { + hook_type: "command".to_string(), + command: hook_command.clone(), + timeout: Some(5), + }], }; // Get or create hooks config let hooks = settings.hooks.get_or_insert_with(HooksConfig::default); - // Check if already installed - let already_installed = hooks.pre_tool_use.iter().any(|h| h.command.contains("cch")); + // Check if already installed (look inside nested hooks[].command) + let already_installed = hooks + .pre_tool_use + .iter() + .any(|m| m.hooks.iter().any(|h| h.command.contains("cch"))); if already_installed { println!("✓ CCH is already installed"); @@ -94,10 +125,10 @@ pub async fn run(scope: Scope, binary_path: Option) -> Result<()> { } // Add CCH to all hook events - hooks.pre_tool_use.push(hook_entry.clone()); - hooks.post_tool_use.push(hook_entry.clone()); - hooks.session_start.push(hook_entry.clone()); - hooks.permission_request.push(hook_entry); + hooks.pre_tool_use.push(matcher_entry.clone()); + hooks.post_tool_use.push(matcher_entry.clone()); + hooks.stop.push(matcher_entry.clone()); + hooks.session_start.push(matcher_entry); // Save settings save_settings(&settings_path, &settings)?; @@ -106,8 +137,8 @@ pub async fn run(scope: Scope, binary_path: Option) -> Result<()> { println!("Hook registered for events:"); println!(" • PreToolUse"); println!(" • PostToolUse"); + println!(" • Stop"); println!(" • SessionStart"); - println!(" • PermissionRequest"); println!(); println!("To verify installation:"); println!(" cch validate"); @@ -220,20 +251,26 @@ pub async fn uninstall(scope: Scope) -> Result<()> { if let Some(hooks) = &mut settings.hooks { let before = hooks.pre_tool_use.len() + hooks.post_tool_use.len() - + hooks.session_start.len() - + hooks.permission_request.len(); + + hooks.stop.len() + + hooks.session_start.len(); - hooks.pre_tool_use.retain(|h| !h.command.contains("cch")); - hooks.post_tool_use.retain(|h| !h.command.contains("cch")); - hooks.session_start.retain(|h| !h.command.contains("cch")); hooks - .permission_request - .retain(|h| !h.command.contains("cch")); + .pre_tool_use + .retain(|m| !m.hooks.iter().any(|h| h.command.contains("cch"))); + hooks + .post_tool_use + .retain(|m| !m.hooks.iter().any(|h| h.command.contains("cch"))); + hooks + .stop + .retain(|m| !m.hooks.iter().any(|h| h.command.contains("cch"))); + hooks + .session_start + .retain(|m| !m.hooks.iter().any(|h| h.command.contains("cch"))); let after = hooks.pre_tool_use.len() + hooks.post_tool_use.len() - + hooks.session_start.len() - + hooks.permission_request.len(); + + hooks.stop.len() + + hooks.session_start.len(); if before == after { println!("CCH was not installed"); @@ -243,8 +280,8 @@ pub async fn uninstall(scope: Scope) -> Result<()> { // Clean up empty hooks config if hooks.pre_tool_use.is_empty() && hooks.post_tool_use.is_empty() + && hooks.stop.is_empty() && hooks.session_start.is_empty() - && hooks.permission_request.is_empty() { settings.hooks = None; } diff --git a/cch_cli/src/hooks.rs b/cch_cli/src/hooks.rs index 7accbf8..c2605df 100644 --- a/cch_cli/src/hooks.rs +++ b/cch_cli/src/hooks.rs @@ -18,8 +18,8 @@ use crate::models::{ pub async fn process_event(event: Event, debug_config: &DebugConfig) -> Result { let start_time = std::time::Instant::now(); - // Load configuration - let config = Config::load(None)?; + // Load configuration using the event's cwd (sent by Claude Code) for project-level config + let config = Config::load(event.cwd.as_ref().map(|p| Path::new(p.as_str())))?; // Evaluate rules (with optional debug tracking) let (matched_rules, response, rule_evaluations) = @@ -41,7 +41,7 @@ pub async fn process_event(event: Event, debug_config: &DebugConfig) -> Result bool { // Check operations (event types) if let Some(ref operations) = matchers.operations { - let event_type_str = event.event_type.to_string(); + let event_type_str = event.hook_event_name.to_string(); if !operations.contains(&event_type_str) { return false; } @@ -322,7 +322,7 @@ fn matches_rule_with_debug(event: &Event, rule: &Rule) -> (bool, Option Result<()> { Ok(()) } -async fn process_hook_event(cli: &Cli, config: &config::Config) -> Result<()> { +async fn process_hook_event(cli: &Cli, _config: &config::Config) -> Result<()> { let mut buffer = String::new(); io::stdin().read_to_string(&mut buffer)?; @@ -241,10 +241,13 @@ async fn process_hook_event(cli: &Cli, config: &config::Config) -> Result<()> { info!( "Processing event: {} ({})", - event.event_type, event.session_id + event.hook_event_name, event.session_id ); - let debug_config = models::DebugConfig::new(cli.debug_logs, config.settings.debug_logs); + // Reload config using the event's cwd so we read the correct project's hooks.yaml + let project_config = + config::Config::load(event.cwd.as_ref().map(|p| std::path::Path::new(p.as_str())))?; + let debug_config = models::DebugConfig::new(cli.debug_logs, project_config.settings.debug_logs); let response = hooks::process_event(event, &debug_config).await?; let json = serde_json::to_string(&response)?; diff --git a/cch_cli/src/models.rs b/cch_cli/src/models.rs index 29beb1a..be119d5 100644 --- a/cch_cli/src/models.rs +++ b/cch_cli/src/models.rs @@ -931,7 +931,7 @@ mod event_details_tests { #[test] fn test_extract_bash_event() { let event = Event { - event_type: EventType::PreToolUse, + hook_event_name: EventType::PreToolUse, tool_name: Some("Bash".to_string()), tool_input: Some(serde_json::json!({ "command": "git push --force" @@ -939,6 +939,10 @@ mod event_details_tests { session_id: "test-session".to_string(), timestamp: Utc::now(), user_id: None, + transcript_path: None, + cwd: None, + permission_mode: None, + tool_use_id: None, }; let details = EventDetails::extract(&event); @@ -948,7 +952,7 @@ mod event_details_tests { #[test] fn test_extract_write_event() { let event = Event { - event_type: EventType::PreToolUse, + hook_event_name: EventType::PreToolUse, tool_name: Some("Write".to_string()), tool_input: Some(serde_json::json!({ "filePath": "/path/to/file.rs" @@ -956,6 +960,10 @@ mod event_details_tests { session_id: "test-session".to_string(), timestamp: Utc::now(), user_id: None, + transcript_path: None, + cwd: None, + permission_mode: None, + tool_use_id: None, }; let details = EventDetails::extract(&event); @@ -967,7 +975,7 @@ mod event_details_tests { #[test] fn test_extract_write_event_file_path() { let event = Event { - event_type: EventType::PreToolUse, + hook_event_name: EventType::PreToolUse, tool_name: Some("Write".to_string()), tool_input: Some(serde_json::json!({ "file_path": "/path/to/file.rs" @@ -975,6 +983,10 @@ mod event_details_tests { session_id: "test-session".to_string(), timestamp: Utc::now(), user_id: None, + transcript_path: None, + cwd: None, + permission_mode: None, + tool_use_id: None, }; let details = EventDetails::extract(&event); @@ -986,7 +998,7 @@ mod event_details_tests { #[test] fn test_extract_edit_event() { let event = Event { - event_type: EventType::PreToolUse, + hook_event_name: EventType::PreToolUse, tool_name: Some("Edit".to_string()), tool_input: Some(serde_json::json!({ "filePath": "/path/to/file.rs" @@ -994,6 +1006,10 @@ mod event_details_tests { session_id: "test-session".to_string(), timestamp: Utc::now(), user_id: None, + transcript_path: None, + cwd: None, + permission_mode: None, + tool_use_id: None, }; let details = EventDetails::extract(&event); @@ -1005,7 +1021,7 @@ mod event_details_tests { #[test] fn test_extract_read_event() { let event = Event { - event_type: EventType::PreToolUse, + hook_event_name: EventType::PreToolUse, tool_name: Some("Read".to_string()), tool_input: Some(serde_json::json!({ "filePath": "/path/to/file.rs" @@ -1013,6 +1029,10 @@ mod event_details_tests { session_id: "test-session".to_string(), timestamp: Utc::now(), user_id: None, + transcript_path: None, + cwd: None, + permission_mode: None, + tool_use_id: None, }; let details = EventDetails::extract(&event); @@ -1024,7 +1044,7 @@ mod event_details_tests { #[test] fn test_extract_glob_event() { let event = Event { - event_type: EventType::PreToolUse, + hook_event_name: EventType::PreToolUse, tool_name: Some("Glob".to_string()), tool_input: Some(serde_json::json!({ "pattern": "*.rs", @@ -1033,6 +1053,10 @@ mod event_details_tests { session_id: "test-session".to_string(), timestamp: Utc::now(), user_id: None, + transcript_path: None, + cwd: None, + permission_mode: None, + tool_use_id: None, }; let details = EventDetails::extract(&event); @@ -1043,7 +1067,7 @@ mod event_details_tests { #[test] fn test_extract_grep_event() { let event = Event { - event_type: EventType::PreToolUse, + hook_event_name: EventType::PreToolUse, tool_name: Some("Grep".to_string()), tool_input: Some(serde_json::json!({ "pattern": "fn main", @@ -1052,6 +1076,10 @@ mod event_details_tests { session_id: "test-session".to_string(), timestamp: Utc::now(), user_id: None, + transcript_path: None, + cwd: None, + permission_mode: None, + tool_use_id: None, }; let details = EventDetails::extract(&event); @@ -1062,7 +1090,7 @@ mod event_details_tests { #[test] fn test_extract_session_start_event() { let event = Event { - event_type: EventType::SessionStart, + hook_event_name: EventType::SessionStart, tool_name: None, tool_input: Some(serde_json::json!({ "source": "vscode", @@ -1073,6 +1101,10 @@ mod event_details_tests { session_id: "test-session".to_string(), timestamp: Utc::now(), user_id: None, + transcript_path: None, + cwd: None, + permission_mode: None, + tool_use_id: None, }; let details = EventDetails::extract(&event); @@ -1088,12 +1120,16 @@ mod event_details_tests { #[test] fn test_extract_unknown_tool() { let event = Event { - event_type: EventType::PreToolUse, + hook_event_name: EventType::PreToolUse, tool_name: Some("FutureTool".to_string()), tool_input: None, session_id: "test-session".to_string(), timestamp: Utc::now(), user_id: None, + transcript_path: None, + cwd: None, + permission_mode: None, + tool_use_id: None, }; let details = EventDetails::extract(&event); @@ -1141,10 +1177,15 @@ fn default_enabled() -> bool { } /// Claude Code hook event data structure +/// +/// Claude Code sends events with `hook_event_name` as the field name. +/// The `alias = "event_type"` preserves backward compatibility with +/// debug commands and tests that use the old field name. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Event { - /// Hook event type - pub event_type: EventType, + /// Hook event type (Claude Code sends as `hook_event_name`) + #[serde(alias = "event_type")] + pub hook_event_name: EventType, /// Name of the tool being used #[serde(skip_serializing_if = "Option::is_none")] @@ -1157,15 +1198,34 @@ pub struct Event { /// Unique session identifier pub session_id: String, - /// ISO 8601 timestamp + /// ISO 8601 timestamp (Claude Code may not send this, so default to now) + #[serde(default = "chrono::Utc::now")] pub timestamp: DateTime, /// User identifier if available #[serde(skip_serializing_if = "Option::is_none")] pub user_id: Option, + + /// Path to session transcript (sent by Claude Code) + #[serde(skip_serializing_if = "Option::is_none")] + pub transcript_path: Option, + + /// Current working directory (sent by Claude Code) + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + + /// Permission mode (sent by Claude Code) + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, + + /// Tool use ID (sent by Claude Code) + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_use_id: Option, } /// Supported hook event types +/// +/// Includes all event types that Claude Code can send to hooks. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "PascalCase")] pub enum EventType { @@ -1176,6 +1236,12 @@ pub enum EventType { SessionStart, SessionEnd, PreCompact, + Stop, + PostToolUseFailure, + SubagentStart, + SubagentStop, + Notification, + Setup, } impl std::fmt::Display for EventType { @@ -1188,14 +1254,24 @@ impl std::fmt::Display for EventType { EventType::SessionStart => write!(f, "SessionStart"), EventType::SessionEnd => write!(f, "SessionEnd"), EventType::PreCompact => write!(f, "PreCompact"), + EventType::Stop => write!(f, "Stop"), + EventType::PostToolUseFailure => write!(f, "PostToolUseFailure"), + EventType::SubagentStart => write!(f, "SubagentStart"), + EventType::SubagentStop => write!(f, "SubagentStop"), + EventType::Notification => write!(f, "Notification"), + EventType::Setup => write!(f, "Setup"), } } } /// Binary output structure for hook responses +/// +/// Sent to Claude Code via stdout. The `continue` field controls whether +/// the operation proceeds or is blocked. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Response { /// Whether the operation should proceed + #[serde(rename = "continue")] pub continue_: bool, /// Additional context to inject @@ -1504,7 +1580,7 @@ impl EventDetails { EventDetails::Grep { pattern, path } } None if matches!( - event.event_type, + event.hook_event_name, EventType::SessionStart | EventType::SessionEnd ) => { diff --git a/cch_cli/tests/common/mod.rs b/cch_cli/tests/common/mod.rs index 9549cd1..7f8fc5d 100644 --- a/cch_cli/tests/common/mod.rs +++ b/cch_cli/tests/common/mod.rs @@ -125,7 +125,7 @@ impl Timer { /// Parse CCH response from command output #[derive(Debug, Deserialize)] pub struct CchResponse { - #[serde(rename = "continue_")] + #[serde(rename = "continue")] pub continue_: bool, pub context: Option, pub reason: Option, diff --git a/cch_cli/tests/fixtures/expected/allowed-response.json b/cch_cli/tests/fixtures/expected/allowed-response.json index 1d3c29c..293391c 100644 --- a/cch_cli/tests/fixtures/expected/allowed-response.json +++ b/cch_cli/tests/fixtures/expected/allowed-response.json @@ -1,3 +1,3 @@ { - "continue_": true + "continue": true } diff --git a/cch_cli/tests/fixtures/expected/blocked-response.json b/cch_cli/tests/fixtures/expected/blocked-response.json index 4c05d6e..bd73f39 100644 --- a/cch_cli/tests/fixtures/expected/blocked-response.json +++ b/cch_cli/tests/fixtures/expected/blocked-response.json @@ -1,4 +1,4 @@ { - "continue_": false, + "continue": false, "reason": "Blocked by rule" } diff --git a/cch_cli/tests/iq_new_commands.rs b/cch_cli/tests/iq_new_commands.rs index 7869a29..139d1bd 100644 --- a/cch_cli/tests/iq_new_commands.rs +++ b/cch_cli/tests/iq_new_commands.rs @@ -138,7 +138,7 @@ fn test_debug_pretooluse_bash() { .success() .stdout(predicate::str::contains("Simulated Event")) .stdout(predicate::str::contains("Response")) - .stdout(predicate::str::contains("continue_")); + .stdout(predicate::str::contains("\"continue\"")); } #[test] @@ -268,12 +268,25 @@ fn test_install_creates_settings_json() { let content = fs::read_to_string(&settings).unwrap(); assert!( - content.contains("pre_tool_use"), - "Should have pre_tool_use hook" + content.contains("PreToolUse"), + "Should have PreToolUse hook" ); assert!( - content.contains("post_tool_use"), - "Should have post_tool_use hook" + content.contains("PostToolUse"), + "Should have PostToolUse hook" + ); + assert!(content.contains("Stop"), "Should have Stop hook"); + assert!( + content.contains("SessionStart"), + "Should have SessionStart hook" + ); + assert!( + content.contains("\"matcher\""), + "Should have matcher field in nested structure" + ); + assert!( + content.contains("\"type\": \"command\""), + "Should have type: command in hook entry" ); } @@ -309,8 +322,8 @@ fn test_uninstall_removes_hooks() { let settings = temp_dir.path().join(".claude/settings.json"); let content = fs::read_to_string(&settings).unwrap(); assert!( - !content.contains("pre_tool_use"), - "Should not have pre_tool_use hook" + !content.contains("PreToolUse"), + "Should not have PreToolUse hook after uninstall" ); } diff --git a/cch_cli/tests/oq_us1_blocking.rs b/cch_cli/tests/oq_us1_blocking.rs index 73bf3c4..8620002 100644 --- a/cch_cli/tests/oq_us1_blocking.rs +++ b/cch_cli/tests/oq_us1_blocking.rs @@ -38,8 +38,8 @@ fn test_us1_force_push_blocked() { // Response should indicate blocking result.stdout( - predicate::str::contains(r#""continue_":false"#) - .or(predicate::str::contains(r#""continue_": false"#)) + predicate::str::contains(r#""continue":false"#) + .or(predicate::str::contains(r#""continue": false"#)) .and( predicate::str::contains("block-force-push") .or(predicate::str::contains("Blocked")), @@ -75,8 +75,8 @@ fn test_us1_safe_push_allowed() { // Response should allow the operation result.stdout( - predicate::str::contains(r#""continue_":true"#) - .or(predicate::str::contains(r#""continue_": true"#)), + predicate::str::contains(r#""continue":true"#) + .or(predicate::str::contains(r#""continue": true"#)), ); evidence.pass("Safe push event correctly allowed", timer.elapsed_ms()); @@ -113,8 +113,8 @@ fn test_us1_hard_reset_blocked() { // Response should indicate blocking result.stdout( - predicate::str::contains(r#""continue_":false"#) - .or(predicate::str::contains(r#""continue_": false"#)), + predicate::str::contains(r#""continue":false"#) + .or(predicate::str::contains(r#""continue": false"#)), ); evidence.pass("Hard reset event correctly blocked", timer.elapsed_ms()); diff --git a/cch_cli/tests/oq_us2_injection.rs b/cch_cli/tests/oq_us2_injection.rs index 4d2cfc5..6c59699 100644 --- a/cch_cli/tests/oq_us2_injection.rs +++ b/cch_cli/tests/oq_us2_injection.rs @@ -55,8 +55,8 @@ fn test_us2_cdk_context_injection() { // Response should allow and include context result.stdout( - predicate::str::contains(r#""continue_":true"#) - .or(predicate::str::contains(r#""continue_": true"#)), + predicate::str::contains(r#""continue":true"#) + .or(predicate::str::contains(r#""continue": true"#)), ); // Note: Context injection depends on the skill file existing @@ -107,8 +107,8 @@ fn test_us2_non_matching_no_injection() { // Response should allow without context injection result.stdout( - predicate::str::contains(r#""continue_":true"#) - .or(predicate::str::contains(r#""continue_": true"#)), + predicate::str::contains(r#""continue":true"#) + .or(predicate::str::contains(r#""continue": true"#)), ); evidence.pass( @@ -167,8 +167,8 @@ fn test_us2_extension_based_injection() { // Response should allow result.stdout( - predicate::str::contains(r#""continue_":true"#) - .or(predicate::str::contains(r#""continue_": true"#)), + predicate::str::contains(r#""continue":true"#) + .or(predicate::str::contains(r#""continue": true"#)), ); evidence.pass( diff --git a/cch_cli/tests/oq_us3_validators.rs b/cch_cli/tests/oq_us3_validators.rs index 59ea9d7..1b386c4 100644 --- a/cch_cli/tests/oq_us3_validators.rs +++ b/cch_cli/tests/oq_us3_validators.rs @@ -63,8 +63,8 @@ fn test_us3_validator_blocks_console_log() { // Response should block result.stdout( - predicate::str::contains(r#""continue_":false"#) - .or(predicate::str::contains(r#""continue_": false"#)), + predicate::str::contains(r#""continue":false"#) + .or(predicate::str::contains(r#""continue": false"#)), ); evidence.pass( @@ -130,8 +130,8 @@ fn test_us3_validator_allows_clean_code() { // Response should allow result.stdout( - predicate::str::contains(r#""continue_":true"#) - .or(predicate::str::contains(r#""continue_": true"#)), + predicate::str::contains(r#""continue":true"#) + .or(predicate::str::contains(r#""continue": true"#)), ); evidence.pass( @@ -216,8 +216,8 @@ print("Done") // With fail_open=true, should allow on timeout result.stdout( - predicate::str::contains(r#""continue_":true"#) - .or(predicate::str::contains(r#""continue_": true"#)), + predicate::str::contains(r#""continue":true"#) + .or(predicate::str::contains(r#""continue": true"#)), ); evidence.pass( diff --git a/cch_cli/tests/oq_us4_permissions.rs b/cch_cli/tests/oq_us4_permissions.rs index e56a362..4ebb65a 100644 --- a/cch_cli/tests/oq_us4_permissions.rs +++ b/cch_cli/tests/oq_us4_permissions.rs @@ -55,8 +55,8 @@ fn test_us4_permission_request_injection() { // Response should allow and potentially include context result.stdout( - predicate::str::contains(r#""continue_":true"#) - .or(predicate::str::contains(r#""continue_": true"#)), + predicate::str::contains(r#""continue":true"#) + .or(predicate::str::contains(r#""continue": true"#)), ); evidence.pass( @@ -114,7 +114,7 @@ fn test_us4_permission_event_type_filter() { let stdout = String::from_utf8_lossy(&output.stdout); // Should allow - the permission rule requires operations: ["PermissionRequest"] - assert!(stdout.contains(r#""continue_":true"#) || stdout.contains(r#""continue_": true"#)); + assert!(stdout.contains(r#""continue":true"#) || stdout.contains(r#""continue": true"#)); evidence.pass( "PreToolUse event does not match PermissionRequest filter", @@ -171,8 +171,8 @@ fn test_us4_file_operation_explanation() { // Response should allow (permission explanations don't block, they inject) result.stdout( - predicate::str::contains(r#""continue_":true"#) - .or(predicate::str::contains(r#""continue_": true"#)), + predicate::str::contains(r#""continue":true"#) + .or(predicate::str::contains(r#""continue": true"#)), ); evidence.pass( diff --git a/mastering-hooks/references/hooks-yaml-schema.md b/mastering-hooks/references/hooks-yaml-schema.md index c6069f3..d7c4287 100644 --- a/mastering-hooks/references/hooks-yaml-schema.md +++ b/mastering-hooks/references/hooks-yaml-schema.md @@ -37,6 +37,12 @@ hooks: |-------|-------------|-------------------| | `PreToolUse` | Before tool executes | tool_name, tool_input, file_path | | `PostToolUse` | After tool completes | tool_name, tool_input, tool_output, file_path | +| `Stop` | Session stop event | session_id | +| `PostToolUseFailure` | After tool fails | tool_name, error | +| `SubagentStart` | Subagent launched | agent_type | +| `SubagentStop` | Subagent completed | agent_type | +| `Notification` | System notification | message | +| `Setup` | Initial setup event | configuration | | `PermissionRequest` | User approval requested | tool_name, permission_type | | `UserPromptSubmit` | User sends message | prompt_text | | `SessionStart` | New session begins | session_id, project_path | diff --git a/mastering-hooks/references/quick-reference.md b/mastering-hooks/references/quick-reference.md index 8df33a2..e363801 100644 --- a/mastering-hooks/references/quick-reference.md +++ b/mastering-hooks/references/quick-reference.md @@ -8,6 +8,12 @@ Fast lookup tables for events, matchers, actions, and file locations. |-------|------------|-------------| | `PreToolUse` | Before any tool executes | Inject context, validate inputs | | `PostToolUse` | After tool completes | Log actions, trigger follow-ups | +| `Stop` | Session stop event | Cleanup, final logging | +| `PostToolUseFailure` | After tool fails | Error logging, fallback actions | +| `SubagentStart` | Subagent launched | Track agent activity | +| `SubagentStop` | Subagent completed | Agent completion logging | +| `Notification` | System notification | System event tracking | +| `Setup` | Initial setup event | Configuration loading | | `PermissionRequest` | User asked to approve | Auto-approve/deny patterns | | `UserPromptSubmit` | User sends message | Inject session context | | `SessionStart` | New session begins | Load project context | diff --git a/mastering-hooks/references/troubleshooting-guide.md b/mastering-hooks/references/troubleshooting-guide.md index 10a312b..37091ca 100644 --- a/mastering-hooks/references/troubleshooting-guide.md +++ b/mastering-hooks/references/troubleshooting-guide.md @@ -37,12 +37,14 @@ cch debug PreToolUse --tool Write --path test.py -v ```bash cat .claude/settings.json ``` - Look for: + Look for the nested matcher/hooks structure: ```json { "hooks": { - "PreToolUse": "cch run-hook PreToolUse", - "PostToolUse": "cch run-hook PostToolUse" + "PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "/path/to/cch", "timeout": 5 }] }], + "PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "/path/to/cch", "timeout": 5 }] }], + "Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "/path/to/cch", "timeout": 5 }] }], + "SessionStart": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "/path/to/cch", "timeout": 5 }] }] } } ``` @@ -341,6 +343,38 @@ enabled_when: "env.CI == 'true'" --- +### Issue: "missing field `event_type`" Parse Error + +**Symptoms**: Every hook call fails with `hook error` and logs show `missing field 'event_type'`. + +**Root cause**: Claude Code sends events with the field name `hook_event_name`, not `event_type`. If your CCH binary expects `event_type`, it can't parse the JSON. + +**Resolution**: +1. Update CCH binary to v1.1.0+ which accepts both `hook_event_name` and `event_type` (via serde alias) +2. Rebuild and reinstall: + ```bash + cargo install --path cch_cli + cch install + ``` + +**Protocol reference**: Claude Code's JSON event format: +```json +{ + "hook_event_name": "PreToolUse", + "session_id": "abc123", + "tool_name": "Bash", + "tool_input": {"command": "git status"}, + "cwd": "/path/to/project", + "transcript_path": "/path/to/transcript", + "permission_mode": "default", + "tool_use_id": "toolu_xxx" +} +``` + +Note: Claude Code does **not** send a `timestamp` field. CCH defaults to `Utc::now()`. + +--- + ## Debugging Workflow ### Step-by-Step Debug Process