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.1k45 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