diff --git a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx index 73259974..dce58b9a 100644 --- a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx +++ b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx @@ -63,6 +63,8 @@ export interface HookQuickTemplatesProps { } // ========== Hook Templates ========== +// NOTE: Hook input is received via stdin (not environment variable) +// Use: const fs=require('fs');const p=JSON.parse(fs.readFileSync(0,'utf8')||'{}'); /** * Predefined hook templates for quick installation @@ -90,7 +92,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ command: 'node', args: [ '-e', - 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(/workflow-session\\.json$|session-metadata\\.json$/.test(file)){const fs=require("fs");try{const content=fs.readFileSync(file,"utf8");const data=JSON.parse(content);const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_STATE_CHANGED",file:file,sessionId:data.session_id||"",status:data.status||"unknown",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})}catch(e){}}' + 'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(/workflow-session\\.json$|session-metadata\\.json$/.test(file)){try{const content=fs.readFileSync(file,"utf8");const data=JSON.parse(content);const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_STATE_CHANGED",file:file,sessionId:data.session_id||"",status:data.status||"unknown",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})}catch(e){}}' ] }, // --- Notification --- @@ -117,7 +119,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ command: 'node', args: [ '-e', - 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(file){const cp=require("child_process");cp.spawnSync("npx",["prettier","--write",file],{stdio:"inherit",shell:true})}' + 'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(file){const cp=require("child_process");cp.spawnSync("npx",["prettier","--write",file],{stdio:"inherit",shell:true})}' ] }, { @@ -130,7 +132,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ command: 'node', args: [ '-e', - 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(file){const cp=require("child_process");cp.spawnSync("npx",["eslint","--fix",file],{stdio:"inherit",shell:true})}' + 'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(file){const cp=require("child_process");cp.spawnSync("npx",["eslint","--fix",file],{stdio:"inherit",shell:true})}' ] }, { @@ -143,7 +145,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ command: 'node', args: [ '-e', - 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(/\\.env|secret|credential|\\.key$/.test(file)){process.stderr.write("Blocked: modifying sensitive file "+file);process.exit(2)}' + 'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(/\\.env|secret|credential|\\.key$/.test(file)){process.stderr.write("Blocked: modifying sensitive file "+file);process.exit(2)}' ] }, { @@ -169,7 +171,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ command: 'node', args: [ '-e', - 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(file){const cp=require("child_process");const payload=JSON.stringify({type:"FILE_MODIFIED",file:file,project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})}' + 'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");const file=(p.tool_input&&p.tool_input.file_path)||"";if(file){const cp=require("child_process");const payload=JSON.stringify({type:"FILE_MODIFIED",file:file,project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})}' ] }, { @@ -181,7 +183,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ command: 'node', args: [ '-e', - 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_SUMMARY",transcript:p.transcript_path||"",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})' + 'const fs=require("fs");const p=JSON.parse(fs.readFileSync(0,"utf8")||"{}");const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_SUMMARY",transcript:p.transcript_path||"",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})' ] }, { @@ -221,7 +223,7 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ description: 'Sync memory V2 status to dashboard on changes', category: 'notification', trigger: 'PostToolUse', - matcher: 'core_memory', + matcher: 'mcp__ccw-tools__core_memory', command: 'node', args: [ '-e', diff --git a/ccw/frontend/src/components/hook/HookWizard.tsx b/ccw/frontend/src/components/hook/HookWizard.tsx index d5f6b5b5..8f11d685 100644 --- a/ccw/frontend/src/components/hook/HookWizard.tsx +++ b/ccw/frontend/src/components/hook/HookWizard.tsx @@ -84,6 +84,9 @@ interface HookTemplate { timeout?: number; } +// NOTE: Hook input is received via stdin (not environment variable) +// Node.js: const fs=require('fs');const p=JSON.parse(fs.readFileSync(0,'utf8')||'{}'); +// Bash: INPUT=$(cat) const HOOK_TEMPLATES: Record = { 'memory-update-queue': { event: 'Stop', @@ -95,13 +98,13 @@ const HOOK_TEMPLATES: Record = { event: 'UserPromptSubmit', matcher: '', command: 'node', - args: ['-e', "const p=JSON.parse(process.env.HOOK_INPUT||'{}');require('child_process').spawnSync('ccw',['tool','exec','skill_context_loader',JSON.stringify({prompt:p.user_prompt||''})],{stdio:'inherit'})"], + args: ['-e', "const fs=require('fs');const p=JSON.parse(fs.readFileSync(0,'utf8')||'{}');require('child_process').spawnSync('ccw',['tool','exec','skill_context_loader',JSON.stringify({prompt:p.prompt||''})],{stdio:'inherit'})"], }, 'skill-context-auto': { event: 'UserPromptSubmit', matcher: '', command: 'node', - args: ['-e', "const p=JSON.parse(process.env.HOOK_INPUT||'{}');require('child_process').spawnSync('ccw',['tool','exec','skill_context_loader',JSON.stringify({mode:'auto',prompt:p.user_prompt||''})],{stdio:'inherit'})"], + args: ['-e', "const fs=require('fs');const p=JSON.parse(fs.readFileSync(0,'utf8')||'{}');require('child_process').spawnSync('ccw',['tool','exec','skill_context_loader',JSON.stringify({mode:'auto',prompt:p.prompt||''})],{stdio:'inherit'})"], }, 'danger-bash-confirm': { event: 'PreToolUse', @@ -114,7 +117,7 @@ const HOOK_TEMPLATES: Record = { event: 'PreToolUse', matcher: 'Write|Edit', command: 'bash', - args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); PROTECTED=".env|.git/|package-lock.json|yarn.lock|.credentials|secrets|id_rsa|.pem$|.key$"; if echo "$FILE" | grep -qiE "$PROTECTED"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"deny\\",\\"permissionDecisionReason\\":\\"Protected file cannot be modified: $FILE\\"}}" && exit 0; fi; exit 0'], + args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); PROTECTED=".env|.git/|package-lock.json|yarn.lock|.credentials|secrets|id_rsa|.pem$|.key$"; if echo "$FILE" | grep -qiE "$PROTECTED"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"deny\\",\\"permissionDecisionReason\\":\\"Protected file cannot be modified: $FILE\\"}}" >&2 && exit 2; fi; exit 0'], timeout: 5000, }, 'danger-git-destructive': { @@ -135,7 +138,7 @@ const HOOK_TEMPLATES: Record = { event: 'PreToolUse', matcher: 'Write|Edit|Bash', command: 'bash', - args: ['-c', 'INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty"); if [ "$TOOL" = "Bash" ]; then CMD=$(echo "$INPUT" | jq -r ".tool_input.command // empty"); SYS_PATHS="/etc/|/usr/|/bin/|/sbin/|/boot/|/sys/|/proc/|C:\\\\Windows|C:\\\\Program Files"; if echo "$CMD" | grep -qiE "$SYS_PATHS"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"ask\\",\\"permissionDecisionReason\\":\\"System path operation requires confirmation\\"}}" && exit 0; fi; else FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); SYS_PATHS="/etc/|/usr/|/bin/|/sbin/|C:\\\\Windows|C:\\\\Program Files"; if echo "$FILE" | grep -qiE "$SYS_PATHS"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"deny\\",\\"permissionDecisionReason\\":\\"Cannot modify system file: $FILE\\"}}" && exit 0; fi; fi; exit 0'], + args: ['-c', 'INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty"); if [ "$TOOL" = "Bash" ]; then CMD=$(echo "$INPUT" | jq -r ".tool_input.command // empty"); SYS_PATHS="/etc/|/usr/|/bin/|/sbin/|/boot/|/sys/|/proc/|C:\\\\Windows|C:\\\\Program Files"; if echo "$CMD" | grep -qiE "$SYS_PATHS"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"ask\\",\\"permissionDecisionReason\\":\\"System path operation requires confirmation\\"}}" && exit 0; fi; else FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); SYS_PATHS="/etc/|/usr/|/bin/|/sbin/|C:\\\\Windows|C:\\\\Program Files"; if echo "$FILE" | grep -qiE "$SYS_PATHS"; then echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"PreToolUse\\",\\"permissionDecision\\":\\"deny\\",\\"permissionDecisionReason\\":\\"Cannot modify system file: $FILE\\"}}" >&2 && exit 2; fi; fi; exit 0'], timeout: 5000, }, 'danger-permission-change': { diff --git a/ccw/src/core/routes/hooks-routes.ts b/ccw/src/core/routes/hooks-routes.ts index e09fd368..74abd786 100644 --- a/ccw/src/core/routes/hooks-routes.ts +++ b/ccw/src/core/routes/hooks-routes.ts @@ -94,6 +94,75 @@ function getHooksConfig(projectPath: string): { global: { path: string; hooks: u }; } +/** + * Normalize hook data to Claude Code's official nested format + * Official format: { matcher?: string, hooks: [{ type: 'command', command: string, timeout?: number }] } + * + * IMPORTANT: All timeout values from frontend are in MILLISECONDS and must be converted to SECONDS. + * Official Claude Code spec requires timeout in seconds. + * + * @param {Object} hookData - Hook configuration (may be flat or nested format) + * @returns {Object} Normalized hook data in official format + */ +function normalizeHookFormat(hookData: Record): Record { + /** + * Convert timeout from milliseconds to seconds + * Frontend always sends milliseconds, Claude Code expects seconds + */ + const convertTimeout = (timeout: number): number => { + // Always convert from milliseconds to seconds + // This is safe because: + // - Frontend (HookWizard) uses milliseconds (e.g., 5000ms) + // - Claude Code official spec requires seconds + // - Minimum valid timeout is 1 second, so any value < 1000ms becomes 1s + return Math.max(1, Math.ceil(timeout / 1000)); + }; + + // If already in nested format with hooks array, validate and convert + if (hookData.hooks && Array.isArray(hookData.hooks)) { + // Ensure each hook in the array has required fields + const normalizedHooks = (hookData.hooks as Array>).map(h => { + const normalized: Record = { + type: h.type || 'command', + command: h.command || '', + }; + // Convert timeout from milliseconds to seconds + if (typeof h.timeout === 'number') { + normalized.timeout = convertTimeout(h.timeout); + } + return normalized; + }); + + return { + ...(hookData.matcher !== undefined ? { matcher: hookData.matcher } : { matcher: '' }), + hooks: normalizedHooks, + }; + } + + // Convert flat format to nested format + // Old format: { command: '...', timeout: 5000, name: '...', failMode: '...' } + // New format: { matcher: '', hooks: [{ type: 'command', command: '...', timeout: 5 }] } + if (hookData.command && typeof hookData.command === 'string') { + const nestedHook: Record = { + type: 'command', + command: hookData.command, + }; + + // Convert timeout from milliseconds to seconds + if (typeof hookData.timeout === 'number') { + nestedHook.timeout = convertTimeout(hookData.timeout); + } + + return { + matcher: typeof hookData.matcher === 'string' ? hookData.matcher : '', + hooks: [nestedHook], + }; + } + + // Return as-is if we can't normalize (let Claude Code validate) + return hookData; +} + /** * Save a hook to settings file * @param {string} projectPath @@ -125,17 +194,19 @@ function saveHookToSettings( settings.hooks[event] = [settings.hooks[event]]; } + // Normalize hook data to official format + const normalizedData = normalizeHookFormat(hookData); + // Check if we're replacing an existing hook if (typeof hookData.replaceIndex === 'number') { const index = hookData.replaceIndex; - delete hookData.replaceIndex; const hooksForEvent = settings.hooks[event] as unknown[]; if (index >= 0 && index < hooksForEvent.length) { - hooksForEvent[index] = hookData; + hooksForEvent[index] = normalizedData; } } else { // Add new hook - (settings.hooks[event] as unknown[]).push(hookData); + (settings.hooks[event] as unknown[]).push(normalizedData); } // Ensure directory exists and write file diff --git a/ccw/src/core/routes/system-routes.ts b/ccw/src/core/routes/system-routes.ts index 79b82f10..a0b6ebd5 100644 --- a/ccw/src/core/routes/system-routes.ts +++ b/ccw/src/core/routes/system-routes.ts @@ -202,22 +202,27 @@ function installRecommendedHook( settings.hooks[event] = []; } - // Check if hook already exists (by command) + // Check if hook already exists (by command in nested hooks array) const existingHooks = (settings.hooks[event] || []) as Array>; - const existingIndex = existingHooks.findIndex( - (h) => (h as Record).command === hook.command - ); + const existingIndex = existingHooks.findIndex((entry) => { + const hooks = (entry as Record).hooks as Array> | undefined; + if (!hooks || !Array.isArray(hooks)) return false; + return hooks.some((h) => (h as Record).command === hook.command); + }); if (existingIndex >= 0) { return { success: true, installed: { id: hookId, event, status: 'already-exists' } }; } - // Add new hook + // Add new hook in Claude Code's official nested format + // Format: { matcher: '', hooks: [{ type: 'command', command: '...', timeout: 5 }] } settings.hooks[event].push({ - name: hook.name, - command: hook.command, - timeout: 5000, - failMode: 'silent' + matcher: '', + hooks: [{ + type: 'command', + command: hook.command, + timeout: 5 // seconds, not milliseconds + }] }); // Ensure directory exists diff --git a/docs/public/icon-concepts.html b/docs/public/icon-concepts.html index c576c944..302710d7 100644 --- a/docs/public/icon-concepts.html +++ b/docs/public/icon-concepts.html @@ -2371,7 +2371,7 @@
- + @@ -2400,9 +2400,9 @@ - - - + + + @@ -2412,18 +2412,18 @@ - + - + - - - + + + @@ -2449,7 +2449,7 @@
- + @@ -2465,21 +2465,21 @@ - - - + + + - + - - - + + + @@ -2500,7 +2500,7 @@
- + @@ -2516,18 +2516,18 @@ - - - + + + - + - + @@ -2536,7 +2536,7 @@
- + @@ -2549,18 +2549,18 @@ - - - + + + - + - + @@ -2570,6 +2570,406 @@
+ +
+

D1-V2++ Minimal: Theme Line Art Adaptive

+
简约线条版 — 纯 currentColor 跟随主题色 · 无渐变无滤镜 · 极简轨道+点
+
+ Simplified from D1-V2+: pure currentColor strokes and fills, no gradients, no filters.
+ Automatically adapts to light/dark theme. Front/back orbit layering preserved via opacity contrast. +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 128px +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 48px +
+
+
+ + + + + + + + + + + + + + + + +
+ 24px +
+
+
+ + + + + + + + + + + + + + + + +
+ 16px +
+
+ +
+
+
+ + + + + + +
+ Brand Blue +
+
+
+ + + + + + +
+ Claude +
+
+
+ + + + + + +
+ OpenAI +
+
+
+ + + + + + +
+ Purple +
+
+
+ + + + + + +
+ White +
+
+
+ + + + + + +
+ Dark on Light +
+
+
+ + +
+

D1-V2+++: Branded Agents Hybrid

+
品牌代理 — currentColor 轨道 + AI 图标保留品牌色 · 半自适应主题
+
+ Based on D1-V2++ Minimal: orbits and core use currentColor for theme adaptation,
+ but AI agent icons retain their brand colors (Claude #D97757, OpenAI #10A37F, Gemini #4285F4). +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 128px +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 48px +
+
+
+ + + + + + + + + + + + +
+ 24px +
+
+
+ + + + + + + + + + + + +
+ 16px +
+
+ +
+
+
+ + + + + + +
+ Brand Blue +
+
+
+ + + + + + +
+ Claude +
+
+
+ + + + + + +
+ OpenAI +
+
+
+ + + + + + +
+ Purple +
+
+
+ + + + + + +
+ White +
+
+
+ + + + + + +
+ Dark on Light +
+
+
+

D1-V3: Sharp Circuit

@@ -2814,7 +3214,7 @@
- + @@ -2830,19 +3230,19 @@ - - - + + + - + - - + + diff --git a/docs/reference/hook-templates-analysis.md b/docs/reference/hook-templates-analysis.md new file mode 100644 index 00000000..a9ad56ce --- /dev/null +++ b/docs/reference/hook-templates-analysis.md @@ -0,0 +1,279 @@ +# Hook 模板分析报告 + +> 基于 Claude Code 官方 Hook 规范对 `ccw/frontend` 前端实现的检查 + +--- + +## 概要 + +| 检查项 | 状态 | 严重级别 | +|--------|------|----------| +| 触发器类型支持 | 12/18 支持 | ⚠️ 缺失 6 种 | +| 命令结构格式 | 不合规 | 🔴 CRITICAL | +| 输入读取方式 | 不合规 | 🔴 CRITICAL | +| Bash 脚本跨平台 | 不兼容 | 🟠 ERROR | +| JSON 决策输出 | 合规 | ✅ 正确 | +| Matcher 格式 | 部分问题 | ⚠️ WARNING | + +--- + +## 1. CRITICAL 问题 + +### 1.1 命令结构:`command` + `args` 数组格式 + +**官方规范**:使用单一 `command` 字符串 +```json +{ + "type": "command", + "command": "bash .claude/hooks/validate.sh", + "timeout": 30 +} +``` + +**当前实现**:使用 `command` + `args` 数组 +```typescript +command: 'node', +args: ['-e', 'const cp=require("child_process");...'] +``` + +**影响文件**: +- `HookQuickTemplates.tsx` 第 77-229 行(所有 16 个模板) +- `HookWizard.tsx` 第 87-148 行(HOOK_TEMPLATES 对象) + +**修复方案**: +```typescript +// 错误格式 +command: 'node', +args: ['-e', 'script...'] + +// 正确格式 +command: "node -e 'script...'" +``` + +--- + +### 1.2 输入读取:`process.env.HOOK_INPUT` vs stdin + +**官方规范**:Hook 输入通过 **stdin** 传入 JSON +``` +输入:JSON 通过 stdin 传入 +``` + +**当前实现**:使用环境变量 +```javascript +const p=JSON.parse(process.env.HOOK_INPUT||"{}"); +``` + +**影响位置**: +- `HookQuickTemplates.tsx`: 第 93, 120, 133, 146, 172, 184, 228 行 + +**修复方案**: +```javascript +// Node.js 内联脚本 +const fs=require('fs');const p=JSON.parse(fs.readFileSync(0,'utf8')||"{}"); + +// Bash 脚本 +INPUT=$(cat) +CMD=$(echo "$INPUT" | jq -r '.tool_input.command') +``` + +--- + +## 2. ERROR 问题 + +### 2.1 Bash 脚本在 Windows 上失败 + +**问题**:`HookWizard.tsx` 中所有 `danger-*` 模板使用 `bash -c`: +```typescript +command: 'bash', +args: ['-c', 'INPUT=$(cat); CMD=$(echo "$INPUT" | jq -r ...'] +``` + +**失败原因**: +1. Windows 默认没有 `bash`(需要 WSL 或 Git Bash) +2. 使用 Unix 命令:`cat`, `jq`, `grep -qiE` +3. 使用 Unix shell 语法:`$(...)`, `if; then; fi` + +**影响模板**: +- `danger-bash-confirm` (第 106-112 行) +- `danger-file-protection` (第 113-119 行) +- `danger-git-destructive` (第 120-126 行) +- `danger-network-confirm` (第 127-133 行) +- `danger-system-paths` (第 134-140 行) +- `danger-permission-change` (第 141-147 行) + +**修复方案**: +1. 使用 `node -e` 替代 `bash -c`(跨平台) +2. 或提供 PowerShell 版本的检测脚本 +3. 或在运行时检测平台并选择对应脚本 + +--- + +### 2.2 平台检测使用浏览器 UA + +**问题**:`convertToClaudeCodeFormat` 函数(第 185 行) +```javascript +const isWindows = typeof navigator !== 'undefined' && navigator.userAgent.includes('Win'); +``` + +**错误场景**:用户在 Mac 浏览器中配置,但 Hook 在远程 Windows 机器执行 + +**修复方案**:从后端 API 获取实际执行平台信息 + +--- + +## 3. WARNING 问题 + +### 3.1 无效 MCP Matcher 格式 + +**位置**:`HookQuickTemplates.tsx` 第 224 行 +```typescript +matcher: 'core_memory' +``` + +**官方规范**:MCP 工具命名格式 `mcp____` +```typescript +// 正确格式 +matcher: 'mcp__ccw-tools__core_memory' +``` + +--- + +### 3.2 空 Matcher 滥用 + +**位置**:`HookWizard.tsx` 第 90, 96, 102 行 + +空 matcher `''` 是有效的,但意味着 Hook 会对该事件类型的所有工具触发。对于 `Stop` 事件没有问题,但需要确认是否为预期行为。 + +--- + +### 3.3 引号转义脆弱 + +**位置**:`HookWizard.tsx` 第 173, 187-191 行 + +当前转义逻辑: +```javascript +// bash 脚本 +const escapedScript = script.replace(/'/g, "'\\''"); + +// Windows node 脚本 +const escapedScript = script.replace(/"/g, '\\"'); +``` + +**问题**:对于包含反引号、`$()`、嵌套引号的复杂脚本可能失败 + +--- + +### 3.4 Exit Code 2 未使用 + +**官方规范**:exit code 2 用于阻止操作并显示反馈 + +**当前状态**:仅 `block-sensitive-files` 模板使用 `process.exit(2)`,其他 `danger-*` 模板通过 JSON 输出 `permissionDecision` 但未配合 exit code + +**修复**:在输出 deny 决策后应使用 `exit 2` +```bash +echo '{"hookSpecificOutput":{...}}' && exit 0 # 当前 +echo '{"hookSpecificOutput":{...}}' && exit 2 # 应改为 exit 2 以阻止 +``` + +--- + +## 4. 触发器类型支持情况 + +### 4.1 完整支持表 + +| 触发器 | 代码支持 | UI 过滤器 | 状态 | +|--------|----------|-----------|------| +| SessionStart | ✅ | ✅ | 完整 | +| SessionEnd | ✅ | ❌ | 代码有,UI 无 | +| UserPromptSubmit | ✅ | ✅ | 完整 | +| PreToolUse | ✅ | ✅ | 完整 | +| PostToolUse | ✅ | ✅ | 完整 | +| PostToolUseFailure | ✅ | ❌ | 代码有,UI 无 | +| PermissionRequest | ✅ | ❌ | 代码有,UI 无 | +| Notification | ✅ | ❌ | 代码有,UI 无 | +| Stop | ✅ | ✅ | 完整 | +| SubagentStart | ✅ | ❌ | 代码有,UI 无 | +| SubagentStop | ✅ | ❌ | 代码有,UI 无 | +| PreCompact | ✅ | ❌ | 代码有,UI 无 | +| **TeammateIdle** | ❌ | ❌ | **缺失** | +| **TaskCompleted** | ❌ | ❌ | **缺失** | +| **ConfigChange** | ❌ | ❌ | **缺失** | +| **WorktreeCreate** | ❌ | ❌ | **缺失** | +| **WorktreeRemove** | ❌ | ❌ | **缺失** | + +### 4.2 需要添加的触发器 + +**缺失的 6 种触发器**: +1. `TeammateIdle` - 团队成员空闲时触发 +2. `TaskCompleted` - 任务标记完成时触发 +3. `ConfigChange` - 配置文件外部修改时触发 +4. `WorktreeCreate` - 工作树创建时触发 +5. `WorktreeRemove` - 工作树移除时触发 + +--- + +## 5. 正确实现的部分 + +| 项目 | 状态 | 说明 | +|------|------|------| +| 触发器类型名称 | ✅ | 使用的触发器名称符合官方规范 | +| Matcher 正则语法 | ✅ | `Write\|Edit`、`Bash` 等格式正确 | +| Timeout 单位转换 | ✅ | 毫秒→秒转换正确 | +| JSON 决策输出格式 | ✅ | `hookSpecificOutput` 结构符合规范 | +| Bash stdin 读取 | ✅ | `INPUT=$(cat)` 方式正确 | + +--- + +## 6. 修复优先级 + +### P0 - 必须立即修复 ✅ 已修复 +1. **命令格式**:将 `command` + `args` 合并为单一字符串 +2. **输入读取**:将 `process.env.HOOK_INPUT` 改为 stdin 读取 + +### P1 - 尽快修复 ✅ 已修复 +3. **Windows 兼容**:将 `bash -c` 脚本改为 `node -e` 或提供 PowerShell 版本 +4. **Exit code 2**:在 deny 场景使用正确的 exit code + +### P2 - 后续优化 +5. **缺失触发器**:添加 6 种缺失的触发器类型支持 +6. **UI 过滤器**:将所有支持的触发器添加到过滤器 +7. **MCP Matcher**:修正 `core_memory` 为 `mcp__ccw-tools__core_memory` ✅ 已修复 +8. **平台检测**:从后端获取实际执行平台 + +--- + +## 7. 已完成的修复 + +### 7.1 后端修复 + +**文件**: `ccw/src/core/routes/system-routes.ts` +- `installRecommendedHook` 函数 (第 216-231 行) +- 修复:使用官方嵌套格式 `{ matcher: '', hooks: [{ type: 'command', command: '...', timeout: 5 }] }` +- 修复:timeout 从毫秒改为秒 + +**文件**: `ccw/src/core/routes/hooks-routes.ts` +- 新增 `normalizeHookFormat` 函数 +- 自动将旧格式转换为新格式 +- 自动将 timeout 从毫秒转换为秒 + +### 7.2 前端修复 + +**文件**: `ccw/frontend/src/components/hook/HookQuickTemplates.tsx` +- 所有模板从 `process.env.HOOK_INPUT` 改为 `fs.readFileSync(0, 'utf8')` +- 修复 `memory-sync-dashboard` 的 matcher: `core_memory` → `mcp__ccw-tools__core_memory` + +**文件**: `ccw/frontend/src/components/hook/HookWizard.tsx` +- `skill-context-keyword` 和 `skill-context-auto` 模板修复 stdin 读取 +- `danger-file-protection` 和 `danger-system-paths` 的 deny 决策使用 exit 2 + +--- + +## 7. 文件位置参考 + +| 文件 | 主要问题 | +|------|----------| +| `src/components/hook/HookQuickTemplates.tsx` | 命令格式、输入读取、MCP matcher | +| `src/components/hook/HookWizard.tsx` | Bash 跨平台、命令格式、平台检测 | +| `src/pages/HookManagerPage.tsx` | 缺失触发器类型、UI 过滤器不完整 | +| `src/components/hook/HookCard.tsx` | HookTriggerType 类型定义不完整 |