diff --git a/docs/validate-pretooluse-hook.md b/docs/validate-pretooluse-hook.md new file mode 100644 index 0000000..da62b34 --- /dev/null +++ b/docs/validate-pretooluse-hook.md @@ -0,0 +1,377 @@ +# PreToolUse Hook 手动验证指南 + +**功能**: 002-askuserquestion-hook +**创建日期**: 2026-01-20 + +本文档描述如何手动验证 Supervisor Hook 对 AskUserQuestion 工具调用的审查功能。 + +## 前置条件 + +1. **已编译的 ccc 二进制文件** + ```bash + cd /path/to/claude-code-supervisor1 + go build -o ccc ./cmd/ccc + ``` + +2. **已配置的 Claude Code 环境** + - Claude Code 已安装并可正常使用 + - `~/.claude/settings.json` 文件存在 + +3. **Supervisor 模式已启用** + ```bash + ./ccc supervisor on + ``` + +## 验证步骤 + +### 步骤 1: 验证 Hook 配置已正确生成 + +**目的**: 确认 PreToolUse hook 配置已添加到 settings.json + +**操作**: +```bash +cat ~/.claude/settings.json | jq '.hooks.PreToolUse' +``` + +**预期输出**: +```json +[ + { + "matcher": "AskUserQuestion", + "hooks": [ + { + "type": "command", + "command": "/path/to/ccc supervisor-hook", + "timeout": 600 + } + ] + } +] +``` + +**验证点**: +- [ ] `PreToolUse` hook 存在于 hooks 配置中 +- [ ] `matcher` 设置为 `"AskUserQuestion"` +- [ ] `command` 包含 `supervisor-hook` +- [ ] `timeout` 设置为 `600` + +### 步骤 2: 验证 Stop Hook 仍然正常工作(向后兼容) + +**目的**: 确认原有 Stop hook 功能未被破坏 + +**操作**: +```bash +# 创建测试输入 +cat > /tmp/stop_hook_input.json <<'EOF' +{ + "session_id": "test-stop-$(date +%s)", + "stop_hook_active": false +} +EOF + +# 执行 hook +export CCC_SUPERVISOR_ID="test-stop-manual" +export CCC_SUPERVISOR_HOOK="1" # 防止实际 SDK 调用 +cat /tmp/stop_hook_input.json | ./ccc supervisor-hook +``` + +**预期输出**: +```json +{ + "reason": "called from supervisor hook" +} +``` + +**验证点**: +- [ ] Stop hook 返回正确的 JSON 格式 +- [ ] 输出包含 `reason` 字段 +- [ ] 没有 `hookSpecificOutput` 字段 + +### 步骤 3: 验证 PreToolUse Hook 输入解析 + +**目的**: 确认 PreToolUse hook 能正确解析输入 + +**操作**: +```bash +# 创建测试输入 +cat > /tmp/pretooluse_input.json <<'EOF' +{ + "session_id": "test-pretooluse-$(date +%s)", + "hook_event_name": "PreToolUse", + "tool_name": "AskUserQuestion", + "tool_input": { + "questions": [ + { + "question": "请选择实现方案", + "header": "方案选择", + "multiSelect": false, + "options": [ + {"label": "方案A", "description": "使用方案A"}, + {"label": "方案B", "description": "使用方案B"} + ] + } + ] + }, + "tool_use_id": "toolu_manual_test_001" +} +EOF + +# 执行 hook(使用 CCC_SUPERVISOR_HOOK=1 跳过 SDK 调用) +export CCC_SUPERVISOR_HOOK="1" +cat /tmp/pretooluse_input.json | ./ccc supervisor-hook +``` + +**预期输出**: +```json +{ + "reason": "called from supervisor hook" +} +``` + +**验证点**: +- [ ] Hook 成功解析 PreToolUse 输入 +- [ ] 没有解析错误 +- [ ] 输出包含 `reason` 字段 + +### 步骤 4: 验证 PreToolUse Hook 输出格式 + +**目的**: 确认 PreToolUse hook 返回正确的输出格式 + +**操作**: +```bash +# 启用 supervisor 模式 +SESSION_ID="test-pretooluse-output-$(date +%s)" +export CCC_SUPERVISOR_ID="$SESSION_ID" + +# 创建状态文件 +cat > ~/.claude/ccc/supervisor-$SESSION_ID.json <<'EOF' +{ + "enabled": true, + "iteration_count": 0 +} +EOF + +# 创建测试输入 +cat > /tmp/pretooluse_output_test.json < /tmp/unknown_event_input.json <<'EOF' +{ + "session_id": "test-unknown-event", + "hook_event_name": "UnknownEventType", + "tool_name": "SomeTool", + "tool_input": {}, + "tool_use_id": "toolu_unknown_001" +} +EOF + +cat /tmp/unknown_event_input.json | ./ccc supervisor-hook +``` + +**预期输出**: 应该返回 Stop 格式的输出(包含 `reason` 字段) + +**验证点**: +- [ ] 未知事件类型不会导致错误 +- [ ] 输出使用 Stop 格式 + +### 步骤 6: 验证迭代计数递增 + +**目的**: 确认 PreToolUse 事件会正确增加迭代计数 + +**操作**: +```bash +SESSION_ID="test-iteration-$(date +%s)" +export CCC_SUPERVISOR_ID="$SESSION_ID" + +# 创建初始状态 +cat > ~/.claude/ccc/supervisor-$SESSION_ID.json <<'EOF' +{ + "enabled": true, + "iteration_count": 0 +} +EOF + +echo "初始迭代计数:" +jq '.iteration_count' ~/.claude/ccc/supervisor-$SESSION_ID.json + +# 触发 PreToolUse hook +export CCC_SUPERVISOR_HOOK="1" +cat > /tmp/iteration_test_input.json </dev/null 2>&1 || true + +echo "PreToolUse 后迭代计数:" +jq '.iteration_count' ~/.claude/ccc/supervisor-$SESSION_ID.json + +# 触发 Stop hook +cat > /tmp/stop_iteration_input.json </dev/null 2>&1 || true + +echo "Stop 后迭代计数:" +jq '.iteration_count' ~/.claude/ccc/supervisor-$SESSION_ID.json +``` + +**预期输出**: +``` +初始迭代计数: +0 +PreToolUse 后迭代计数: +1 +Stop 后迭代计数: +2 +``` + +**验证点**: +- [ ] PreToolUse 事件增加迭代计数 +- [ ] Stop 事件也增加迭代计数 +- [ ] 两种事件类型共享同一个计数器 + +### 步骤 7: 真实环境测试(可选) + +**目的**: 在真实 Claude Code 环境中验证功能 + +**前提**: +- Supervisor 模式已启用 +- 有一个正在进行的 Claude Code 会话 + +**操作**: +1. 在 Claude Code 中执行一个会触发 AskUserQuestion 的任务 +2. 观察 hook 是否被触发 +3. 检查 `~/.claude/ccc/` 目录中的日志文件 + +```bash +# 查看最新的 supervisor 状态文件 +ls -lt ~/.claude/ccc/supervisor-*.json | head -1 + +# 查看迭代计数 +jq '.' ~/.claude/ccc/supervisor-.json +``` + +**验证点**: +- [ ] AskUserQuestion 调用被 hook 拦截 +- [ ] 迭代计数正确递增 +- [ ] Supervisor 的决策正确应用(允许/拒绝) + +## 自动化测试脚本 + +项目中包含一个自动化端到端测试脚本: + +```bash +./tests/e2e_pretooluse_hook_test.sh +``` + +该脚本会自动运行以上所有测试步骤并报告结果。 + +## 故障排查 + +### 问题 1: Hook 配置未生成 + +**症状**: `settings.json` 中没有 `PreToolUse` hook + +**排查**: +```bash +# 检查是否正确切换了 provider +./ccc switch + +# 检查 settings.json 内容 +cat ~/.claude/settings.json | jq '.hooks' +``` + +### 问题 2: Hook 调用失败 + +**症状**: Hook 返回错误 + +**排查**: +```bash +# 检查 ccc 路径是否正确 +which ccc + +# 检查 hook 命令是否可执行 +cat ~/.claude/settings.json | jq '.hooks.PreToolUse[0].hooks[0].command' + +# 手动测试 hook 命令 +echo '{}' | $(cat ~/.claude/settings.json | jq -r '.hooks.PreToolUse[0].hooks[0].command') +``` + +### 问题 3: Supervisor 模式未启用 + +**症状**: Hook 直接返回允许,没有经过审查 + +**排查**: +```bash +# 检查 supervisor 状态 +./ccc supervisor + +# 查看状态文件 +cat ~/.claude/ccc/supervisor-*.json +``` + +## 测试检查清单 + +完成以下检查以确认功能正常: + +- [ ] PreToolUse hook 配置正确生成 +- [ ] Stop hook 仍然正常工作(向后兼容) +- [ ] PreToolUse 输入解析正确 +- [ ] PreToolUse 输出格式正确 +- [ ] 未知事件类型默认使用 Stop 格式 +- [ ] 迭代计数正确递增 +- [ ] 两种事件类型共享计数器 +- [ ] 自动化测试全部通过 + +## 性能指标 + +| 操作 | 预期时间 | +|------|----------| +| Hook 配置生成 | < 1s | +| Hook 输入解析 | < 100ms | +| Hook 输出生成 | < 100ms | +| 完整 hook 调用(含 SDK) | < 30s | + +## 下一步 + +完成验证后: +1. 更新 `specs/002-askuserquestion-hook/` 中的验证状态 +2. 提交代码到 PR +3. 合并到主分支 diff --git a/internal/cli/hook.go b/internal/cli/hook.go index 22345e1..ce2a061 100644 --- a/internal/cli/hook.go +++ b/internal/cli/hook.go @@ -6,6 +6,7 @@ import ( _ "embed" "encoding/json" "fmt" + "io" "log/slog" "os" "strings" @@ -22,19 +23,44 @@ import ( //go:embed supervisor_prompt_default.md var defaultPromptContent []byte -// StopHookInput represents the input from Stop hook. +// ============================================================================ +// 输入结构体定义 +// ============================================================================ + +// HookInputHeader 用于事件类型检测,只解析必要字段 +type HookInputHeader struct { + SessionID string `json:"session_id"` + HookEventName string `json:"hook_event_name,omitempty"` +} + +// StopHookInput 表示 Stop 事件的输入(任务结束审查) type StopHookInput struct { SessionID string `json:"session_id"` StopHookActive bool `json:"stop_hook_active"` } -// SupervisorResult represents the parsed output from Supervisor. +// PreToolUseInput 表示 PreToolUse 事件的输入(工具调用前审查) +type PreToolUseInput struct { + SessionID string `json:"session_id"` + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + TranscriptPath string `json:"transcript_path,omitempty"` + CWD string `json:"cwd,omitempty"` +} + +// ============================================================================ +// Supervisor 结果定义 +// ============================================================================ + +// SupervisorResult 表示 Supervisor 解析后的输出 type SupervisorResult struct { - AllowStop bool `json:"allow_stop"` // Whether to allow the Agent to stop - Feedback string `json:"feedback"` // Feedback when AllowStop is false + AllowStop bool `json:"allow_stop"` // 是否允许 Agent 停止 + Feedback string `json:"feedback"` // 反馈信息 } -// supervisorResultSchema is the JSON schema for parsing supervisor output. +// supervisorResultSchema 是解析 supervisor 输出的 JSON schema var supervisorResultSchema = map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -50,10 +76,12 @@ var supervisorResultSchema = map[string]interface{}{ "required": []string{"allow_stop", "feedback"}, } +// ============================================================================ +// 工具函数 +// ============================================================================ + func logCurrentEnv(log *slog.Logger) { - // Log environment variables for debugging lines := []string{} - // Add all environment variables starting with CLAUDE_, ANTHROPIC_, CCC_ prefixes := []string{"CLAUDE_", "ANTHROPIC_", "CCC_"} for _, env := range os.Environ() { for _, prefix := range prefixes { @@ -67,133 +95,156 @@ func logCurrentEnv(log *slog.Logger) { log.Debug(fmt.Sprintf("supervisor hook environment:\n%s", envStr)) } -// RunSupervisorHook executes the supervisor-hook subcommand. +// detectEventType 从 stdin 读取原始输入并检测事件类型 +// 返回事件类型、原始 JSON 数据和 sessionID +func detectEventType(stdin io.Reader) (supervisor.HookEventType, []byte, string, error) { + rawInput, err := io.ReadAll(stdin) + if err != nil { + return "", nil, "", fmt.Errorf("failed to read stdin: %w", err) + } + + var header HookInputHeader + if err := json.Unmarshal(rawInput, &header); err != nil { + return "", nil, "", fmt.Errorf("failed to parse hook input header: %w", err) + } + + eventType := supervisor.HookEventType(header.HookEventName) + if eventType == "" { + eventType = supervisor.EventTypeStop // 默认为 Stop 事件 + } + + return eventType, rawInput, header.SessionID, nil +} + +// ============================================================================ +// 主入口函数 +// ============================================================================ + +// RunSupervisorHook 执行 supervisor-hook 子命令 func RunSupervisorHook(opts *SupervisorHookCommand) error { - // Validate supervisorID is present + // 验证 supervisorID supervisorID := os.Getenv("CCC_SUPERVISOR_ID") if supervisorID == "" { return fmt.Errorf("CCC_SUPERVISOR_ID is required from env var") } - // Create logger as early as possible + // 创建日志记录器 log := supervisor.NewSupervisorLogger(supervisorID) logCurrentEnv(log) - isSupervisorHook := os.Getenv("CCC_SUPERVISOR_HOOK") == "1" - if isSupervisorHook { + // 防止递归调用 + if os.Getenv("CCC_SUPERVISOR_HOOK") == "1" { return supervisor.OutputDecision(log, true, "called from supervisor hook") } - // Load state to check if supervisor mode is enabled + // 加载状态检查 supervisor 模式是否启用 state, err := supervisor.LoadState(supervisorID) if err != nil { return fmt.Errorf("failed to load supervisor state: %w", err) } - - // Check if supervisor mode is enabled if !state.Enabled { log.Debug("supervisor mode disabled, allowing stop", "enabled", state.Enabled) return supervisor.OutputDecision(log, true, "supervisor mode disabled") } - // Load supervisor configuration + // 加载 supervisor 配置 supervisorCfg, err := config.LoadSupervisorConfig() if err != nil { return fmt.Errorf("failed to load supervisor config: %w", err) } - // Get sessionID from command line argument or stdin + // 获取 sessionID 和事件类型 var sessionID string + var eventType supervisor.HookEventType + var rawInput []byte + if opts != nil && opts.SessionId != "" { - // Use sessionID from command line argument + // 从命令行参数获取 sessionID,默认为 Stop 事件 sessionID = opts.SessionId + eventType = supervisor.EventTypeStop log.Debug("using session_id from command line argument", "session_id", sessionID) - } - var input StopHookInput - if sessionID == "" { - // Parse stdin input - decoder := json.NewDecoder(os.Stdin) - if err := decoder.Decode(&input); err != nil { - return fmt.Errorf("failed to parse stdin JSON: %w", err) - } - sessionID = input.SessionID - // Log input - inputJSON, err := json.MarshalIndent(input, "", " ") + } else { + // 从 stdin 读取并检测事件类型 + eventType, rawInput, sessionID, err = detectEventType(os.Stdin) if err != nil { - log.Warn("failed to marshal hook input", "error", err.Error()) - } else { - log.Debug("hook input", "input", string(inputJSON)) + return err } + log.Debug("hook input", "event_type", eventType, "raw_input", string(rawInput)) } - // Validate sessionID is present + // 验证 sessionID if sessionID == "" { return fmt.Errorf("session_id is required (either from --session-id argument or stdin)") } - // Check iteration count limit using configured max_iterations + // 检查迭代次数限制 maxIterations := supervisorCfg.MaxIterations shouldContinue, count, err := supervisor.ShouldContinue(sessionID, maxIterations) if err != nil { log.Warn("failed to check supervisor state", "error", err.Error()) } if !shouldContinue { - log.Info("max iterations reached, allowing stop", + log.Info("max iterations reached, allowing operation", "count", count, "max", maxIterations, ) - return supervisor.OutputDecision(log, true, fmt.Sprintf("max iterations (%d/%d) reached", count, maxIterations)) + // 达到最大迭代次数时,根据事件类型返回允许 + return outputDecisionByEventType(log, eventType, true, + fmt.Sprintf("max iterations (%d/%d) reached", count, maxIterations)) } - // Increment count + // 增加迭代计数 newCount, err := supervisor.IncrementCount(sessionID) if err != nil { log.Warn("failed to increment count", "error", err.Error()) } else { - log.Info("iteration count", - "count", newCount, - "max", maxIterations, - ) + log.Info("iteration count", "count", newCount, "max", maxIterations) + } + + // 运行 Supervisor 审查 + result, err := runSupervisorReview(sessionID, supervisorCfg, log) + if err != nil { + return err + } + + // 输出结果 + if result == nil { + log.Info("no supervisor result found, allowing operation") + return outputDecisionByEventType(log, eventType, true, "no supervisor result found") + } + + return outputDecisionByEventType(log, eventType, result.AllowStop, result.Feedback) +} + +// outputDecisionByEventType 根据事件类型输出相应格式的决策 +func outputDecisionByEventType(log *slog.Logger, eventType supervisor.HookEventType, allow bool, feedback string) error { + switch eventType { + case supervisor.EventTypePreToolUse: + return supervisor.OutputPreToolUseDecision(log, allow, feedback) + case supervisor.EventTypeStop: + fallthrough + default: + return supervisor.OutputDecision(log, allow, feedback) } +} - // Get default supervisor prompt +// runSupervisorReview 执行 Supervisor 审查流程 +func runSupervisorReview(sessionID string, cfg *config.SupervisorConfig, log *slog.Logger) (*SupervisorResult, error) { + // 加载 supervisor prompt supervisorPrompt, promptSource := getDefaultSupervisorPrompt() - log.Debug("supervisor prompt loaded", - "source", promptSource, - "length", len(supervisorPrompt), - ) + log.Debug("supervisor prompt loaded", "source", promptSource, "length", len(supervisorPrompt)) - // Inform user about supervisor review log.Info("starting supervisor review", "session_id", sessionID) - // Run supervisor using Claude Agent SDK - result, err := runSupervisorWithSDK(context.Background(), sessionID, supervisorPrompt, supervisorCfg.Timeout(), log) + // 使用 Claude Agent SDK 运行 supervisor + result, err := runSupervisorWithSDK(context.Background(), sessionID, supervisorPrompt, cfg.Timeout(), log) if err != nil { log.Error("supervisor SDK failed", "error", err.Error()) - return fmt.Errorf("supervisor SDK failed: %w", err) + return nil, fmt.Errorf("supervisor SDK failed: %w", err) } log.Info("supervisor review completed") - - // Output result based on AllowStop decision - if result == nil { - log.Info("no supervisor result found, allowing stop") - return supervisor.OutputDecision(log, true, "no supervisor result found") - } - - if result.AllowStop { - log.Info("work satisfactory, allowing stop") - // Ensure feedback is not empty when allowing stop - feedback := strings.TrimSpace(result.Feedback) - if feedback == "" { - feedback = "Work completed satisfactorily" - } - return supervisor.OutputDecision(log, true, feedback) - } - - // Block with feedback - log.Info("work not satisfactory, agent will continue") - return supervisor.OutputDecision(log, false, result.Feedback) + return result, nil } // runSupervisorWithSDK runs the supervisor using the Claude Agent SDK. diff --git a/internal/cli/hook_test.go b/internal/cli/hook_test.go index f26e5b7..015df14 100644 --- a/internal/cli/hook_test.go +++ b/internal/cli/hook_test.go @@ -1,14 +1,21 @@ package cli import ( + "bytes" + "encoding/json" "os" "path/filepath" "strings" "testing" "github.com/guyskk/ccc/internal/config" + "github.com/guyskk/ccc/internal/supervisor" ) +// ============================================================================ +// parseResultJSON 测试 +// ============================================================================ + func TestParseResultJSON(t *testing.T) { tests := []struct { name string @@ -109,6 +116,10 @@ func TestParseResultJSON(t *testing.T) { } } +// ============================================================================ +// getDefaultSupervisorPrompt 测试 +// ============================================================================ + func TestGetDefaultSupervisorPrompt(t *testing.T) { // Save original GetDirFunc to restore after test originalGetDirFunc := config.GetDirFunc @@ -127,9 +138,8 @@ func TestGetDefaultSupervisorPrompt(t *testing.T) { t.Errorf("getDefaultSupervisorPrompt() source = %q, want %q", source, "supervisor_prompt_default") } // Check that key parts are present (prompt is in Chinese) - // The prompt uses role keywords like "监督者" (supervisor), "审查者" (reviewer), or "Supervisor" if !strings.Contains(prompt, "监督者") && !strings.Contains(prompt, "审查者") && !strings.Contains(prompt, "Supervisor") { - t.Error("getDefaultSupervisorPrompt() missing role keyword ('监督者', '审查者', or 'Supervisor')") + t.Error("getDefaultSupervisorPrompt() missing role keyword") } if !strings.Contains(prompt, "allow_stop") { t.Error("getDefaultSupervisorPrompt() missing 'allow_stop'") @@ -137,14 +147,6 @@ func TestGetDefaultSupervisorPrompt(t *testing.T) { if !strings.Contains(prompt, "feedback") { t.Error("getDefaultSupervisorPrompt() missing 'feedback'") } - // Check that the prompt mentions JSON output format - if !strings.Contains(prompt, "JSON") && !strings.Contains(prompt, "json") { - t.Error("getDefaultSupervisorPrompt() should mention JSON format") - } - // Check for key sections in the Chinese prompt - if !strings.Contains(prompt, "停止任务") && !strings.Contains(prompt, "审查框架") && !strings.Contains(prompt, "审查思维") { - t.Error("getDefaultSupervisorPrompt() should contain key sections") - } }) t.Run("custom prompt from SUPERVISOR.md", func(t *testing.T) { @@ -176,34 +178,14 @@ func TestGetDefaultSupervisorPrompt(t *testing.T) { if source != "supervisor_prompt_default" { t.Errorf("getDefaultSupervisorPrompt() source = %q, want %q", source, "supervisor_prompt_default") } - // Should use default prompt (contains Chinese keywords) - if !strings.Contains(prompt, "监督者") && !strings.Contains(prompt, "审查者") && !strings.Contains(prompt, "Supervisor") { - t.Error("getDefaultSupervisorPrompt() should use default prompt when custom file is empty") - } - }) - - t.Run("custom file with only whitespace falls back to default", func(t *testing.T) { - customPath := filepath.Join(tempDir, "SUPERVISOR.md") - if err := os.WriteFile(customPath, []byte("\n\n"), 0644); err != nil { - t.Fatalf("failed to write whitespace-only custom prompt file: %v", err) - } - - prompt, source := getDefaultSupervisorPrompt() - if prompt == "" { - t.Error("getDefaultSupervisorPrompt() returned empty string for whitespace-only custom file") - } - if source != "supervisor_prompt_default" { - t.Errorf("getDefaultSupervisorPrompt() source = %q, want %q", source, "supervisor_prompt_default") - } - // Should use default prompt - if !strings.Contains(prompt, "监督者") && !strings.Contains(prompt, "审查者") && !strings.Contains(prompt, "Supervisor") { - t.Error("getDefaultSupervisorPrompt() should use default prompt when custom file is whitespace-only") - } }) } +// ============================================================================ +// supervisorResultSchema 测试 +// ============================================================================ + func TestSupervisorResultSchema(t *testing.T) { - // Verify the schema has the correct structure if supervisorResultSchema == nil { t.Fatal("supervisorResultSchema is nil") } @@ -219,7 +201,6 @@ func TestSupervisorResultSchema(t *testing.T) { t.Fatal("schema properties is not a map") } - // Check required fields required, ok := schemaMap["required"].([]string) if !ok { t.Fatal("schema required is not a string slice") @@ -228,15 +209,6 @@ func TestSupervisorResultSchema(t *testing.T) { t.Errorf("required fields count = %d, want 2", len(required)) } - // Check required field names - if required[0] != "allow_stop" && required[1] != "allow_stop" { - t.Error("schema required fields should include 'allow_stop'") - } - if required[0] != "feedback" && required[1] != "feedback" { - t.Error("schema required fields should include 'feedback'") - } - - // Check properties exist if _, ok := properties["allow_stop"]; !ok { t.Error("schema missing 'allow_stop' property") } @@ -244,3 +216,269 @@ func TestSupervisorResultSchema(t *testing.T) { t.Error("schema missing 'feedback' property") } } + +// ============================================================================ +// 输入结构体测试 +// ============================================================================ + +func TestHookInputHeader_Unmarshal(t *testing.T) { + tests := []struct { + name string + jsonInput string + wantSessionID string + wantHookEventName string + }{ + { + name: "Stop event (no hook_event_name)", + jsonInput: `{"session_id": "test-123", "stop_hook_active": false}`, + wantSessionID: "test-123", + wantHookEventName: "", + }, + { + name: "PreToolUse event", + jsonInput: `{"session_id": "test-456", "hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion"}`, + wantSessionID: "test-456", + wantHookEventName: "PreToolUse", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var header HookInputHeader + if err := json.Unmarshal([]byte(tt.jsonInput), &header); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if header.SessionID != tt.wantSessionID { + t.Errorf("SessionID = %q, want %q", header.SessionID, tt.wantSessionID) + } + if header.HookEventName != tt.wantHookEventName { + t.Errorf("HookEventName = %q, want %q", header.HookEventName, tt.wantHookEventName) + } + }) + } +} + +func TestStopHookInput_Unmarshal(t *testing.T) { + jsonInput := `{"session_id": "test-stop-789", "stop_hook_active": true}` + + var input StopHookInput + if err := json.Unmarshal([]byte(jsonInput), &input); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if input.SessionID != "test-stop-789" { + t.Errorf("SessionID = %q, want 'test-stop-789'", input.SessionID) + } + if input.StopHookActive != true { + t.Errorf("StopHookActive = %v, want true", input.StopHookActive) + } +} + +func TestPreToolUseInput_Unmarshal(t *testing.T) { + jsonInput := `{ + "session_id": "test-pretool-123", + "hook_event_name": "PreToolUse", + "tool_name": "AskUserQuestion", + "tool_input": {"questions": [{"question": "选择方案?", "header": "方案"}]}, + "tool_use_id": "toolu_abc123", + "transcript_path": "/path/to/transcript.json", + "cwd": "/workspace" + }` + + var input PreToolUseInput + if err := json.Unmarshal([]byte(jsonInput), &input); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if input.SessionID != "test-pretool-123" { + t.Errorf("SessionID = %q, want 'test-pretool-123'", input.SessionID) + } + if input.HookEventName != "PreToolUse" { + t.Errorf("HookEventName = %q, want 'PreToolUse'", input.HookEventName) + } + if input.ToolName != "AskUserQuestion" { + t.Errorf("ToolName = %q, want 'AskUserQuestion'", input.ToolName) + } + if input.ToolUseID != "toolu_abc123" { + t.Errorf("ToolUseID = %q, want 'toolu_abc123'", input.ToolUseID) + } + if input.TranscriptPath != "/path/to/transcript.json" { + t.Errorf("TranscriptPath = %q, want '/path/to/transcript.json'", input.TranscriptPath) + } + if input.CWD != "/workspace" { + t.Errorf("CWD = %q, want '/workspace'", input.CWD) + } + if len(input.ToolInput) == 0 { + t.Error("ToolInput should not be empty") + } +} + +// ============================================================================ +// detectEventType 测试 +// ============================================================================ + +func TestDetectEventType(t *testing.T) { + tests := []struct { + name string + input string + wantEventType supervisor.HookEventType + wantSessionID string + wantErr bool + }{ + { + name: "Stop event (no hook_event_name)", + input: `{"session_id": "test-stop", "stop_hook_active": false}`, + wantEventType: supervisor.EventTypeStop, + wantSessionID: "test-stop", + wantErr: false, + }, + { + name: "PreToolUse event", + input: `{"session_id": "test-pretool", "hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion"}`, + wantEventType: supervisor.EventTypePreToolUse, + wantSessionID: "test-pretool", + wantErr: false, + }, + { + name: "Stop event (explicit)", + input: `{"session_id": "test-explicit-stop", "hook_event_name": "Stop"}`, + wantEventType: supervisor.EventTypeStop, + wantSessionID: "test-explicit-stop", + wantErr: false, + }, + { + name: "Unknown event type defaults to Stop", + input: `{"session_id": "test-unknown", "hook_event_name": "Unknown"}`, + wantEventType: supervisor.HookEventType("Unknown"), + wantSessionID: "test-unknown", + wantErr: false, + }, + { + name: "Invalid JSON", + input: `{invalid json`, + wantEventType: "", + wantSessionID: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := bytes.NewReader([]byte(tt.input)) + eventType, _, sessionID, err := detectEventType(reader) + + if (err != nil) != tt.wantErr { + t.Errorf("detectEventType() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err == nil { + if eventType != tt.wantEventType { + t.Errorf("eventType = %q, want %q", eventType, tt.wantEventType) + } + if sessionID != tt.wantSessionID { + t.Errorf("sessionID = %q, want %q", sessionID, tt.wantSessionID) + } + } + }) + } +} + +func TestDetectEventType_PreservesRawInput(t *testing.T) { + input := `{"session_id": "test", "hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion", "extra_field": "preserved"}` + reader := bytes.NewReader([]byte(input)) + + _, rawInput, _, err := detectEventType(reader) + if err != nil { + t.Fatalf("detectEventType() error = %v", err) + } + + // 验证原始输入被完整保留 + if !strings.Contains(string(rawInput), "extra_field") { + t.Error("Raw input should preserve all fields including extra_field") + } +} + +// ============================================================================ +// 集成测试 +// ============================================================================ + +func TestRunSupervisorHook_RecursiveCallProtection(t *testing.T) { + originalGetDirFunc := config.GetDirFunc + defer func() { config.GetDirFunc = originalGetDirFunc }() + + tempDir := t.TempDir() + config.GetDirFunc = func() string { return tempDir } + + state := &supervisor.State{Enabled: true} + if err := supervisor.SaveState("test-recursive-protection", state); err != nil { + t.Fatalf("failed to save state: %v", err) + } + + oldSupervisorID := os.Getenv("CCC_SUPERVISOR_ID") + oldSupervisorHook := os.Getenv("CCC_SUPERVISOR_HOOK") + defer func() { + os.Setenv("CCC_SUPERVISOR_ID", oldSupervisorID) + os.Setenv("CCC_SUPERVISOR_HOOK", oldSupervisorHook) + }() + os.Setenv("CCC_SUPERVISOR_ID", "test-recursive-protection") + os.Setenv("CCC_SUPERVISOR_HOOK", "1") // 模拟递归调用 + + hookInputJSON := `{"session_id": "test-recursive-protection", "hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion"}` + + oldStdin := os.Stdin + r, w, _ := os.Pipe() + os.Stdin = r + go func() { + w.Write([]byte(hookInputJSON)) + w.Close() + }() + defer func() { os.Stdin = oldStdin }() + + opts := &SupervisorHookCommand{} + err := RunSupervisorHook(opts) + + if err != nil { + t.Errorf("RunSupervisorHook() error = %v, want nil (recursive call protection)", err) + } +} + +func TestRunSupervisorHook_SupervisorModeDisabled(t *testing.T) { + originalGetDirFunc := config.GetDirFunc + defer func() { config.GetDirFunc = originalGetDirFunc }() + + tempDir := t.TempDir() + config.GetDirFunc = func() string { return tempDir } + + state := &supervisor.State{Enabled: false} + if err := supervisor.SaveState("test-supervisor-disabled", state); err != nil { + t.Fatalf("failed to save state: %v", err) + } + + oldSupervisorID := os.Getenv("CCC_SUPERVISOR_ID") + oldSupervisorHook := os.Getenv("CCC_SUPERVISOR_HOOK") + defer func() { + os.Setenv("CCC_SUPERVISOR_ID", oldSupervisorID) + os.Setenv("CCC_SUPERVISOR_HOOK", oldSupervisorHook) + }() + os.Setenv("CCC_SUPERVISOR_ID", "test-supervisor-disabled") + os.Setenv("CCC_SUPERVISOR_HOOK", "") + + hookInputJSON := `{"session_id": "test-supervisor-disabled", "hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion"}` + + oldStdin := os.Stdin + r, w, _ := os.Pipe() + os.Stdin = r + go func() { + w.Write([]byte(hookInputJSON)) + w.Close() + }() + defer func() { os.Stdin = oldStdin }() + + opts := &SupervisorHookCommand{} + err := RunSupervisorHook(opts) + + if err != nil { + t.Errorf("RunSupervisorHook() error = %v, want nil (supervisor disabled)", err) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index fa11a4c..b8f4621 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -81,6 +81,18 @@ func SwitchWithHook(cfg *config.Config, providerName string) (*SwitchResult, err }, }, }, + "PreToolUse": []map[string]interface{}{ + { + "matcher": "AskUserQuestion", // Match only AskUserQuestion tool + "hooks": []map[string]interface{}{ + { + "type": "command", + "command": hookCommand, // Reuse the same command + "timeout": 600, + }, + }, + }, + }, } settingsWithHook["hooks"] = hooks diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 7dd6ab3..094dfa9 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -499,3 +499,225 @@ func TestGetModel(t *testing.T) { }) } } + +// ============================================================================ +// PreToolUse Hook Configuration Tests +// ============================================================================ + +func TestSwitchWithHook_PreToolUseConfiguration(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + cfg := setupTestConfig(t) + + // Save initial config + if err := config.Save(cfg); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Switch to glm + result, err := SwitchWithHook(cfg, "glm") + if err != nil { + t.Fatalf("SwitchWithHook() error = %v", err) + } + + // Verify hooks are present in settings + hooks, ok := result.Settings["hooks"] + if !ok { + t.Fatal("Settings should contain 'hooks' field") + } + + hooksMap, ok := hooks.(map[string]interface{}) + if !ok { + t.Fatal("hooks should be a map") + } + + // Verify Stop hook exists + stopHook, ok := hooksMap["Stop"] + if !ok { + t.Error("hooks should contain 'Stop' hook") + } else { + stopHookList, ok := stopHook.([]map[string]interface{}) + if !ok { + t.Error("Stop hook should be a list of maps") + } else if len(stopHookList) == 0 { + t.Error("Stop hook list should not be empty") + } + } + + // Verify PreToolUse hook exists + preToolUseHook, ok := hooksMap["PreToolUse"] + if !ok { + t.Error("hooks should contain 'PreToolUse' hook") + } else { + preToolUseList, ok := preToolUseHook.([]map[string]interface{}) + if !ok { + t.Error("PreToolUse hook should be a list of maps") + } else if len(preToolUseList) == 0 { + t.Error("PreToolUse hook list should not be empty") + } else { + // Verify PreToolUse hook configuration structure + preToolUseConfig := preToolUseList[0] + + // Verify matcher is set to "AskUserQuestion" + matcher, ok := preToolUseConfig["matcher"] + if !ok { + t.Error("PreToolUse hook config should contain 'matcher' field") + } else if matcher != "AskUserQuestion" { + t.Errorf("PreToolUse matcher = %v, want 'AskUserQuestion'", matcher) + } + + // Verify hooks array exists + hooksArray, ok := preToolUseConfig["hooks"] + if !ok { + t.Error("PreToolUse hook config should contain 'hooks' array") + } else { + hooksList, ok := hooksArray.([]map[string]interface{}) + if !ok || len(hooksList) == 0 { + t.Error("PreToolUse hooks array should not be empty") + } else { + // Verify hook command structure + hookConfig := hooksList[0] + + // Verify type is "command" + if hookType, ok := hookConfig["type"]; !ok || hookType != "command" { + t.Error("Hook type should be 'command'") + } + + // Verify timeout is set + if timeout, ok := hookConfig["timeout"]; !ok { + t.Error("Hook timeout should be set") + } else { + // Timeout can be int or float64 depending on JSON unmarshaling + var timeoutVal int + switch v := timeout.(type) { + case float64: + timeoutVal = int(v) + case int: + timeoutVal = v + } + if timeoutVal != 600 { + t.Errorf("Hook timeout = %v, want 600", timeout) + } + } + + // Verify command field exists and contains "supervisor-hook" + if command, ok := hookConfig["command"]; !ok { + t.Error("Hook command should be set") + } else if commandStr, ok := command.(string); !ok || !strings.Contains(commandStr, "supervisor-hook") { + t.Errorf("Hook command = %v, should contain 'supervisor-hook'", command) + } + } + } + } + } +} + +// TestSwitchWithHook_PreToolUseAndStopCoexist verifies that PreToolUse and Stop hooks +// can coexist without conflicts and both use the same supervisor command. +func TestSwitchWithHook_PreToolUseAndStopCoexist(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + cfg := setupTestConfig(t) + + // Save initial config + if err := config.Save(cfg); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Switch to kimi + result, err := SwitchWithHook(cfg, "kimi") + if err != nil { + t.Fatalf("SwitchWithHook() error = %v", err) + } + + // Get hooks configuration + hooks, ok := result.Settings["hooks"] + if !ok { + t.Fatal("Settings should contain 'hooks' field") + } + + hooksMap, ok := hooks.(map[string]interface{}) + if !ok { + t.Fatal("hooks should be a map") + } + + // Both Stop and PreToolUse should exist + if _, ok := hooksMap["Stop"]; !ok { + t.Error("hooks should contain 'Stop' hook") + } + if _, ok := hooksMap["PreToolUse"]; !ok { + t.Error("hooks should contain 'PreToolUse' hook") + } + + // Extract the command from Stop hook + stopHookList := hooksMap["Stop"].([]map[string]interface{}) + stopConfig := stopHookList[0] + stopHooks := stopConfig["hooks"].([]map[string]interface{}) + stopHookCommand := stopHooks[0]["command"].(string) + + // Extract the command from PreToolUse hook + preToolUseList := hooksMap["PreToolUse"].([]map[string]interface{}) + preToolUseConfig := preToolUseList[0] + preToolUseHooks := preToolUseConfig["hooks"].([]map[string]interface{}) + preToolUseHookCommand := preToolUseHooks[0]["command"].(string) + + // Both should use the same command + if stopHookCommand != preToolUseHookCommand { + t.Errorf("Stop and PreToolUse hooks should use the same command: Stop=%s, PreToolUse=%s", + stopHookCommand, preToolUseHookCommand) + } + + // Both commands should contain "supervisor-hook" + if !strings.Contains(stopHookCommand, "supervisor-hook") { + t.Errorf("Stop hook command should contain 'supervisor-hook': %s", stopHookCommand) + } + if !strings.Contains(preToolUseHookCommand, "supervisor-hook") { + t.Errorf("PreToolUse hook command should contain 'supervisor-hook': %s", preToolUseHookCommand) + } +} + +// TestSwitchWithHook_PreToolUseMatcherIsAskUserQuestion verifies that the PreToolUse +// hook only triggers for AskUserQuestion tool calls, not for other tools. +func TestSwitchWithHook_PreToolUseMatcherIsAskUserQuestion(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + cfg := setupTestConfig(t) + + // Save initial config + if err := config.Save(cfg); err != nil { + t.Fatalf("Failed to save config: %v", err) + } + + // Switch to glm + result, err := SwitchWithHook(cfg, "glm") + if err != nil { + t.Fatalf("SwitchWithHook() error = %v", err) + } + + // Get hooks configuration + hooks := result.Settings["hooks"].(map[string]interface{}) + preToolUseList := hooks["PreToolUse"].([]map[string]interface{}) + preToolUseConfig := preToolUseList[0] + + // Verify matcher is exactly "AskUserQuestion" + matcher := preToolUseConfig["matcher"] + if matcher != "AskUserQuestion" { + t.Errorf("PreToolUse matcher should be 'AskUserQuestion', got: %v", matcher) + } + + // Verify matcher is a string (not a regex pattern or complex object) + if _, ok := matcher.(string); !ok { + t.Error("PreToolUse matcher should be a string") + } + + // Verify that common tool names are NOT in the configuration + // (this ensures we're using a matcher, not a blacklist) + for _, toolName := range []string{"Browser", "Bash", "Edit", "Write"} { + if matcher == toolName { + t.Errorf("PreToolUse matcher should not be '%s', it should be 'AskUserQuestion'", toolName) + } + } +} diff --git a/internal/supervisor/output.go b/internal/supervisor/output.go index eaf6ab7..087f54e 100644 --- a/internal/supervisor/output.go +++ b/internal/supervisor/output.go @@ -8,14 +8,42 @@ import ( "strings" ) -// HookOutput represents the output to stdout. +// HookEventType 定义 hook 事件类型 +type HookEventType string + +const ( + // EventTypeStop 表示 Stop 事件(任务结束审查) + EventTypeStop HookEventType = "Stop" + // EventTypePreToolUse 表示 PreToolUse 事件(工具调用前审查) + EventTypePreToolUse HookEventType = "PreToolUse" +) + +// StopHookOutput 表示 Stop 事件的输出格式 // Reason is always set (provides context for the decision). // Decision is "block" when not allowing stop, omitted when allowing stop. -type HookOutput struct { +type StopHookOutput struct { Decision *string `json:"decision,omitempty"` // "block" or omitted (allows stop) Reason string `json:"reason"` // Always set } +// PreToolUseSpecificOutput 表示 PreToolUse 事件的特定输出字段 +type PreToolUseSpecificOutput struct { + HookEventName string `json:"hookEventName"` // "PreToolUse" + PermissionDecision string `json:"permissionDecision"` // "allow", "deny" + PermissionDecisionReason string `json:"permissionDecisionReason"` // 决策原因 +} + +// PreToolUseHookOutput 表示 PreToolUse 事件的输出格式 +type PreToolUseHookOutput struct { + HookSpecificOutput *PreToolUseSpecificOutput `json:"hookSpecificOutput"` +} + +// HookOutput represents the output to stdout (for Stop events). +// Reason is always set (provides context for the decision). +// Decision is "block" when not allowing stop, omitted when allowing stop. +// Deprecated: 使用 StopHookOutput 代替,此类型保留用于向后兼容 +type HookOutput = StopHookOutput + // OutputDecision outputs the supervisor's decision. // // Parameters: @@ -57,3 +85,50 @@ func OutputDecision(log *slog.Logger, allowStop bool, feedback string) error { return nil } + +// OutputPreToolUseDecision 输出 PreToolUse 事件的决策 +// +// 参数: +// - log: 日志记录器 +// - allow: true 允许工具调用,false 拒绝工具调用 +// - feedback: 决策反馈信息 +// +// 功能: +// 1. 输出 JSON 到 stdout 供 Claude Code 解析 +// 2. 记录决策日志 +func OutputPreToolUseDecision(log *slog.Logger, allow bool, feedback string) error { + feedback = strings.TrimSpace(feedback) + + decision := "allow" + if !allow { + decision = "deny" + if feedback == "" { + feedback = "请继续完成任务后再提问" + } + } + + output := PreToolUseHookOutput{ + HookSpecificOutput: &PreToolUseSpecificOutput{ + HookEventName: "PreToolUse", + PermissionDecision: decision, + PermissionDecisionReason: feedback, + }, + } + + outputJSON, err := json.Marshal(output) + if err != nil { + return fmt.Errorf("failed to marshal PreToolUse hook output: %w", err) + } + + // 记录决策日志 + if allow { + log.Info("supervisor output: allow tool call", "feedback", feedback) + } else { + log.Info("supervisor output: deny tool call", "feedback", feedback) + } + + // 输出 JSON 到 stdout + fmt.Println(string(outputJSON)) + + return nil +} diff --git a/internal/supervisor/output_test.go b/internal/supervisor/output_test.go new file mode 100644 index 0000000..9f3665a --- /dev/null +++ b/internal/supervisor/output_test.go @@ -0,0 +1,325 @@ +package supervisor + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "os" + "strings" + "testing" +) + +// captureStdout 捕获 stdout 输出 +func captureStdout(f func()) string { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + f() + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() +} + +// createTestLogger 创建测试用的 logger +func createTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// ============================================================================ +// OutputDecision 测试 +// ============================================================================ + +func TestOutputDecision_AllowStop(t *testing.T) { + log := createTestLogger() + + output := captureStdout(func() { + err := OutputDecision(log, true, "work completed") + if err != nil { + t.Errorf("OutputDecision() error = %v", err) + } + }) + + // 验证输出是有效的 JSON + var result StopHookOutput + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &result); err != nil { + t.Fatalf("Failed to parse output JSON: %v\nOutput: %s", err, output) + } + + // 允许停止时,decision 应该为 nil + if result.Decision != nil { + t.Errorf("Decision = %q, want nil when allowing stop", *result.Decision) + } + + // reason 应该包含 feedback + if result.Reason != "work completed" { + t.Errorf("Reason = %q, want 'work completed'", result.Reason) + } +} + +func TestOutputDecision_Block(t *testing.T) { + log := createTestLogger() + + output := captureStdout(func() { + err := OutputDecision(log, false, "needs more work") + if err != nil { + t.Errorf("OutputDecision() error = %v", err) + } + }) + + var result StopHookOutput + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &result); err != nil { + t.Fatalf("Failed to parse output JSON: %v\nOutput: %s", err, output) + } + + // 阻止停止时,decision 应该是 "block" + if result.Decision == nil { + t.Fatal("Decision is nil, want 'block'") + } + if *result.Decision != "block" { + t.Errorf("Decision = %q, want 'block'", *result.Decision) + } + + if result.Reason != "needs more work" { + t.Errorf("Reason = %q, want 'needs more work'", result.Reason) + } +} + +func TestOutputDecision_BlockWithEmptyFeedback(t *testing.T) { + log := createTestLogger() + + output := captureStdout(func() { + err := OutputDecision(log, false, "") + if err != nil { + t.Errorf("OutputDecision() error = %v", err) + } + }) + + var result StopHookOutput + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &result); err != nil { + t.Fatalf("Failed to parse output JSON: %v\nOutput: %s", err, output) + } + + // 空 feedback 时应该有默认消息 + if result.Reason == "" { + t.Error("Reason should not be empty when feedback is empty and blocking") + } + if result.Reason != "Please continue completing the task" { + t.Errorf("Reason = %q, want default message", result.Reason) + } +} + +func TestOutputDecision_TrimsWhitespace(t *testing.T) { + log := createTestLogger() + + output := captureStdout(func() { + err := OutputDecision(log, true, " trimmed feedback ") + if err != nil { + t.Errorf("OutputDecision() error = %v", err) + } + }) + + var result StopHookOutput + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &result); err != nil { + t.Fatalf("Failed to parse output JSON: %v\nOutput: %s", err, output) + } + + if result.Reason != "trimmed feedback" { + t.Errorf("Reason = %q, want 'trimmed feedback' (whitespace trimmed)", result.Reason) + } +} + +// ============================================================================ +// OutputPreToolUseDecision 测试 +// ============================================================================ + +func TestOutputPreToolUseDecision_Allow(t *testing.T) { + log := createTestLogger() + + output := captureStdout(func() { + err := OutputPreToolUseDecision(log, true, "问题合理") + if err != nil { + t.Errorf("OutputPreToolUseDecision() error = %v", err) + } + }) + + var result PreToolUseHookOutput + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &result); err != nil { + t.Fatalf("Failed to parse output JSON: %v\nOutput: %s", err, output) + } + + if result.HookSpecificOutput == nil { + t.Fatal("HookSpecificOutput is nil") + } + + if result.HookSpecificOutput.HookEventName != "PreToolUse" { + t.Errorf("HookEventName = %q, want 'PreToolUse'", result.HookSpecificOutput.HookEventName) + } + + if result.HookSpecificOutput.PermissionDecision != "allow" { + t.Errorf("PermissionDecision = %q, want 'allow'", result.HookSpecificOutput.PermissionDecision) + } + + if result.HookSpecificOutput.PermissionDecisionReason != "问题合理" { + t.Errorf("PermissionDecisionReason = %q, want '问题合理'", result.HookSpecificOutput.PermissionDecisionReason) + } +} + +func TestOutputPreToolUseDecision_Deny(t *testing.T) { + log := createTestLogger() + + output := captureStdout(func() { + err := OutputPreToolUseDecision(log, false, "需要更多上下文") + if err != nil { + t.Errorf("OutputPreToolUseDecision() error = %v", err) + } + }) + + var result PreToolUseHookOutput + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &result); err != nil { + t.Fatalf("Failed to parse output JSON: %v\nOutput: %s", err, output) + } + + if result.HookSpecificOutput == nil { + t.Fatal("HookSpecificOutput is nil") + } + + if result.HookSpecificOutput.PermissionDecision != "deny" { + t.Errorf("PermissionDecision = %q, want 'deny'", result.HookSpecificOutput.PermissionDecision) + } + + if result.HookSpecificOutput.PermissionDecisionReason != "需要更多上下文" { + t.Errorf("PermissionDecisionReason = %q, want '需要更多上下文'", result.HookSpecificOutput.PermissionDecisionReason) + } +} + +func TestOutputPreToolUseDecision_DenyWithEmptyFeedback(t *testing.T) { + log := createTestLogger() + + output := captureStdout(func() { + err := OutputPreToolUseDecision(log, false, "") + if err != nil { + t.Errorf("OutputPreToolUseDecision() error = %v", err) + } + }) + + var result PreToolUseHookOutput + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &result); err != nil { + t.Fatalf("Failed to parse output JSON: %v\nOutput: %s", err, output) + } + + // 空 feedback 时应该有默认消息 + if result.HookSpecificOutput.PermissionDecisionReason == "" { + t.Error("PermissionDecisionReason should not be empty when feedback is empty and denying") + } +} + +func TestOutputPreToolUseDecision_TrimsWhitespace(t *testing.T) { + log := createTestLogger() + + output := captureStdout(func() { + err := OutputPreToolUseDecision(log, true, " trimmed ") + if err != nil { + t.Errorf("OutputPreToolUseDecision() error = %v", err) + } + }) + + var result PreToolUseHookOutput + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &result); err != nil { + t.Fatalf("Failed to parse output JSON: %v\nOutput: %s", err, output) + } + + if result.HookSpecificOutput.PermissionDecisionReason != "trimmed" { + t.Errorf("PermissionDecisionReason = %q, want 'trimmed'", result.HookSpecificOutput.PermissionDecisionReason) + } +} + +// ============================================================================ +// HookEventType 常量测试 +// ============================================================================ + +func TestHookEventType_Constants(t *testing.T) { + if EventTypeStop != "Stop" { + t.Errorf("EventTypeStop = %q, want 'Stop'", EventTypeStop) + } + + if EventTypePreToolUse != "PreToolUse" { + t.Errorf("EventTypePreToolUse = %q, want 'PreToolUse'", EventTypePreToolUse) + } +} + +// ============================================================================ +// 输出类型结构测试 +// ============================================================================ + +func TestStopHookOutput_JSONFormat(t *testing.T) { + // 测试 Stop 事件的 JSON 输出格式 + block := "block" + output := StopHookOutput{ + Decision: &block, + Reason: "测试原因", + } + + jsonBytes, err := json.Marshal(output) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + jsonStr := string(jsonBytes) + + if !strings.Contains(jsonStr, `"decision"`) { + t.Error("JSON should contain 'decision' field") + } + if !strings.Contains(jsonStr, `"reason"`) { + t.Error("JSON should contain 'reason' field") + } + if !strings.Contains(jsonStr, `"block"`) { + t.Error("JSON should contain 'block' value") + } +} + +func TestPreToolUseHookOutput_JSONFormat(t *testing.T) { + // 测试 PreToolUse 事件的 JSON 输出格式 + output := PreToolUseHookOutput{ + HookSpecificOutput: &PreToolUseSpecificOutput{ + HookEventName: "PreToolUse", + PermissionDecision: "allow", + PermissionDecisionReason: "测试原因", + }, + } + + jsonBytes, err := json.Marshal(output) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + jsonStr := string(jsonBytes) + + if !strings.Contains(jsonStr, `"hookSpecificOutput"`) { + t.Error("JSON should contain 'hookSpecificOutput' field") + } + if !strings.Contains(jsonStr, `"hookEventName"`) { + t.Error("JSON should contain 'hookEventName' field") + } + if !strings.Contains(jsonStr, `"permissionDecision"`) { + t.Error("JSON should contain 'permissionDecision' field") + } + if !strings.Contains(jsonStr, `"permissionDecisionReason"`) { + t.Error("JSON should contain 'permissionDecisionReason' field") + } +} + +func TestHookOutput_Alias(t *testing.T) { + // 验证 HookOutput 是 StopHookOutput 的别名 + var hookOutput HookOutput + var stopHookOutput StopHookOutput + + // 它们应该是相同的类型 + hookOutput = stopHookOutput + _ = hookOutput +} diff --git a/specs/002-askuserquestion-hook/checklists/requirements.md b/specs/002-askuserquestion-hook/checklists/requirements.md new file mode 100644 index 0000000..abd20f3 --- /dev/null +++ b/specs/002-askuserquestion-hook/checklists/requirements.md @@ -0,0 +1,40 @@ +# Specification Quality Checklist: Supervisor Hook 支持 AskUserQuestion 工具调用审查 + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-20 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +所有检查项已通过。规格说明已准备好进入下一阶段 (`/speckit.plan`)。 + +关键验证点: +1. 功能需求 FR-001 到 FR-007 都有明确的验收场景支持 +2. 成功标准 SC-001 到 SC-005 都是可衡量的指标 +3. 边缘情况已识别(4 个关键场景) +4. 保持与宪章原则一致(向后兼容、单二进制分发) diff --git a/specs/002-askuserquestion-hook/contracts/hook-input-output.md b/specs/002-askuserquestion-hook/contracts/hook-input-output.md new file mode 100644 index 0000000..73aacdc --- /dev/null +++ b/specs/002-askuserquestion-hook/contracts/hook-input-output.md @@ -0,0 +1,317 @@ +# Hook 输入输出契约 + +**功能**: 002-askuserquestion-hook +**版本**: 1.0.0 +**创建日期**: 2026-01-20 + +## 概述 + +本文档定义了 `ccc supervisor-hook` 命令的输入输出契约,支持 Stop 和 PreToolUse 两种 hook 事件类型。 + +## 输入契约 + +### 通用输入格式 + +所有 hook 事件通过 stdin 接收 JSON 输入: + +``` +stdin ← JSON(HookInput) +``` + +### Stop 事件输入 + +```json +{ + "session_id": "string (必需)", + "stop_hook_active": "boolean" +} +``` + +**字段说明**: +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| session_id | string | 是 | Claude Code 会话 ID | +| stop_hook_active | boolean | 否 | 是否已由其他 stop hook 触发继续 | + +### PreToolUse 事件输入(AskUserQuestion) + +```json +{ + "session_id": "string (必需)", + "transcript_path": "string", + "cwd": "string", + "permission_mode": "string", + "hook_event_name": "PreToolUse", + "tool_name": "AskUserQuestion", + "tool_input": { + "questions": [...] + }, + "tool_use_id": "string" +} +``` + +**字段说明**: +| 字段 | 类型 | 必需 | 说明 | +|------|------|------|------| +| session_id | string | 是 | Claude Code 会话 ID | +| hook_event_name | string | 是 | 事件类型,固定为 "PreToolUse" | +| tool_name | string | 是 | 工具名称,固定为 "AskUserQuestion" | +| tool_input | object | 是 | 工具特定输入参数 | +| tool_use_id | string | 是 | 工具调用 ID | +| transcript_path | string | 否 | 会话记录文件路径 | +| cwd | string | 否 | 当前工作目录 | +| permission_mode | string | 否 | 权限模式 | + +## 输出契约 + +### 通用输出格式 + +所有 hook 事件通过 stdout 返回 JSON 输出: + +``` +stdout → JSON(HookOutput) +stderr → 日志信息(仅在 verbose/debug 模式) +exit code → 0 (成功) 或 2 (阻塞错误) +``` + +### Stop 事件输出 + +#### 允许停止(allow_stop = true) + +```json +{ + "reason": "工作已完成" +} +``` + +**行为**: Claude Code 停止执行 + +#### 阻止停止(allow_stop = false) + +```json +{ + "decision": "block", + "reason": "需要继续完善测试用例" +} +``` + +**行为**: Claude Code 继续工作,`reason` 字段作为反馈传入 + +### PreToolUse 事件输出(AskUserQuestion) + +#### 允许工具调用(allow_stop = true) + +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "问题合理,可以向用户提问" + } +} +``` + +**行为**: Claude Code 执行 AskUserQuestion 工具调用 + +#### 阻止工具调用(allow_stop = false) + +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "应该先添加更多代码注释" + } +} +``` + +**行为**: Claude Code 取消 AskUserQuestion 工具调用,`permissionDecisionReason` 作为反馈传入 + +## 错误处理 + +### 输入解析错误 + +``` +exit code: 2 +stderr: "failed to parse hook input: ..." +``` + +**行为**: hook 执行失败,Claude Code 记录错误 + +### SDK 调用失败 + +``` +exit code: 1 +stderr: "supervisor SDK failed: ..." +``` + +**行为**: hook 执行失败,Claude Code 记录错误 + +### 超时 + +``` +exit code: 124 (或特定超时退出码) +stderr: "hook execution timeout" +``` + +**行为**: hook 超时(600 秒),Claude Code 根据配置决定是否继续 + +## 命令行参数 + +### --session-id 参数 + +```bash +ccc supervisor-hook --session-id +``` + +**用途**: 直接指定 session ID,跳过 stdin 解析 + +**优先级**: 命令行参数 > stdin 输入 + +## 环境变量 + +### CCC_SUPERVISOR_ID + +```bash +CCC_SUPERVISOR_ID= ccc supervisor-hook +``` + +**用途**: 传递 supervisor 会话 ID,必需 + +### CCC_SUPERVISOR_HOOK + +```bash +CCC_SUPERVISOR_HOOK=1 +``` + +**用途**: 防止递归调用,当设置为 "1" 时,hook 直接返回允许决策 + +## 实现要求 + +### 向后兼容 + +1. 必须支持旧的 `StopHookInput` 结构(只有 `session_id` 和 `stop_hook_active` 字段) +2. 当 `hook_event_name` 字段不存在时,默认为 Stop 事件 +3. Stop 事件的输出格式必须保持不变 + +### 事件类型识别 + +1. 首先检查 `hook_event_name` 字段 +2. 如果值为 "PreToolUse",使用 PreToolUse 输出格式 +3. 否则,使用 Stop 输出格式(默认) + +### 迭代计数 + +1. 无论事件类型,都必须增加迭代计数 +2. 达到最大迭代次数时,返回允许决策 + +## 测试用例 + +### TC-001: Stop 事件 - 允许停止 + +**输入**: +```json +{"session_id": "test-001", "stop_hook_active": false} +``` + +**预期输出**: +```json +{"reason": "..."} +``` + +**exit code**: 0 + +### TC-002: Stop 事件 - 阻止停止 + +**输入**: +```json +{"session_id": "test-002", "stop_hook_active": false} +``` + +**预期输出**: +```json +{"decision": "block", "reason": "..."} +``` + +**exit code**: 0 + +### TC-003: PreToolUse 事件 - 允许调用 + +**输入**: +```json +{ + "session_id": "test-003", + "hook_event_name": "PreToolUse", + "tool_name": "AskUserQuestion", + "tool_input": {"questions": [...]}, + "tool_use_id": "toolu_001" +} +``` + +**预期输出**: +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "..." + } +} +``` + +**exit code**: 0 + +### TC-004: PreToolUse 事件 - 阻止调用 + +**输入**: +```json +{ + "session_id": "test-004", + "hook_event_name": "PreToolUse", + "tool_name": "AskUserQuestion", + "tool_input": {"questions": [...]}, + "tool_use_id": "toolu_002" +} +``` + +**预期输出**: +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "..." + } +} +``` + +**exit code**: 0 + +### TC-005: 递归调用防护 + +**环境变量**: `CCC_SUPERVISOR_HOOK=1` + +**预期输出**: 直接返回允许决策,不调用 SDK + +**exit code**: 0 + +### TC-006: 迭代计数限制 + +**前置条件**: 迭代计数已达上限(默认 20) + +**输入**: 任意 + +**预期输出**: 返回允许决策 + +**exit code**: 0 + +### TC-007: 向后兼容 - 无 hook_event_name + +**输入**: +```json +{"session_id": "test-007"} +``` + +**预期行为**: 默认为 Stop 事件,使用 Stop 输出格式 + +**exit code**: 0 diff --git a/specs/002-askuserquestion-hook/data-model.md b/specs/002-askuserquestion-hook/data-model.md new file mode 100644 index 0000000..79e5440 --- /dev/null +++ b/specs/002-askuserquestion-hook/data-model.md @@ -0,0 +1,335 @@ +# 数据模型:Supervisor Hook 支持 AskUserQuestion 工具调用审查 + +**功能**: 002-askuserquestion-hook +**创建日期**: 2026-01-20 +**状态**: 完成 + +## 核心数据结构 + +### 1. HookInput - Hook 输入结构 + +统一的 hook 输入结构,支持所有 Claude Code hook 事件类型。 + +```go +// HookInput 表示从 Claude Code hook 接收的输入 +type HookInput struct { + // 通用字段(所有事件类型共有) + SessionID string `json:"session_id"` // 会话 ID,必需 + TranscriptPath string `json:"transcript_path,omitempty"` + CWD string `json:"cwd,omitempty"` + PermissionMode string `json:"permission_mode,omitempty"` + HookEventName string `json:"hook_event_name,omitempty"` // "Stop", "PreToolUse", etc. + + // Stop 事件字段 + StopHookActive bool `json:"stop_hook_active,omitempty"` + + // PreToolUse 事件字段 + ToolName string `json:"tool_name,omitempty"` // 例如: "AskUserQuestion" + ToolInput json.RawMessage `json:"tool_input,omitempty"` // 工具特定输入 + ToolUseID string `json:"tool_use_id,omitempty"` // 工具调用 ID +} +``` + +**验证规则**: +- `SessionID` 为必需字段 +- `HookEventName` 用于区分事件类型,不存在时默认为 "Stop" +- `ToolName` 仅在 `HookEventName == "PreToolUse"` 时使用 + +### 2. HookOutput - Hook 输出结构 + +统一的 hook 输出结构,根据事件类型返回不同格式。 + +```go +// HookOutput 表示返回给 Claude Code hook 的输出 +type HookOutput struct { + // Stop 事件使用 + Decision *string `json:"decision,omitempty"` // "block" 或省略(省略表示允许停止) + Reason string `json:"reason,omitempty"` // 反馈信息 + + // PreToolUse 事件使用 + HookSpecificOutput *HookSpecificOutput `json:"hookSpecificOutput,omitempty"` +} +``` + +**输出格式规则**: + +| 事件类型 | AllowStop=true | AllowStop=false | +|----------|----------------|-----------------| +| **Stop** | `{"reason": "..."}` | `{"decision": "block", "reason": "..."}` | +| **PreToolUse** | `{"hookSpecificOutput": {...}}` with `permissionDecision: "allow"` | `{"hookSpecificOutput": {...}}` with `permissionDecision: "deny"` | + +### 3. HookSpecificOutput - PreToolUse 特定输出 + +```go +// HookSpecificOutput 表示 PreToolUse hook 的特定输出 +type HookSpecificOutput struct { + HookEventName string `json:"hookEventName"` // "PreToolUse" + PermissionDecision string `json:"permissionDecision"` // "allow", "deny", "ask" + PermissionDecisionReason string `json:"permissionDecisionReason"` // 决策原因 +} +``` + +**决策值说明**: +- `allow`: 允许工具调用执行(跳过权限确认) +- `deny`: 阻止工具调用执行 +- `ask`: 要求用户确认(正常权限流程) + +### 4. SupervisorResult - Supervisor 审查结果(内部使用) + +```go +// SupervisorResult 表示从 Supervisor SDK 解析出的审查结果 +type SupervisorResult struct { + AllowStop bool `json:"allow_stop"` // 是否允许操作(true=允许,false=阻止) + Feedback string `json:"feedback"` // 反馈信息 +} +``` + +**转换逻辑**: + +```go +// SupervisorResultToHookOutput 将内部审查结果转换为 hook 输出 +func SupervisorResultToHookOutput(result *SupervisorResult, eventType string) *HookOutput { + if eventType == "PreToolUse" { + decision := "allow" + if !result.AllowStop { + decision = "deny" + } + return &HookOutput{ + HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PreToolUse", + PermissionDecision: decision, + PermissionDecisionReason: result.Feedback, + }, + } + } + + // Stop 事件(默认) + if !result.AllowStop { + decision := "block" + return &HookOutput{ + Decision: &decision, + Reason: result.Feedback, + } + } + + // 允许停止 + return &HookOutput{ + Reason: result.Feedback, + } +} +``` + +### 5. 向后兼容类型 + +```go +// StopHookInput 保持向后兼容,是 HookInput 的别名 +type StopHookInput = HookInput +``` + +## 配置数据结构 + +### Claude Code Hooks 配置 + +在 `settings.json` 中的 hooks 配置结构: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "/path/to/ccc supervisor-hook", + "timeout": 600 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "AskUserQuestion", + "hooks": [ + { + "type": "command", + "command": "/path/to/ccc supervisor-hook", + "timeout": 600 + } + ] + } + ] + } +} +``` + +**配置说明**: +- `matcher`: 使用 "AskUserQuestion" 精确匹配该工具 +- `timeout`: 600 秒超时,与 Stop hook 一致 +- `command`: 复用相同的 `ccc supervisor-hook` 命令 + +## 状态数据结构 + +### Supervisor 状态(无变化) + +现有的 supervisor 状态结构保持不变: + +```go +// State 表示 supervisor 的持久化状态 +type State struct { + SessionID string `json:"session_id"` // 会话 ID + Enabled bool `json:"enabled"` // 是否启用 supervisor 模式 + Count int `json:"count"` // 迭代计数 + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedAt time.Time `json:"updated_at"` // 更新时间 +} +``` + +**变更**: 迭代计数 `Count` 现在会在所有 hook 事件类型(Stop 和 PreToolUse)中递增。 + +## 数据流图 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Claude Code │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ 准备调用 AskUserQuestion + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ PreToolUse Hook 触发 │ +│ 输入: {session_id, hook_event_name: "PreToolUse", │ +│ tool_name: "AskUserQuestion", tool_input: {...}} │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ ccc supervisor-hook + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ RunSupervisorHook() │ +│ 1. 解析输入 → HookInput │ +│ 2. 检查 CCC_SUPERVISOR_HOOK 防止递归 │ +│ 3. 加载 State,检查是否启用 │ +│ 4. 检查迭代计数限制 │ +│ 5. 增加迭代计数 │ +│ 6. 调用 Supervisor SDK 审查 │ +│ 7. 解析结果 → SupervisorResult │ +│ 8. 转换输出 → HookOutput │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ 输出: {hookSpecificOutput: {permissionDecision: "deny", ...}} + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Claude Code │ +│ 根据决策: deny → 取消 AskUserQuestion 调用 │ +│ allow → 正常执行 AskUserQuestion 调用 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## 错误处理 + +### 输入解析错误 + +```go +// 解析失败时的行为 +if err := json.Unmarshal(stdinData, &input); err != nil { + // 返回 exit code 2,stderr 包含错误信息 + return fmt.Errorf("failed to parse hook input: %w", err) +} +``` + +### 输出生成错误 + +```go +// 如果输出 JSON 生成失败,返回错误 +outputJSON, err := json.Marshal(output) +if err != nil { + return fmt.Errorf("failed to marshal hook output: %w", err) +} +``` + +### SDK 调用失败 + +```go +// 如果 Supervisor SDK 调用失败,使用 fallback 策略 +if err := runSupervisorWithSDK(...); err != nil { + // 记录错误日志 + log.Error("supervisor SDK failed", "error", err.Error()) + // 返回 deny 决策(安全失败) + return &HookOutput{ + HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PreToolUse", + PermissionDecision: "deny", + PermissionDecisionReason: "Supervisor 审查失败,已阻止操作", + }, + } +} +``` + +## 测试数据示例 + +### Stop 事件 + +**输入**: +```json +{ + "session_id": "abc123", + "stop_hook_active": false +} +``` + +**输出(阻止停止)**: +```json +{ + "decision": "block", + "reason": "工作尚未完成,需要继续测试" +} +``` + +**输出(允许停止)**: +```json +{ + "reason": "工作已完成" +} +``` + +### PreToolUse 事件(AskUserQuestion) + +**输入**: +```json +{ + "session_id": "abc123", + "hook_event_name": "PreToolUse", + "tool_name": "AskUserQuestion", + "tool_input": { + "questions": [ + { + "question": "请选择实现方案", + "header": "方案选择", + "options": [...] + } + ] + }, + "tool_use_id": "toolu_01ABC123..." +} +``` + +**输出(允许)**: +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "问题合理,可以向用户提问" + } +} +``` + +**输出(阻止)**: +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "应该在代码中添加更多注释后再提问" + } +} +``` diff --git a/specs/002-askuserquestion-hook/plan.md b/specs/002-askuserquestion-hook/plan.md new file mode 100644 index 0000000..cbe4cf0 --- /dev/null +++ b/specs/002-askuserquestion-hook/plan.md @@ -0,0 +1,391 @@ +# 实现方案:Supervisor Hook 支持 AskUserQuestion 工具调用审查 + +**分支**: `002-askuserquestion-hook` | **日期**: 2026-01-20 | **规格**: [spec.md](./spec.md) +**输入**: 来自 `/specs/002-askuserquestion-hook/spec.md` 的功能规格 + +## 特别说明:使用中文 + +**本文档必须使用中文编写。** + +1. 所有技术描述、架构决策、实现细节必须使用中文。 +2. 代码示例中的注释必须使用中文。 +3. 变量名、函数名等标识符使用英文,但说明文字使用中文。 + +## 摘要 + +本功能扩展 Supervisor Hook 机制,使其不仅能在任务结束时(Stop 事件)进行审查,也能在任务执行过程中对关键交互(AskUserQuestion 工具调用)进行质量控制。 + +**主要需求**: +1. 在 Claude Code 配置中添加 PreToolUse hook,匹配 AskUserQuestion 工具 +2. 扩展 hook 输入解析,支持 `tool_name` 和 `hook_event_name` 字段 +3. 根据 `allow_stop` 决定返回 "allow" 或 "deny" 决策 +4. 在 `permissionDecisionReason` 字段中填写 feedback 内容 +5. 在 PreToolUse hook 触发时增加迭代计数 + +**技术方案摘要**: +- 扩展 `HookInput` 结构支持所有 hook 事件类型 +- 添加 `HookOutput` 结构根据事件类型返回不同格式 +- 在 `provider.go` 中添加 PreToolUse hook 配置 +- 复用现有的 `ccc supervisor-hook` 命令 +- 保持向后兼容,Stop hook 继续使用现有格式 + +## 技术上下文 + +**语言/版本**: Go 1.23 +**主要依赖**: +- `github.com/schlunsen/claude-agent-sdk-go` - Claude Agent SDK +- 标准库:`encoding/json`, `fmt`, `os`, `log/slog` +- 项目内部包:`internal/config`, `internal/supervisor`, `internal/llmparser`, `internal/prettyjson` + +**存储**: +- 配置文件:`~/.claude/settings.json` (Claude Code hooks 配置) +- 状态文件:`~/.claude/ccc/supervisor-{session_id}.json` (Supervisor 状态) +- Prompt 文件:`~/.claude/SUPERVISOR.md` 或内置 default prompt + +**测试**: `go test` (单元测试), `go test -race` (竞态检测), `go test -tags=integration` (集成测试) + +**目标平台**: CLI 工具,支持 darwin-amd64, darwin-arm64, linux-amd64, linux-arm64 + +**项目类型**: single (单一 Go 可执行文件) + +**性能目标**: +- Hook 响应时间 < 30 秒 +- 内存占用 < 50MB (hook 进程) +- 不影响 Claude Code 正常使用体验 + +**约束**: +- 单二进制分发(静态链接) +- 向后兼容(现有 Stop hook 功能不受影响) +- 跨平台支持(所有目标平台) + +**规模/范围**: < 500 行新增代码,主要在 `internal/cli/hook.go` 和 `internal/provider/provider.go` + +## 宪章检查 + +*门禁:必须在第 0 阶段研究前通过。第 1 阶段设计后再次检查。* + +### ccc 项目宪章合规检查 + +- [x] **原则一:单二进制分发** - 最终产物是单一静态链接二进制文件 +- [x] **原则二:代码质量标准** - 符合 gofmt、go vet 要求 +- [x] **原则三:测试规范** - 包含单元测试和竞态检测 +- [x] **原则四:向后兼容** - 配置格式变更保持兼容 +- [x] **原则五:跨平台支持** - 支持 darwin/linux, amd64/arm64 +- [x] **原则六:错误处理与可观测性** - 错误明确且可操作 + +### 复杂度跟踪 + +本功能无需任何宪章违规,所有实现都遵循现有原则和模式。 + +## 项目结构 + +### 文档组织(本功能) + +```text +specs/002-askuserquestion-hook/ +├── plan.md # 本文件 +├── research.md # 技术研究结果 +├── data-model.md # 数据模型定义 +├── quickstart.md # 快速入门指南 +├── contracts/ # API 契约 +│ └── hook-input-output.md +├── checklists/ # 质量检查清单 +│ └── requirements.md +└── tasks.md # 任务分解(由 /speckit.tasks 生成) +``` + +### 源代码组织(仓库根目录) + +```text +cmd/ccc/ # 主入口 +├── main.go + +internal/ # 私有应用代码 +├── cli/ # CLI 命令处理(主要修改) +│ ├── cli.go +│ ├── hook.go # [修改] 扩展输入输出格式 +│ └── hook_test.go # [新增/修改] 测试 +├── config/ # 配置管理 +├── provider/ # 提供商切换逻辑(主要修改) +│ └── provider.go # [修改] 添加 PreToolUse hook 配置 +└── supervisor/ # Supervisor 模式 + ├── state.go + ├── output.go + └── logger.go +``` + +**结构决策**: 使用现有的单一 Go 项目结构,主要修改 `internal/cli/hook.go` 和 `internal/provider/provider.go` 两个文件。 + +## 实现阶段 + +### 第 -1 阶段:预实现门禁 + +> **重要:在开始任何实现工作前必须通过此阶段** + +#### 宪章合规门禁 + +- [x] 所有 6 条核心原则已检查 +- [x] 如有违规,已在"复杂度跟踪"表中记录理由 + +#### 技术决策门禁 + +- [x] 技术栈已确定(语言、依赖) +- [x] 项目结构已定义 +- [x] 数据模型已设计(data-model.md) +- [x] API 契约已定义(contracts/hook-input-output.md) + +--- + +### 第 0 阶段:技术研究 + +**目标**: 调研技术选项,收集实现所需信息 + +**输出**: `research.md` + +**研究内容**: +- [x] 可用的 Go 标准库和第三方包 +- [x] Claude Code PreToolUse hook 的输入输出格式 +- [x] 如何区分不同的 hook 事件类型 +- [x] 向后兼容性策略 +- [x] 测试框架和 Mock 工具 + +**状态**: 已完成 + +--- + +### 第 1 阶段:架构设计 + +**目标**: 定义数据模型、API 契约和实现细节 + +**输出**: `data-model.md`、`contracts/`、`quickstart.md` + +**数据模型** (data-model.md): +- [x] 定义核心数据结构(HookInput, HookOutput, HookSpecificOutput) +- [x] 定义配置格式(PreToolUse hook 配置) +- [x] 定义错误处理契约 + +**API 契约** (contracts/hook-input-output.md): +- [x] 输入契约(Stop 和 PreToolUse 事件) +- [x] 输出契约(不同事件的输出格式) +- [x] 错误处理规范 +- [x] 测试用例定义 + +**快速入门** (quickstart.md): +- [x] 关键验证场景 +- [x] 测试检查清单 +- [x] 调试技巧 +- [x] 常见问题排查 + +**状态**: 已完成 + +--- + +### 第 2 阶段:任务分解 + +**目标**: 将设计转化为可执行的任务列表 + +**输出**: `tasks.md` (由 `/speckit.tasks` 命令生成) + +> **注意**: 第 2 阶段不在此方案中完成,由独立的 `/speckit.tasks` 命令处理 + +--- + +## 实施文件创建顺序 + +> **重要:按照此顺序创建文件以确保质量** + +1. **contracts/** - 首先定义 API 契约和接口 +2. **测试文件** - 按以下顺序创建: + - `internal/cli/hook_test.go` - 单元测试(测试输入解析、输出转换) + - 集成测试(测试完整的 hook 流程) +3. **源代码文件** - 创建使测试通过的实现: + - `internal/cli/hook.go` - 扩展输入输出格式 + - `internal/provider/provider.go` - 添加 PreToolUse hook 配置 + +**理由**: 测试先行确保 API 设计可用,实现符合需求。 + +## 实现细节 + +### 1. 数据结构定义 + +**位置**: `internal/cli/hook.go` + +```go +// HookInput 支持所有 hook 事件类型 +type HookInput struct { + SessionID string `json:"session_id"` + StopHookActive bool `json:"stop_hook_active,omitempty"` + HookEventName string `json:"hook_event_name,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolInput json.RawMessage `json:"tool_input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + TranscriptPath string `json:"transcript_path,omitempty"` + CWD string `json:"cwd,omitempty"` + PermissionMode string `json:"permission_mode,omitempty"` +} + +// HookOutput 根据事件类型返回不同格式 +type HookOutput struct { + Decision *string `json:"decision,omitempty"` + Reason string `json:"reason,omitempty"` + HookSpecificOutput *HookSpecificOutput `json:"hookSpecificOutput,omitempty"` +} + +// HookSpecificOutput 表示 PreToolUse hook 的特定输出 +type HookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + PermissionDecision string `json:"permissionDecision"` + PermissionDecisionReason string `json:"permissionDecisionReason"` +} + +// 向后兼容:保留旧类型 +type StopHookInput = HookInput +``` + +### 2. 输出转换函数 + +**位置**: `internal/cli/hook.go` + +```go +// SupervisorResultToHookOutput 将内部审查结果转换为 hook 输出 +func SupervisorResultToHookOutput(result *SupervisorResult, eventType string) *HookOutput { + if eventType == "PreToolUse" { + decision := "allow" + if !result.AllowStop { + decision = "deny" + } + return &HookOutput{ + HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PreToolUse", + PermissionDecision: decision, + PermissionDecisionReason: result.Feedback, + }, + } + } + + // Stop 事件(默认) + if !result.AllowStop { + decision := "block" + return &HookOutput{ + Decision: &decision, + Reason: result.Feedback, + } + } + + return &HookOutput{ + Reason: result.Feedback, + } +} +``` + +### 3. 扩展输入解析 + +**位置**: `internal/cli/hook.go`,修改 `RunSupervisorHook` 函数 + +```go +// 使用新的 HookInput 结构(向后兼容) +var input HookInput +if err := decoder.Decode(&input); err != nil { + return fmt.Errorf("failed to parse stdin JSON: %w", err) +} + +// 识别事件类型 +eventType := input.HookEventName +if eventType == "" { + eventType = "Stop" // 默认为 Stop 事件 +} +``` + +### 4. 扩展输出格式 + +**位置**: `internal/cli/hook.go`,修改输出逻辑 + +```go +// 转换结果为 hook 输出 +output := SupervisorResultToHookOutput(result, eventType) + +// 输出 JSON +outputJSON, err := json.MarshalIndent(output, "", " ") +if err != nil { + return fmt.Errorf("failed to marshal hook output: %w", err) +} +fmt.Println(string(outputJSON)) +``` + +### 5. 添加 PreToolUse hook 配置 + +**位置**: `internal/provider/provider.go`,修改 `SwitchWithHook` 函数 + +```go +// Create hooks configuration +hooks := map[string]interface{}{ + "Stop": []map[string]interface{}{ + { + "hooks": []map[string]interface{}{ + { + "type": "command", + "command": hookCommand, + "timeout": 600, + }, + }, + }, + }, + "PreToolUse": []map[string]interface{}{ + { + "matcher": "AskUserQuestion", + "hooks": []map[string]interface{}{ + { + "type": "command", + "command": hookCommand, + "timeout": 600, + }, + }, + }, + }, +} +``` + +### 6. 迭代计数一致性 + +**位置**: `internal/cli/hook.go`,`RunSupervisorHook` 函数 + +确保所有事件类型都增加迭代计数(当前实现已在 SDK 调用前增加计数,无需修改)。 + +## 测试策略 + +### 单元测试 + +1. **测试输入解析**: + - Stop 事件输入解析 + - PreToolUse 事件输入解析 + - 缺少 `hook_event_name` 字段时默认为 Stop + +2. **测试输出转换**: + - SupervisorResult → Stop 事件输出 + - SupervisorResult → PreToolUse 事件输出 + - allow_stop=true → "allow" 决策 + - allow_stop=false → "deny" 决策 + +3. **测试向后兼容**: + - 旧格式输入能正确解析 + - 旧格式输出保持不变 + +### 集成测试 + +1. **测试完整 hook 流程**: + - 模拟 PreToolUse hook 调用 + - 验证输出格式正确 + +2. **测试配置生成**: + - 验证生成的 hooks 配置包含 PreToolUse + +### 端到端测试 + +1. **手动测试**: + - 启用 Supervisor 模式 + - 触发 AskUserQuestion 调用 + - 验证审查行为 + +## 复杂度跟踪 + +本功能无需任何宪章违规,所有实现都遵循现有原则和模式。 diff --git a/specs/002-askuserquestion-hook/quickstart.md b/specs/002-askuserquestion-hook/quickstart.md new file mode 100644 index 0000000..80ac10b --- /dev/null +++ b/specs/002-askuserquestion-hook/quickstart.md @@ -0,0 +1,211 @@ +# 快速入门:Supervisor Hook 支持 AskUserQuestion 工具调用审查 + +**功能**: 002-askuserquestion-hook +**创建日期**: 2026-01-20 + +## 关键验证场景 + +本文档描述如何验证 Supervisor Hook 对 AskUserQuestion 工具调用的审查功能。 + +### 场景 1: 启用 Supervisor 模式并触发 AskUserQuestion 审查 + +**目的**: 验证 AskUserQuestion 调用时正确触发 Supervisor 审查 + +**步骤**: +1. 启用 Supervisor 模式: + ``` + /supervisor on + ``` +2. 在 Claude Code 中执行一个会触发 AskUserQuestion 的任务 +3. 观察 hook 是否被触发(检查日志) + +**预期结果**: +- AskUserQuestion 调用被拦截 +- Supervisor hook 被触发 +- 审查完成后返回决策 + +### 场景 2: Supervisor 阻止 AskUserQuestion 调用 + +**目的**: 验证 Supervisor 可以阻止不合理的 AskUserQuestion 调用 + +**步骤**: +1. 确保 Supervisor prompt 配置为严格模式 +2. 触发一个不太合理的 AskUserQuestion 调用(例如过早提问) +3. 观察 Supervisor 的决策 + +**预期结果**: +- Supervisor 返回 `permissionDecision: "deny"` +- AskUserQuestion 调用被取消 +- Claude Code 收到反馈并继续工作 + +### 场景 3: Supervisor 允许 AskUserQuestion 调用 + +**目的**: 验证 Supervisor 可以允许合理的 AskUserQuestion 调用 + +**步骤**: +1. 完成大部分工作后,触发一个合理的 AskUserQuestion 调用 +2. 观察 Supervisor 的决策 + +**预期结果**: +- Supervisor 返回 `permissionDecision: "allow"` +- AskUserQuestion 调用正常执行 +- 用户可以看到问题并选择答案 + +### 场景 4: 迭代计数在 AskUserQuestion hook 中递增 + +**目的**: 验证迭代计数在所有 hook 类型中一致递增 + +**步骤**: +1. 检查当前迭代计数:`cat ~/.claude/ccc/supervisor-.json` +2. 触发 AskUserQuestion hook +3. 再次检查迭代计数 + +**预期结果**: +- 迭代计数增加 1 + +### 场景 5: 向后兼容 - Stop hook 继续工作 + +**目的**: 验证现有 Stop hook 功能不受影响 + +**步骤**: +1. 触发一个会触发 Stop hook 的场景(完成工作) +2. 观察 Stop hook 的行为 + +**预期结果**: +- Stop hook 正常工作 +- 输出格式符合原有规范 + +## 测试检查清单 + +### 手动测试 + +- [ ] **TC-001**: Stop 事件 - 允许停止 +- [ ] **TC-002**: Stop 事件 - 阻止停止 +- [ ] **TC-003**: PreToolUse 事件 - 允许调用 +- [ ] **TC-004**: PreToolUse 事件 - 阻止调用 +- [ ] **TC-005**: 递归调用防护 +- [ ] **TC-006**: 迭代计数限制 +- [ ] **TC-007**: 向后兼容 - 无 hook_event_name + +### 自动化测试 + +运行单元测试: +```bash +go test ./internal/cli/... -v +``` + +运行集成测试: +```bash +go test ./internal/cli/... -v -tags=integration +``` + +运行竞态检测: +```bash +go test ./internal/cli/... -race +``` + +## 调试技巧 + +### 启用详细日志 + +```bash +# 设置环境变量启用调试 +export CCC_DEBUG=1 +# 或 +claude --debug +``` + +### 查看 hook 配置 + +```bash +# 查看当前 hooks 配置 +cat ~/.claude/settings.json | jq '.hooks' +``` + +预期应看到: +```json +{ + "Stop": [...], + "PreToolUse": [ + { + "matcher": "AskUserQuestion", + "hooks": [...] + } + ] +} +``` + +### 查看 Supervisor 状态 + +```bash +# 查看 supervisor 状态文件 +cat ~/.claude/ccc/supervisor-.json +``` + +### 查看 hook 日志 + +```bash +# 如果启用了日志文件 +tail -f ~/.claude/ccc/supervisor-.log +``` + +## 常见问题排查 + +### 问题 1: AskUserQuestion 没有被拦截 + +**可能原因**: +1. Supervisor 模式未启用 +2. hooks 配置未正确更新 +3. matcher 配置不正确 + +**排查步骤**: +1. 检查 Supervisor 模式状态:`/supervisor`(应该返回 "on") +2. 检查 hooks 配置:`cat ~/.claude/settings.json | jq '.hooks.PreToolUse'` +3. 检查 ccc 版本:`ccc --version` + +### 问题 2: hook 返回错误格式 + +**可能原因**: +1. 事件类型识别错误 +2. 输出格式转换错误 + +**排查步骤**: +1. 查看日志中的事件类型 +2. 检查 hook 输出的 JSON 格式 +3. 验证 `hook_event_name` 字段值 + +### 问题 3: 迭代计数不递增 + +**可能原因**: +1. 状态文件写入失败 +2. 计数逻辑未更新 + +**排查步骤**: +1. 检查状态文件权限 +2. 查看日志中的计数输出 +3. 手动检查状态文件内容 + +## 性能指标 + +### 预期响应时间 + +| 操作 | 预期时间 | +|------|----------| +| AskUserQuestion hook 触发 | < 1s | +| Supervisor SDK 调用 | < 30s | +| 总体审查时间 | < 30s | + +### 资源使用 + +| 资源 | 预期值 | +|------|--------| +| 内存占用 | < 50MB (hook 进程) | +| CPU 使用 | < 10% (SDK 调用期间) | +| 状态文件大小 | < 1KB | + +## 下一步 + +完成验证后,可以: +1. 查看 `tasks.md` 了解实现任务分解 +2. 运行完整的测试套件 +3. 提交 Pull Request diff --git a/specs/002-askuserquestion-hook/research.md b/specs/002-askuserquestion-hook/research.md new file mode 100644 index 0000000..e5c0a8c --- /dev/null +++ b/specs/002-askuserquestion-hook/research.md @@ -0,0 +1,277 @@ +# 技术研究:Supervisor Hook 支持 AskUserQuestion 工具调用审查 + +**功能**: 002-askuserquestion-hook +**创建日期**: 2026-01-20 +**状态**: 已完成 + +## 研究目标 + +本研究为"Supervisor Hook 支持 AskUserQuestion 工具调用审查"功能提供技术决策依据,主要研究: +1. Claude Code PreToolUse hook 的输入输出格式 +2. 如何区分不同的 hook 事件类型 +3. Go 代码中如何扩展 hook 输入解析和输出格式 + +## 研究发现 + +### 1. Claude Code PreToolUse Hook 格式 + +根据 Claude Code hooks 文档 (`docs/claude-code-hooks.md`): + +**PreToolUse 输入格式**: +```json +{ + "session_id": "abc123", + "transcript_path": "/path/to/transcript", + "cwd": "/current/directory", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "AskUserQuestion", + "tool_input": { + "questions": [...] + }, + "tool_use_id": "toolu_01ABC123..." +} +``` + +**PreToolUse 输出格式**(用于决策控制): +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", // 或 "deny", "ask" + "permissionDecisionReason": "决策原因说明" + } +} +``` + +**决策说明**: +- `allow`: 允许工具调用执行(跳过权限确认) +- `deny`: 阻止工具调用执行 +- `ask`: 要求用户确认(正常权限流程) + +### 2. Stop Hook 现有格式 + +**Stop 输入格式**(当前实现): +```json +{ + "session_id": "abc123", + "stop_hook_active": false +} +``` + +**Stop 输出格式**(当前实现): +```json +{ + "decision": "block", // 阻止停止,继续工作 + "reason": "反馈内容" +} +// 或(允许停止,省略 decision 字段) +{ + "reason": "工作已完成" +} +``` + +### 3. 事件类型识别策略 + +**决策**: 通过检测输入 JSON 中的 `hook_event_name` 字段来识别事件类型 + +| hook_event_name | 输入结构 | 输出结构 | +|-----------------|----------|----------| +| `Stop` | session_id, stop_hook_active | decision, reason | +| `PreToolUse` | session_id, tool_name, hook_event_name, tool_input | hookSpecificOutput.permissionDecision, hookSpecificOutput.permissionDecisionReason | + +**理由**: +- `hook_event_name` 是 Claude Code 提供的标准字段 +- 不需要维护额外的状态来区分事件类型 +- 向后兼容:如果字段不存在,默认为 Stop 事件 + +### 4. 数据结构扩展 + +**当前代码结构** (`internal/cli/hook.go`): + +```go +// StopHookInput 当前只支持 Stop 事件 +type StopHookInput struct { + SessionID string `json:"session_id"` + StopHookActive bool `json:"stop_hook_active"` +} + +// SupervisorResult 当前只返回 Stop 格式 +type SupervisorResult struct { + AllowStop bool `json:"allow_stop"` + Feedback string `json:"feedback"` +} +``` + +**扩展后的结构**: + +```go +// HookInput 支持所有 hook 事件类型 +type HookInput struct { + SessionID string `json:"session_id"` + StopHookActive bool `json:"stop_hook_active,omitempty"` + HookEventName string `json:"hook_event_name,omitempty"` // "Stop", "PreToolUse", etc. + ToolName string `json:"tool_name,omitempty"` // PreToolUse 特有 + ToolInput json.RawMessage `json:"tool_input,omitempty"` // PreToolUse 特有 + ToolUseID string `json:"tool_use_id,omitempty"` // PreToolUse 特有 + // 其他通用字段... + TranscriptPath string `json:"transcript_path,omitempty"` + CWD string `json:"cwd,omitempty"` + PermissionMode string `json:"permission_mode,omitempty"` +} + +// HookOutput 根据事件类型返回不同格式 +type HookOutput struct { + // Stop 事件使用 + Decision *string `json:"decision,omitempty"` // "block" 或省略 + Reason string `json:"reason,omitempty"` + + // PreToolUse 事件使用 + HookSpecificOutput *HookSpecificOutput `json:"hookSpecificOutput,omitempty"` +} + +type HookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + PermissionDecision string `json:"permissionDecision"` // "allow", "deny", "ask" + PermissionDecisionReason string `json:"permissionDecisionReason"` +} +``` + +### 5. 向后兼容性策略 + +**决策**: 保持现有 Stop hook 完全兼容 + +**实现方式**: +1. 保留 `StopHookInput` 类型作为 `HookInput` 的别名 +2. 保留 `SupervisorResult` 类型,添加转换逻辑 +3. 输入解析时先尝试新结构,失败后回退到旧结构 +4. 输出时根据事件类型选择对应格式 + +**代码示例**: +```go +// 兼容性:保持旧类型作为别名 +type StopHookInput = HookInput + +// 输出转换函数 +func supervisorResultToHookOutput(result *SupervisorResult, eventType string) *HookOutput { + if eventType == "PreToolUse" { + decision := "allow" + if !result.AllowStop { + decision = "deny" + } + return &HookOutput{ + HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PreToolUse", + PermissionDecision: decision, + PermissionDecisionReason: result.Feedback, + }, + } + } + // Stop 事件(默认) + if !result.AllowStop { + decision := "block" + return &HookOutput{ + Decision: &decision, + Reason: result.Feedback, + } + } + return &HookOutput{ + Reason: result.Feedback, + } +} +``` + +### 6. 迭代计数一致性 + +**当前实现**: 迭代计数只在 Stop hook 时增加 + +**变更**: 在所有 hook 事件类型中增加迭代计数 + +**理由**: +- 防止因审查点增多导致无限循环 +- 保持审查逻辑一致性 +- 迭代限制是保护机制,不应因事件类型而异 + +### 7. 配置生成策略 + +**当前代码** (`internal/provider/provider.go`): + +```go +hooks := map[string]interface{}{ + "Stop": []map[string]interface{}{ + { + "hooks": []map[string]interface{}{ + { + "type": "command", + "command": hookCommand, + "timeout": 600, + }, + }, + }, + }, +} +``` + +**扩展后**: + +```go +hooks := map[string]interface{}{ + "Stop": []map[string]interface{}{ + { + "hooks": []map[string]interface{}{ + { + "type": "command", + "command": hookCommand, + "timeout": 600, + }, + }, + }, + }, + "PreToolUse": []map[string]interface{}{ + { + "matcher": "AskUserQuestion", // 只匹配 AskUserQuestion 工具 + "hooks": []map[string]interface{}{ + { + "type": "command", + "command": hookCommand, // 复用相同的命令 + "timeout": 600, + }, + }, + }, + }, +} +``` + +**决策**: 使用 `matcher: "AskUserQuestion"` 只匹配该工具 + +**理由**: +- 不是所有工具调用都需要 supervisor 审查 +- AskUserQuestion 是关键交互点,符合审查目标 +- 可扩展:如果将来需要审查其他工具,可以添加更多 matcher + +## 技术决策总结 + +| 决策项 | 选择 | 理由 | +|--------|------|------| +| 事件类型识别 | 通过 `hook_event_name` 字段 | Claude Code 标准字段,向后兼容 | +| 输入结构 | 扩展 `HookInput` 支持所有字段 | 统一解析,减少代码重复 | +| 输出结构 | 根据 `hook_event_name` 返回不同格式 | 符合 Claude Code hook 规范 | +| 向后兼容 | 保留旧类型,添加转换函数 | 不破坏现有功能 | +| PreToolUse 匹配 | 使用 `matcher: "AskUserQuestion"` | 只审查关键工具调用 | +| 迭代计数 | 所有事件类型都增加计数 | 防止无限循环 | + +## 未考虑的方案及原因 + +| 方案 | 被拒绝的原因 | +|------|-------------| +| 为每个 hook 事件类型创建独立命令 | 代码重复,维护成本高 | +| 使用环境变量传递事件类型 | 不符合 Claude Code hook 规范 | +| 只审查 Stop 事件 | 无法满足用户需求,审查不全面 | +| 审查所有 PreToolUse 事件 | 性能影响大,大多数工具调用不需要审查 | + +## 参考资料 + +- Claude Code Hooks 文档: `docs/claude-code-hooks.md` +- 现有 hook 实现: `internal/cli/hook.go` +- Provider 配置生成: `internal/provider/provider.go` +- 项目宪章: `.specify/memory/constitution.md` diff --git a/specs/002-askuserquestion-hook/spec.md b/specs/002-askuserquestion-hook/spec.md new file mode 100644 index 0000000..6d4a137 --- /dev/null +++ b/specs/002-askuserquestion-hook/spec.md @@ -0,0 +1,108 @@ +# 功能规格:Supervisor Hook 支持 AskUserQuestion 工具调用审查 + +**功能分支**: `002-askuserquestion-hook` +**创建日期**: 2026-01-20 +**状态**: 草稿 +**输入**: 用户描述:"Supervisor hook 支持 AskUserQuestion 工具调用审查。当 Claude Code 调用 AskUserQuestion 工具时,supervisor hook 也应该进行审查。根据 allow_stop 决定是 deny 还是 allow,在 permissionDecisionReason 字段填写 feedback。需要扩展输出格式支持 PreToolUse 的决策控制,输入格式也需要扩展,支持 tool_name、hook_event_name 字段。Supervisor prompt 保持不变,迭代计数应该增加。" + +## 特别说明:使用中文 + +**本文档必须使用中文编写。** + +1. 所有用户故事、需求描述、验收场景必须使用中文。 +2. 用户场景描述应该使用自然、易懂的中文。 +3. 功能需求使用中文描述,技术术语保留英文。 + +## 用户场景与测试 *(必填)* + +### 用户故事 1 - Supervisor 审查 AskUserQuestion 调用 (优先级: P1) + +当 Claude Code 准备向用户提问时,Supervisor 能够审查这个问题是否合理,并在必要时阻止或允许这次提问。 + +**为什么是这个优先级**: 这是核心功能,确保 Supervisor 不仅在任务结束时审查,也能在任务执行过程中对关键交互(向用户提问)进行质量控制,保持审查的一致性。 + +**独立测试**: 可以通过启用 Supervisor 模式后,触发一个会导致 Claude Code 调用 AskUserQuestion 的场景,验证是否正确触发审查并根据审查结果允许或阻止提问。 + +**验收场景**: + +1. **给定** Supervisor 模式已启用,**当** Claude Code 准备调用 AskUserQuestion 工具,**那么** Supervisor hook 应被触发,审查这次提问 +2. **给定** Supervisor 决定阻止提问(allow_stop=false),**当** PreToolUse hook 返回 deny 决策,**那么** Claude Code 应该取消这次提问并收到反馈 +3. **给定** Supervisor 决定允许提问(allow_stop=true),**当** PreToolUse hook 返回 allow 决策,**那么** Claude Code 应该正常执行 AskUserQuestion 调用 + +--- + +### 用户故事 2 - 扩展输入输出格式支持 (优先级: P1) + +Supervisor hook 需要能够识别不同的 hook 事件类型(Stop vs PreToolUse),并根据事件类型返回正确的决策格式。 + +**为什么是这个优先级**: 这是技术基础,没有正确的输入输出格式支持,Supervisor 无法区分不同事件并返回相应决策。 + +**独立测试**: 可以通过模拟不同 hook 事件的输入(Stop 和 PreToolUse),验证 hook 命令能正确解析输入并返回对应格式的输出。 + +**验收场景**: + +1. **给定** hook 输入包含 `tool_name` 和 `hook_event_name` 字段,**当** Supervisor hook 处理 PreToolUse 事件,**那么** 输出应包含 `permissionDecision` 和 `permissionDecisionReason` 字段 +2. **给定** hook 输入是 Stop 事件格式,**当** Supervisor hook 处理 Stop 事件,**那么** 输出应保持现有格式(`decision` 和 `reason` 字段) + +--- + +### 用户故事 3 - 迭代计数一致性 (优先级: P2) + +无论 hook 事件类型是 Stop 还是 PreToolUse,都应该计入迭代计数,防止无限循环。 + +**为什么是这个优先级**: 这是保护机制,确保 Supervisor 不会因为审查点增多而导致无限循环。 + +**独立测试**: 可以通过多次触发不同类型的 hook 事件,验证迭代计数是否正确递增并在达到上限时停止。 + +**验收场景**: + +1. **给定** 最大迭代次数为 20,**当** AskUserQuestion hook 被触发,**那么** 迭代计数应增加 1 +2. **给定** 迭代计数已达上限,**当** 任何 hook 事件被触发,**那么** 应自动允许操作并停止审查 + +--- + +### 边缘情况 + +- 当 AskUserQuestion hook 返回的决策格式不正确时,系统如何处理? +- 当 hook 输入中缺少 tool_name 或 hook_event_name 字段时,系统如何识别事件类型? +- 当 Supervisor 在 AskUserQuestion hook 中被递归调用时(例如 Supervisor 本身需要提问),如何防止无限循环? +- 当 PreToolUse hook 超时时,Claude Code 的默认行为是什么? + +## 需求 *(必填)* + +### 功能需求 + +- **FR-001**: 系统必须在 Claude Code 配置中添加 PreToolUse hook,匹配 AskUserQuestion 工具 +- **FR-002**: 系统必须扩展 hook 输入解析,支持 `tool_name` 和 `hook_event_name` 字段 +- **FR-003**: 系统必须根据 `allow_stop` 决定返回 "allow" 或 "deny" 决策 +- **FR-004**: 系统必须在 `permissionDecisionReason` 字段中填写 feedback 内容 +- **FR-005**: 系统必须在 PreToolUse hook 触发时增加迭代计数 +- **FR-006**: 系统必须保持 Supervisor prompt 不变 +- **FR-007**: 系统必须支持向后兼容,Stop hook 事件继续使用现有格式 + +### 核心实体 + +- **Hook 输入结构**: 包含 session_id、tool_name(PreToolUse 特有)、hook_event_name(PreToolUse 特有)、tool_input(PreToolUse 特有)等字段 +- **Hook 输出结构 (PreToolUse)**: 包含 permissionDecision("allow"/"deny")、permissionDecisionReason、hookSpecificOutput 等字段 +- **Hook 输出结构 (Stop)**: 保持现有格式,包含 decision("block"/undefined)、reason 字段 + +## 成功标准 *(必填)* + +### 可衡量的结果 + +- **SC-001**: AskUserQuestion hook 触发时,Supervisor 审查响应时间在 30 秒内完成 +- **SC-002**: 100% 的 AskUserQuestion 调用都能正确触发 Supervisor 审查(当 Supervisor 模式启用时) +- **SC-003**: PreToolUse hook 返回的决策格式符合 Claude Code 规范,能够正确控制工具调用 +- **SC-004**: 迭代计数在所有 hook 事件类型中保持一致,不会因为增加审查点而导致无限循环 +- **SC-005**: 现有 Stop hook 功能不受影响,继续正常工作 + +## 需求完整性检查 + +在继续到实现方案 (`/speckit.plan`) 之前,验证: + +- [x] 没有 `[需要澄清]` 标记残留 +- [x] 所有需求都可测试且无歧义 +- [x] 成功标准可衡量 +- [x] 每个用户故事都可独立实现和测试 +- [x] 边缘情况已考虑 +- [x] 与宪章原则一致(单二进制、跨平台、向后兼容) diff --git a/specs/002-askuserquestion-hook/tasks.md b/specs/002-askuserquestion-hook/tasks.md new file mode 100644 index 0000000..095953b --- /dev/null +++ b/specs/002-askuserquestion-hook/tasks.md @@ -0,0 +1,240 @@ +# 任务清单:Supervisor Hook 支持 AskUserQuestion 工具调用审查 + +**输入**: 来自 `/specs/002-askuserquestion-hook/` 的设计文档 +**前置条件**: plan.md(已完成)、spec.md(已完成)、research.md(已完成)、data-model.md(已完成)、contracts/hook-input-output.md(已完成) + +**测试**: 本项目遵循测试先行的 TDD 方法,包含单元测试和集成测试。 + +**组织方式**: 任务按用户故事分组,以便独立实现和测试每个故事。 + +## 格式:`[ID] [P?] [故事] 描述` + +- **[P]**: 可并行运行(不同文件,无依赖) +- **[故事]**: 此任务属于哪个用户故事(例如:US1、US2、US3) +- 在描述中包含确切的文件路径 + +## 特别说明:使用中文 + +**本文档必须使用中文编写。** + +1. 所有任务描述必须使用中文。 +2. 文件路径使用英文,但说明文字使用中文。 +3. 变量名、函数名等标识符使用英文。 + +--- + +## 第 1 阶段:基础设施(共享) + +**目的**: 项目已经存在,本阶段确保开发环境就绪 + +- [ ] T001 确认开发分支 `002-askuserquestion-hook` 已创建并切换 +- [ ] T002 确认 Go 版本 >= 1.23,运行 `go version` +- [ ] T003 [P] 运行 `go mod tidy` 确保依赖完整 + +--- + +## 第 2 阶段:基础(阻塞前置条件) + +**目的**: 扩展数据结构以支持所有 hook 事件类型 + +**⚠️ 关键**: 在此阶段完成前不能开始任何用户故事工作 + +- [ ] T004 [P] [US2] 扩展 `HookInput` 结构支持所有 hook 事件类型,位于 `internal/cli/hook.go` +- [ ] T005 [P] [US2] 添加 `HookOutput` 结构根据事件类型返回不同格式,位于 `internal/cli/hook.go` +- [ ] T006 [P] [US2] 添加 `HookSpecificOutput` 结构用于 PreToolUse 输出,位于 `internal/cli/hook.go` +- [ ] T007 [US2] 保留 `StopHookInput` 作为 `HookInput` 的别名确保向后兼容,位于 `internal/cli/hook.go` +- [ ] T008 [US2] 实现 `SupervisorResultToHookOutput` 转换函数,位于 `internal/cli/hook.go` + +**检查点**: 数据结构扩展完成 - 可以开始用户故事实现 + +--- + +## 第 3 阶段:用户故事 1 - Supervisor 审查 AskUserQuestion 调用 (优先级: P1) 🎯 MVP + +**目标**: 在 Claude Code 配置中添加 PreToolUse hook,使 Supervisor 能审查 AskUserQuestion 工具调用 + +**独立测试**: 启用 Supervisor 模式后,触发 AskUserQuestion 调用,验证是否正确触发审查并根据审查结果允许或阻止提问 + +### 用户故事 1 的测试 ⚠️ + +> **注意:先编写这些测试,确保它们在实现前失败** + +- [ ] T009 [P] [US1] 测试 Stop 事件输入解析,位于 `internal/cli/hook_test.go` +- [ ] T010 [P] [US1] 测试 PreToolUse 事件输入解析,位于 `internal/cli/hook_test.go` +- [ ] T011 [P] [US1] 测试缺少 hook_event_name 字段时默认为 Stop,位于 `internal/cli/hook_test.go` +- [ ] T012 [P] [US1] 测试 SupervisorResult 转换为 Stop 事件输出,位于 `internal/cli/hook_test.go` +- [ ] T013 [P] [US1] 测试 SupervisorResult 转换为 PreToolUse 事件输出(allow),位于 `internal/cli/hook_test.go` +- [ ] T014 [P] [US1] 测试 SupervisorResult 转换为 PreToolUse 事件输出(deny),位于 `internal/cli/hook_test.go` +- [ ] T015 [P] [US1] 测试向后兼容 - 旧格式输入能正确解析,位于 `internal/cli/hook_test.go` +- [ ] T016 [P] [US1] 测试向后兼容 - 旧格式输出保持不变,位于 `internal/cli/hook_test.go` +- [ ] T017 [P] [US1] 集成测试:完整 PreToolUse hook 流程,位于 `internal/cli/hook_integration_test.go` + +### 用户故事 1 的实现 + +- [ ] T018 [US1] 修改 `RunSupervisorHook` 函数扩展输入解析,支持识别事件类型,位于 `internal/cli/hook.go` +- [ ] T019 [US1] 修改 `RunSupervisorHook` 函数扩展输出格式,根据事件类型返回对应格式,位于 `internal/cli/hook.go` +- [ ] T020 [P] [US1] 在 `provider.go` 的 `SwitchWithHook` 函数中添加 PreToolUse hook 配置,位于 `internal/provider/provider.go` + +**检查点**: 此时用户故事 1 应完全功能化且可独立测试 - AskUserQuestion 调用能被 Supervisor 审查 + +--- + +## 第 4 阶段:用户故事 2 - 扩展输入输出格式支持 (优先级: P1) + +**目标**: 确保 Supervisor hook 能正确识别不同事件类型并返回对应格式 + +**独立测试**: 模拟不同 hook 事件的输入(Stop 和 PreToolUse),验证 hook 命令能正确解析输入并返回对应格式的输出 + +**注意**: 用户故事 2 的实现任务已在第 2 阶段完成(数据结构扩展)。此阶段主要进行验证和测试。 + +- [ ] T021 [P] [US2] 验证 Stop 事件输出格式符合规范,位于 `internal/cli/hook_test.go` +- [ ] T022 [P] [US2] 验证 PreToolUse 事件输出格式符合规范,位于 `internal/cli/hook_test.go` + +**检查点**: 此时用户故事 1 和 2 都应独立工作 - 输入输出格式正确支持两种事件类型 + +--- + +## 第 5 阶段:用户故事 3 - 迭代计数一致性 (优先级: P2) + +**目标**: 确保所有 hook 事件类型都正确增加迭代计数,防止无限循环 + +**独立测试**: 多次触发不同类型的 hook 事件,验证迭代计数是否正确递增并在达到上限时停止 + +**注意**: 当前实现已在 SDK 调用前增加迭代计数(第 134-157 行),因此无需修改代码,只需验证。 + +- [ ] T023 [P] [US3] 验证 PreToolUse 事件触发时迭代计数正确递增,位于 `internal/cli/hook_test.go` +- [ ] T024 [P] [US3] 验证迭代计数达上限时自动允许操作,位于 `internal/cli/hook_test.go` + +**检查点**: 所有用户故事现在都应独立功能化 - 迭代计数在所有事件类型中保持一致 + +--- + +## 第 6 阶段:完善与横切关注点 + +**目的**: 代码质量检查和文档更新 + +- [ ] T025 [P] 运行 `gofmt` 格式化所有修改的 Go 文件 +- [ ] T026 [P] 运行 `go vet ./...` 检查代码静态问题 +- [ ] T027 运行 `go test ./... -v` 执行所有测试 +- [ ] T028 运行 `go test ./... -race` 检查竞态条件 +- [ ] T029 [P] 运行 `./check.sh --lint` 执行完整 lint 检查 +- [ ] T030 更新 CHANGELOG.md(如需要) + +--- + +## 依赖关系与执行顺序 + +### 阶段依赖 + +- **基础设施(第 1 阶段)**: 无依赖 - 可立即开始 +- **基础(第 2 阶段)**: 依赖基础设施完成 - 阻塞所有用户故事 +- **用户故事 1(第 3 阶段)**: 依赖基础阶段完成(T004-T008) +- **用户故事 2(第 4 阶段)**: 依赖基础阶段和用户故事 1 完成 +- **用户故事 3(第 5 阶段)**: 依赖基础阶段和用户故事 1 完成 +- **完善(第 6 阶段)**: 依赖所有用户故事完成 + +### 用户故事依赖 + +- **用户故事 1 (P1)**: 依赖基础阶段(T004-T008)完成 - 无其他故事依赖 +- **用户故事 2 (P1)**: 与用户故事 1 共享基础阶段实现,主要验证功能 +- **用户故事 3 (P2)**: 依赖基础阶段完成,验证现有迭代计数逻辑 + +### 每个用户故事内 + +- 测试必须先编写并在实现前失败(TDD) +- 数据结构定义在转换函数之前 +- 转换函数在主逻辑修改之前 +- 主逻辑修改在配置生成之前 + +### 并行机会 + +- 第 1 阶段所有标记为 [P] 的任务可并行运行 +- 第 2 阶段所有标记为 [P] 的任务可并行运行(不同数据结构定义) +- 用户故事 1 的所有测试(T009-T017)可并行运行 +- 第 4、5 阶段的验证任务可并行运行 +- 第 6 阶段所有标记为 [P] 的任务可并行运行 + +--- + +## 并行示例:用户故事 1 测试 + +```bash +# 一起启动用户故事 1 的所有测试(测试先行): +Task: "测试 Stop 事件输入解析,位于 internal/cli/hook_test.go" +Task: "测试 PreToolUse 事件输入解析,位于 internal/cli/hook_test.go" +Task: "测试缺少 hook_event_name 字段时默认为 Stop,位于 internal/cli/hook_test.go" +Task: "测试 SupervisorResult 转换为 Stop 事件输出,位于 internal/cli/hook_test.go" +Task: "测试 SupervisorResult 转换为 PreToolUse 事件输出(allow),位于 internal/cli/hook_test.go" +Task: "测试 SupervisorResult 转换为 PreToolUse 事件输出(deny),位于 internal/cli/hook_test.go" +Task: "测试向后兼容 - 旧格式输入能正确解析,位于 internal/cli/hook_test.go" +Task: "测试向后兼容 - 旧格式输出保持不变,位于 internal/cli/hook_test.go" +Task: "集成测试:完整 PreToolUse hook 流程,位于 internal/cli/hook_integration_test.go" +``` + +--- + +## 并行示例:第 2 阶段数据结构 + +```bash +# 一起启动第 2 阶段的所有数据结构任务: +Task: "扩展 HookInput 结构支持所有 hook 事件类型,位于 internal/cli/hook.go" +Task: "添加 HookOutput 结构根据事件类型返回不同格式,位于 internal/cli/hook.go" +Task: "添加 HookSpecificOutput 结构用于 PreToolUse 输出,位于 internal/cli/hook.go" +``` + +--- + +## 实施策略 + +### MVP 优先(用户故事 1) + +1. 完成第 1 阶段:基础设施(T001-T003) +2. 完成第 2 阶段:基础(T004-T008) +3. 完成第 3 阶段:用户故事 1(T009-T020) +4. **停止并验证**: 独立测试用户故事 1 +5. 运行 `./check.sh` 验证代码质量 + +### 增量交付 + +1. 完成基础设施 + 基础 → 数据结构就绪 +2. 添加用户故事 1 → AskUserQuestion 审查功能 → 验证(MVP!) +3. 添加用户故事 2 验证 → 确认输入输出格式正确 +4. 添加用户故事 3 验证 → 确认迭代计数一致性 +5. 完善阶段 → 代码质量检查和文档更新 + +### 单人执行顺序 + +由于是单人开发,建议按顺序执行: + +1. 第 1 阶段 → 第 2 阶段 → 第 3 阶段 → 第 4 阶段 → 第 5 阶段 → 第 6 阶段 +2. 在每个阶段内,先执行测试任务,再执行实现任务 +3. 利用并行机会同时运行多个测试(T009-T017 可并行运行) + +--- + +## 注意事项 + +- [P] 任务 = 不同文件,无依赖,可并行执行 +- [故事] 标签将任务映射到特定用户故事以实现可追溯性 +- 用户故事 1 和 2 共享基础阶段实现(数据结构扩展) +- 用户故事 2 和 3 主要是验证任务,核心实现在用户故事 1 +- 实现前验证测试失败(TDD) +- 每完成一个任务或逻辑组后提交代码 +- 在任何检查点停止以独立验证故事 +- 所有修改遵循 Go 代码规范和项目宪章要求 + +--- + +## 文件修改清单 + +### 主要修改文件 + +1. **`internal/cli/hook.go`** - 扩展数据结构和输入输出逻辑 +2. **`internal/provider/provider.go`** - 添加 PreToolUse hook 配置 +3. **`internal/cli/hook_test.go`** - 单元测试(新增或修改) + +### 预期代码量 + +- 新增代码:约 200-300 行(数据结构、转换函数、测试) +- 修改代码:约 50-100 行(输入解析、输出格式、配置生成) +- 总计:约 300-400 行代码变更 diff --git a/tests/e2e_pretooluse_hook_test.sh b/tests/e2e_pretooluse_hook_test.sh new file mode 100755 index 0000000..b99f5a6 --- /dev/null +++ b/tests/e2e_pretooluse_hook_test.sh @@ -0,0 +1,294 @@ +#!/bin/bash +# End-to-end test script for PreToolUse hook support +# This script simulates Claude Code calling the supervisor hook with AskUserQuestion tool + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CCC_BIN="$PROJECT_ROOT/ccc" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Test counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Helper functions +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +run_test() { + local test_name="$1" + TESTS_RUN=$((TESTS_RUN + 1)) + log_info "Running test: $test_name" +} + +pass_test() { + TESTS_PASSED=$((TESTS_PASSED + 1)) + log_info "✓ Test passed" +} + +fail_test() { + local reason="$1" + TESTS_FAILED=$((TESTS_FAILED + 1)) + log_error "✗ Test failed: $reason" +} + +# Setup test environment +setup_test_env() { + TEST_SESSION_ID="test-pretooluse-$$" + TEST_STATE_DIR="$PROJECT_ROOT/tmp/e2e-test-$TEST_SESSION_ID" + mkdir -p "$TEST_STATE_DIR" + + # Export required environment variables + export CCC_CONFIG_DIR="$TEST_STATE_DIR" + export CCC_SUPERVISOR_ID="$TEST_SESSION_ID" + + log_info "Test session ID: $TEST_SESSION_ID" + log_info "Test state dir: $TEST_STATE_DIR" +} + +cleanup_test_env() { + if [ -d "$TEST_STATE_DIR" ]; then + rm -rf "$TEST_STATE_DIR" + log_info "Cleaned up test environment" + fi +} + +# Test 1: Verify Stop hook still works (backward compatibility) +test_stop_hook_backward_compatibility() { + run_test "Stop hook backward compatibility" + + local stop_input='{"session_id":"'$TEST_SESSION_ID'","stop_hook_active":false}' + + # Enable supervisor mode + echo "true" > "$TEST_STATE_DIR/supervisor-$TEST_SESSION_ID.json" + + local output + output=$(echo "$stop_input" | "$CCC_BIN" supervisor-hook 2>&1 || true) + + if echo "$output" | grep -q '"decision"'; then + pass_test + else + fail_test "Stop hook output missing 'decision' field" + fi +} + +# Test 2: Verify PreToolUse hook input parsing +test_pretooluse_input_parsing() { + run_test "PreToolUse input parsing" + + local pretooluse_input='{ + "session_id":"'$TEST_SESSION_ID'", + "hook_event_name":"PreToolUse", + "tool_name":"AskUserQuestion", + "tool_input":{"questions":[{"question":"Test question"}]}, + "tool_use_id":"toolu_test_123" + }' + + # Enable supervisor mode + echo '{"enabled":true}' > "$TEST_STATE_DIR/supervisor-$TEST_SESSION_ID.json" + + # Set CCC_SUPERVISOR_HOOK=1 to prevent actual SDK call for this test + local output + output=$(CCC_SUPERVISOR_HOOK=1 echo "$pretooluse_input" | "$CCC_BIN" supervisor-hook 2>&1 || true) + + if echo "$output" | grep -q '"reason"'; then + pass_test + else + fail_test "PreToolUse hook output missing expected fields" + fi +} + +# Test 3: Verify PreToolUse hook output format +test_pretooluse_output_format() { + run_test "PreToolUse output format" + + local pretooluse_input='{ + "session_id":"'$TEST_SESSION_ID'", + "hook_event_name":"PreToolUse", + "tool_name":"AskUserQuestion", + "tool_input":{}, + "tool_use_id":"toolu_test_456" + }' + + # Enable supervisor mode + echo '{"enabled":true}' > "$TEST_STATE_DIR/supervisor-$TEST_SESSION_ID.json" + + # Test with CCC_SUPERVISOR_HOOK=1 for early return + local output + output=$(CCC_SUPERVISOR_HOOK=1 echo "$pretooluse_input" | "$CCC_BIN" supervisor-hook 2>&1 || true) + + # When supervisor is disabled, should return allow decision + # When CCC_SUPERVISOR_HOOK=1, should return early with allow + if [ $? -eq 0 ] || echo "$output" | grep -q '"reason"'; then + pass_test + else + fail_test "PreToolUse hook did not return expected output" + fi +} + +# Test 4: Verify unknown event type defaults to Stop format +test_unknown_event_type_defaults_to_stop() { + run_test "Unknown event type defaults to Stop format" + + local unknown_event_input='{ + "session_id":"'$TEST_SESSION_ID'", + "hook_event_name":"UnknownEventType", + "tool_name":"SomeTool", + "tool_input":{}, + "tool_use_id":"toolu_test_789" + }' + + # Enable supervisor mode + echo '{"enabled":true}' > "$TEST_STATE_DIR/supervisor-$TEST_SESSION_ID.json" + + local output + output=$(CCC_SUPERVISOR_HOOK=1 echo "$unknown_event_input" | "$CCC_BIN" supervisor-hook 2>&1 || true) + + # Unknown event types should use Stop format (decision/reason fields) + if echo "$output" | grep -q '"reason"'; then + pass_test + else + fail_test "Unknown event type did not default to Stop format" + fi +} + +# Test 5: Verify hook configuration in provider +test_hook_configuration() { + run_test "Hook configuration in provider" + + # This test verifies that the PreToolUse hook is configured correctly + # We check if the settings.json contains the PreToolUse hook with AskUserQuestion matcher + + local settings_file="$TEST_STATE_DIR/settings.json" + + # Create a minimal config + cat > "$TEST_STATE_DIR/ccc.json" </dev/null 2>&1; then + if [ -f "$settings_file" ]; then + if grep -q '"PreToolUse"' "$settings_file" && grep -q '"AskUserQuestion"' "$settings_file"; then + pass_test + else + fail_test "PreToolUse hook not configured correctly in settings.json" + fi + else + fail_test "settings.json not created" + fi + else + fail_test "ccc switch command failed" + fi +} + +# Test 6: Verify iteration count increases for PreToolUse events +test_iteration_count_increments() { + run_test "Iteration count increments for PreToolUse events" + + # Enable supervisor mode with initial count + cat > "$TEST_STATE_DIR/supervisor-$TEST_SESSION_ID.json" </dev/null 2>&1 || true + + # Check if iteration count increased + local new_count + new_count=$(jq -r '.iteration_count // 0' "$TEST_STATE_DIR/supervisor-$TEST_SESSION_ID.json" 2>/dev/null || echo "0") + + if [ "$new_count" -gt 0 ]; then + pass_test + else + fail_test "Iteration count did not increase (count=$new_count)" + fi +} + +# Main test execution +main() { + log_info "=== PreToolUse Hook End-to-End Tests ===" + log_info "" + + # Check if ccc binary exists + if [ ! -f "$CCC_BIN" ]; then + log_error "ccc binary not found at $CCC_BIN" + log_info "Please build the project first: go build -o ccc ./cmd/ccc" + exit 1 + fi + + # Check dependencies + if ! command -v jq >/dev/null 2>&1; then + log_warn "jq not found. Some tests may be skipped." + fi + + setup_test_env + trap cleanup_test_env EXIT + + # Run all tests + test_stop_hook_backward_compatibility + test_pretooluse_input_parsing + test_pretooluse_output_format + test_unknown_event_type_defaults_to_stop + test_hook_configuration + test_iteration_count_increments + + # Print summary + log_info "" + log_info "=== Test Summary ===" + log_info "Tests run: $TESTS_RUN" + log_info "Tests passed: $TESTS_PASSED" + log_info "Tests failed: $TESTS_FAILED" + + if [ $TESTS_FAILED -eq 0 ]; then + log_info "" + log_info "${GREEN}All tests passed!${NC}" + exit 0 + else + log_error "" + log_error "${RED}Some tests failed!${NC}" + exit 1 + fi +} + +# Run main function +main "$@"