From 775928456d18fa0774909ccbe51fa157ae4d8476 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sat, 20 Dec 2025 23:14:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=8A=B6=E6=80=81=E8=B7=9F=E8=B8=AA=E5=92=8C?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E6=8A=AB=E9=9C=B2=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=92=A9=E5=AD=90=E7=AE=A1=E7=90=86=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/commands/workflow/execute.md | 12 + .claude/workflows/context-tools.md | 15 +- .claude/workflows/file-modification.md | 28 +- ccw/src/core/routes/hooks-routes.ts | 108 ++++++++ ccw/src/core/session-clustering-service.ts | 255 +++++++++++------- .../dashboard-js/components/hook-manager.js | 17 +- ccw/src/templates/dashboard-js/i18n.js | 14 +- ccw/src/templates/dashboard-js/views/home.js | 12 +- .../dashboard-js/views/hook-manager.js | 33 ++- 9 files changed, 356 insertions(+), 138 deletions(-) diff --git a/.claude/commands/workflow/execute.md b/.claude/commands/workflow/execute.md index f226eb73..c88a01b1 100644 --- a/.claude/commands/workflow/execute.md +++ b/.claude/commands/workflow/execute.md @@ -56,6 +56,7 @@ Phase 2: Planning Document Validation └─ Validate .task/ contains IMPL-*.json files Phase 3: TodoWrite Generation + ├─ Update session status to "active" (Step 0) ├─ Parse TODO_LIST.md for task statuses ├─ Generate TodoWrite for entire workflow └─ Prepare session context paths @@ -80,6 +81,7 @@ Phase 5: Completion Resume Mode (--resume-session): ├─ Skip Phase 1 & Phase 2 └─ Entry Point: Phase 3 (TodoWrite Generation) + ├─ Update session status to "active" (if not already) └─ Continue: Phase 4 → Phase 5 ``` @@ -179,6 +181,16 @@ bash(cat .workflow/active/${sessionId}/workflow-session.json) ### Phase 3: TodoWrite Generation **Applies to**: Both normal and resume modes (resume mode entry point) +**Step 0: Update Session Status to Active** +Before generating TodoWrite, update session status from "planning" to "active": +```bash +# Update session status (idempotent - safe to run if already active) +jq '.status = "active" | .execution_started_at = (.execution_started_at // now | todate)' \ + .workflow/active/${sessionId}/workflow-session.json > tmp.json && \ + mv tmp.json .workflow/active/${sessionId}/workflow-session.json +``` +This ensures the dashboard shows the session as "ACTIVE" during execution. + **Process**: 1. **Create TodoWrite List**: Generate task list from TODO_LIST.md (not from task JSONs) - Parse TODO_LIST.md to extract all tasks with current statuses diff --git a/.claude/workflows/context-tools.md b/.claude/workflows/context-tools.md index e2874696..0f444a18 100644 --- a/.claude/workflows/context-tools.md +++ b/.claude/workflows/context-tools.md @@ -1,8 +1,19 @@ ## MCP Tools Usage -### smart_search - Code Search (REQUIRED) +### smart_search - Code Search (REQUIRED - HIGHEST PRIORITY) -**When**: Find code, understand codebase structure, locate implementations +**OVERRIDES**: All other search/discovery rules in other workflow files + +**When**: ANY code discovery task, including: +- Find code, understand codebase structure, locate implementations +- Explore unknown locations +- Verify file existence before reading +- Pattern-based file discovery + +**Priority Rule**: +1. **Always use smart_search FIRST** for any code/file discovery +2. Only use Built-in Grep for single-file exact line search (after location confirmed) +3. Only use Built-in Read for known, confirmed file paths **Workflow** (search first, init if needed): ```javascript diff --git a/.claude/workflows/file-modification.md b/.claude/workflows/file-modification.md index 1764bf4d..23bd6bf3 100644 --- a/.claude/workflows/file-modification.md +++ b/.claude/workflows/file-modification.md @@ -40,27 +40,25 @@ write_file(path="/existing.ts", content="...", backup=true) // Create backup fi ## Priority Logic +> **Note**: Search priority is defined in `context-tools.md` - smart_search has HIGHEST PRIORITY for all discovery tasks. + +**Search & Discovery** (defer to context-tools.md): +1. **smart_search FIRST** for any code/file discovery +2. Built-in Grep only for single-file exact line search (location already confirmed) +3. Exa for external/public knowledge + **File Reading**: -1. Known single file → Built-in Read -2. Multiple files OR pattern matching → smart_search (MCP) -3. Unknown location → smart_search then Read -4. Large codebase + repeated access → smart_search (indexed) +1. Unknown location → **smart_search first**, then Read +2. Known confirmed file → Built-in Read directly +3. Pattern matching → smart_search (action="find_files") **File Editing**: 1. Always try built-in Edit first 2. Fails 1+ times → edit_file (MCP) 3. Still fails → write_file (MCP) -**Search**: -1. External knowledge → Exa (MCP) -2. Exact pattern in small codebase → Built-in Grep -3. Semantic/unknown location → smart_search (MCP) -4. Large codebase + repeated searches → smart_search (indexed) - ## Decision Triggers -**Start with simplest tool** (Read, Edit, Grep) -**Escalate to MCP tools** when built-ins fail or inappropriate -**Use semantic search** for exploratory tasks -**Use indexed search** for large, stable codebases -**Use Exa** for external/public knowledge +**Search tasks** → Always start with smart_search (per context-tools.md) +**Known file edits** → Start with built-in Edit, escalate to MCP if fails +**External knowledge** → Use Exa diff --git a/ccw/src/core/routes/hooks-routes.ts b/ccw/src/core/routes/hooks-routes.ts index d4339da9..a3175bdb 100644 --- a/ccw/src/core/routes/hooks-routes.ts +++ b/ccw/src/core/routes/hooks-routes.ts @@ -180,6 +180,31 @@ function deleteHookFromSettings(projectPath, scope, event, hookIndex) { } } +// ======================================== +// Session State Tracking (for progressive disclosure) +// ======================================== + +// Track sessions that have received startup context +// Key: sessionId, Value: timestamp of first context load +const sessionContextState = new Map(); + +// Cleanup old sessions (older than 24 hours) +function cleanupOldSessions() { + const cutoff = Date.now() - 24 * 60 * 60 * 1000; + for (const [sessionId, state] of sessionContextState.entries()) { + if (new Date(state.firstLoad).getTime() < cutoff) { + sessionContextState.delete(sessionId); + } + } +} + +// Run cleanup every hour +setInterval(cleanupOldSessions, 60 * 60 * 1000); + // ======================================== // Route Handler // ======================================== @@ -260,6 +285,89 @@ export async function handleHooksRoutes(ctx: RouteContext): Promise { return true; } + // API: Unified Session Context endpoint (Progressive Disclosure) + // Automatically detects first prompt vs subsequent prompts + // - First prompt: returns cluster-based session overview + // - Subsequent prompts: returns intent-matched sessions based on prompt + if (pathname === '/api/hook/session-context' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { sessionId, prompt } = body as { sessionId?: string; prompt?: string }; + + if (!sessionId) { + return { + success: true, + content: '', + error: 'sessionId is required' + }; + } + + try { + const projectPath = url.searchParams.get('path') || initialPath; + const { SessionClusteringService } = await import('../session-clustering-service.js'); + const clusteringService = new SessionClusteringService(projectPath); + + // Check if this is the first prompt for this session + const existingState = sessionContextState.get(sessionId); + const isFirstPrompt = !existingState; + + // Update session state + if (isFirstPrompt) { + sessionContextState.set(sessionId, { + firstLoad: new Date().toISOString(), + loadCount: 1, + lastPrompt: prompt + }); + } else { + existingState.loadCount++; + existingState.lastPrompt = prompt; + } + + // Determine which type of context to return + let contextType: 'session-start' | 'context'; + let content: string; + + if (isFirstPrompt) { + // First prompt: return session overview with clusters + contextType = 'session-start'; + content = await clusteringService.getProgressiveIndex({ + type: 'session-start', + sessionId + }); + } else if (prompt && prompt.trim().length > 0) { + // Subsequent prompts with content: return intent-matched sessions + contextType = 'context'; + content = await clusteringService.getProgressiveIndex({ + type: 'context', + sessionId, + prompt + }); + } else { + // Subsequent prompts without content: return minimal context + contextType = 'context'; + content = ''; // No context needed for empty prompts + } + + return { + success: true, + type: contextType, + isFirstPrompt, + loadCount: sessionContextState.get(sessionId)?.loadCount || 1, + content, + sessionId + }; + } catch (error) { + console.error('[Hooks] Failed to generate session context:', error); + return { + success: true, + content: '', + sessionId, + error: (error as Error).message + }; + } + }); + return true; + } + // API: Get hooks configuration if (pathname === '/api/hooks' && req.method === 'GET') { const projectPathParam = url.searchParams.get('path'); diff --git a/ccw/src/core/session-clustering-service.ts b/ccw/src/core/session-clustering-service.ts index e2c60c29..5fef9074 100644 --- a/ccw/src/core/session-clustering-service.ts +++ b/ccw/src/core/session-clustering-service.ts @@ -833,64 +833,110 @@ export class SessionClusteringService { /** * Get recent sessions index (for session-start) + * Shows sessions grouped by clusters with progressive disclosure */ private async getRecentSessionsIndex(): Promise { - const sessions = await this.collectSessions({ scope: 'recent' }); + // 1. Get all active clusters + const allClusters = this.coreMemoryStore.listClusters('active'); - // Sort by created_at descending (most recent first) - const sortedSessions = sessions - .filter(s => s.created_at) - .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')) - .slice(0, 5); // Top 5 recent sessions + // Sort clusters by most recent activity (based on member last_accessed) + const clustersWithActivity = allClusters.map(cluster => { + const members = this.coreMemoryStore.getClusterMembers(cluster.id); + const memberMetadata = members + .map(m => this.coreMemoryStore.getSessionMetadata(m.session_id)) + .filter((m): m is SessionMetadataCache => m !== null); - if (sortedSessions.length === 0) { - return ` -## 📋 Recent Sessions + const lastActivity = memberMetadata.reduce((latest, m) => { + const accessed = m.last_accessed || m.created_at || ''; + return accessed > latest ? accessed : latest; + }, ''); -No recent sessions found. Start a new workflow to begin tracking. + return { cluster, members, memberMetadata, lastActivity }; + }).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)); -**MCP Tools**: -\`\`\` -# Search sessions -Use tool: mcp__ccw-tools__core_memory -Parameters: { "action": "search", "query": "" } - -# Create new session -Parameters: { "action": "save", "content": "" } -\`\`\` -`; - } - - // Generate table - let table = `| # | Session | Type | Title | Date |\n`; - table += `|---|---------|------|-------|------|\n`; - - sortedSessions.forEach((s, idx) => { - const type = s.session_type === 'core_memory' ? 'Core' : - s.session_type === 'workflow' ? 'Workflow' : 'CLI'; - const title = (s.title || '').substring(0, 40); - const date = s.created_at ? new Date(s.created_at).toLocaleDateString() : ''; - table += `| ${idx + 1} | ${s.session_id} | ${type} | ${title} | ${date} |\n`; + // 2. Get unclustered recent sessions + const allSessions = await this.collectSessions({ scope: 'recent' }); + const clusteredSessionIds = new Set(); + clustersWithActivity.forEach(c => { + c.members.forEach(m => clusteredSessionIds.add(m.session_id)); }); - return ` -## 📋 Recent Sessions (Last 30 days) + const unclusteredSessions = allSessions + .filter(s => !clusteredSessionIds.has(s.session_id)) + .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')) + .slice(0, 3); -${table} + // 3. Build output + let output = `\n## 📋 Session Context (Progressive Disclosure)\n\n`; -**Resume via MCP**: -\`\`\` -Use tool: mcp__ccw-tools__core_memory -Parameters: { "action": "load", "id": "${sortedSessions[0].session_id}" } -\`\`\` + // Show top 2 active clusters + const topClusters = clustersWithActivity.slice(0, 2); + if (topClusters.length > 0) { + output += `### 🔗 Active Clusters\n\n`; ---- -**Tip**: Sessions are sorted by most recent. Use \`search\` action to find specific topics. -`; + for (const { cluster, memberMetadata } of topClusters) { + output += `**${cluster.name}** (${memberMetadata.length} sessions)\n`; + if (cluster.intent) { + output += `> Intent: ${cluster.intent}\n`; + } + output += `\n| Session | Type | Title |\n|---------|------|-------|\n`; + + // Show top 3 members per cluster + const displayMembers = memberMetadata.slice(0, 3); + for (const m of displayMembers) { + const type = m.session_type === 'core_memory' ? 'Core' : + m.session_type === 'workflow' ? 'Workflow' : 'CLI'; + const title = (m.title || '').substring(0, 35); + output += `| ${m.session_id} | ${type} | ${title} |\n`; + } + + if (memberMetadata.length > 3) { + output += `| ... | ... | +${memberMetadata.length - 3} more |\n`; + } + output += `\n`; + } + } + + // Show unclustered recent sessions + if (unclusteredSessions.length > 0) { + output += `### 📝 Recent Sessions (Unclustered)\n\n`; + output += `| Session | Type | Title | Date |\n`; + output += `|---------|------|-------|------|\n`; + + for (const s of unclusteredSessions) { + const type = s.session_type === 'core_memory' ? 'Core' : + s.session_type === 'workflow' ? 'Workflow' : 'CLI'; + const title = (s.title || '').substring(0, 30); + const date = s.created_at ? new Date(s.created_at).toLocaleDateString() : ''; + output += `| ${s.session_id} | ${type} | ${title} | ${date} |\n`; + } + output += `\n`; + } + + // If nothing found + if (topClusters.length === 0 && unclusteredSessions.length === 0) { + output += `No recent sessions found. Start a new workflow to begin tracking.\n\n`; + } + + // Add MCP tools reference + const topSession = topClusters[0]?.memberMetadata[0] || unclusteredSessions[0]; + const topClusterId = topClusters[0]?.cluster.id; + + output += `**MCP Tools**:\n\`\`\`\n`; + if (topSession) { + output += `# Resume session\nmcp__ccw-tools__core_memory({ "operation": "export", "id": "${topSession.session_id}" })\n\n`; + } + if (topClusterId) { + output += `# Load cluster context\nmcp__ccw-tools__core_memory({ "operation": "search", "query": "cluster:${topClusterId}" })\n`; + } + output += `\`\`\`\n`; + + return output; } /** * Get intent-matched sessions index (for context with prompt) + * Shows sessions grouped by clusters and ranked by relevance */ private async getIntentMatchedIndex(prompt: string, sessionId?: string): Promise { const sessions = await this.collectSessions({ scope: 'all' }); @@ -917,14 +963,27 @@ No sessions available for intent matching. access_count: 0 }; + // Build session-to-cluster mapping + const sessionClusterMap = new Map(); + const allClusters = this.coreMemoryStore.listClusters('active'); + for (const cluster of allClusters) { + const members = this.coreMemoryStore.getClusterMembers(cluster.id); + for (const member of members) { + const existing = sessionClusterMap.get(member.session_id) || []; + existing.push(cluster); + sessionClusterMap.set(member.session_id, existing); + } + } + // Calculate relevance scores for all sessions const scoredSessions = sessions .filter(s => s.session_id !== sessionId) // Exclude current session .map(s => ({ session: s, - score: this.calculateRelevance(promptSession, s) + score: this.calculateRelevance(promptSession, s), + clusters: sessionClusterMap.get(s.session_id) || [] })) - .filter(item => item.score >= 0.3) // Minimum relevance threshold + .filter(item => item.score >= 0.15) // Minimum relevance threshold (lowered for file-path-based keywords) .sort((a, b) => b.score - a.score) .slice(0, 8); // Top 8 relevant sessions @@ -938,70 +997,82 @@ No sessions match current intent. Consider: **MCP Tools**: \`\`\` -Use tool: mcp__ccw-tools__core_memory -Parameters: { "action": "search", "query": "" } +mcp__ccw-tools__core_memory({ "operation": "search", "query": "" }) \`\`\` `; } - // Group by relevance tier - const highRelevance = scoredSessions.filter(s => s.score >= 0.6); - const mediumRelevance = scoredSessions.filter(s => s.score >= 0.4 && s.score < 0.6); - const lowRelevance = scoredSessions.filter(s => s.score < 0.4); + // Group sessions by cluster + const clusterGroups = new Map(); + const unclusteredSessions: typeof scoredSessions = []; + + for (const item of scoredSessions) { + if (item.clusters.length > 0) { + // Add to the highest-priority cluster + const primaryCluster = item.clusters[0]; + const existing = clusterGroups.get(primaryCluster.id) || { cluster: primaryCluster, sessions: [] }; + existing.sessions.push(item); + clusterGroups.set(primaryCluster.id, existing); + } else { + unclusteredSessions.push(item); + } + } + + // Sort cluster groups by best session score + const sortedGroups = Array.from(clusterGroups.values()) + .sort((a, b) => Math.max(...b.sessions.map(s => s.score)) - Math.max(...a.sessions.map(s => s.score))); // Generate output - let output = ` -## 📋 Intent-Matched Sessions + let output = `\n## 📋 Intent-Matched Sessions\n\n`; + output += `**Detected Intent**: ${(promptSession.keywords || []).slice(0, 5).join(', ') || 'General'}\n\n`; -**Detected Intent**: ${(promptSession.keywords || []).slice(0, 5).join(', ') || 'General'} + // Show clustered sessions + if (sortedGroups.length > 0) { + output += `### 🔗 Matched Clusters\n\n`; -`; + for (const { cluster, sessions: clusterSessions } of sortedGroups.slice(0, 2)) { + const avgScore = Math.round(clusterSessions.reduce((sum, s) => sum + s.score, 0) / clusterSessions.length * 100); + output += `**${cluster.name}** (${avgScore}% avg match)\n`; + if (cluster.intent) { + output += `> ${cluster.intent}\n`; + } + output += `\n| Session | Match | Title |\n|---------|-------|-------|\n`; - if (highRelevance.length > 0) { - output += `### 🔥 Highly Relevant (${highRelevance.length})\n`; - output += `| Session | Type | Match | Summary |\n`; - output += `|---------|------|-------|--------|\n`; - for (const item of highRelevance) { + for (const item of clusterSessions.slice(0, 3)) { + const matchPct = Math.round(item.score * 100); + const title = (item.session.title || '').substring(0, 35); + output += `| ${item.session.session_id} | ${matchPct}% | ${title} |\n`; + } + output += `\n`; + } + } + + // Show unclustered sessions + if (unclusteredSessions.length > 0) { + output += `### 📝 Individual Matches\n\n`; + output += `| Session | Type | Match | Title |\n`; + output += `|---------|------|-------|-------|\n`; + + for (const item of unclusteredSessions.slice(0, 4)) { const type = item.session.session_type === 'core_memory' ? 'Core' : item.session.session_type === 'workflow' ? 'Workflow' : 'CLI'; const matchPct = Math.round(item.score * 100); - const summary = (item.session.title || item.session.summary || '').substring(0, 35); - output += `| ${item.session.session_id} | ${type} | ${matchPct}% | ${summary} |\n`; + const title = (item.session.title || '').substring(0, 30); + output += `| ${item.session.session_id} | ${type} | ${matchPct}% | ${title} |\n`; } output += `\n`; } - if (mediumRelevance.length > 0) { - output += `### 📌 Related (${mediumRelevance.length})\n`; - output += `| Session | Type | Match | Summary |\n`; - output += `|---------|------|-------|--------|\n`; - for (const item of mediumRelevance) { - const type = item.session.session_type === 'core_memory' ? 'Core' : - item.session.session_type === 'workflow' ? 'Workflow' : 'CLI'; - const matchPct = Math.round(item.score * 100); - const summary = (item.session.title || item.session.summary || '').substring(0, 35); - output += `| ${item.session.session_id} | ${type} | ${matchPct}% | ${summary} |\n`; - } - output += `\n`; + // Add MCP tools reference + const topSession = scoredSessions[0]; + const topCluster = sortedGroups[0]?.cluster; + + output += `**MCP Tools**:\n\`\`\`\n`; + output += `# Resume top match\nmcp__ccw-tools__core_memory({ "operation": "export", "id": "${topSession.session.session_id}" })\n`; + if (topCluster) { + output += `\n# Load cluster context\nmcp__ccw-tools__core_memory({ "operation": "search", "query": "cluster:${topCluster.id}" })\n`; } - - if (lowRelevance.length > 0) { - output += `### 💡 May Be Useful (${lowRelevance.length})\n`; - const sessionList = lowRelevance.map(s => s.session.session_id).join(', '); - output += `${sessionList}\n\n`; - } - - // Add resume command for top match - const topMatch = scoredSessions[0]; - output += `**Resume Top Match**: -\`\`\` -Use tool: mcp__ccw-tools__core_memory -Parameters: { "action": "load", "id": "${topMatch.session.session_id}" } -\`\`\` - ---- -**Tip**: Sessions ranked by semantic similarity to your prompt. -`; + output += `\`\`\`\n`; return output; } diff --git a/ccw/src/templates/dashboard-js/components/hook-manager.js b/ccw/src/templates/dashboard-js/components/hook-manager.js index b14282f5..3df8a412 100644 --- a/ccw/src/templates/dashboard-js/components/hook-manager.js +++ b/ccw/src/templates/dashboard-js/components/hook-manager.js @@ -138,23 +138,14 @@ const HOOK_TEMPLATES = { category: 'memory', timeout: 5000 }, - // Session Context - Progressive Disclosure (session start - recent sessions) + // Session Context - Fires once per session at startup + // Uses state file to detect first prompt, only fires once 'session-context': { event: 'UserPromptSubmit', matcher: '', command: 'bash', - args: ['-c', 'curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"session-start\\",\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\"}" http://localhost:3456/api/hook 2>/dev/null | jq -r ".content // empty"'], - description: 'Load recent sessions at session start (time-sorted)', - category: 'context', - timeout: 5000 - }, - // Session Context - Continuous Disclosure (intent matching on every prompt) - 'session-context-continuous': { - event: 'UserPromptSubmit', - matcher: '', - command: 'bash', - args: ['-c', 'PROMPT=$(cat | jq -r ".prompt // empty"); curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"context\\",\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\",\\"prompt\\":\\"$PROMPT\\"}" http://localhost:3456/api/hook 2>/dev/null | jq -r ".content // empty"'], - description: 'Load intent-matched sessions on every prompt (similarity-based)', + args: ['-c', 'STATE_FILE="/tmp/.ccw-session-$CLAUDE_SESSION_ID"; [ -f "$STATE_FILE" ] && exit 0; touch "$STATE_FILE"; curl -s -X POST -H "Content-Type: application/json" -d "{\\"sessionId\\":\\"$CLAUDE_SESSION_ID\\"}" http://localhost:3456/api/hook/session-context 2>/dev/null | jq -r ".content // empty"'], + description: 'Load session context once at startup (cluster overview)', category: 'context', timeout: 5000 } diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index b438740a..f1798e70 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -99,7 +99,7 @@ const i18n = { // Search 'search.placeholder': 'Search...', - // Session cards + // Session cards - 3 states: planning, active, completed (archived location) 'session.status.active': 'ACTIVE', 'session.status.archived': 'ARCHIVED', 'session.status.planning': 'PLANNING', @@ -714,10 +714,8 @@ const i18n = { 'hook.template.gitAddDesc': 'Auto stage written files', // Hook Quick Install Templates - 'hook.tpl.sessionContext': 'Session Context (Start)', - 'hook.tpl.sessionContextDesc': 'Load recent sessions at session start (time-sorted)', - 'hook.tpl.sessionContextContinuous': 'Session Context (Continuous)', - 'hook.tpl.sessionContextContinuousDesc': 'Load intent-matched sessions on every prompt (similarity-based)', + 'hook.tpl.sessionContext': 'Session Context', + 'hook.tpl.sessionContextDesc': 'Load cluster overview once at session start', 'hook.tpl.codexlensSync': 'CodexLens Auto-Sync', 'hook.tpl.codexlensSyncDesc': 'Auto-update code index when files are written or edited', 'hook.tpl.ccwDashboardNotify': 'CCW Dashboard Notify', @@ -2021,10 +2019,8 @@ const i18n = { 'hook.template.gitAddDesc': '自动暂存写入的文件', // Hook Quick Install Templates - 'hook.tpl.sessionContext': 'Session 上下文(启动)', - 'hook.tpl.sessionContextDesc': '会话启动时加载最近会话(按时间排序)', - 'hook.tpl.sessionContextContinuous': 'Session 上下文(持续)', - 'hook.tpl.sessionContextContinuousDesc': '每次提示词时加载意图匹配会话(相似度排序)', + 'hook.tpl.sessionContext': 'Session 上下文', + 'hook.tpl.sessionContextDesc': '会话启动时加载集群概览(仅触发一次)', 'hook.tpl.codexlensSync': 'CodexLens 自动同步', 'hook.tpl.codexlensSyncDesc': '文件写入或编辑时自动更新代码索引', 'hook.tpl.ccwDashboardNotify': 'CCW 控制面板通知', diff --git a/ccw/src/templates/dashboard-js/views/home.js b/ccw/src/templates/dashboard-js/views/home.js index 8ec48f15..fc4786b7 100644 --- a/ccw/src/templates/dashboard-js/views/home.js +++ b/ccw/src/templates/dashboard-js/views/home.js @@ -102,24 +102,28 @@ function renderSessionCard(session) { const isActive = session._isActive !== false; const date = session.created_at; - // Detect planning status from session.status field - const isPlanning = session.status === 'planning'; + // Get session status from metadata (default to 'planning' for new sessions) + // 3 states: planning → active → completed (archived) + const sessionStatus = session.status || 'planning'; + const isPlanning = sessionStatus === 'planning'; // Get session type badge const sessionType = session.type || 'workflow'; const typeBadge = sessionType !== 'workflow' ? `${sessionType}` : ''; // Determine status badge class and text - // Priority: archived > planning > active + // Priority: archived location > planning status > active status let statusClass, statusText; if (!isActive) { - // Archived sessions always show as ARCHIVED regardless of status field + // Archived sessions (completed) always show as ARCHIVED statusClass = 'archived'; statusText = t('session.status.archived'); } else if (isPlanning) { + // Planning state - session created but not yet executed statusClass = 'planning'; statusText = t('session.status.planning'); } else { + // Active state - session is being executed statusClass = 'active'; statusText = t('session.status.active'); } diff --git a/ccw/src/templates/dashboard-js/views/hook-manager.js b/ccw/src/templates/dashboard-js/views/hook-manager.js index 19293d13..c30b801d 100644 --- a/ccw/src/templates/dashboard-js/views/hook-manager.js +++ b/ccw/src/templates/dashboard-js/views/hook-manager.js @@ -101,7 +101,6 @@ async function renderHookManager() {
${renderQuickInstallCard('session-context', t('hook.tpl.sessionContext'), t('hook.tpl.sessionContextDesc'), 'UserPromptSubmit', '')} - ${renderQuickInstallCard('session-context-continuous', t('hook.tpl.sessionContextContinuous'), t('hook.tpl.sessionContextContinuousDesc'), 'UserPromptSubmit', '')} ${renderQuickInstallCard('codexlens-update', t('hook.tpl.codexlensSync'), t('hook.tpl.codexlensSyncDesc'), 'PostToolUse', 'Write|Edit')} ${renderQuickInstallCard('ccw-notify', t('hook.tpl.ccwDashboardNotify'), t('hook.tpl.ccwDashboardNotifyDesc'), 'PostToolUse', 'Write')} ${renderQuickInstallCard('log-tool', t('hook.tpl.toolLogger'), t('hook.tpl.toolLoggerDesc'), 'PostToolUse', 'All')} @@ -506,11 +505,37 @@ async function uninstallHookTemplate(templateId) { const template = HOOK_TEMPLATES[templateId]; if (!template) return; + // Extract unique identifier from template args for matching + // Template args format: ['-c', 'actual command...'] + const templateArgs = template.args || []; + const templateFullCmd = templateArgs.length > 0 ? templateArgs.join(' ') : ''; + + // Define unique patterns for each template type + const uniquePatterns = { + 'session-context': 'api/hook/session-context', + 'codexlens-update': 'codexlens update', + 'ccw-notify': 'api/hook', + 'log-tool': 'tool-usage.log', + 'lint-check': 'eslint', + 'git-add': 'git add', + 'memory-file-read': 'memory track', + 'memory-file-write': 'memory track', + 'memory-prompt-track': 'memory track' + }; + + const uniquePattern = uniquePatterns[templateId] || template.command; + + // Helper to check if a hook matches the template + const matchesTemplate = (h) => { + const hookCmd = h.hooks?.[0]?.command || h.command || ''; + return hookCmd.includes(uniquePattern); + }; + // Find and remove from project hooks const projectHooks = hookConfig.project?.hooks?.[template.event]; if (projectHooks) { const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks]; - const index = hookList.findIndex(h => h.command === template.command); + const index = hookList.findIndex(matchesTemplate); if (index !== -1) { await removeHook('project', template.event, index); return; @@ -521,12 +546,14 @@ async function uninstallHookTemplate(templateId) { const globalHooks = hookConfig.global?.hooks?.[template.event]; if (globalHooks) { const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks]; - const index = hookList.findIndex(h => h.command === template.command); + const index = hookList.findIndex(matchesTemplate); if (index !== -1) { await removeHook('global', template.event, index); return; } } + + showRefreshToast('Hook not found', 'error'); } function attachHookEventListeners() {