Hooks
What superpowers-optimized runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add REPOZY/superpowers-optimized > /plugin install superpowers-optimized@superpowers-optimized
Ships with superpowers-optimized. Installing the plugin gets these hooks.
What fires, and when
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
startup|clear|compact"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd" session-start - Matches
startup|clear|compactnode "${CLAUDE_PLUGIN_ROOT}/hooks/session-memory.js" node "${CLAUDE_PLUGIN_ROOT}/hooks/context-engine.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.
node "${CLAUDE_PLUGIN_ROOT}/hooks/skill-activator.js"
PostToolUse
- Matches
Edit|Writenode "${CLAUDE_PLUGIN_ROOT}/hooks/track-edits.js" - Matches
Skillnode "${CLAUDE_PLUGIN_ROOT}/hooks/track-session-stats.js"
Stop
node "${CLAUDE_PLUGIN_ROOT}/hooks/stop-reminders.js"
SubagentStop
node "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-guard.js"
PreToolUse
- Matches
Bashnode "${CLAUDE_PLUGIN_ROOT}/hooks/safety/block-dangerous-commands.js" - Matches
Read|Edit|Write|Bashnode "${CLAUDE_PLUGIN_ROOT}/hooks/safety/protect-secrets.js" - Matches
Bashnode "${CLAUDE_PLUGIN_ROOT}/hooks/bash-compress-hook.js" - Matches
Skillnode "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-guard.js"
Where it lives
- hooks/bash-compress-hook.jsRunsGitHub
Read the script
#!/usr/bin/env node 'use strict'; /** * Bash Output Compression Hook — PreToolUse/Bash * * Intercepts Bash tool calls, classifies the command, and rewrites * compressible commands to run through bash-optimizer.js. The optimizer * executes the original command and compresses its output before it * enters Claude's context. * * Non-compressible commands pass through unchanged (returns {}). * Fail-open: any error results in the original command running unmodified. * * Disable mechanisms: * - Environment variable: SP_NO_COMPRESS=1 * - Project file: .sp-no-compress in project root * * Cross-platform: Works on macOS, Linux, and Windows. * Zero dependencies: uses only Node.js built-ins. */ const path = require('path'); const fs = require('fs'); const os = require('os'); const { RULES, NEVER_COMPRESS } = require('./compression-rules'); // ── Global disable check ── if (process.env.SP_NO_COMPRESS === '1') { process.stdout.write('{}'); process.exit(0); } /** * Adaptive re-run detection. * * Tracks whether a command was recently compressed. If Claude runs the * exact same command again within 60 seconds, we pass it through raw — * Claude is likely re-running because the compressed output was insufficient. * On the third run, we compress again (routine check pattern). * * State is stored in a session-scoped temp file that is automatically * cleaned up by the OS. */ function shouldSkipForRerun(cmd, sessionId) { const trackFile = path.join(os.tmpdir(), `sp-compress-${sessionId || 'default'}.json`); let tracking = {}; try { tracking = JSON.parse(fs.readFileSync(trackFile, 'utf8')); } catch {} const key = cmd.replace(/\s+/g, ' ').trim(); const prev = tracking[key]; if (prev && prev.compressed && (Date.now() - prev.ts < 60000)) { // Same command was compressed < 60s ago — Claude might want full output tracking[key] = { compressed: false, ts: Date.now() }; try { fs.writeFileSync(trackFile, JSON.stringify(tracking)); } catch {} return true; } return false; } function markCompressed(cmd, sessionId) { const trackFile = path.join(os.tmpdir(), `sp-compress-${sessionId || 'default'}.json`); let tracking = {}; try { tracking = JSON.parse(fs.readFileSync(trackFile, 'utf8')); } catch {} const key = cmd.replace(/\s+/g, ' ').trim(); tracking[key] = { compressed: true, ts: Date.now() }; try { fs.writeFileSync(trackFile, JSON.stringify(tracking)); } catch {} } async function main() { let input = ''; for await (const chunk of process.stdin) input += chunk; try { const data = JSON.parse(input); const { tool_name, tool_input, session_id, cwd } = data; // Only handle Bash tool calls if (tool_name !== 'Bash') { process.stdout.write('{}'); return; } const cmd = (tool_input?.command || '').trim(); if (!cmd) { process.stdout.write('{}'); return; } // ── Project-level disable ── const projectDir = cwd || process.cwd(); try { if (fs.existsSync(path.join(projectDir, '.sp-no-compress'))) { process.stdout.write('{}'); return; } } catch {} // ── RTK coexistence ── // If RTK is already handling this command, don't double-compress if (/^rtk(\s|\.exe\s)/.test(cmd)) { process.stdout.write('{}'); return; } // ── Never-compress list ── if (NEVER_COMPRESS.some(pattern => pattern.test(cmd))) { process.stdout.write('{}'); return; } // ── Find matching compression rule ── const rule = RULES.find(r => r.match.test(cmd)); if (!rule) { process.stdout.write('{}'); return; } // ── Adaptive re-run detection ── if (shouldSkipForRerun(cmd, session_id)) { process.stdout.write('{}'); return; } // ── Rewrite command to run through bash-optimizer.js ── // Use forward slashes for all platforms (bash on Windows handles them) const optimizerPath = path.join(__dirname, 'bash-optimizer.js').replace(/\\/g, '/'); const b64 = Buffer.from(cmd).toString('base64'); const rewrittenCmd = `node "${optimizerPath}" "${b64}" "${rule.type}"`; // Preserve all original tool_input fields, only replace the command const updatedInput = { ...tool_input, command: rewrittenCmd }; // Track this command as compressed for re-run detection markCompressed(cmd, session_id); process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `smart-compress: ${rule.type}`, updatedInput, }, })); } catch (e) { // Fail-open: any error lets the original command through unchanged process.stdout.write('{}'); } } main(); - hooks/bash-optimizer.jsGitHub
- hooks/compression-rules.jsGitHub
- hooks/context-engine.jsRunsGitHub
Read the script
#!/usr/bin/env node /** * SessionStart Hook — Context Engine * * Runs on every session start. Executes git commands to compute: * - Recently committed files (since the last session's HEAD, else the last commit) * - Blast radius: tracked files whose reference to a changed file actually * resolves to that file's path. Word-level matches are never counted. * - Recent commit history and change statistics * * Writes context-snapshot.json to the project root. * Auto-adds context-snapshot.json to .gitignore on first write. * Fails silently on any error — never blocks session start. * * Input: stdin JSON with { cwd, ... } (falls back to process.cwd()) * Output: stdout {} always */ const { execSync } = require('child_process'); const { createHash } = require('crypto'); const fs = require('fs'); const path = require('path'); const MAX_FILES = 10; // cap blast radius queries to avoid slowness on large diffs const MIN_NAME_LEN = 3; // skip very short filenames to avoid false-positive grep hits const TIMEOUT_MS = 5000; // max time for any single git command // Cross-session watermark: stores the HEAD hash from the previous session start // so the next session can diff against it and show everything that changed since. // Per-project: hashes the cwd so multi-project users don't clobber each other's watermarks. function getLastHeadFile(cwd) { const hash = createHash('md5').update(cwd).digest('hex').slice(0, 12); return path.join( process.env.HOME || process.env.USERPROFILE || '.', '.claude', 'hooks-logs', `last-session-head-${hash}.txt` ); } // Generic basenames that match too many files and produce noisy blast radius results const BASENAME_DENYLIST = new Set([ 'index', 'main', 'test', 'tests', 'spec', 'utils', 'util', 'helpers', 'helper', 'config', 'setup', 'app', 'types', 'constants', 'common', 'shared', 'lib', 'mod', // Added after measuring: these produced the worst false positives. // "SKILL.md" claimed 21 dependents, ".claude-plugin/plugin.json" claimed 16 — // every file that merely used the word "skill" or "plugin" in prose. 'skill', 'plugin', 'version', 'readme', 'changelog', 'license', 'package', 'manifest', 'schema', 'model', 'client', 'server', 'core', 'base', ]); // Extensions stripped when comparing a reference to a file path, so that // "./foo", "./foo.js" and "./foo/index.js" all normalise to the same target. const MODULE_EXTENSIONS = [ '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.py', '.rb', '.go', '.rs', '.java', '.kt', '.php', '.cs', '.json', '.yaml', '.yml', '.md', '.sh', ]; /** Normalise a path for comparison: forward slashes, no extension, no /index. */ function normalizeTarget(p) { let out = p.replace(/\\/g, '/').replace(/^\.\//, ''); for (const ext of MODULE_EXTENSIONS) { if (out.toLowerCase().endsWith(ext)) { out = out.slice(0, -ext.length); break; } } return out.replace(/\/index$/, ''); } /** * Extract path-like tokens from a source line that could refer to `basename`. * Quotes, backticks, parentheses and trailing punctuation are stripped. */ function extractPathTokens(line, basename) { const tokens = []; const re = /[A-Za-z0-9_@.\-/\\]+/g; let m; while ((m = re.exec(line)) !== null) { const raw = m[0].replace(/[.,;:)\]}]+$/, ''); if (raw.toLowerCase().includes(basename.toLowerCase())) tokens.push(raw); } return tokens; } /** * Does `line` in `fromFile` contain a reference that actually resolves to * `changedFile`? This is the whole point of the rewrite: an edge is kept only * when the reference can be resolved to the changed file's real path. A bare * word that merely matches the basename is not evidence and is dropped. */ function referenceResolves(line, fromFile, changedFile, basename) { const target = normalizeTarget(changedFile); const fromDir = path.posix.dirname(fromFile.replace(/\\/g, '/')); for (const token of extractPathTokens(line, basename)) { // A reference must carry a path separator. A bare token — even one that // looks like a filename ("SKILL.md") — cannot identify *which* file is // meant, and counting it is how the old implementation invented dependents. // Under-reporting is the correct failure direction here. if (!token.includes('/') && !token.includes('\\')) continue; const normalized = token.replace(/\\/g, '/'); // Relative specifier resolved against the referencing file's directory. if (normalized.startsWith('./') || normalized.startsWith('../')) { const resolved = path.posix.normalize(path.posix.join(fromDir, normalized)); if (normalizeTarget(resolved) === target) return true; continue; } // Repo-relative or suffix reference (docs and configs use these). const norm = normalizeTarget(normalized.replace(/^\/+/, '')); if (norm === target || target.endsWith('/' + norm)) return true; } return false; } function run(cmd, cwd) { try { return execSync(cmd, { encoding: 'utf8', timeout: TIMEOUT_MS, cwd }).trim(); } catch { return ''; } } function ensureGitignored(cwd) { try { const gitignorePath = path.join(cwd, '.gitignore'); let content = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : ''; const lines = content.split('\n').map(l => l.trim()); if (lines.includes('context-snapshot.json')) return; // already present const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; const hasSection = content.includes('# AI assistant artifacts'); if (!hasSection) { fs.appendFileSync(gitignorePath, `${prefix}\n# AI assistant artifacts\ncontext-snapshot.json\n`); } else { fs.appendFileSync(gitignorePath, `${prefix}context-snapshot.json\n`); } } catch { // Silently ignore — never block session start } } async function main() { let input = ''; for await (const chunk of process.stdin) input += chunk; let cwd; try { - hooks/session-memory.jsRunsGitHub
Read the script
#!/usr/bin/env node /** * SessionStart Hook — Project Memory * * Injects the project's memory files: project-map.md (+ staleness), the last * [saved] session-log entries, state.md, open known-issues, and the * context-snapshot summary. * * This runs as its own hook, separate from session-start (the router), because * Claude Code caps each hook's additionalContext at 10,000 characters. Over the * cap, the whole string is replaced by a file path and a 2,000-character preview * that the model is never told to read. When router and memory shared one hook, * every session crossed the cap and the model saw only the first 2,000 characters * of the router and none of the memory. Each hook's output is measured on its own, * so the split gives each a full budget — and this hook fills its budget in * priority order instead of overflowing it. * * Shared with hooks/codex/session-start-adapter.js so the two injectors cannot * drift apart again. * * Input: stdin JSON { cwd, session_id, ... } * Output: Claude: { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext } } * Cursor / other: { additional_context } * {} when the project has no memory files. */ 'use strict'; const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // Under the 10,000-character cap with room for the tag wrappers. const MEMORY_BUDGET = 9500; const NOTE_RESERVE = 300; // kept free for the omission notes, which are added after admission const STATE_MAX = 3000; const ENTRY_MAX = 1500; // session-log entry cap, matches context-management const MAX_KNOWN_ISSUES = 5; const MAX_SAVED_ENTRIES = 2; const MAP_FULL_MAX_LINES = 200; // above this, only Critical Constraints + Hot Files const PRIORITY_MAP_SECTIONS = ['Critical Constraints']; function read(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } } function git(cmd, cwd) { try { return execSync(cmd, { cwd, encoding: 'utf8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; } } function clip(text, max, note) { if (text.length <= max) return text; const cut = text.slice(0, Math.max(0, max - note.length - 1)); const atLine = cut.lastIndexOf('\n'); return (atLine > max / 2 ? cut.slice(0, atLine) : cut).trimEnd() + '\n' + note; } // ── project-map.md ─────────────────────────────────────────────────────────── /** Split markdown into a preamble and its "## " sections, preserving order. */ function splitSections(md) { const sections = []; let preamble = ''; let cur = null; for (const line of md.split('\n')) { if (line.startsWith('## ')) { if (cur) sections.push(cur); cur = { name: line.slice(3).trim(), text: line }; } else if (cur) { cur.text += '\n' + line; } else { preamble += (preamble ? '\n' : '') + line; } } if (cur) sections.push(cur); return { preamble: preamble.trim(), sections: sections.map(s => ({ ...s, text: s.text.trim() })) }; } /** * Which documented files changed since the map's recorded commit. * Returns null (not stale / not applicable) or the <project-map-stale> text. */ function mapStaleness(cwd, raw) { const m = raw.match(/Git: ([a-f0-9]+)/); if (!m) return null; const mapHash = m[1]; const head = git('git rev-parse --short HEAD', cwd); if (!head || head === mapHash) return null; const changedRaw = git(`git diff --name-only ${mapHash}..HEAD`, cwd); const changed = new Set((changedRaw || '').split('\n').filter(Boolean)); if (changed.size === 0) { return `<project-map-stale>project-map.md records Git: ${mapHash} but current HEAD is ${head}, and the diff between them could not be computed (the recorded commit may no longer exist). Verify any structural claim from the map against the filesystem before relying on it.</project-map-stale>`; } // Paths the map documents: any token containing a slash. const documented = new Set(); for (const t of raw.matchAll(/[A-Za-z0-9_@.\-]+(?:\/[A-Za-z0-9_@.\-]+)+/g)) { documented.add(t[0].replace(/[.,;:)]+$/, '')); } const hits = [...changed].filter(f => { if (documented.has(f)) return true; for (const d of documented) if (d.endsWith('/') && f.startsWith(d)) return true; return false; }); if (hits.length === 0) { return `<project-map-stale>project-map.md records Git: ${mapHash} and HEAD is now ${head}, but none of the files it documents changed. Its Key Files and Critical Constraints are still accurate — treat the map as usable and just refresh the header hash next time you touch it.</project-map-stale>`; } const MAX = 10; let list = hits.slice(0, MAX).join(', '); if (hits.length > MAX) list += ` (+${hits.length - MAX} more)`; return `<project-map-stale>project-map.md is stale in ${hits.length} place(s). These documented files changed since the map was written (Git: ${mapHash} → ${head}):\n${list}\n\nRe-read ONLY those files and update their Key Files entries plus the header hash. Everything else in the map is still accurate — do not regenerate it.</project-map-stale>`; } // ── session-log.md ─────────────────────────────────────────────────────────── /** Live [saved] entries, oldest first. [superseded ...] entries are never injected. */ function liveSavedEntries(cwd) { const raw = read(path.join(cwd, 'session-log.md')); if (!raw) return []; const entries = []; let cur = null; for (const line of raw.split('\n')) { if (line.startsWith('## ')) { if (cur !== null) entries.push(cur.trim()); cur = /\[saved\]/.test(line) && !/\[superseded/.test(line) ? line : null; } else if (cur !== null) { cur += '\n' + line; } } if (cur !== null) entries.push(cur.trim()); return entries; } // ── state.md ───────────────────────────────────────────────────────────────── /** * cleared — "Current Goal" says "no active task" → soft notice * stale — a commit landed after state.md was - hooks/skill-activator.jsRunsGitHub
Read the script
#!/usr/bin/env node /** * UserPromptSubmit Hook — Proactive Skill Activation + Memory Recall * * Analyzes the user's prompt before Claude processes it and injects * two types of context: * * 1. Skill hints — which superpowers-optimized skills are relevant to * this prompt (reinforces using-superpowers routing deterministically). * * 2. Memory recall — relevant past decisions from session-log.md that * match keywords extracted from the prompt. Surfaces historical context * automatically at the moment it's needed, without requiring the AI to * remember to grep the log manually. * * Features: * - Micro-task detection: short, specific prompts skip both features entirely * - Confidence threshold: only suggests skills when match confidence is meaningful * - Memory recall: keyword-based grep of session-log.md, ≤2 entries * - Session-scoped dedupe: an entry is injected at most once per session, * including entries the SessionStart hook already showed * - Context pressure gate: blocks plan execution when the window is too full, * sized against the model's real context window (see resolveContextWindow) * - Smart routing: fewer false positives, zero overhead for simple tasks * * Input: stdin JSON with { prompt, session_id, cwd, ... } * Output: stdout JSON with additionalContext suggesting relevant skills * and/or surfacing relevant past decisions */ const fs = require('fs'); const path = require('path'); // Resolve hooks directory from this script's location const HOOKS_DIR = __dirname; // Load skill rules let RULES = []; try { const rulesPath = path.join(HOOKS_DIR, 'skill-rules.json'); RULES = JSON.parse(fs.readFileSync(rulesPath, 'utf8')).rules || []; } catch (e) { // If rules can't be loaded, hook is a no-op process.stdout.write('{}'); process.exit(0); } const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 }; // Minimum score threshold — matches below this are discarded as noise const CONFIDENCE_THRESHOLD = 2; // ── Memory recall constants ─────────────────────────────────────────────────── const MAX_MEMORY_ENTRIES = 2; // Never inject more than 2 matched entries const MIN_KEYWORD_LENGTH = 4; // Skip tokens shorter than this const MAX_ENTRY_CHARS = 1500; // Truncate oversized entries (~250 words / ~375 tokens) // Ranking constants. Flat keyword-density scoring degenerated on normal-length // prompts: with 38 extracted keywords, a genuinely relevant entry earned 0.023 // from density while the newest entry earned 0.300 from recency alone — recency // outweighed relevance ~13x, so recall returned "the most recent entries that // share any word with the prompt". IDF weighting fixes the ranking; the // relevance floor is what actually stops irrelevant entries being injected. const MAX_SCORING_KEYWORDS = 12; // Only the most distinctive terms get to vote const MIN_RELEVANCE = 0.30; // Share of the corpus's discriminating weight an entry must match const MIN_COVERAGE = 0.25; // Share of the prompt's own keywords an entry must match const RECENCY_WEIGHT = 0.10; // Recency breaks ties; it must never outrank relevance const MAX_TIED_MATCHES = 3; // More entries than this tied for best = the query is not specific // Common English words that produce noisy false-positive matches const STOP_WORDS = new Set([ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during', 'before', 'after', 'this', 'that', 'these', 'those', 'my', 'your', 'his', 'her', 'its', 'our', 'their', 'what', 'which', 'who', 'when', 'where', 'why', 'how', 'all', 'both', 'each', 'every', 'any', 'some', 'not', 'only', 'than', 'too', 'very', 'just', 'now', 'also', 'but', 'and', 'or', 'if', 'then', 'so', 'let', 'get', 'got', 'go', 'make', 'know', 'think', 'see', 'look', 'use', 'using', 'used', 'like', 'want', 'need', 'please', 'here', 'there', 'about', 'more', 'other', 'new', 'good', 'right', 'well', 'really', 'actually', 'already', 'still', 'even', 'back', 'thing', 'things', 'way', 'work', 'works', 'worked', // Prompt scaffolding — words that appear in almost every instruction and // carry no domain signal. Measured: these inflated keyword counts, which // depressed every entry's relevance score and let recency take over. 'first', 'once', 'done', 'else', 'take', 'takes', 'comes', 'come', 'step', 'steps', 'start', 'starts', 'within', 'following', 'follow', 'imagine', 'idea', 'ideas', 'fast', 'forward', 'whether', 'something', 'anything', 'everything', 'nothing', 'someone', 'maybe', 'sure', 'much', 'many', 'most', 'less', 'next', 'last', 'keep', 'keeps', 'made', 'give', 'gives', 'given', 'said', 'says', 'tell', 'find', 'finds', 'able', 'lets', 'going', 'gets', 'stated', 'thanks', 'thank', // Not added, measured: generic verbs ("changes", "exactly", "needs"…). Replayed // on 16 real prompts they removed as many correct recalls as wrong ones. // NOT stop words, deliberately: "state" (state.md), "plan", "hook", "map", // "memory", "context" — these are the domain nouns recall exists to match. ]); /** * Detect micro-tasks that should skip skill routing entirely. * Returns true if the prompt is clearly a small, specific action. */ function isMicroTask(prompt) { if (!prompt || typeof prompt !== 'string') return false; const lower = prompt.toLowerCase().trim(); const wordCount = lower.split(/\s+/).length; // Very short prompts with specific action words are likely micro-tasks if (wordCount <= 8) { const microPatterns = [ /^(fix|change|rename|update|replace|set|remove|delete|add)\s+(the\s+)?(typo|name|variable|import|spacing|indent)/i, /^rename\s+\S+\s+to\s+\S+$/i, /^(change|update|set)\s+.+\s+(to|=)\s+.+$/i, /^remove\s+(the\s+)? - hooks/stop-reminders.jsRunsGitHub
Read the script
#!/usr/bin/env node /** * Stop Hook — Contextual Reminders * * When Claude finishes responding, checks session context and provides * gentle reminders about TDD, verification, and commit hygiene. * These reminders reinforce discipline skills deterministically. * * Uses a file-based guard to fire only once per session to prevent * infinite loops (Stop hook returning content causes Claude to resume). * * Input: stdin JSON with { session_id, cwd, ... } * Output: stdout JSON with decision/reason continuation payload (only when * actionable reminders exist), or {} to let Claude stop normally. * Uses decision+reason rather than hookSpecificOutput for broader version compat. */ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const LOG_DIR = path.join( process.env.HOME || process.env.USERPROFILE || '.', '.claude', 'hooks-logs' ); const EDIT_LOG = path.join(LOG_DIR, 'edit-log.txt'); const LAST_SAVED_FILE = path.join(LOG_DIR, 'last-saved-entry.txt'); const STATS_FILE = path.join(LOG_DIR, 'session-stats.json'); const GUARD_FILE = path.join(LOG_DIR, 'stop-hook-fired.lock'); // Guard: only fire once per session (prevent infinite loop) // The guard file is created on first fire and checked on subsequent fires. // It auto-expires after 2 minutes so subsequent Claude stops can show reminders. const GUARD_TTL_MS = 2 * 60 * 1000; function shouldFire() { try { if (fs.existsSync(GUARD_FILE)) { const stat = fs.statSync(GUARD_FILE); const age = Date.now() - stat.mtimeMs; if (age < GUARD_TTL_MS) { return false; // Guard is active, don't fire } } return true; } catch { return true; } } function setGuard() { try { if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); fs.writeFileSync(GUARD_FILE, new Date().toISOString()); } catch { // Ignore guard write errors } } // Common test file patterns const TEST_PATTERNS = [ /\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /_test\.(go|py|rb)$/, /test_[^/]+\.py$/, /Tests?\.[^/]+$/, /\.test$/, /__tests__\//, /(?:^|[/\\])test[-_][^/\\]+\.[jt]sx?$/i, /[/\\]tests?[/\\]/i, ]; // Common source file patterns (non-test, non-config) const SOURCE_PATTERNS = [ /\.(js|jsx|ts|tsx|py|rb|go|rs|java|cs|cpp|c|h|hpp|swift|kt|scala|php)$/, ]; // Files that are clearly config/non-source const CONFIG_PATTERNS = [ /package\.json$/, /tsconfig.*\.json$/, /\.eslintrc/, /\.prettierrc/, /\.gitignore$/, /\.env/, /Dockerfile/, /docker-compose/, /\.ya?ml$/, /\.toml$/, /\.cfg$/, /\.ini$/, /\.md$/, /\.lock$/, /CLAUDE\.md$/, /SKILL\.md$/, ]; function isTestFile(filePath) { return TEST_PATTERNS.some(p => p.test(filePath)); } function isSourceFile(filePath) { return SOURCE_PATTERNS.some(p => p.test(filePath)) && !CONFIG_PATTERNS.some(p => p.test(filePath)); } /** * Parse a raw log line into an entry object. * Supports both legacy 3-field format and new 4-field format with session_id. */ function parseLogLine(line) { const parts = line.split(' | '); if (parts.length < 3) return null; if (parts.length >= 4) { // New format: timestamp | session_id | tool | filePath return { timestamp: parts[0], sessionId: parts[1] || null, tool: parts[2], filePath: parts.slice(3).join(' | '), }; } // Legacy format: timestamp | tool | filePath return { timestamp: parts[0], sessionId: null, tool: parts[1], filePath: parts.slice(2).join(' | '), }; } /** * Return true if an entry matches the given sessionId filter. * When sessionId is provided: only entries with a matching sessionId pass. * When sessionId is null/undefined: legacy entries (no sessionId) pass. */ function matchesSession(entry, sessionId) { if (sessionId) { return entry.sessionId === sessionId; } return entry.sessionId === null; } /** * Normalise a path for containment checks: resolved, long-form on Windows (the * session scratchpad is logged as C:\Users\ABCDEF~1\..., the cwd as the long name), * and case-folded where the filesystem is case-insensitive. */ function canonicalPath(p) { // Resolve the deepest ancestor that still exists, then re-append the rest, so a // deleted file is normalised the same way as the directory it lived in. let head = path.resolve(p); const tail = []; for (;;) { try { head = fs.realpathSync.native(head); break; } catch { const parent = path.dirname(head); if (parent === head) break; tail.unshift(path.basename(head)); head = parent; } } const out = path.join(head, ...tail); return process.platform === 'win32' || process.platform === 'darwin' ? out.toLowerCase() : out; } /** * Keep only edits to files inside the project. Every reminder this hook emits is * about the project at cwd — its git status, its state.md, its session-log.md — * so a scratch script or an edit in another repo must not count toward them. */ function scopeToProject(edits, cwd) { if (!cwd) return edits; const root = canonicalPath(cwd); const prefix = root.endsWith(path.sep) ? root : root + path.sep; return edits // track-edits.js resolves relative paths against cwd before logging; do the same. .map(e => (path.isAbsolute(e.filePath) ? e : { ...e, filePath: path.resolve(cwd, e.filePath) })) .filter(e => { const p = canonicalPath(e.filePath); return p === root || p.startsWith(prefix); }); } /** * Read recent edits from the edit log (last 30 minutes), filtered to the current session. */ function getRecentEdits(sessionId) { try { if (!fs.existsSync(EDIT_LOG)) return []; const content = fs.readFileSync(EDIT_LOG, 'utf8'); const lines = content.split('\n').filter(Boolean); const cutoff = new Date(Date.now() - 30 * 60 * 1000); return lines .map(parseLogLine) .filter(entry => entry && new Date(entry.timestamp) > cu - hooks/subagent-guard.jsRunsGitHub
Read the script
#!/usr/bin/env node /** * Subagent Guard — PreToolUse(Skill) + SubagentStop Hook * * Keeps subagents from using superpowers-optimized skills, which are written * for the main conversation and derail a subagent's narrow task. * * PreToolUse(Skill): Claude Code passes agent_id only when a hook fires inside * a subagent (2.1.100+). A call to one of this plugin's skills there is denied * before it runs; the subagent receives the reason as a tool error and carries * on with its task. The main session and other plugins' skills are untouched. * * SubagentStop: the backstop for versions that do not send agent_id. When the * subagent's transcript is readable, it decides: block only if one of this * plugin's skills actually ran (a denied call's result is an error). Only * without a transcript does it fall back to matching the final message's text, * which can mistake a mention for an invocation. A block forces a full redo. * * Prompt-based instructions ("do NOT invoke skills") remain the first layer. * * Logs violations to: ~/.claude/hooks-logs/subagent-violations.jsonl */ const fs = require('fs'); const path = require('path'); // Patterns that indicate a subagent invoked skills or spawned sub-subagents. // Each pattern requires an action verb (invoke/invoking/using/use/run/running/called/calling) // immediately before the skill reference so that bare mentions in file content or // code comments do not trigger false positives. const SKILL_NAMES = [ 'using-superpowers', 'brainstorming', 'deliberation', 'writing-plans', 'executing-plans', 'subagent-driven-development', 'systematic-debugging', 'test-driven-development', 'verification-before-completion', 'token-efficiency', 'context-management', 'dispatching-parallel-agents', 'requesting-code-review', 'receiving-code-review', 'finishing-a-development-branch', 'error-recovery', 'frontend-design', 'claude-md-creator', 'self-consistency-reasoner', 'using-git-worktrees', 'premise-check', 'red-team', 'refactoring', 'performance-investigation', 'dependency-management', ]; const ACTION_VERB = '(?:invoking?|using|use|running?|called?|calling|activat(?:e|ed|ing)|trigger(?:ing|ed)?|execut(?:e|ed|ing)|launch(?:ing|ed)?|spawn(?:ing|ed)?|start(?:ing|ed)?)\\s+(?:the\\s+)?'; const VIOLATION_PATTERNS = [ /Invoke the superpowers-optimized/i, /I'm using the .+ skill/i, /Skill\s*\(\s*["']?superpowers/i, /skill:\s*["']?(superpowers|using-superpowers|brainstorming|deliberation|systematic-debugging|test-driven-development|verification|executing-plans|writing-plans|context-management|frontend-design|refactoring|performance-investigation|dependency-management)/i, ...SKILL_NAMES.map(name => new RegExp(ACTION_VERB + name, 'i')), ]; const PLUGIN_PREFIX = 'superpowers-optimized:'; const DENY_REASON = "Subagents do not use superpowers-optimized skills. Continue your assigned task with your normal tools."; const BLOCK_REASON = [ 'SKILL LEAKAGE DETECTED: You invoked a superpowers-optimized skill, which is not allowed for subagents.', 'Redo your assigned task using only your core tools (Read, Edit, Write, Bash, Grep, Glob).', 'Do NOT invoke the Skill tool. Do NOT reference any superpowers-optimized skills.', 'Focus only on the task you were given.' ].join(' '); /** True for one of this plugin's skills, namespaced or by its bare name. */ function isPluginSkill(skill) { if (typeof skill !== 'string' || !skill) return false; if (skill.startsWith(PLUGIN_PREFIX)) return true; return SKILL_NAMES.includes(skill); } /** * Read a subagent transcript and return the plugin skills that actually ran * (a Skill tool_use whose tool_result is not an error). Returns null when the * transcript cannot be read, so the caller can fall back to text matching. */ function pluginSkillsThatRan(transcriptPath) { let content; try { content = fs.readFileSync(transcriptPath, 'utf8'); } catch (_) { return null; } const calls = new Map(); // tool_use id -> skill name const ran = []; for (const line of content.split('\n')) { if (!line.trim()) continue; let row; try { row = JSON.parse(line); } catch (_) { continue; } const blocks = row && row.message && Array.isArray(row.message.content) ? row.message.content : []; for (const b of blocks) { if (b.type === 'tool_use' && b.name === 'Skill' && isPluginSkill(b.input && b.input.skill)) { calls.set(b.id, b.input.skill); } else if (b.type === 'tool_result' && calls.has(b.tool_use_id) && b.is_error !== true) { ran.push(calls.get(b.tool_use_id)); } } } return ran; } function handlePreToolUse(data) { const skill = data.tool_input && data.tool_input.skill; if (!data.agent_id || data.tool_name !== 'Skill' || !isPluginSkill(skill)) return {}; logViolation(data.agent_id, data.agent_type || 'unknown', `skill:${skill}`, 'denied'); return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: DENY_REASON, }, }; } function handleSubagentStop(data) { const agentId = data.agent_id || 'unknown'; const agentType = data.agent_type || 'unknown'; if (data.agent_transcript_path) { const ran = pluginSkillsThatRan(data.agent_transcript_path); if (ran) { // The skill call from before the forced redo stays in the transcript; // re-blocking on it would loop the subagent forever. if (ran.length === 0 || data.stop_hook_active) return {}; logViolation(agentId, agentType, `ran:${ran.join(',')}`, 'blocked'); return { decision: 'block', reason: BLOCK_REASON }; } } // No readable transcript: check the final message's wording instead. const lastMessage = data.last_assistant_message || ''; for (const pattern of VIOLATION_PATTERNS) { if (pattern.test(lastMessage)) { logViolation(agentId, agentType, pattern, 'blocked'); return { decision: 'block', reason: B - hooks/track-edits.jsRunsGitHub
- hooks/track-session-stats.jsRunsGitHub
All 10 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
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.
The production-grade fork of obra/superpowers — same trusted workflow, dramatically leaner, safer, and more intelligent.

