Writing
Hook
Hooks
What sloptrim runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
> /plugin marketplace add seyedehsanhadi/sloptrim > /plugin install sloptrim@sloptrim
Ships with sloptrim. Installing the plugin gets these hooks.
Where it lives
- hooks/sloptrim-activate.jsGitHub
Read the script
#!/usr/bin/env node // SessionStart hook: emits the prose contract for the active mode. 'use strict'; process.on('uncaughtException', () => process.exit(0)); process.on('unhandledRejection', () => process.exit(0)); const fs = require('fs'); const { readMode, writeMode, contract, LEVELS, sweepLedgers, FLAG } = require('./sloptrim-lib'); // The mode flag is written the first time this hook runs, so its absence is the // only reliable signal that nobody has used this install yet. // If the flag cannot be written the welcome would reappear every session while // promising otherwise, so only claim first-run when the write will actually stick. let firstRun = false; try { firstRun = !fs.existsSync(FLAG); } catch (e) { /* unreadable config: stay quiet rather than repeat */ } const WELCOME = [ 'sloptrim is installed and on.', '', 'Prose saved through supported file-edit tools is scored against 71 documented patterns.', 'Files are accepted up to 512 KB for plain text and 4 MB for supported archives.', 'The detector scores the first 262,144 characters of extracted prose. Fired patterns', 'are named so they get fixed before the file ships. Code, config and commits are untouched.', '', 'It is a command-line tool and a plugin. There is no website and no hosted version.', 'Nothing you write is uploaded, there is no account, and no text leaves this machine.', '', ' /sloptrim doctor check the install', ' /sloptrim check score one file without changing it', ' /sloptrim off turn it off', ' /sloptrim help everything else', '', 'Shown once. It will not appear again.', '', ].join('\n'); sweepLedgers(); const mode = readMode(); if (mode === 'off') process.exit(0); if (LEVELS.includes(mode)) writeMode(mode); // Only greet if the flag really landed; otherwise "shown once" would be a lie. if (firstRun && !fs.existsSync(FLAG)) firstRun = false; process.stdout.write((firstRun ? WELCOME + '\n' : '') + contract(mode)); process.exit(0); - hooks/sloptrim-guard.jsGitHub
Read the script
#!/usr/bin/env node // PostToolUse hook: scores a saved prose file, nudges past threshold. 'use strict'; process.on('uncaughtException', () => process.exit(0)); process.on('unhandledRejection', () => process.exit(0)); const path = require('path'); const fs = require('fs'); const { readMode, parseStdin, runDetectResult, verdict, logDeliverable } = require('./sloptrim-lib'); // ------------------------------------------------------------------------ // SCOPE: Which files are prose, which are documents, and which are neither. // ------------------------------------------------------------------------ const PROSE_EXT = new Set([ '.md', '.mdx', '.markdown', '.txt', '.text', '.rst', '.tex', '.org', '.adoc', ]); const OFFICE_EXT = new Set([ '.docx', '.docm', '.pptx', '.pptm', '.xlsx', '.xlsm', '.odt', '.odp', '.ods', '.epub', ]); const BINARY_PROSE = new Set(['.pdf', '.rtf']); // A notebook is prose in its markdown cells and code everywhere else, so the // detector is handed the path and pulls out only the markdown. const NOTEBOOK_EXT = new Set(['.ipynb']); module.exports = { PROSE_EXT, OFFICE_EXT, NOTEBOOK_EXT, BINARY_PROSE }; if (require.main !== module) return; const mode = readMode(); if (mode === 'off' || mode === 'lite') process.exit(0); const input = parseStdin(); const sid = input.session_id; // NotebookEdit names its target notebook_path, not file_path. const ti = input.tool_input || {}; let filePath = String(ti.file_path || ti.notebook_path || ''); if (!filePath) { // Codex aliases apply_patch to Write for hook matching, but sends the patch in // tool_input.command. Re-run this same guard once per resulting file so the // established one-file path stays the only scoring implementation. const patch = String(ti.command || ''); const paths = [...patch.matchAll(/^\*\*\* (?:Add|Update) File:\s*(.+?)\s*$/gm)] .map((m) => m[1].trim()); const moves = [...patch.matchAll(/^\*\*\* Move to:\s*(.+?)\s*$/gm)] .map((m) => m[1].trim()); // Bound the fan-out of one patch. const changed = [...new Set(paths.concat(moves))].slice(0, 16); if (!changed.length) process.exit(0); const { spawnSync } = require('child_process'); const contexts = []; for (const changedPath of changed) { const childInput = { ...input, tool_name: 'Write', tool_input: { ...ti, file_path: changedPath } }; const child = spawnSync(process.execPath, [__filename], { input: JSON.stringify(childInput), encoding: 'utf8', env: process.env, timeout: 20000, maxBuffer: 4 * 1024 * 1024, }); try { const parsed = JSON.parse(child.stdout || ''); const context = parsed.hookSpecificOutput && parsed.hookSpecificOutput.additionalContext; if (context) contexts.push(context); } catch (e) { /* a clean file produces no output */ } } if (contexts.length) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: contexts.join('\n'), }, })); process.exit(0); } filePath = path.resolve(typeof input.cwd === 'string' ? input.cwd : process.cwd(), filePath); const ext = path.extname(filePath).toLowerCase(); const base = path.basename(filePath); if (/(^|[\\/])node_modules[\\/]|[\\/]\.git[\\/]|[\\/]memory[\\/]/i.test(filePath)) process.exit(0); const selfRoot = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(__dirname, '..'); try { const rel = path.relative(selfRoot, path.resolve(filePath)); if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) process.exit(0); } catch (e) { /* unresolvable path: fall through and score it */ } // Instruction files are the agent's own scaffolding, not a deliverable. The exempt names // are exact, because agents.txt and memory.txt are ordinary prose a user may well write. // Outside the prose extensions the stem is enough: skill.pdf is scaffolding whatever its // format, and matching it here rather than lower down keeps it out of the binary log too. const lowerBase = base.toLowerCase(); const SKIP_NAMES = new Set(['claude.md', 'claude.local.md', 'agents.md', 'agents.local.md', 'skill.md', 'memory.md']); const SKIP_STEMS = new Set(['claude', 'claude.local', 'agents', 'agents.local', 'skill', 'memory']); const stem = lowerBase.slice(0, lowerBase.length - ext.length); if (SKIP_NAMES.has(lowerBase)) process.exit(0); if (!PROSE_EXT.has(ext) && SKIP_STEMS.has(stem)) process.exit(0); const isOffice = OFFICE_EXT.has(ext) || NOTEBOOK_EXT.has(ext); if (!PROSE_EXT.has(ext) && !isOffice) { if (BINARY_PROSE.has(ext)) { logDeliverable({ t: Date.now(), file: base, kind: 'binary' }, sid); } process.exit(0); } // ------------------------------------------------------------------------ // READ: Plain text is read here; zip-based documents are handed to the detector by path. // ------------------------------------------------------------------------ function failed(reason) { logDeliverable({ t: Date.now(), file: base, kind: 'failed', reason }, sid); process.exit(0); } try { const stat = fs.statSync(filePath); if (!stat.isFile()) process.exit(0); const limit = isOffice ? 4 * 1024 * 1024 : 512 * 1024; if (stat.size > limit) { logDeliverable({ t: Date.now(), file: base, kind: 'skipped', reason: 'size', size: stat.size, limit }, sid); process.exit(0); } } catch (e) { failed('cannot read file'); } // Keep the extension: TeX needs syntax-aware scoring, including through hooks. const detected = runDetectResult('', { timeout: isOffice ? 15000 : 8000 }, [filePath]); if (!detected.ok) failed(detected.kind === 'no-python' ? 'Python unavailable' : detected.detail === 'ETIMEDOUT' ? 'detector timed out' : 'detector could not read or score this file'); let report; try { report = JSON.parse(detected.out); } catch (e) { failed('invalid detector report'); } const m = (report && report._metrics) || {}; const score = m.ai_tell_score; if (typeof score !== 'number') fai - hooks/sloptrim-lib.jsGitHub
Read the script
// Shared state, mode flags, ledger, and detect.py runner for all hooks. 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); const { execFileSync } = require('child_process'); // ------------------------------------------------------------------------ // PATHS: Where the mode flag, the session ledgers and the detector live. // ------------------------------------------------------------------------ const CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); const FLAG = path.join(CONFIG_DIR, '.sloptrim-active'); const LEDGER = path.join(CONFIG_DIR, '.sloptrim-ledger.json'); const LEDGER_CAP = 20; const ROOT = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(__dirname, '..'); const LEVELS = ['lite', 'full', 'strict']; // ------------------------------------------------------------------------ // MODE: Reading and writing the one-word level flag. // ------------------------------------------------------------------------ function readMode() { try { const v = fs.readFileSync(FLAG, 'utf8').trim().slice(0, 16); if (v === 'off') return 'off'; if (LEVELS.includes(v)) return v; } catch (e) {} const d = (process.env.SLOPTRIM_DEFAULT_MODE || 'full').trim(); return LEVELS.includes(d) || d === 'off' ? d : 'full'; } function writeMode(mode) { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); try { const st = fs.lstatSync(FLAG); if (!st.isFile()) fs.unlinkSync(FLAG); } catch (e) {} let flags; try { flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW; } catch (e) { flags = 'w'; } const fd = fs.openSync(FLAG, flags, 0o600); try { fs.writeSync(fd, mode); } finally { fs.closeSync(fd); } } catch (e) {} } function readStdin() { try { return fs.readFileSync(0, 'utf8'); } catch (e) { return ''; } } function parseStdin() { try { const v = JSON.parse(readStdin() || '{}'); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch (e) { return {}; } } const DETECT = [ path.join(ROOT, 'scripts', 'detect.py'), path.join(ROOT, 'skills', 'sloptrim', 'scripts', 'detect.py'), ].find((p) => { try { return fs.statSync(p).isFile(); } catch (e) { return false; } }) || path.join(ROOT, 'scripts', 'detect.py'); // ------------------------------------------------------------------------ // DETECTOR BRIDGE: Runs detect.py with the file path as an argument array, never through a shell. // ------------------------------------------------------------------------ function detectorMessage(e) { const err = String((e && e.stderr) || ''); const last = err.split(/\r?\n/).filter((l) => l.trim()).pop(); if (last) { try { const j = JSON.parse(last); if (j && j.error) return String(j.error).slice(0, 200); } catch (e2) {} return last.trim().slice(0, 200); } if (e && e.code) return String(e.code).slice(0, 200); return `exit ${e && e.status != null ? e.status : 'unknown'}`; } function runDetectResult(inputText, opts, extraArgs) { const base = { input: inputText, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, env: { ...process.env, PYTHONIOENCODING: 'utf-8' }, ...opts, }; const args = [DETECT].concat(extraArgs || []); let ran = null; for (const exe of ['python', 'python3', 'py']) { try { const out = execFileSync(exe, args, base); if (String(out || '').trim()) return { ok: true, out, kind: 'ok', detail: '' }; return { ok: false, out: null, kind: 'no-output', detail: '' }; } catch (e) { // A timeout is the only failure worth stopping for: it is the one that costs the // full budget again on each retry. Everything else must keep trying the next name, // because on Windows the first `python` on PATH is often the Store alias, which // starts and exits non-zero while `py` two names later works perfectly. if (e && e.code === 'ETIMEDOUT') { ran = e; break; } // A launcher that is not installed says nothing about the detector, so it must // not be remembered as a detector failure. if (e && e.status != null && !ran) ran = e; } } if (ran) return { ok: false, out: null, kind: 'detector-error', detail: detectorMessage(ran) }; return { ok: false, out: null, kind: 'no-python', detail: '' }; } function runDetect(inputText, opts, extraArgs) { const r = runDetectResult(inputText, opts, extraArgs); return r.ok ? r.out : null; } // ------------------------------------------------------------------------ // PLAIN LANGUAGE: Turning a 0-100 score into words a person can act on. // ------------------------------------------------------------------------ function verdict(score) { if (typeof score !== 'number') return 'not scored'; if (score <= 20) return 'reads clean'; if (score <= 40) return 'reads mostly clean'; if (score <= 60) return 'reads with some AI tells'; if (score <= 80) return 'reads with heavy AI tells'; return 'reads with pervasive AI tells'; } function ledgerPath(sessionId) { const raw = String(sessionId || ''); if (!raw) return LEDGER; // Stripping unsafe characters and truncating both merge distinct ids onto one file. // Hashing the whole id keeps the filename safe and short without collapsing any two. const safe = raw.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 40); const tag = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 12); const current = path.join(CONFIG_DIR, `.sloptrim-ledger-${safe}-${tag}.json`); // A session already running when the plugin updated has its history under the older // name. Keep using that file rather than orphaning what the user already scored. try { if (!fs.existsSync(current)) { const legacy = path.join(CONFIG_DIR, `.sloptrim-ledger-${raw.replace(/[^a-zA-Z0-9-]/g, '').slic - hooks/sloptrim-stats.jsGitHub
Read the script
#!/usr/bin/env node // /sloptrim stats: session token usage and own-prose score. 'use strict'; process.on('uncaughtException', () => process.exit(0)); process.on('unhandledRejection', () => process.exit(0)); const fs = require('fs'); const path = require('path'); const { CONFIG_DIR, parseStdin, readMode, runDetect } = require('./sloptrim-lib'); function newestSession(dir) { let best = null; let stack = [dir]; while (stack.length) { const d = stack.pop(); let entries; try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch (e) { continue; } for (const e of entries) { const p = path.join(d, e.name); if (e.isDirectory()) stack.push(p); else if (e.name.endsWith('.jsonl')) { // A transcript can vanish or lock between the listing and the stat. Losing one // candidate is fine; throwing here would abort the walk and report no session. let mt; try { mt = fs.statSync(p).mtimeMs; } catch (err) { continue; } if (!best || mt > best.mt) best = { p, mt }; } } } return best && best.p; } const input = parseStdin(); const sessionFile = input.transcript_path || newestSession(path.join(CONFIG_DIR, 'projects')); if (!sessionFile || !fs.existsSync(sessionFile)) { process.stdout.write('sloptrim-stats: no session transcript found'); process.exit(0); } let outputTokens = 0; let cacheRead = 0; let turns = 0; const texts = []; const seenIds = new Set(); for (const line of fs.readFileSync(sessionFile, 'utf8').split('\n')) { if (!line.trim()) continue; let entry; try { entry = JSON.parse(line); } catch (e) { continue; } if (!entry || typeof entry !== 'object') continue; if (entry.type !== 'assistant' || !entry.message) continue; // One assistant reply can arrive as several transcript lines when it carries // parallel tool calls, and every line repeats the same usage object. Counting per // line inflates both the turn count and the token totals. const content = entry.message.content; if (Array.isArray(content)) { for (const c of content) { if (c && c.type === 'text' && c.text) texts.push(c.text); } } const id = entry.message.id; if (id && seenIds.has(id)) continue; if (id) seenIds.add(id); const usage = entry.message.usage || {}; outputTokens += usage.output_tokens || 0; cacheRead += usage.cache_read_input_tokens || 0; turns += 1; } let prose = texts.join('\n\n').replace(/```[\s\S]*?```/g, ' '); if (prose.length > 40000) prose = prose.slice(-40000); let scoreLine = 'prose score: n/a (no assistant text)'; if (prose.trim().length > 200) { try { const out = runDetect(prose, { timeout: 15000 }); const report = JSON.parse(out); const m = report._metrics || {}; const pats = Object.keys(report).filter((k) => !k.startsWith('_')).length; scoreLine = `session prose ai_tell_score: ${m.ai_tell_score} (${m.ai_tell_band}), ${pats} pattern(s) flagged`; } catch (e) { scoreLine = 'prose score: n/a (detector unavailable)'; } } process.stdout.write([ `sloptrim-stats (level: ${readMode()})`, `assistant turns: ${turns}`, `output tokens: ${outputTokens.toLocaleString('en-US')}`, `cache-read input tokens: ${cacheRead.toLocaleString('en-US')}`, scoreLine, 'Numbers read from the session transcript (usage fields) - measured, not estimated.', ].join('\n')); process.exit(0); - hooks/sloptrim-statusline.ps1GitHub
Read the script
# Combined statusline: shows active ponytail/caveman/sloptrim levels. $ErrorActionPreference = 'SilentlyContinue' try { $null = [Console]::In.ReadToEnd() } catch {} $claude = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME '.claude' } function Seg($file, $label, $default) { $p = Join-Path $claude $file $v = $default if (Test-Path $p) { try { $v = (Get-Content -Raw -LiteralPath $p).Trim() } catch { $v = $default } } if (-not $v -or $v -eq 'off') { return '' } return "[$label`:$($v.ToUpper())]" } $parts = @( (Seg '.ponytail-mode' 'PONY' ''), (Seg '.caveman-active' 'CAVE' ''), (Seg '.sloptrim-active' 'SLOP' $( $d = "$($env:SLOPTRIM_DEFAULT_MODE)".Trim() if ($d -cin @('lite', 'full', 'strict', 'off')) { $d } else { 'full' })) ) | Where-Object { $_ } $esc = [char]27 $dim = "$esc[38;5;245m" $reset = "$esc[0m" Write-Host "$dim$($parts -join ' ')$reset" -NoNewline - hooks/sloptrim-subagent.jsGitHub
Read the script
#!/usr/bin/env node // SubagentStart hook: gives a spawned agent the same contract the session has. // // SessionStart context reaches the parent thread only. Without this hook, an // agent that delegates a document to a subagent gets prose written with no // contract at all, and the only thing standing between that prose and the user // is the file guard, which fires after the fact and only when the subagent // happens to save through Write or Edit. // // The measured version of that gap: six documents drafted by subagents scored // the same whether the tool was switched on or off, because the contract was // never in their context either way. // // SubagentStart drops raw stdout. The context has to be returned in the // hookSpecificOutput form or it is silently discarded, which looks exactly like // a working hook. 'use strict'; process.on('uncaughtException', () => process.exit(0)); process.on('unhandledRejection', () => process.exit(0)); const { readMode, contract, LEVELS } = require('./sloptrim-lib'); const mode = readMode(); if (mode === 'off' || !LEVELS.includes(mode)) process.exit(0); try { process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SubagentStart', additionalContext: contract(mode), }, })); } catch (e) { // A stdout failure at hook exit must not surface as a hook failure. } process.exit(0); - hooks/sloptrim-tracker.jsGitHub
All 7 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.
Ships withsloptrim
A local detector for AI-writing patterns. Scores every prose file your agent saves. Python standard library only, no network, no model.
Get the whole plugin
Stats
396
Stars
27
Forks
Active
Maintenance
Python
Language
Apache-2.0
License
13d ago
Last commit
2mo ago
Created
22h ago
Added

