Skip to content
Education
Hook

Hooks

What claude-tutor runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
claude-tutor
1425 skills5 commands2 hooks
Install
> /plugin marketplace add kirilxd/claude-tutor
> /plugin install claude-tutor@kirilxd-plugins

Ships with claude-tutor. Installing the plugin gets these hooks.

Where it lives

  • hooks/enforce-paths.jsGitHub
    Read the script
    const fs = require('fs');
    
    try {
      const input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
      const filePath = input.tool_input?.file_path || '';
      const content = input.tool_input?.content || input.tool_input?.new_string || '';
    
      // Block writes to relative learning/ paths (project directory instead of ~/.claude/learning/)
      // Matches: learning/plans/..., learning/progress/..., ./learning/plans/..., or
      // absolute project paths like /Users/.../project/learning/plans/...
      const hasLearningSubdir = filePath.includes('/learning/plans/') || filePath.includes('/learning/progress/') ||
        filePath.startsWith('learning/plans/') || filePath.startsWith('learning/progress/');
      if (hasLearningSubdir && !filePath.includes('/.claude/learning/')) {
        process.stderr.write(
          `BLOCKED: Learning data must be saved to ~/.claude/learning/, not the project directory. ` +
          `Use the absolute path ~/.claude/learning/plans/ or ~/.claude/learning/progress/.`
        );
        process.exit(2);
      }
    
      // Only check ~/.claude/learning/ directory writes from here on
      if (!filePath.includes('/.claude/learning/')) {
        process.exit(0);
      }
    
      const quizFields = ['"quizzes"', '"weakAreas"', '"strongAreas"', '"spacedRepetition"', '"overallScore"'];
      const planFields = ['"modules"', '"resources"', '"timeCommitment"', '"totalEstimatedTime"'];
      const hasQuizData = quizFields.some(f => content.includes(f));
      const hasPlanData = planFields.some(f => content.includes(f));
    
      // Block quiz data written to plans directory
      if (filePath.includes('/learning/plans/') && hasQuizData) {
        const slug = filePath.split('/').pop().replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
        process.stderr.write(
          `BLOCKED: Quiz progress must be saved to ~/.claude/learning/progress/${slug}.json, not to plans/. ` +
          `Never add quizzes, weakAreas, strongAreas, spacedRepetition, or overallScore to plan files.`
        );
        process.exit(2);
      }
    
      // Block plan data written to progress directory
      if (filePath.includes('/learning/progress/') && hasPlanData) {
        process.stderr.write(
          `BLOCKED: Plan data (modules, resources) must be saved to ~/.claude/learning/plans/, not progress/. ` +
          `Never add modules or resources to progress files.`
        );
        process.exit(2);
      }
    
      // Validate schema: progress files must use correct field names
      if (filePath.includes('/learning/progress/') && content.trim().startsWith('{')) {
        const allowedProgressFields = ['topic', 'quizzes', 'weakAreas', 'strongAreas', 'overallScore', 'spacedRepetition'];
        try {
          const parsed = JSON.parse(content);
          const topKeys = Object.keys(parsed);
          const unknownKeys = topKeys.filter(k => !allowedProgressFields.includes(k));
          if (unknownKeys.length > 0) {
            process.stderr.write(
              `BLOCKED: Progress file contains unknown fields: ${unknownKeys.join(', ')}. ` +
              `Allowed fields are: ${allowedProgressFields.join(', ')}. ` +
              `Use "quizzes" (not "quiz_history", "results", "history", etc).`
            );
            process.exit(2);
          }
          // Validate overallScore is percentage (0-100), not fraction (0-1)
          if (parsed.overallScore != null && parsed.overallScore > 0 && parsed.overallScore <= 1) {
            process.stderr.write(
              `BLOCKED: overallScore must be a percentage (0-100), not a fraction. ` +
              `Got ${parsed.overallScore} — did you mean ${Math.round(parsed.overallScore * 100)}?`
            );
            process.exit(2);
          }
        } catch (e) {
          // Not valid JSON yet (partial edit) — allow it
        }
      }
    
      // Validate schema: plan files must use correct field names
      if (filePath.includes('/learning/plans/') && content.trim().startsWith('{')) {
        const allowedPlanFields = ['topic', 'slug', 'created', 'level', 'goal', 'depth', 'timeCommitment', 'modules', 'totalEstimatedTime', 'diagnostic'];
        try {
          const parsed = JSON.parse(content);
          const topKeys = Object.keys(parsed);
          const unknownKeys = topKeys.filter(k => !allowedPlanFields.includes(k));
          if (unknownKeys.length > 0) {
            process.stderr.write(
              `BLOCKED: Plan file contains unknown fields: ${unknownKeys.join(', ')}. ` +
              `Allowed fields are: ${allowedPlanFields.join(', ')}. ` +
              `Quiz data (quizzes, weakAreas, etc) belongs in progress/, not plans/.`
            );
            process.exit(2);
          }
        } catch (e) {
          // Not valid JSON yet (partial edit) — allow it
        }
      }
    
      // Block any learning data written to root (except index.json and profile.json)
      const isRootFile = filePath.match(/\/\.claude\/learning\/[^/]+$/);
      const allowedRootFiles = ['index.json', 'profile.json'];
      if (isRootFile) {
        const fileName = filePath.split('/').pop();
        if (!allowedRootFiles.includes(fileName)) {
          process.stderr.write(
            `BLOCKED: Only index.json and profile.json belong in ~/.claude/learning/. ` +
            `Plans go in plans/, progress goes in progress/.`
          );
          process.exit(2);
        }
      }
    
      process.exit(0);
    } catch (e) {
      // Don't block on hook errors
      process.exit(0);
    }
    
  • hooks/session-start.jsGitHub
    Read the script
    const fs = require('fs');
    const path = require('path');
    
    try {
      const learningDir = path.join(process.env.HOME, '.claude', 'learning');
      const indexPath = path.join(learningDir, 'index.json');
    
      if (!fs.existsSync(indexPath)) {
        process.exit(0);
      }
    
      const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
      const today = new Date().toISOString().split('T')[0];
      let dueCount = 0;
      const dueTopics = [];
    
      for (const [slug, topic] of Object.entries(index.topics || {})) {
        const progressPath = path.join(learningDir, topic.progressFile);
        if (!fs.existsSync(progressPath)) continue;
    
        const progress = JSON.parse(fs.readFileSync(progressPath, 'utf8'));
        const sr = progress.spacedRepetition || {};
        let topicDue = 0;
    
        for (const concept of Object.values(sr)) {
          if (concept.nextReview && concept.nextReview <= today) {
            topicDue++;
          }
        }
    
        if (topicDue > 0) {
          dueCount += topicDue;
          dueTopics.push(`${topic.displayName || slug} (${topicDue})`);
        }
      }
    
      if (dueCount > 0) {
        const topicList = dueTopics.join(', ');
        console.log(`You have ${dueCount} concept(s) due for review: ${topicList}. Run /review to see details or /quiz to practice.`);
      }
    } catch (e) {
      // Silently exit — don't block session start for data issues
      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-tutor

Turn Claude Code into your personal tutor — personalized learning plans, adaptive quizzes, SM-2 spaced repetition, and a web dashboard. Works with any topic.

Get the whole plugin
Stats
142
Stars
17
Forks
Maintained
Maintenance
JavaScript
Language
MIT
License
3mo ago
Last commit
6mo ago
Created
4h ago
Added

Repo: kirilxd/claude-tutor