Hooks
What claude-scholar runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add Galaxy-Dawn/claude-scholar > /plugin install claude-scholar@claude-scholar
Ships with claude-scholar. Installing the plugin gets these hooks.
What fires, and when
PreToolUse
- Matches
Bash|Write|Editnode ${CLAUDE_PLUGIN_ROOT}/hooks/security-guard.js
SessionEnd
- Matches
*node ${CLAUDE_PLUGIN_ROOT}/hooks/session-summary.js
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
- Matches
*node ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.js
Stop
- Matches
*node ${CLAUDE_PLUGIN_ROOT}/hooks/stop-summary.js
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
- Matches
*node ${CLAUDE_PLUGIN_ROOT}/hooks/skill-forced-eval.js
In the plugin's words
How claude-scholar describes its own hook set.
Oh My Claude 自动化工作流钩子 - 跨平台 JavaScript 实现
Where it lives
- hooks/hook-common.jsGitHub
Read the script
/** * Cross-platform Hook shared utility library * Provides cross-platform compatible shared functions for Claude Code Hooks * * @module hook-common */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); /** * Get Git status information * @param {string} cwd - Current working directory * @returns {Object} Git info object */ function getGitInfo(cwd) { try { // Check if this is a Git repository execSync('git rev-parse --git-dir', { cwd, stdio: 'pipe' }); // Get branch name let branch = 'unknown'; try { branch = execSync('git branch --show-current', { cwd, encoding: 'utf8', stdio: 'pipe' }).trim(); } catch { branch = 'unknown'; } // Get changed files let changes = ''; try { changes = execSync('git status --porcelain', { cwd, encoding: 'utf8', stdio: 'pipe' }); } catch { changes = ''; } const changeList = changes.trim().split('\n').filter(Boolean); const hasChanges = changeList.length > 0; return { is_repo: true, branch, changes_count: changeList.length, has_changes: hasChanges, changes: changeList }; } catch { return { is_repo: false, branch: 'unknown', changes_count: 0, has_changes: false, changes: [] }; } } /** * Get todo item information * @param {string} cwd - Current working directory * @returns {Object} Todo item information */ function getTodoInfo(cwd) { const todoFiles = [ path.join(cwd, 'docs', 'todo.md'), path.join(cwd, 'TODO.md'), path.join(cwd, '.claude', 'todos.md'), path.join(cwd, 'TODO'), path.join(cwd, 'notes', 'todo.md') ]; for (const file of todoFiles) { if (fs.existsSync(file)) { try { const content = fs.readFileSync(file, 'utf8'); const totalMatches = content.match(/^[\-\*] \[[ x]\]/gi) || []; const total = totalMatches.length; const doneMatches = content.match(/^[\-\*] \[x\]/gi) || []; const done = doneMatches.length; const pending = total - done; return { found: true, file: path.basename(file), path: file, total, done, pending }; } catch { continue; } } } return { found: false, file: null, path: null, total: 0, done: 0, pending: 0 }; } /** * Resolve repository root when available * @param {string} cwd - Current working directory * @returns {string} Repository root or cwd fallback */ function getRepoRoot(cwd) { try { return execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf8', stdio: 'pipe' }).trim(); } catch { return cwd; } } /** * Parse simple YAML frontmatter from a Markdown file. * Supports the project-memory binding metadata shape. * @param {string} filePath * @returns {Object|null} */ function parseFrontmatterFile(filePath) { try { const content = fs.readFileSync(filePath, 'utf8'); const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) return null; const data = {}; for (const line of match[1].split('\n')) { const parts = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/); if (!parts) continue; const key = parts[1]; let value = parts[2].trim(); if (value === 'true') value = true; else if (value === 'false') value = false; else value = value.replace(/^['"]|['"]$/g, ''); data[key] = value; } return data; } catch { return null; } } /** * Detect whether the current repository is bound to Obsidian project memory * @param {string} cwd - Current working directory * @returns {Object} Binding info */ function getProjectMemoryBinding(cwd) { const repoRoot = getRepoRoot(cwd); const projectMemoryDir = path.join(repoRoot, '.claude', 'project-memory'); const registryPath = path.join(projectMemoryDir, 'registry.yaml'); if (!fs.existsSync(registryPath)) { return { bound: false, repoRoot, registryPath, projectId: null, status: null, autoSync: false, vaultRoot: null, hubNote: null, memoryPath: null }; } let project = null; let memoryPath = null; if (fs.existsSync(projectMemoryDir)) { const memoryFiles = fs.readdirSync(projectMemoryDir) .filter(name => name.endsWith('.md')) .map(name => path.join(projectMemoryDir, name)); for (const candidate of memoryFiles) { const frontmatter = parseFrontmatterFile(candidate); if (!frontmatter) continue; if (frontmatter.repo_root && (frontmatter.repo_root === repoRoot || cwd.startsWith(frontmatter.repo_root))) { project = frontmatter; memoryPath = candidate; break; } } if (!memoryPath && memoryFiles.length > 0) { memoryPath = memoryFiles[0]; project = parseFrontmatterFile(memoryPath) || null; } } return { bound: true, repoRoot, registryPath, projectId: project ? project.project_id || null : null, status: project ? project.status || null : null, autoSync: Boolean(project && project.auto_sync), vaultRoot: project ? project.vault_root || null : null, hubNote: project ? project.hub_note || null : null, memoryPath }; } /** * Heuristically detect whether a directory looks like a research repo * @param {string} cwd - Current working directory * @returns {Object} Detection result */ function detectResearchProject(cwd) { const repoRoot = getRepoRoot(cwd); const markers = [ '.git', 'README.md', 'docs', 'notes', 'plan', 'results', 'outputs', 'src', 'scripts' ]; const hits = markers.filter(marker => fs.existsSync(path.join(repoRoot, marker))); const candidate = hits.length >= 3 || (hits.includes('.git') && hits.includes('READ - hooks/security-guard.jsRunsGitHub
Read the script
#!/usr/bin/env node /** * PreToolUse Hook: Security guard layer (cross-platform version) * * Event: PreToolUse * Two-tier security: Block (exit 2) | Confirm (exit 0 + ask user) */ const path = require('path'); const os = require('os'); const common = require('./hook-common'); // Read stdin input let input = {}; try { const stdinData = require('fs').readFileSync(0, 'utf8'); if (stdinData.trim()) { input = JSON.parse(stdinData); } } catch { // Use default empty object } const toolName = input.tool_name || ''; const cwd = input.cwd || process.cwd(); let decision = 'allow'; let reason = ''; let confirmLabel = ''; function isPathInside(basePath, targetPath) { const relative = path.relative(basePath, targetPath); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } // === Bash command security check === if (toolName === 'Bash') { const command = input.tool_input?.command || ''; // Tier 1: Block — catastrophic, no recovery const blockPatterns = [ /rm\s+-rf\s+\/(\s|$)/, // rm -rf / /rm\s+--no-preserve-root/, // rm --no-preserve-root /dd\s+if=\/dev\/(zero|random)/, // dd from /dev/zero or /dev/random />\s*\/dev\/(sd|nvme|vda)/, // Write to block devices /mkfs\./, // Format filesystem /rm\s+-rf?\s+\/(etc|usr|bin|sbin)(\/|\s|$)/, // Remove system dirs /rm\s+-rf\s+\/home\/[^\/\s]*\/?(\s|$)/, // Remove user home dir /rm\s+-rf\s+\/Users\/[^\/\s]*\/?(\s|$)/, // Remove macOS user dir ]; for (const pattern of blockPatterns) { if (pattern.test(command)) { decision = 'deny'; reason = 'Catastrophic command detected'; break; } } // Tier 2: Confirm — dangerous but sometimes legitimate if (decision === 'allow') { const confirmPatterns = [ { pattern: /git\s+push\s+.*(-f|--force)/, label: 'git push --force (overwrites remote history)' }, { pattern: /git\s+reset\s+--hard/, label: 'git reset --hard (discards all uncommitted changes)' }, { pattern: /git\s+clean\s+-[a-z]*f/, label: 'git clean -f (permanently deletes untracked files)' }, { pattern: /git\s+(checkout|restore)\s+\./, label: 'git checkout/restore . (discards all working tree changes)' }, { pattern: /rm\s+-[rf]/, label: 'rm -rf (recursive/force delete)' }, { pattern: /chmod\s+(-R\s+)?777/, label: 'chmod 777 (world-writable permissions)' }, { pattern: /npm\s+publish/, label: 'npm publish (publishes package to registry)' }, { pattern: /pip\s+upload|twine\s+upload/, label: 'pip/twine upload (publishes package to PyPI)' }, { pattern: /docker\s+system\s+prune/, label: 'docker system prune (removes all unused resources)' }, { pattern: /DROP\s+(DATABASE|TABLE)/i, label: 'SQL DROP (destroys database/table)' }, { pattern: /DELETE\s+FROM\s+(?!.*WHERE)/i, label: 'DELETE without WHERE (deletes all rows)' }, { pattern: /UPDATE\s+\S+\s+SET\s+(?!.*WHERE)/i, label: 'UPDATE without WHERE (updates all rows)' }, { pattern: /TRUNCATE\s+TABLE/i, label: 'SQL TRUNCATE (empties entire table)' }, ]; for (const { pattern, label } of confirmPatterns) { if (pattern.test(command)) { confirmLabel = label; break; } } } // === File write security check === } else if (toolName === 'Write' || toolName === 'Edit') { const filePath = input.tool_input?.file_path || ''; const homeDir = os.homedir(); const repoRoot = common.getRepoRoot(cwd); const resolved = path.resolve(cwd, filePath); // Tier 1: Block — system paths const sensitivePaths = [ '/etc/', '/usr/bin/', '/usr/sbin/', '/bin/', '/sbin/', '/System/', '/dev/', '/proc/', '/sys/' ]; for (const sp of sensitivePaths) { if (filePath.startsWith(sp)) { decision = 'deny'; reason = `Writing to system path denied: ${sp}`; break; } } // Block — outside repo and home if (decision === 'allow') { const insideRepo = isPathInside(repoRoot, resolved); const insideHome = isPathInside(homeDir, resolved); if (!insideRepo && !insideHome) { decision = 'deny'; reason = 'Path traversal attack detected: resolved path is outside the repository and home directory'; } else if (!insideRepo && insideHome) { const relativeHomePath = path.relative(homeDir, resolved) || path.basename(resolved); confirmLabel = `home directory path outside repo (~/${relativeHomePath})`; } } // Tier 2: Confirm — sensitive files if (decision === 'allow') { const fileName = path.basename(filePath); if (fileName.startsWith('.env')) { confirmLabel = `.env file (${fileName})`; } if (!confirmLabel) { const sensitiveFiles = ['credentials.json', 'key.pem', 'key.json', 'id_rsa']; for (const sf of sensitiveFiles) { if (fileName === sf) { confirmLabel = `sensitive file (${sf})`; break; } } } if (!confirmLabel) { const sensitivePathPatterns = ['.aws/credentials', '.npmrc']; for (const sp of sensitivePathPatterns) { if (filePath.includes(sp)) { confirmLabel = `sensitive path (${sp})`; break; } } } } } // === Build output === if (decision === 'deny') { const errorOutput = { hookSpecificOutput: { permissionDecision: 'deny' }, systemMessage: `🛑 Blocked: ${reason}\n\nTo perform this operation, run it manually in the terminal.` }; console.error(JSON.stringify(errorOutput)); process.exit(2); } else { const result = { continue: true }; if (confirmLabel) { result.systemMessage = `⚠️ CONFIRM REQUIRED: ${confirmLabel}\n\nYou MUST ask the user for explicit confirmation before executing this operation. Do NOT proceed without user approval.`; } console.log(JSON.stringify(result)); process.exit(0); } - hooks/session-start.jsRunsGitHub
Read the script
#!/usr/bin/env node /** * SessionStart Hook: Display project status (cross-platform version) * * Event: SessionStart * Function: Display project status, Git info, todos, plugins, and commands at session start */ const path = require('path'); const os = require('os'); const fs = require('fs'); // Import shared utility library const common = require('./hook-common'); // Import package manager detection const { getPackageManager, getSelectionPrompt } = require('../scripts/lib/package-manager'); // Read stdin input let input = {}; try { const stdinData = require('fs').readFileSync(0, 'utf8'); if (stdinData.trim()) { input = JSON.parse(stdinData); } } catch { // Use default empty object } const cwd = input.cwd || process.cwd(); const projectName = path.basename(cwd); const homeDir = os.homedir(); const binding = common.getProjectMemoryBinding(cwd); const researchCandidate = common.detectResearchProject(cwd); // Build output let output = ''; // Session start info output += `🚀 ${projectName} Session started\n`; output += `▸ Time: ${common.formatDateTime()}\n`; output += `▸ Directory: ${cwd}\n\n`; // Git status const gitInfo = common.getGitInfo(cwd); if (gitInfo.is_repo) { output += `▸ Git branch: ${gitInfo.branch}\n\n`; if (gitInfo.has_changes) { output += `⚠️ Uncommitted changes (${gitInfo.changes_count} files):\n`; // Show change list (up to 5) const statusIcons = { 'M': '📝', // Modified 'A': '➕', // Added 'D': '❌', // Deleted 'R': '🔄', // Renamed '??': '❓' // Untracked }; for (let i = 0; i < Math.min(gitInfo.changes.length, 5); i++) { const change = gitInfo.changes[i]; const status = change.substring(0, 2).trim(); const file = change.substring(3).trim(); const icon = statusIcons[status] || '•'; output += ` ${icon} ${file}\n`; } if (gitInfo.changes_count > 5) { output += ` ... (${gitInfo.changes_count - 5} more files)\n`; } } else { output += `✅ Working directory clean\n`; } output += '\n'; } else { output += `▸ Git: Not a repository\n\n`; } if (binding.bound) { output += '🧠 Obsidian project KB: bound\n'; output += ` - Project: ${binding.projectId || 'unknown'}\n`; output += ` - Status: ${binding.status || 'unknown'}\n`; output += ` - Auto-sync: ${binding.autoSync ? 'on' : 'off'}\n`; if (binding.vaultRoot) { output += ` - Vault root: ${binding.vaultRoot}\n`; } output += ' - Suggested commands: /kb-status, /kb-sync, /kb-lint\n\n'; } else if (researchCandidate.candidate) { output += '🧠 Obsidian project KB: research repo candidate\n'; output += ` - Detected markers: ${researchCandidate.markers.join(', ')}\n`; output += ' - Suggested command: /kb-init\n\n'; } // Package manager detection try { const pm = getPackageManager(); output += `📦 Package manager: ${pm.name} (${pm.source})\n`; // If detected via fallback, suggest setup if (pm.source === 'fallback' || pm.source === 'default') { output += `💡 Run /setup-pm to configure preferred package manager\n`; } } catch (err) { // Package manager detection failed, silently ignore } output += '\n'; // Todos output += `📋 Todos:\n`; const todoInfo = common.getTodoInfo(cwd); if (todoInfo.found) { output += ` - ${todoInfo.pending} pending / ${todoInfo.done} completed\n`; // Show top 5 pending items if (fs.existsSync(todoInfo.path)) { try { const content = fs.readFileSync(todoInfo.path, 'utf8'); const pendingItems = content.match(/^[\-\*] \[ \].+$/gm) || []; if (pendingItems.length > 0) { output += `\n Recent todos:\n`; for (let i = 0; i < Math.min(5, pendingItems.length); i++) { const item = pendingItems[i].replace(/^[\-\*] \[ \]\s*/, '').substring(0, 60); output += ` - ${item}\n`; } } } catch { // Ignore errors } } } else { output += ` No todo file found (TODO.md, docs/todo.md etc)\n`; } output += '\n'; // Enabled plugins output += `🔌 Enabled plugins:\n`; const enabledPlugins = common.getEnabledPlugins(homeDir); if (enabledPlugins.length > 0) { for (let i = 0; i < Math.min(5, enabledPlugins.length); i++) { output += ` - ${enabledPlugins[i].name}\n`; } if (enabledPlugins.length > 5) { output += ` ... and ${enabledPlugins.length - 5} more plugins\n`; } } else { output += ` None\n`; } output += '\n'; // Available commands output += `💡 Available commands:\n`; const availableCommands = common.getAvailableCommands(homeDir); if (availableCommands.length > 0) { for (const cmd of availableCommands.slice(0, 5)) { const description = common.getCommandDescription(cmd.path) || `${cmd.plugin} command`; const truncatedDesc = description.length > 40 ? description.substring(0, 40) + '...' : description; output += ` /${cmd.name.padEnd(20)} ${truncatedDesc}\n`; } if (availableCommands.length > 5) { output += ` ... and ${availableCommands.length - 5} more commands, use /help to list all\n`; } } else { output += ` No commands found\n`; } // Output JSON const result = { continue: true, systemMessage: output }; console.log(JSON.stringify(result)); process.exit(0); - hooks/session-summary.jsRunsGitHub
Read the script
#!/usr/bin/env node /** * SessionEnd Hook: Work Log + Smart Suggestions (Cross-platform) * * Event: SessionEnd * Purpose: Create work log, record changes and generate smart suggestions */ const path = require('path'); const fs = require('fs'); const os = require('os'); const common = require('./hook-common'); // Read stdin input let input = {}; try { const stdinData = require('fs').readFileSync(0, 'utf8'); if (stdinData.trim()) { input = JSON.parse(stdinData); } } catch { // Use default empty object } const cwd = input.cwd || process.cwd(); const sessionId = input.session_id || 'unknown'; const transcriptPath = input.transcript_path || ''; const binding = common.getProjectMemoryBinding(cwd); const MAX_LOGGED_GIT_CHANGES = 20; const MAX_LOGGED_MEMORY_CHANGES = 5; // Create work log directory const logDir = path.join(cwd, '.claude', 'logs'); fs.mkdirSync(logDir, { recursive: true }); // Generate log filename const now = new Date(); const dateStr = now.toISOString().split('T')[0].replace(/-/g, ''); const logFile = path.join(logDir, `session-${dateStr}-${sessionId.substring(0, 8)}.md`); // Get project info const projectName = path.basename(cwd); // Build log content let logContent = ''; logContent += `# 📝 Work Log - ${projectName}\n`; logContent += `\n`; logContent += `**Session ID**: ${sessionId}\n`; logContent += `**Time**: ${common.formatDateTime(now)}\n`; logContent += `**Directory**: ${cwd}\n`; logContent += `\n`; // Git change statistics logContent += `## 📊 Session Changes\n`; const gitInfo = common.getGitInfo(cwd); const changesDetails = gitInfo.is_repo ? common.getChangesDetails(cwd) : { added: 0, modified: 0, deleted: 0 }; if (gitInfo.is_repo) { logContent += `**Branch**: ${gitInfo.branch}\n`; logContent += `\n`; const shownGitChanges = gitInfo.changes.slice(0, MAX_LOGGED_GIT_CHANGES); logContent += '```\n'; if (gitInfo.has_changes) { for (const change of shownGitChanges) { logContent += `${change}\n`; } if (gitInfo.changes.length > MAX_LOGGED_GIT_CHANGES) { logContent += `... (${gitInfo.changes.length - MAX_LOGGED_GIT_CHANGES} more changes omitted)\n`; } } else { logContent += 'No changes\n'; } logContent += '```\n'; // Change statistics logContent += `\n`; logContent += '| Type | Count |\n'; logContent += '|------|------|\n'; logContent += `| Added | ${changesDetails.added} |\n`; logContent += `| Modified | ${changesDetails.modified} |\n`; logContent += `| Deleted | ${changesDetails.deleted} |\n`; } else { logContent += 'Not a Git repository\n'; } logContent += `\n`; if (binding.bound) { logContent += `## 🧠 Obsidian Project Memory\n`; logContent += `\n`; logContent += `- Project: ${binding.projectId || 'unknown'}\n`; logContent += `- Status: ${binding.status || 'unknown'}\n`; logContent += `- Auto-sync: ${binding.autoSync ? 'on' : 'off'}\n`; if (binding.vaultRoot) { logContent += `- Vault root: ${binding.vaultRoot}\n`; } logContent += `- Minimum write-back to verify after research-state turns:\n`; logContent += ` - Daily/YYYY-MM-DD.md\n`; logContent += ` - ${binding.memoryPath || '.claude/project-memory/<project_id>.md'}\n`; logContent += ` - 00-Hub.md (only when top-level project status changes)\n`; logContent += `\n`; } // Read transcript to extract key operations (if available) if (transcriptPath && fs.existsSync(transcriptPath)) { try { const transcript = fs.readFileSync(transcriptPath, 'utf8'); const toolMatches = transcript.match(/Tool used: [A-Z][a-z]*/g) || []; if (toolMatches.length > 0) { // Count tool usage const toolCounts = {}; for (const match of toolMatches) { const tool = match.replace('Tool used: ', ''); toolCounts[tool] = (toolCounts[tool] || 0) + 1; } // Sort and take top 10 const sortedTools = Object.entries(toolCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 10); if (sortedTools.length > 0) { logContent += `## 🔧 Key Operations\n`; logContent += `\n`; for (const [tool, count] of sortedTools) { logContent += `| ${tool} | ${count} times |\n`; } logContent += `\n`; } } } catch { // Ignore errors } } // Next steps logContent += `## 🎯 Next Steps\n`; logContent += `\n`; if (gitInfo.has_changes) { logContent += `- ⚠️ Uncommitted changes detected (${gitInfo.changes_count} files)\n`; } else { logContent += '- ✅ Working directory clean\n'; } // Todo reminders const todoInfo = common.getTodoInfo(cwd); if (todoInfo.found) { logContent += `- Update todos: ${todoInfo.file} (${todoInfo.pending} pending)\n`; } // CLAUDE.md memory update check const homeDir = os.homedir(); const claudeMdCheck = common.checkClaudeMdUpdate(homeDir); if (claudeMdCheck.needsUpdate) { logContent += `- ⚠️ **CLAUDE.md memory needs updating** (${claudeMdCheck.changedFiles.length} source files changed)\n`; logContent += ` Run "/update-memory" to sync latest memory\n`; // Record change details to log logContent += `\n`; logContent += `### CLAUDE.md Change Details\n`; logContent += `\n`; logContent += `| Type | File | Modified |\n`; logContent += `|------|------|----------|\n`; for (const file of claudeMdCheck.changedFiles.slice(0, MAX_LOGGED_MEMORY_CHANGES)) { logContent += `| ${file.type} | ${file.relativePath} | ${file.mtime} |\n`; } if (claudeMdCheck.changedFiles.length > MAX_LOGGED_MEMORY_CHANGES) { logContent += `| ... | ${claudeMdCheck.changedFiles.length - MAX_LOGGED_MEMORY_CHANGES} more files omitted | ... |\n`; } } else { logContent += `- ✅ CLAUDE.md memory is up to date\n`; } logContent += '- View context snapshot: `cat .claude/session-context-*.md`\n'; logContent += `\n`; // Write log file fs.writeFileSync(logFile, logContent, 'utf8'); // Clean up old log files (older than 30 days) try { const maxAge = 30 * 24 * 60 * 60 * 1000; const files = fs.readdirS - hooks/skill-forced-eval.jsRunsGitHub
Read the script
#!/usr/bin/env node /** * UserPromptSubmit Hook: Forced skill activation flow (cross-platform version) * * Event: UserPromptSubmit * Function: Force AI to evaluate available skills and begin implementation after activation */ const path = require('path'); const fs = require('fs'); const os = require('os'); const common = require('./hook-common'); // Read stdin input let input = {}; try { const stdinData = require('fs').readFileSync(0, 'utf8'); if (stdinData.trim()) { input = JSON.parse(stdinData); } } catch { // Use default empty object } const userPrompt = input.user_prompt || ''; const cwd = input.cwd || process.cwd(); // Check if it is a slash command (escape) if (userPrompt.startsWith('/')) { // Distinguish commands from paths: // - Commands: /commit, /update-github (no second slash after the first) // - Paths: /Users/xxx, /path/to/file (contains path separators) const rest = userPrompt.substring(1); if (rest.includes('/')) { // This is a path, continue with skill scanning } else { // This is a command, skip skill evaluation console.log(JSON.stringify({ continue: true })); process.exit(0); } } const homeDir = os.homedir(); // Dynamically collect skill list function collectSkills() { const skills = []; const skillsDir = path.join(homeDir, '.claude', 'skills'); // 1. Collect local skills if (fs.existsSync(skillsDir)) { const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name); for (const skillName of skillDirs) { skills.push(skillName); } } // 2. Collect plugin skills const pluginsCache = path.join(homeDir, '.claude', 'plugins', 'cache'); if (fs.existsSync(pluginsCache)) { const marketplaces = fs.readdirSync(pluginsCache, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name); for (const marketplace of marketplaces) { const marketplacePath = path.join(pluginsCache, marketplace); const plugins = fs.readdirSync(marketplacePath, { withFileTypes: true }) .filter(d => d.isDirectory() && !d.name.startsWith('.')) .map(d => d.name); for (const plugin of plugins) { const pluginPath = path.join(marketplacePath, plugin); const versions = fs.readdirSync(pluginPath, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name) .sort() .reverse(); if (versions.length > 0) { const latestVersion = versions[0]; const skillsDirPath = path.join(pluginPath, latestVersion, 'skills'); if (fs.existsSync(skillsDirPath)) { const skillDirs = fs.readdirSync(skillsDirPath, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name); for (const skillName of skillDirs) { skills.push(`${plugin}:${skillName}`); } } } } } } // Deduplicate return [...new Set(skills)].sort(); } // Categorize skills into groups function categorizeSkills(skills) { const categories = { 'Research & Writing': /research|paper|writing|citation|review-response|rebuttal|post-acceptance|doc-coauthoring|latex|daily-paper|ml-paper|results-analysis|results-report|brainstorm/, 'Development': /coding|git|code-review|bug|architecture|verification|tdd|uv-package|webapp-testing|kaggle|driven-development|development-branch|planning|dispatching|executing|using-superpowers/, 'Plugin Dev': /skill-|command-|hook-|mcp-|agent-identifier|plugin-structure/, 'Design & UI': /frontend|ui-ux|web-design|canvas|brand|theme|algorithmic-art|slack-gif|figma/, 'Documents': /docx|xlsx|pptx|pdf|internal-comms|web-artifacts/, }; const grouped = {}; for (const cat of Object.keys(categories)) { grouped[cat] = []; } grouped['Other'] = []; for (const skill of skills) { let matched = false; for (const [cat, regex] of Object.entries(categories)) { if (regex.test(skill)) { grouped[cat].push(skill); matched = true; break; } } if (!matched) { grouped['Other'].push(skill); } } return grouped; } // Keyword-to-skill mapping for pre-matching // Note: \b doesn't work with CJK characters, so we use separate patterns // English keywords use \b for precision; Chinese keywords match without \b const KEYWORD_SKILL_MAP = [ { keywords: /\b(git|github|commit|push|pull|merge|rebase|branch|tag|stash|cherry.?pick|develop|master|main)\b|分支|合并|推送|提交代码|上传.*分支|主分支/i, skills: ['git-workflow'] }, { keywords: /\b(debug|bug|error|broken|failing|traceback|exception)\b|排查|调试|报错|出错|不工作/i, skills: ['bug-detective'] }, { keywords: /\b(tdd|test.?driven)\b|写测试|测试驱动/i, skills: ['superpowers:test-driven-development'] }, { keywords: /\b(code.?review|review code)\b|审查代码|代码审查/i, skills: ['code-review-excellence'] }, { keywords: /\b(paper|manuscript|draft)\b|论文|写作|投稿/i, skills: ['ml-paper-writing'] }, { keywords: /\b(research|idea|brainstorm)\b|研究|构思|文献/i, skills: ['research-ideation'] }, { keywords: /\b(rebuttal|reviewer|response to reviewer)\b|审稿|回复审稿/i, skills: ['review-response'] }, { keywords: /\b(frontend|landing.?page|dashboard)\b|前端|界面|网页设计/i, skills: ['frontend-design'] }, { keywords: /\b(create|write|develop|improve).*skill|skill.*(开发|创建|写|改进)|开发.*skill|写.*skill|改进.*skill/i, skills: ['skill-development'] }, { keywords: /\b(create|write|develop).*hook|hook.*(开发|创建|写)|开发.*hook|写.*hook/i, skills: ['hook-development'] }, { keywords: /\b(create|write|develop).*command|slash.*command|command.*(开发|创建|写)|开发.*command|写.*命令/i, skills: ['command-development'] }, { keywords: /\b(create|write|develop).*agent|agent.*(开发|创建|写)|开发.*agent|写.*agent/i, skills: ['agent-identifier'] }, { keywords: /\b(mcp)\b|mcp.*server|mcp.*集成/i, skills: ['mcp-integration'] }, { keywords: /\b(architecture|factory|registry)\b|架构|设计模式/i, skills: ['archit - hooks/stop-summary.jsRunsGitHub
Read the script
#!/usr/bin/env node /** * Stop Hook: Display basic status + AI summary prompt (Cross-platform) * * Event: Stop * Purpose: Display Git status, change statistics and temp files when session stops */ const common = require('./hook-common'); // Read stdin input let input = {}; try { const stdinData = require('fs').readFileSync(0, 'utf8'); if (stdinData.trim()) { input = JSON.parse(stdinData); } } catch { // Use default empty object } const cwd = input.cwd || process.cwd(); const reason = input.reason || 'task_complete'; const binding = common.getProjectMemoryBinding(cwd); const path = require('path'); function getTempBucket(file) { const normalized = file.replace(/\\/g, '/'); const knownRoots = ['plan', 'docs/plans', '.claude/temp', 'tmp', 'temp']; for (const root of knownRoots) { if (normalized === root || normalized.startsWith(`${root}/`)) { return root; } } const dirname = path.dirname(normalized); if (dirname === '.' || dirname === '') { return '.'; } return normalized.split('/')[0]; } // Build message function buildMessage() { let msg = '\n---\n'; msg += '✅ Session ended\n\n'; // Git info const gitInfo = common.getGitInfo(cwd); if (gitInfo.is_repo) { msg += '📁 Git repository\n'; msg += ` Branch: ${gitInfo.branch}\n`; if (gitInfo.has_changes) { const changesDetails = common.getChangesDetails(cwd); msg += ' Changes:\n'; if (changesDetails.added > 0) msg += ` 增加: ${changesDetails.added} files\n`; if (changesDetails.modified > 0) msg += ` 修改: ${changesDetails.modified} files\n`; if (changesDetails.deleted > 0) msg += ` 删除: ${changesDetails.deleted} files\n`; } else { msg += ' Status: clean\n'; } } else { msg += '📁 Not a Git repository\n'; } msg += '\n'; // Temp file detection const tempInfo = common.detectTempFiles(cwd); if (tempInfo.count > 0) { msg += `🧹 Temp files: ${tempInfo.count}\n`; const grouped = {}; for (const file of tempInfo.files) { const dir = getTempBucket(file); if (!grouped[dir]) grouped[dir] = []; grouped[dir].push(file); } const orderedGroups = Object.entries(grouped).sort(([a], [b]) => { if (a === '.' && b !== '.') return -1; if (a !== '.' && b === '.') return 1; return a.localeCompare(b); }); for (const [dir, files] of orderedGroups) { const label = dir === '.' ? './' : `${dir}/`; msg += ` 📂 ${label} (${files.length})\n`; } } else { msg += '✅ No temp files\n'; } if (binding.bound) { msg += '\n🧠 Bound Obsidian KB\n'; msg += ` Project: ${binding.projectId || 'unknown'}\n`; msg += ' Minimum maintenance after research-state turns:\n'; msg += ' • Daily/YYYY-MM-DD.md\n'; msg += ` • ${binding.memoryPath || '.claude/project-memory/<project_id>.md'}\n`; msg += ' • 00-Hub.md (only when top-level project status changes)\n'; } msg += '---'; return msg; } // Build and return const systemMessage = buildMessage(); const result = { continue: true, systemMessage: systemMessage }; console.log(JSON.stringify(result)); process.exit(0);
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Semi-automated research assistant for academic research and software development. Supports Claude Code, Codex CLI, Kimi Code CLI, and OpenCode across ideation, coding, experiments, writing, and publication.
Repo: Galaxy-Dawn/claude-scholar

