Skip to content
Automation
Hook

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.

From plugin
claude-scholar
5.6k45 skills6 agents65 commands5 hooks
Install
> /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

  • MatchesBash|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
Read hooks/hooks.json

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.

Ships withclaude-scholar

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.

Get the whole plugin, auto-invoked